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..45ddf42b7 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -11,4 +11,6 @@ export const rootRoute: string export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' +export const statisticsNavRouteId = 'statistics-nav' 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/NavTabs.module.scss b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss index 5c0f801eb..4c396af43 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss @@ -68,6 +68,7 @@ display: flex; align-items: center; gap: $sp-2; + position: relative; &.active { font-weight: 700; } @@ -104,6 +105,27 @@ color: var(--invertButtonColor); } } + + .hasChildren { + display: block; + + .submenu { + background: transparent; + border-radius: 0; + box-shadow: none; + display: none; + padding: 0; + position: static; + + li { + padding: $sp-2 $sp-6 $sp-2 $sp-10; + } + } + + &.menuOpen .submenu { + display: block; + } + } } } } @@ -115,6 +137,76 @@ align-items: center; } +.menuTrigger { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + gap: $sp-2; + line-height: inherit; + padding: 0; +} + +.chevron { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.hasChildren { + .menuTrigger { + width: 100%; + } + + .chevron { + transition: transform 160ms ease; + } + + &:hover, + &.menuOpen { + z-index: 5; + } + + &.menuOpen .chevron { + transform: rotate(-180deg); + } + + .submenu { + background: #fff; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(10, 10, 10, 0.12); + display: none; + left: 0; + min-width: 220px; + padding: 8px 0; + position: absolute; + top: calc(100% - 4px); + z-index: 120; + + li { + display: block; + font-weight: 400; + line-height: 22px; + margin-left: 0; + padding: 10px 16px; + white-space: nowrap; + + &:hover, + &.active { + font-weight: 700; + } + } + } + + &:hover .submenu, + &.menuOpen .submenu { + display: block; + } +} + .externalIcon { width: 16px; height: 16px; diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx index 1dd411b39..f8494f978 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx @@ -32,6 +32,7 @@ jest.mock('~/libs/shared/lib/hooks', () => ({ jest.mock('~/libs/ui', () => ({ IconOutline: { + ChevronDownIcon: () => chevron-down, ExternalLinkIcon: () => external-link, }, }), { diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx index ea751bff7..35ddefb4c 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx @@ -1,6 +1,7 @@ import { Dispatch, FC, + KeyboardEvent, MouseEvent, SetStateAction, useCallback, @@ -28,6 +29,7 @@ import styles from './NavTabs.module.scss' const NavTabs: FC = () => { const navigate: NavigateFunction = useNavigate() const [isOpen, setIsOpen] = useState(false) + const [openMenuId, setOpenMenuId] = useState() const triggerRef = useRef(null) const { pathname }: { pathname: string } = useLocation() @@ -59,8 +61,20 @@ const NavTabs: FC = () => { const triggerTab = useCallback(() => { setIsOpen(!isOpen) + setOpenMenuId(undefined) }, [isOpen]) + const closeMenus = useCallback(() => { + setIsOpen(false) + setOpenMenuId(undefined) + }, []) + + const navigateToTab = useCallback((tabId: string) => { + setActiveTab(tabId) + closeMenus() + navigate(`${rootRoute}/${tabId}`) + }, [closeMenus, navigate]) + const handleTabClick = useCallback( (event: MouseEvent) => { const { @@ -73,19 +87,36 @@ const NavTabs: FC = () => { } if (tabUrl) { - setIsOpen(false) + closeMenus() window.open(tabUrl, '_blank', 'noopener,noreferrer') return } - setActiveTab(tabId) - setIsOpen(false) - navigate(`${rootRoute}/${tabId}`) + navigateToTab(tabId) }, - [navigate], + [closeMenus, navigateToTab], ) - useClickOutside(triggerRef.current, () => setIsOpen(false)) + const toggleMenu = useCallback((event: MouseEvent, tabId: string) => { + event.stopPropagation() + setOpenMenuId(current => (current === tabId ? undefined : tabId)) + }, []) + + const handleMenuKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenMenuId(undefined) + } + }, []) + + const handleChildClick = useCallback(( + event: MouseEvent, + childId: string, + ) => { + event.stopPropagation() + navigateToTab(childId) + }, [navigateToTab]) + + useClickOutside(triggerRef.current, closeMenus) return (
{
    {tabs.map(tab => { - const isActive = tab.id === activeTab && !tab.url + const hasChildren = Boolean(tab.children?.length) + const isChildActive = tab.children?.some(child => ( + pathname === `/${child.id}` + || pathname.startsWith(`/${child.id}/`) + )) + const isActive = hasChildren + ? Boolean(isChildActive) || tab.id === activeTab + : tab.id === activeTab && !tab.url + const isMenuOpen = openMenuId === tab.id + + if (hasChildren) { + return ( +
  • + +
      + {tab.children?.map(child => { + const isChildItemActive = pathname === `/${child.id}` + || pathname.startsWith(`/${child.id}/`) + + return ( +
    • , + ) { + handleChildClick(event, child.id) + }} + > + {child.title} +
    • + ) + })} +
    +
  • + ) + } return (
  • pathname.includes(`/${item.id}`)) + ) + const matchItem = tabs.find(item => ( + item.children?.some(child => pathMatchesTab(pathname, child.id)) + || pathMatchesTab(pathname, item.id) + )) if (matchItem) { return matchItem.id 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..ae6b811f6 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -0,0 +1,319 @@ +@import '@libs/ui/styles/includes'; + +.chart { + height: 560px; + min-height: 560px; + overflow: visible; + position: relative; + width: 100%; + + @include ltemd { + height: auto; + min-height: 640px; + } +} + +.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: var(--arrow-x, 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%); + } + } + + &.anchored { + position: absolute; + width: min(320px, calc(100% - 16px)); + z-index: 5; + } +} + +.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..00476dfb2 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -0,0 +1,625 @@ +/* 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, packedBoundsHeight, PackedCircle } from './packCircles' +import { MOBILE_MAX_WIDTH, useMobileView } from './useMobileView' +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 +const DOUBLE_TAP_MS = 450 + +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, + } +} + +function getAnchoredPopoverLayout( + circle: PackedCircle, + chartWidth: number, + chartHeight: number, + popoverWidth: number, + popoverHeight: number, +): PopoverLayout { + const bubbleTop = circle.y - circle.r + const bubbleBottom = circle.y + circle.r + const fitsTop = bubbleTop - POPOVER_GAP - popoverHeight >= VIEW_PAD + const fitsBottom = bubbleBottom + POPOVER_GAP + popoverHeight <= chartHeight - VIEW_PAD + const placement: PopoverPlacement = fitsTop || !fitsBottom ? 'top' : 'bottom' + const halfWidth = popoverWidth / 2 + const minLeft = VIEW_PAD + halfWidth + const maxLeft = chartWidth - VIEW_PAD - halfWidth + const left = maxLeft < minLeft + ? chartWidth / 2 + : Math.min(Math.max(circle.x, minLeft), maxLeft) + const layout: PopoverLayout = { + left, + placement, + top: placement === 'top' ? bubbleTop : bubbleBottom, + } + + if (Math.abs(left - circle.x) > 1) { + layout.arrowOffset = halfWidth + (circle.x - left) + } + + return layout +} + +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 lastTapRef = useRef<{ at: number; id: string }>() + const isMobileView = useMobileView() + const [hoveredCategoryId, setHoveredCategoryId] = useState() + const [previewedCategoryId, setPreviewedCategoryId] = useState() + const [viewport, setViewport] = useState(() => ({ + height: 560, + width: typeof window === 'undefined' ? 960 : Math.min(window.innerWidth, 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 isMobile = viewport.width > 0 && viewport.width <= MOBILE_MAX_WIDTH + const packed = useMemo( + () => packCircles( + props.categories.map(category => ({ + id: category.id, + r: radiusForSize(category.size), + })), + viewport.width, + viewport.height, + isMobile ? { fit: 'width' } : undefined, + ), + [isMobile, props.categories, viewport.height, viewport.width], + ) + const packedHeight = useMemo( + () => packedBoundsHeight(packed), + [packed], + ) + + 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 previewedCategory = props.categories.find( + category => category.id === previewedCategoryId, + ) + const popoverCategory = hoveredCategory || previewedCategory + const popoverCircle = popoverCategory + ? packedById.get(popoverCategory.id) + : undefined + const { data: popoverMembers }: SWRResponse = useSWR( + popoverCategory + ? expertSkillCategoryMembersCacheKey(popoverCategory.name) + : undefined, + () => fetchExpertSkillCategoryMembers(popoverCategory?.name || ''), + ) + const topMember = popoverMembers?.[0] + + const hidePopover = useCallback(() => { + lastTapRef.current = undefined + setPreviewedCategoryId(undefined) + setHoveredCategoryId(undefined) + }, []) + + const openMembersTable = useCallback((categoryId: string) => { + lastTapRef.current = undefined + setPreviewedCategoryId(undefined) + if (isMobileView) { + setHoveredCategoryId(undefined) + } + + props.onSelect(categoryId) + }, [isMobileView, props]) + + const handleBubbleClick = useCallback((categoryId: string) => { + if (!isMobileView) { + openMembersTable(categoryId) + return + } + + const now = Date.now() + const lastTap = lastTapRef.current + if (lastTap && lastTap.id === categoryId && now - lastTap.at <= DOUBLE_TAP_MS) { + openMembersTable(categoryId) + return + } + + if (previewedCategoryId === categoryId) { + hidePopover() + return + } + + lastTapRef.current = { at: now, id: categoryId } + setPreviewedCategoryId(categoryId) + setHoveredCategoryId(categoryId) + }, [hidePopover, isMobileView, openMembersTable, previewedCategoryId]) + + useEffect(() => { + const onPointerDown = (event: Event): void => { + const target = event.target + if (target instanceof Element && target.closest('[aria-label="Skill category bubbles"] button')) { + return + } + + hidePopover() + } + + document.addEventListener('pointerdown', onPointerDown) + + return () => { + document.removeEventListener('pointerdown', onPointerDown) + } + }, [hidePopover]) + + const handleKeyDown = useCallback(( + event: KeyboardEvent, + categoryId: string, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openMembersTable(categoryId) + } + }, [openMembersTable]) + + 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 + || category.id === previewedCategoryId + 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 ( + + ) + })} + {popoverCategory && popoverCircle && ( + + )} +
    + ) +} + +interface SkillCategoryPopoverProps { + anchored?: boolean + 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 height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT + const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH + + if (props.anchored) { + setLayout(getAnchoredPopoverLayout( + props.circle, + props.chartWidth, + props.chartHeight, + width, + height, + )) + return + } + + 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, + } + setLayout(getPopoverLayout(props.circle, measured, width, height)) + } + + update() + window.addEventListener('resize', update) + if (!props.anchored) { + window.addEventListener('scroll', update, true) + } + + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [ + props.anchored, + 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) { + if (layout.placement === 'left' || layout.placement === 'right') { + Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + } else { + Object.assign(popoverStyle, { '--arrow-x': `${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 && ( + + +
    +
    + )} +
    + ) + + if (props.anchored || typeof document === 'undefined') { + return popover + } + + return 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..c143ce1ec --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss @@ -0,0 +1,392 @@ +@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 { + background: #E9ECEF; + border-radius: 8px; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + height: fit-content; +} + +.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; + } + } + + 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: 14px; + font-weight: 700; + line-height: 20px; + } + + 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; + } + } +} + +$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: 8%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 35%; + } + + th:nth-child(3), + td:nth-child(3) { + text-align: left; + width: 20%; + } + + th:nth-child(4), + td:nth-child(4) { + width: 23%; + } + + th:nth-child(5), + td:nth-child(5) { + padding-right: 32px; + text-align: right; + width: 18%; + } + + td.empty { + color: #545f71; + font-size: 14px; + height: $row-height * $visible-member-rows; + padding: 24px 16px; + text-align: center; + vertical-align: middle; + } + + .detailsRow { + display: none; + } + + @include ltemd { + thead { + display: none; + } + + th.desktopOnly, + td.desktopOnly { + display: none; + } + + th:first-child, + td:first-child { + padding-left: 8px; + padding-right: 8px; + width: 100px; + text-align: start; + } + + th:nth-child(2), + td:nth-child(2) { + padding-left: 8px; + padding-right: 8px; + width: auto; + } + + .memberRow { + cursor: pointer; + } + + .detailsRow { + display: table-row; + + td { + height: auto; + padding: 12px 8px; + vertical-align: top; + } + } + } +} + +.toggle { + align-items: center; + background: transparent; + border: 0; + color: #767676; + cursor: pointer; + display: none; + flex: 0 0 auto; + justify-content: center; + margin-left: auto; + padding: 8px; + + @include ltemd { + display: inline-flex; + } + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 2px; + } +} + +.chevron { + display: block; + flex: 0 0 20px; + height: 20px; + transition: transform 160ms ease; + width: 20px; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.details { + display: none; + grid-template-columns: 100px minmax(0, 1fr); + margin: 0; + row-gap: 10px; + column-gap: 50px; + + @include ltemd { + display: grid; + } +} + +.detail { + display: contents; + line-height: 20px; + + dt { + color: #0a0a0a; + font-size: 14px; + font-weight: 700; + justify-self: end; + overflow: visible; + padding-right: 8px; + white-space: nowrap; + } + + dd { + color: #1a1a1a; + font-size: 14px; + font-weight: 400; + margin: 0; + min-width: 0; + } +} + +.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: 1 1 auto; + 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; +} 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..48b73acc9 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -0,0 +1,317 @@ +/* eslint-disable react/jsx-no-bind */ +import { + ChangeEvent, + FC, + Fragment, + MouseEvent, + useMemo, + useState, +} 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 [expandedHandles, setExpandedHandles] = useState>(new Set()) + + 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]) + + function toggleExpanded(handle: string): void { + setExpandedHandles(current => { + const next = new Set(current) + if (next.has(handle)) { + next.delete(handle) + } else { + next.add(handle) + } + + return next + }) + } + + 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 + ? ) { + const target = event.target + if (target instanceof Element && target.closest('a')) { + return + } + + toggleExpanded(member.handle) + }} + > + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
RankMemberRatingCountry# of Wins
+ No members match the current filters. +
+ + {rankIcon} + + +
+ + + +
+
+ + {member.rating} + + +
+ {countryCode && ( +
+
+ {NUMBER_FORMATTER.format(member.wins)} +
+
+
+
Rating
+
+ {member.rating} +
+
+
+
Country
+
+ {countryCode && ( +
+
+
+
# of Wins
+
+ {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..3cb911837 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -0,0 +1,67 @@ +@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 48px; + + .header h1 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 33px; + font-weight: 700; + line-height: 40px; + 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; + + @include ltemd { + padding: 0 16px; + } +} + +.hint { + color: #0a0a0a; + font-size: 16px; + font-weight: 700; + 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..10c7e7a97 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -0,0 +1,397 @@ +/* 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', + statisticsNavRouteId: 'statistics-nav', + 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('nests General Statistics and Skill Statistics under Statistics', () => { + const tabs = getTabsConfig(['administrator'], false, false) + + expect(tabs.map(tab => tab.title)) + .toEqual([ + 'Statistics', + 'Talent Search', + 'Showcase', + 'Flexi-Talent', + ]) + expect(tabs[0].children?.map(tab => tab.title)) + .toEqual([ + 'General Statistics', + 'Skill Statistics', + ]) + expect(getTabIdFromPathName('/skill-statistics', ['administrator'], false, false)) + .toBe('statistics-nav') + expect(getTabIdFromPathName('/statistics', ['administrator'], false, false)) + .toBe('statistics-nav') + }) +}) + +describe('SkillStatisticsPage', () => { + const originalInnerWidth = window.innerWidth + + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }) + 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(within(screen.getByRole('table')) + .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.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(within(screen.getByRole('table')) + .getByText('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('centers the empty members message when filters match nobody', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Search members'), { + target: { value: 'no-such-member' }, + }) + + expect(screen.getByText('No members match the current filters.')) + .toBeInTheDocument() + }) + + it('reranks members when filtering by country', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(within(screen.getByRole('table')) + .getByText('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('opens the members table on double click in mobile view', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) + fireEvent.click(bubble) + + expect(screen.queryByRole('heading', { name: 'Members for Programming and Development' })) + .not.toBeInTheDocument() + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + fireEvent.doubleClick(bubble) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + }) + + it('hides the popover when the same bubble is clicked again', async () => { + const now = jest.spyOn(Date, 'now') + + try { + now.mockReturnValue(1_000) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) + fireEvent.click(bubble) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + now.mockReturnValue(1_500) + fireEvent.click(bubble) + + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('hides the popover when clicking outside the bubbles', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + fireEvent.pointerDown(document.body) + + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + }) + + it('expands a member row to show rating, country, and wins', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByText('Justin G')) + + const details = screen.getByLabelText('Details for Ghostar') + expect(within(details) + .getByText('1900')) + .toBeInTheDocument() + expect(within(details) + .getByText('USA')) + .toBeInTheDocument() + expect(within(details) + .getByText('322')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Hide details for Ghostar' })) + .toHaveAttribute('aria-expanded', 'true') + }) + + 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..7adcfd909 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -0,0 +1,128 @@ +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.spec.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.spec.ts new file mode 100644 index 000000000..330cd399d --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.spec.ts @@ -0,0 +1,43 @@ +import { packCircles, packedBoundsHeight } from './packCircles' + +describe('packCircles', () => { + const items = [ + { id: 'a', r: 50 }, + { id: 'b', r: 40 }, + { id: 'c', r: 36 }, + { id: 'd', r: 60 }, + { id: 'e', r: 32 }, + { id: 'f', r: 48 }, + ] + + it('fits a landscape pack inside the given viewport', () => { + const packed = packCircles(items, 960, 560) + const maxX = Math.max(...packed.map(circle => circle.x + circle.r)) + const maxY = Math.max(...packed.map(circle => circle.y + circle.r)) + + expect(packed) + .toHaveLength(items.length) + expect(maxX) + .toBeLessThanOrEqual(960) + expect(maxY) + .toBeLessThanOrEqual(560) + }) + + it('packs a tall portrait cloud when fitting to width', () => { + const manyItems = Array.from({ length: 18 }, (_, index) => ({ + id: `item-${index}`, + r: 36 + ((index % 5) * 8), + })) + const contained = packCircles(manyItems, 360, 640) + const portrait = packCircles(manyItems, 360, 640, { fit: 'width' }) + const height = packedBoundsHeight(portrait) + const maxX = Math.max(...portrait.map(circle => circle.x + circle.r)) + + expect(maxX) + .toBeLessThanOrEqual(360) + expect(height) + .toBeGreaterThan(packedBoundsHeight(contained)) + expect(height) + .toBeGreaterThan(640) + }) +}) 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..14e9846e5 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts @@ -0,0 +1,171 @@ +export type PackedCircle = { + id: string + r: number + x: number + y: number +} + +export type PackCirclesOptions = { + fit?: 'contain' | 'width' + padding?: 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 +} + +function resolveAspect( + width: number, + height: number, + fit: PackCirclesOptions['fit'], +): number { + if (fit === 'width') { + // Portrait cloud: about two bubbles across, stacked long-ways. + return 0.48 + } + + return Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) +} + +/** + * Place circles in a tight non-overlapping cluster, then scale uniformly + * so the pack fits the viewport. `fit: 'width'` keeps bubble size and lets + * the pack grow tall so mobile can scroll the long way. + */ +export function packCircles( + items: Array<{ id: string; r: number }>, + width: number, + height: number, + options: PackCirclesOptions = {}, +): PackedCircle[] { + const padding = options.padding ?? 6 + const fit = options.fit ?? 'contain' + const rng = createRng(items.reduce((seed, item) => seed + hashString(item.id), 1)) + const ordered = shuffleItems(items, rng) + const placed: PackedCircle[] = [] + const aspect = resolveAspect(width, height, fit) + 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 || (fit === 'contain' && 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 = fit === 'width' + ? Math.min(availableWidth / packWidth, 1.05) + : Math.min(availableWidth / packWidth, availableHeight / packHeight) + const scaledWidth = packWidth * scale + const scaledHeight = packHeight * scale + const offsetX = inset + ((availableWidth - scaledWidth) / 2) + const offsetY = fit === 'width' + ? inset + : inset + ((availableHeight - scaledHeight) / 2) + + return placed.map(circle => ({ + id: circle.id, + r: circle.r * scale, + x: ((circle.x - minX) * scale) + offsetX, + y: ((circle.y - minY) * scale) + offsetY, + })) +} + +export function packedBoundsHeight(circles: PackedCircle[], padding: number = 16): number { + if (!circles.length) { + return 0 + } + + return Math.max(...circles.map(circle => circle.y + circle.r)) + padding +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts new file mode 100644 index 000000000..8f423d914 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +export const MOBILE_MAX_WIDTH = 744 + +export function useMobileView(): boolean { + const [isMobile, setIsMobile] = useState(() => ( + typeof window !== 'undefined' && window.innerWidth <= MOBILE_MAX_WIDTH + )) + + useEffect(() => { + const update = (): void => { + setIsMobile(window.innerWidth <= MOBILE_MAX_WIDTH) + } + + update() + window.addEventListener('resize', update) + + return () => { + window.removeEventListener('resize', update) + } + }, []) + + return isMobile +} 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 087ba9099..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, @@ -283,6 +284,7 @@ :global(.highcharts-container), :global(.highcharts-root) { height: 370px !important; + z-index: unset !important; } } @@ -350,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 { @@ -502,6 +520,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + color: #3877EA!important; } .tooltipWins { diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx index e02db6ffe..22abc7001 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx @@ -73,7 +73,7 @@ const StatisticsPage: FC = () => { ? !generalStatistics && !generalStatisticsError : !winners && !winnersError const contentError = activeTab === 'countries' ? generalStatisticsError : winnersError - const valueLabel = activeTab === 'countries' ? 'Members' : 'Winners' + const valueLabel = activeTab === 'countries' ? 'Members' : '1st Places' const selectTab = useCallback((tab: StatisticsTab) => { setActiveTab(tab) @@ -184,7 +184,7 @@ const StatisticsPage: FC = () => { tabIndex={activeTab === 'winners' ? 0 : -1} type='button' > - Winners by Country + First place by Country 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 7ce5e8092..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' @@ -113,7 +113,7 @@ function renderWinnersTooltip(point: StatisticsMapPoint): string { ${flag} ${escapeHtml(point.name)} - Winners: ${NUMBER_FORMATTER.format(point.value)} + # of 1st place Wins: ${NUMBER_FORMATTER.format(point.value)} Top Winners
@@ -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/Reviewer.model.ts b/src/apps/work/src/lib/models/Reviewer.model.ts index 9c2cbb799..80b53835b 100644 --- a/src/apps/work/src/lib/models/Reviewer.model.ts +++ b/src/apps/work/src/lib/models/Reviewer.model.ts @@ -44,6 +44,7 @@ export interface DefaultReviewer { memberReviewerCount?: number opportunityType?: string phaseId?: string + phaseName?: string roleId?: string scorecardId?: string shouldOpenOpportunity?: boolean 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 3e21f961c..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({ @@ -376,6 +454,25 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toBeTruthy() }) + it('accepts a persisted member handle while its member id is resolved during save', async () => { + await expect( + challengeAdvancedOptionsSchema.validate({ + ...baseFormData, + reviewers: [ + { + handle: 'TCConnCopilot', + isMemberReview: true, + memberReviewerCount: 1, + scorecardId: 'scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }), + ) + .resolves + .toBeTruthy() + }) + it('rejects reviewer counts above the manual reviewer limit', async () => { await expect( challengeAdvancedOptionsSchema.validate({ @@ -394,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 f44aa3cfe..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) @@ -441,11 +490,10 @@ export const challengeAdvancedOptionsSchema = yup.object({ ? reviewer.additionalMemberIds : [] const normalizedAssignedMemberSlots = [ - reviewer.memberId, - ...additionalMemberIds, + toNormalizedText(reviewer.memberId) || toNormalizedText(reviewer.handle), + ...additionalMemberIds.map(memberId => toNormalizedText(memberId)), ] .slice(0, reviewerSlots) - .map(memberId => toNormalizedText(memberId)) const missingSlotIndex = normalizedAssignedMemberSlots.findIndex(memberId => !memberId) const hasAllAssignments = normalizedAssignedMemberSlots.length === reviewerSlots && missingSlotIndex === -1 diff --git a/src/apps/work/src/lib/services/challenges.service.spec.ts b/src/apps/work/src/lib/services/challenges.service.spec.ts index ef70a3131..12c417b75 100644 --- a/src/apps/work/src/lib/services/challenges.service.spec.ts +++ b/src/apps/work/src/lib/services/challenges.service.spec.ts @@ -107,7 +107,7 @@ describe('fetchDefaultReviewers', () => { { isMemberReview: true, memberReviewerCount: 1, - phaseId: 'phase-1', + phaseName: 'Review', roleId: 'role-1', scorecardId: 'scorecard-1', shouldOpenOpportunity: true, @@ -119,13 +119,32 @@ describe('fetchDefaultReviewers', () => { { isMemberReview: true, memberReviewerCount: 1, - phaseId: 'phase-1', + phaseName: 'Review', roleId: 'role-1', scorecardId: 'scorecard-1', shouldOpenOpportunity: true, }, ]) }) + + it('requests defaults for the selected timeline template', async () => { + const mockedGet = xhrGetAsync as jest.Mock + + mockedGet.mockResolvedValue([]) + + await fetchDefaultReviewers({ + timelineTemplateId: ' timeline-template-1 ', + trackId: ' track-1 ', + typeId: ' type-1 ', + }) + + expect(mockedGet) + .toHaveBeenCalledWith( + 'https://example.com/default-reviewers' + + '?typeId=type-1&trackId=track-1&timelineTemplateId=timeline-template-1', + expect.any(Object), + ) + }) }) describe('patchChallenge', () => { diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts index fad44bfea..564f4c2fe 100644 --- a/src/apps/work/src/lib/services/challenges.service.ts +++ b/src/apps/work/src/lib/services/challenges.service.ts @@ -414,6 +414,7 @@ function normalizeDefaultReviewer( opportunityType: toOptionalString((reviewer as Record).opportunityType) || toOptionalString((reviewer as Record).type), phaseId: toOptionalString(reviewer.phaseId), + phaseName: toOptionalString(reviewer.phaseName), roleId: toOptionalString(reviewer.roleId), scorecardId: toOptionalString((reviewer as Record).scorecardId), shouldOpenOpportunity: toOptionalBoolean((reviewer as Record).shouldOpenOpportunity), @@ -688,10 +689,19 @@ export async function deleteChallenge(challengeId: string): Promise { } /** - * Fetch default reviewers metadata. + * Fetches default reviewer metadata for a challenge configuration. + * + * @param typeIdOrFilters challenge type id for the legacy positional call, or type, track, + * and timeline-template filters for a template-specific lookup. + * @param trackId challenge track id used with the legacy positional call. + * @returns the normalized default reviewer rows matching the supplied configuration. + * @remarks The reviewer editor uses the filter-object form so challenges with multiple timeline + * templates receive the scorecards and reviewer phases configured for the selected template. + * @throws a normalized request error when the default-reviewer endpoint cannot be reached. */ export async function fetchDefaultReviewers( typeIdOrFilters: string | { + timelineTemplateId?: string trackId?: string typeId?: string } | undefined, @@ -713,6 +723,10 @@ export async function fetchDefaultReviewers( query.set('trackId', filters.trackId.trim()) } + if (filters.timelineTemplateId?.trim()) { + query.set('timelineTemplateId', filters.timelineTemplateId.trim()) + } + try { const queryString = query.toString() const response = await xhrGetAsync( 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 6fe2ac84e..d61c395e1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -23,7 +23,7 @@ payload is available, so the create route can expand to the full editor immediately after the initial draft is created. - `components/*Field.tsx`: field-level components for each challenge section. -- `components/ReviewersField/*`: tabbed human/AI review configuration. Copilot-only users editing Design `Challenge` types see one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults; admins and managers retain the full interface. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. +- `components/ReviewersField/*`: tabbed human/AI review configuration. Every user editing a Design `Challenge` sees one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults. Administrators additionally get a `Show advanced review configuration` toggle that expands the full tabbed interface on demand; copilots and managers only see the Screener selector. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. - `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them. ## Validation Rules @@ -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 @@ -75,16 +75,21 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, keeps outside-label date-picker controls visible and interactive when the shared input wrapper omits an empty internal label, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, initializes missing challenge start dates from existing phase starts or the current date before calculating blank phase rows, recalculates root phase dates when the challenge start changes, and upgrades populated legacy non-task schedules to the scheduling API during serialization even if asynchronous hydration restores a stale disabled flag. It honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, reports rejected schedule edits to the form so manual saves show the relevant validation error and autosave pauses until the edit is corrected, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior and retain a disabled legacy scheduling flag. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. +- `ShowDashboardField`: `Show Dashboard` checkbox shown in Advanced Options only for `Marathon Match` + type challenges. It reloads from the exact string-valued `show_data_dashboard` challenge metadata + entry consumed by the challenge details page, and defaults to checked for fun challenges that have + no saved value yet. Creating a Marathon Match persists `show_data_dashboard` as `true` for fun + challenges and `false` otherwise. - `Test Challenge` checkbox: shown only in Advanced Options after the challenge has been created; it is omitted from Basic Information during initial creation. It defaults unchecked, reloads 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. On the 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. Design challenge manual reviewers always keep 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 978510484..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: () => <>, })) @@ -613,6 +640,9 @@ jest.mock('./CopilotField', () => ({ value={controller.field.value || ''} /> + {controller.fieldState.error?.message + ? {controller.fieldState.error.message} + : undefined}