diff --git a/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss new file mode 100644 index 000000000..6fab0c29a --- /dev/null +++ b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss @@ -0,0 +1,31 @@ +@import "@libs/ui/styles/includes"; + +.container { + min-height: 60vh; + display: flex; + align-items: center; + justify-content: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: $sp-6; + padding: $sp-15 $sp-8; + color: $black-100; + + svg { + color: $black-60; + } +} + +.title { + @include font-barlow; + font-weight: 600; + font-size: 28px; + line-height: 34px; + margin: 0; +} diff --git a/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx new file mode 100644 index 000000000..24130076f --- /dev/null +++ b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx @@ -0,0 +1,48 @@ +import { FC, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' + +import { Button, ContentLayout, IconOutline, IconSolid, PageTitle } from '~/libs/ui' + +import styles from './MemberNotFound.module.scss' + +interface MemberNotFoundProps { + memberHandle?: string +} + +const MemberNotFound: FC = (props: MemberNotFoundProps) => { + const navigate = useNavigate() + + const handleBack = useCallback(() => { + navigate(-1) + }, [navigate]) + + return ( + + Profile Not Found | Topcoder + +
+ +

We were unable to locate that profile

+ {props.memberHandle && ( +

+ No member was found with the handle + {' '} + {props.memberHandle} + . +

+ )} + +
+
+ ) +} + +export default MemberNotFound diff --git a/src/apps/profiles/src/components/MemberNotFound/index.ts b/src/apps/profiles/src/components/MemberNotFound/index.ts new file mode 100644 index 000000000..e48b4584b --- /dev/null +++ b/src/apps/profiles/src/components/MemberNotFound/index.ts @@ -0,0 +1 @@ +export { default as MemberNotFound } from './MemberNotFound' diff --git a/src/apps/profiles/src/components/index.ts b/src/apps/profiles/src/components/index.ts index 8bb7e5e34..bed93b6c6 100644 --- a/src/apps/profiles/src/components/index.ts +++ b/src/apps/profiles/src/components/index.ts @@ -4,3 +4,4 @@ export * from './tc-achievements/SRMView/ChallengesGrid' export * from './EditMemberPropertyBtn' export * from './AddButton' export * from './EmptySection' +export * from './MemberNotFound' diff --git a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx index ae271f591..d55e3bac3 100644 --- a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx +++ b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx @@ -1,11 +1,12 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react' import { Params, useNavigate, useParams } from 'react-router-dom' +import { AxiosError } from 'axios' import { bind } from 'lodash' import { profileGetPublicAsync, useMemberBadges, UserBadge, UserBadgesResponse, UserProfile } from '~/libs/core' import { Button, ContentLayout, IconSolid, LoadingSpinner } from '~/libs/ui' -import { MemberBadgeModal } from '../components' +import { MemberBadgeModal, MemberNotFound } from '../components' import styles from './MemberBadgesPage.module.scss' @@ -19,6 +20,7 @@ const MemberBadgesPage: FC<{}> = () => { ] = useState() const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false) + const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false) const memberBadges: UserBadgesResponse | undefined = useMemberBadges(profile?.userId as number, { limit: 100 }) @@ -39,12 +41,21 @@ const MemberBadgesPage: FC<{}> = () => { useEffect(() => { if (routeParams.memberHandle) { + setProfileReady(false) + setNotFound(false) + setProfile(undefined) + profileGetPublicAsync(routeParams.memberHandle) .then(userProfile => { setProfile(userProfile) setProfileReady(true) }) - // TODO: NOT FOUND PAGE redirect/dispaly via catch + .catch((e: AxiosError) => { + if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) { + setNotFound(true) + setProfileReady(true) + } + }) } }, [routeParams.memberHandle]) @@ -54,9 +65,13 @@ const MemberBadgesPage: FC<{}> = () => { return ( <> - + + + {profileReady && notFound && ( + + )} - {profileReady && profile && !!memberBadges && ( + {profileReady && profile && !notFound && !!memberBadges && ( diff --git a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx index d5a85cf72..7bdead5cb 100644 --- a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx +++ b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx @@ -5,7 +5,7 @@ import { AxiosError } from 'axios' import { profileContext, ProfileContextData, profileGetPublicAsync, UserProfile } from '~/libs/core' import { LoadingSpinner } from '~/libs/ui' -import { rootRoute } from '../profiles.routes' +import { MemberNotFound } from '../components' import { notifyUniNavi } from '../lib' import { ProfilePageLayout } from './page-layout' @@ -21,6 +21,7 @@ const MemberProfilePage: FC<{}> = () => { ] = useState() const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false) + const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false) const { isTalentSearch }: MemberProfileContextValue = useMemberProfileContext() const { profile: authProfile }: ProfileContextData = useContext(profileContext) @@ -31,6 +32,10 @@ const MemberProfilePage: FC<{}> = () => { useEffect(() => { if (routeParams.memberHandle) { + setProfileReady(false) + setNotFound(false) + setProfile(undefined) + profileGetPublicAsync(routeParams.memberHandle) .then(userProfile => { setProfile({ ...userProfile } as UserProfile) @@ -38,7 +43,8 @@ const MemberProfilePage: FC<{}> = () => { }) .catch((e: AxiosError) => { if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) { - navigate(rootRoute) + setNotFound(true) + setProfileReady(true) } }) } @@ -58,7 +64,11 @@ const MemberProfilePage: FC<{}> = () => { <> - {profileReady && profile && ( + {profileReady && notFound && ( + + )} + + {profileReady && profile && !notFound && ( { expect(parseFloat(marker.style.left)) .toBeCloseTo((3.5 / 6) * 100) }) + + it('hides the 2200+ axis label when the distribution has no elite buckets', () => { + render( + , + ) + + expect(screen.getByText('1500')) + .toBeInTheDocument() + expect(screen.queryByText('2200+')) + .not + .toBeInTheDocument() + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index a2d34df9b..d3a4ca25e 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -292,6 +292,29 @@ const getAxisLabelPosition = ( return 100 } +/** + * Returns axis labels that fall within the visible distribution ranges. + * + * Hides labels such as `2200+` when the trimmed histogram has no elite buckets, + * which otherwise stack on top of the final label (e.g. `1500`). + * + * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges. + * @returns {Array<{ label: string, value: number }>} Axis labels to render. + */ +const getVisibleAxisLabels = ( + ranges: RatingDistributionRange[], +): Array<{ label: string, value: number }> => { + if (ranges.length === 0) { + return chartAxisLabels + } + + const chartEnd = ranges[ranges.length - 1].end + + return chartAxisLabels.filter((axisLabel: { label: string, value: number }) => ( + axisLabel.value <= chartEnd + )) +} + /** * Calculates a bar height for a histogram count. * @@ -359,6 +382,9 @@ const MemberRatingInfoModal: FC = (props: MemberRati const markerPosition: number = props.rating !== undefined ? getMarkerPosition(props.rating, distributionRanges) : 0 + const visibleAxisLabels: Array<{ label: string, value: number }> = useMemo(() => ( + getVisibleAxisLabels(distributionRanges) + ), [distributionRanges]) const shouldStackMarkerRating: boolean = props.rating !== undefined && ( markerPosition >= stackedMarkerPositionThreshold || props.rating >= stackedMarkerRatingThreshold @@ -476,7 +502,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati )}
- {chartAxisLabels.map((axisLabel: { label: string, value: number }) => ( + {visibleAxisLabels.map((axisLabel: { label: string, value: number }) => ( ({ __esModule: true, - default: (props: { - options: { - series?: Array<{ data?: number[]; name?: string }> - tooltip?: { pointFormat?: string } - } - }): JSX.Element => { - const series = props.options.series || [] + default: (props: { options: Highcharts.Options }): JSX.Element => { + chartOptions = props.options + const series = props.options.series as Array<{ + data?: number[] + name?: string + }> || [] return (
({ data-series-names={series.map(item => item.name) .join('|')} data-testid='dashboard-chart' - data-tooltip={props.options.tooltip?.pointFormat} /> ) }, @@ -96,10 +97,61 @@ const signupResponse: NewSignupsDashboard = { }, } +const challengeParticipationResponse: ChallengeParticipationDashboard = { + dashboard: 'challenge-participation', + endDate: '2026-02-01T00:00:00.000Z', + months: [{ + month: '2026-01-01', + registrants: 120, + submitters: 75, + }], + startDate: '2026-01-01T00:00:00.000Z', + summary: { + peakMonth: '2026-01-01', + peakMonthRegistrants: 120, + submissionRate: 62.5, + totalUniqueRegistrants: 120, + totalUniqueSubmitters: 75, + }, +} const pointValueToken = '{point.y:,.0f}' const countTooltipValue = `${pointValueToken}` const currencyTooltipValue = `$${pointValueToken}` +/** + * Invokes the tooltip formatter captured from the rendered chart. + * + * Tests use this helper to verify totals independently of Highcharts' DOM renderer. + * + * @param values Hovered series values for one month. + * @returns The formatter's tooltip HTML as one string. + * @throws Error when the chart has no formatter or it returns no HTML. + */ +function formatTooltip(values: number[]): string { + const formatter = chartOptions.tooltip?.formatter + if (!formatter) { + throw new Error('Tooltip formatter is missing') + } + + const context = { + points: values.map(y => ({ y })), + } as unknown as Highcharts.TooltipFormatterContextObject + const tooltip = { + defaultFormatter: () => ['Jan ’26
', 'series rows'], + } as unknown as Highcharts.Tooltip + const result = formatter.call(context, tooltip) + + if (typeof result === 'string') { + return result + } + + if (Array.isArray(result)) { + return result.join('') + } + + throw new Error('Tooltip formatter did not return HTML') +} + describe('DashboardChart', () => { it('formats tooltip thousands with commas', () => { expect(Highcharts.getOptions().lang?.thousandsSep) @@ -126,8 +178,10 @@ describe('DashboardChart', () => { 'data-series-data', '[[125000,140000],[80000,0],[20000,25000]]', ) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .toContain(currencyTooltipValue) + expect(formatTooltip([125_000, 80_000, 20_000])) + .toContain('Total: $225,000') expect(within(table) .getByRole('columnheader', { name: 'Customer A' })) .toBeInTheDocument() @@ -142,20 +196,30 @@ describe('DashboardChart', () => { .toBeInTheDocument() }) - it('keeps existing count dashboards unit-free', () => { + it('keeps count dashboard tooltips unit-free and adds their total', () => { render() - const chart = screen.getByTestId('dashboard-chart') const table = screen.getByRole('table', { name: 'New Signups by Month monthly data', }) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .toContain(countTooltipValue) - expect(chart.getAttribute('data-tooltip')) + expect(chartOptions.tooltip?.pointFormat) .not.toContain(currencyTooltipValue) + expect(formatTooltip([90, 10])) + .toContain('Total: 100') + expect(formatTooltip([90, 10])) + .not.toContain('$100') expect(within(table) .getByRole('cell', { name: '90' })) .toBeInTheDocument() }) + + it('adds a total to grouped report tooltips', () => { + render() + + expect(formatTooltip([120, 75])) + .toContain('Total: 195') + }) }) diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx index 54182e43a..eeeab09db 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx @@ -50,7 +50,7 @@ function getSeriesValue(month: DashboardMonth, key: string): number { * * @param props Dashboard response and compact-card presentation flag. * @returns A stacked or grouped column chart with month categories along the - * bottom axis and an accessible monthly data table. + * bottom axis, monthly tooltip totals, and an accessible monthly data table. * @throws Does not throw. Invalid or absent point values are rendered as zero. */ export const DashboardChart: FC = props => { @@ -119,6 +119,29 @@ export const DashboardChart: FC = props => { text: undefined, }, tooltip: { + /** + * Appends the hovered month's total to Highcharts' shared tooltip. + * + * Highcharts invokes this callback for card and detail charts. + * + * @param tooltip Highcharts tooltip used to render the existing rows. + * @returns The default tooltip content followed by the formatted total. + * @throws Does not throw for the normalized chart-series values. + */ + formatter(tooltip: Highcharts.Tooltip) { + // Highcharts supplies the shared tooltip context through `this`. + // eslint-disable-next-line react/no-this-in-sfc + const points = this.points || [this] + const total = points.reduce((sum, point) => sum + (point.y ?? 0), 0) + // eslint-disable-next-line react/no-this-in-sfc + const content = tooltip.defaultFormatter.call(this, tooltip) + const totalRow = `Total: ${isCurrency ? '$' : ''}` + + `${Highcharts.numberFormat(total, 0)}` + + return Array.isArray(content) + ? [...content, totalRow] + : `${content}${totalRow}` + }, headerFormat: '{point.key}
', pointFormat: ' ' + `{series.name}: ${isCurrency ? '$' : ''}{point.y:,.0f}
`, diff --git a/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx index 05dbbfec6..d2f9fd5c7 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx @@ -17,9 +17,13 @@ import { useLocation, } from 'react-router-dom' -import { fetchReportsIndex } from '../../lib/services' +import { + fetchReportJson, + fetchReportsIndex, + SfdcBillingAccountPaymentRow, +} from '../../lib/services' -import { ReportsPage } from './ReportsPage' +import { BillingAccountsPage, ReportsPage } from './ReportsPage' type MockSelectProps = { disabled?: boolean @@ -86,6 +90,8 @@ jest.mock('../../lib/utils', () => ({ })) const mockedFetchReportsIndex = fetchReportsIndex as jest.Mock +const mockedFetchReportJson = fetchReportJson as jest.Mock +const originalTimezone = process.env.TZ const LocationProbe = (): JSX.Element => { const { pathname }: { pathname: string } = useLocation() @@ -109,6 +115,15 @@ describe('Reports page navigation', () => { }) }) + afterEach(() => { + if (originalTimezone) { + process.env.TZ = originalTimezone + return + } + + delete process.env.TZ + }) + it('opens Bulk Member Lookup from the Reports app root', async () => { render( @@ -137,4 +152,64 @@ describe('Reports page navigation', () => { expect(screen.getByTestId('location')) .toHaveTextContent('/reports/bulk-member-lookup') }) + + it('renders SFDC payment dates in America/New_York', async () => { + process.env.TZ = 'Asia/Colombo' + const paymentDate = '2026-07-31T18:53:33.383-04:00' + const payment: SfdcBillingAccountPaymentRow = { + billingAccountId: '80000001', + category: 'CHALLENGE_PAYMENT', + challengeFee: '0.00', + challengeId: 'challenge-id', + challengeName: 'July payment', + challengeStatus: 'Completed', + isTask: false, + paymentAmount: '100.00', + paymentDate, + paymentId: 'payment-id', + paymentStatus: 'PAID', + winnerFirstName: 'Ada', + winnerHandle: 'ada', + winnerId: '123', + winnerLastName: 'Lovelace', + } + mockedFetchReportJson.mockResolvedValue([payment]) + + render( + + + , + ) + + expect(await screen.findByText('July payment')) + .toBeInTheDocument() + const parsedPaymentDate = new Date(paymentDate) + const displayedDate = parsedPaymentDate + .toLocaleString(undefined, { timeZone: 'America/New_York' }) + const browserDisplayedDate = parsedPaymentDate + .toLocaleString() + + expect(screen.getByText(displayedDate)) + .toBeInTheDocument() + expect(parsedPaymentDate.getUTCDate()) + .toBe(31) + expect(parsedPaymentDate.getDate()) + .toBe(1) + expect(displayedDate) + .not.toBe(browserDisplayedDate) + + mockedFetchReportJson.mockResolvedValueOnce({ + billingAccount: { + budget: '1000.00', + markup: '0.00', + name: 'Boundary account', + startDate: paymentDate, + status: 'Active', + }, + }) + fireEvent.click(screen.getByRole('button', { name: '80000001' })) + + expect(await screen.findByText(browserDisplayedDate)) + .toBeInTheDocument() + }) }) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index eeeecefea..8322afbde 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -178,6 +178,22 @@ const formatPaymentDate = (iso: string): string => { .toLocaleString() } +/** + * Formats an SFDC payment timestamp in the report's America/New_York timezone. + * The input is an ISO date-time string and the returned value is used in payment table cells. + * Invalid inputs are returned unchanged, so this function does not throw parsing errors. + */ +const formatSfdcPaymentDate = (iso: string): string => { + const parsed = Date.parse(iso) + + if (Number.isNaN(parsed)) { + return iso + } + + return new Date(parsed) + .toLocaleString(undefined, { timeZone: 'America/New_York' }) +} + const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [ { key: 'paymentId', label: 'Payment ID' }, { key: 'paymentDate', label: 'Payment date' }, @@ -431,7 +447,7 @@ const BillingAccountReportResults = ( const value = row[colKey] if (colKey === 'paymentDate') { - return formatPaymentDate(String(value)) + return formatSfdcPaymentDate(String(value)) } if (colKey === 'billingAccountId') { diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx index 665c3ec19..f9e4385db 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx @@ -15,6 +15,7 @@ import { TabContentReview } from './TabContentReview' const mockUseRole = jest.fn() const mockTableAppealsForSubmitter = jest.fn() const mockTableAppealsResponse = jest.fn() +const mockTableReview = jest.fn() const mockTableReviewForSubmitter = jest.fn() jest.mock('~/config', () => ({ @@ -78,7 +79,15 @@ jest.mock('../TableNoRecord', () => ({ })) jest.mock('../TableReview', () => ({ - TableReview: () =>
Reviewer reviews
, + TableReview: (props: { datas: SubmissionInfo[] }) => { + mockTableReview(props) + return ( +
+ {props.datas.map(submission => submission.id) + .join(',')} +
+ ) + }, })) jest.mock('../TableReviewForSubmitter', () => ({ @@ -208,4 +217,63 @@ describe('TabContentReview submitter Appeals ownership', () => { ], })) }) + + it('passes both finite-limit Design reviews for one member to the reviewer table', () => { + const olderSubmission = { + id: 'member-submission-older', + isLatest: false, + memberId: 'member-shared', + review: { + phaseName: 'Review', + reviewType: 'Review', + }, + submittedDate: '2026-08-12T10:00:00Z', + type: 'CONTEST_SUBMISSION', + } as SubmissionInfo + const latestSubmission = { + ...olderSubmission, + id: 'member-submission-latest', + isLatest: true, + submittedDate: '2026-08-12T11:00:00Z', + } + const reviewerChallengeInfo = { + ...challengeInfo, + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ count: '2', limit: 'true', unlimited: 'false' }), + }], + submissions: [olderSubmission, latestSubmission], + track: { + id: 'design-track', + name: 'Design', + }, + } as ChallengeInfo + const reviewerContext = { + ...challengeContext, + challengeInfo: reviewerChallengeInfo, + myResources: [], + myRoles: ['Reviewer'], + } as unknown as ChallengeDetailContextModel + mockUseRole.mockReturnValue({ + actionChallengeRole: 'Reviewer', + hasApproverRole: false, + isPrivilegedRole: true, + }) + + render( + + + , + ) + + expect(mockTableReview) + .toHaveBeenLastCalledWith(expect.objectContaining({ + datas: [olderSubmission, latestSubmission], + })) + }) }) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 41e5ae051..46bbebb96 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -770,23 +770,23 @@ export const TabContentReview: FC = (props: Props) => { ) const reviewerRowsForReviewTab = useMemo( () => (shouldSortReviewTabByScore - ? sortSubmissionsByReviewScoreDesc(filteredReviews, useAggregateReviewScore) - : filteredReviews), - [filteredReviews, shouldSortReviewTabByScore, useAggregateReviewScore], + ? sortSubmissionsByReviewScoreDesc(resolvedReviewsWithSubmitter, useAggregateReviewScore) + : resolvedReviewsWithSubmitter), + [resolvedReviewsWithSubmitter, shouldSortReviewTabByScore, useAggregateReviewScore], ) const submitterRowsForReviewTab = useMemo( () => (shouldSortReviewTabByScore - ? sortSubmissionsByReviewScoreDesc(filteredSubmitterReviews, useAggregateReviewScore) - : filteredSubmitterReviews), - [filteredSubmitterReviews, shouldSortReviewTabByScore, useAggregateReviewScore], + ? sortSubmissionsByReviewScoreDesc(resolvedSubmitterReviews, useAggregateReviewScore) + : resolvedSubmitterReviews), + [resolvedSubmitterReviews, shouldSortReviewTabByScore, useAggregateReviewScore], ) const hideHandleColumn = props.isActiveChallenge && actionChallengeRole === REVIEWER // show loading ui when fetching data const reviewRows = isSubmitterView - ? (shouldSortReviewTabByScore ? submitterRowsForReviewTab : filteredSubmitterReviews) - : (shouldSortReviewTabByScore ? reviewerRowsForReviewTab : filteredReviews) + ? submitterRowsForReviewTab + : reviewerRowsForReviewTab if (props.isLoadingReview) { return diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx index 536480f4e..657488568 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx @@ -204,7 +204,8 @@ export const TabContentSubmissions: FC = props => { return } - const key = getSubmissionHistoryKey(memberId, submissionId) + const submissionType = submissionInfoById.get(submissionId)?.type + const key = getSubmissionHistoryKey(memberId, submissionId, submissionType) const entries = historyByMember.get(key) ?? [] if (!entries.length) { return @@ -212,7 +213,7 @@ export const TabContentSubmissions: FC = props => { setHistoryKey(key) }, - [historyByMember], + [historyByMember, submissionInfoById], ) const handleHistoryButtonClick = useCallback( @@ -524,7 +525,11 @@ export const TabContentSubmissions: FC = props => { return - } - const key = getSubmissionHistoryKey(submission.memberId, submission.id) + const key = getSubmissionHistoryKey( + submission.memberId, + submission.id, + submission.type, + ) const historyEntries = historyByMember.get(key) ?? [] if (!historyEntries.length) { return - diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index fec6728bc..a06faab00 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -41,7 +41,7 @@ import { } from '../../models' import { aggregateSubmissionReviews, - challengeHasSubmissionLimit, + getChallengeSubmissionSelectionLimit, isMarathonMatchChallenge, isReviewPhase, isReviewPhaseCurrentlyOpen, @@ -180,6 +180,11 @@ export const TableReview: FC = (props: TableReviewProps) => { [challengeInfo, submissionTypes], ) + const submissionSelectionLimit = useMemo( + () => getChallengeSubmissionSelectionLimit(challengeInfo), + [challengeInfo], + ) + const { closeHistoryModal, historyByMember, @@ -193,11 +198,12 @@ export const TableReview: FC = (props: TableReviewProps) => { datas: reviewPhaseDatas, filteredAll: filteredChallengeSubmissions, isSubmissionTab: true, + maxVisibleSubmissions: submissionSelectionLimit, }) const restrictToLatest = useMemo( - () => challengeHasSubmissionLimit(challengeInfo), - [challengeInfo], + () => submissionSelectionLimit !== undefined, + [submissionSelectionLimit], ) const useAggregateReviewScore = useMemo( () => isMarathonMatchChallenge(challengeInfo), @@ -650,7 +656,11 @@ export const TableReview: FC = (props: TableReviewProps) => { ) } - const historyKeyForRow = getSubmissionHistoryKey(submission.memberId, submission.id) + const historyKeyForRow = getSubmissionHistoryKey( + submission.memberId, + submission.id, + submission.type, + ) const rowHistory = historyByMember.get(historyKeyForRow) ?? [] const buildHistoryAction = (): JSX.Element | undefined => { diff --git a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx index bd6aad00d..cfe24785b 100644 --- a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx +++ b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx @@ -40,7 +40,7 @@ import type { } from '../common/types' import { aggregateSubmissionReviews, - challengeHasSubmissionLimit, + getChallengeSubmissionSelectionLimit, getSubmissionHistoryKey, isAppealsPhase, isAppealsResponsePhase, @@ -155,6 +155,11 @@ export const TableReviewForSubmitter: FC = (props: [challengeInfo?.submissions, datas, submissionTypes], ) + const submissionSelectionLimit = useMemo( + () => getChallengeSubmissionSelectionLimit(challengeInfo), + [challengeInfo], + ) + const { closeHistoryModal, historyByMember, @@ -168,11 +173,12 @@ export const TableReviewForSubmitter: FC = (props: datas, filteredAll, isSubmissionTab: true, + maxVisibleSubmissions: submissionSelectionLimit, }) const restrictToLatest = useMemo( - () => challengeHasSubmissionLimit(challengeInfo), - [challengeInfo], + () => submissionSelectionLimit !== undefined, + [submissionSelectionLimit], ) const useAggregateReviewScore = useMemo( () => isMarathonMatchChallenge(challengeInfo), @@ -524,6 +530,7 @@ export const TableReviewForSubmitter: FC = (props: const historyKeyForSubmission = getSubmissionHistoryKey( submission.memberId, submission.id, + submission.type, ) const historyEntries = historyByMember.get(historyKeyForSubmission) ?? [] const filteredHistory = restrictToLatest diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx index 62f4617fd..512ce4dee 100644 --- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx +++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx @@ -33,7 +33,7 @@ import { import { TableWrapper } from '../TableWrapper' import { SubmissionHistoryModal } from '../SubmissionHistoryModal' import { - challengeHasSubmissionLimit, + getChallengeSubmissionSelectionLimit, getHandleUrl, getSubmissionHistoryKey, isReviewPhaseCurrentlyOpen, @@ -400,7 +400,11 @@ const createHistoryAction = ({ return undefined } - const historyKeyForRow = getSubmissionHistoryKey(data.memberId, data.submissionId) + const historyKeyForRow = getSubmissionHistoryKey( + data.memberId, + data.submissionId, + data.type, + ) const historyEntries = historyByMember.get(historyKeyForRow) ?? [] if (!historyEntries.length) { return undefined @@ -945,9 +949,16 @@ export const TableSubmissionScreening: FC = (props: Props) => { [submissionMetaById], ) + const submissionSelectionLimit = useMemo( + () => getChallengeSubmissionSelectionLimit(challengeInfo), + [challengeInfo], + ) + const submissionHistory = useMemo( - () => partitionSubmissionHistory(primarySubmissionInfos, historySourceSubmissions), - [historySourceSubmissions, primarySubmissionInfos], + () => partitionSubmissionHistory(primarySubmissionInfos, historySourceSubmissions, { + visibleSubmissionCount: submissionSelectionLimit, + }), + [historySourceSubmissions, primarySubmissionInfos, submissionSelectionLimit], ) const { historyByMember, latestSubmissionIds }: SubmissionHistoryPartition = submissionHistory @@ -957,16 +968,14 @@ export const TableSubmissionScreening: FC = (props: Props) => { rows: visibleScreenings, }: ScreeningRowsSelection = useMemo( () => selectVisibleScreeningRows({ - hasSubmissionLimit: challengeHasSubmissionLimit(challengeInfo), latestSubmissionIds, screeningRows: props.screenings, - submissionInfos: primarySubmissionInfos, + submissionLimit: submissionSelectionLimit, }), [ - challengeInfo, latestSubmissionIds, - primarySubmissionInfos, props.screenings, + submissionSelectionLimit, ], ) @@ -1051,7 +1060,8 @@ export const TableSubmissionScreening: FC = (props: Props) => { const openHistoryModal = useCallback( (memberId: string | undefined, submissionId: string): void => { - const key = getSubmissionHistoryKey(memberId, submissionId) + const submissionType = submissionMetaById.get(submissionId)?.type + const key = getSubmissionHistoryKey(memberId, submissionId, submissionType) const historyEntries = historyByMember.get(key) if (!historyEntries || historyEntries.length === 0) { return @@ -1059,7 +1069,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { setHistoryKey(key) }, - [historyByMember], + [historyByMember, submissionMetaById], ) const openReopenDialog = useCallback( diff --git a/src/apps/review/src/lib/hooks/useSubmissionHistory.ts b/src/apps/review/src/lib/hooks/useSubmissionHistory.ts index 649265df0..e318f039d 100644 --- a/src/apps/review/src/lib/hooks/useSubmissionHistory.ts +++ b/src/apps/review/src/lib/hooks/useSubmissionHistory.ts @@ -9,9 +9,14 @@ import { import type { SubmissionHistoryPartition } from '../utils/submissionHistory' interface UseSubmissionHistoryParams { + /** Primary table submissions, including review or screening details. */ datas: SubmissionInfo[] + /** Complete matching challenge history used to rank submissions. */ filteredAll: SubmissionInfo[] + /** Whether the consuming table supports submission-history actions. */ isSubmissionTab: boolean + /** Positive latest-submission count per member/type group. Defaults to one. */ + maxVisibleSubmissions?: number } export interface UseSubmissionHistoryResult { @@ -26,16 +31,23 @@ export interface UseSubmissionHistoryResult { } /** - * Encapsulates submission history modal state and derived metadata for tables. + * Encapsulate submission-history ranking and modal state for Review tables. + * + * @param params - Primary rows, complete matching history, table mode, and visible count. + * @returns Latest selected rows and IDs, older member/type history, and modal callbacks. + * @throws Does not throw; invalid visible counts are normalized by the partition utility. */ export function useSubmissionHistory({ datas, filteredAll, isSubmissionTab, + maxVisibleSubmissions, }: UseSubmissionHistoryParams): UseSubmissionHistoryResult { const submissionHistory = useMemo( - () => partitionSubmissionHistory(datas, filteredAll), - [datas, filteredAll], + () => partitionSubmissionHistory(datas, filteredAll, { + visibleSubmissionCount: maxVisibleSubmissions, + }), + [datas, filteredAll, maxVisibleSubmissions], ) const { @@ -58,7 +70,9 @@ export function useSubmissionHistory({ const openHistoryModal: (memberId: string | undefined, submissionId: string) => void = useCallback( (memberId: string | undefined, submissionId: string): void => { - const key = getSubmissionHistoryKey(memberId, submissionId) + const submissionType = datas.find(submission => submission.id === submissionId)?.type + ?? filteredAll.find(submission => submission.id === submissionId)?.type + const key = getSubmissionHistoryKey(memberId, submissionId, submissionType) const entries = historyByMember.get(key) if (!entries || entries.length === 0) { return @@ -66,7 +80,7 @@ export function useSubmissionHistory({ setHistoryKey(key) }, - [historyByMember], + [datas, filteredAll, historyByMember], ) const closeHistoryModal = useCallback((): void => { diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts index b6c48380a..39f52fb24 100644 --- a/src/apps/review/src/lib/utils/challenge.spec.ts +++ b/src/apps/review/src/lib/utils/challenge.spec.ts @@ -4,6 +4,7 @@ import { buildPhaseTabs, collectReopenEligiblePhaseIds, findPhaseByTabLabel, + getChallengeSubmissionSelectionLimit, hasPendingApprovalReview, isFirst2FinishChallenge, isMarathonMatchChallenge, @@ -51,6 +52,115 @@ const createBackendPhase = ( }) describe('challenge phase tab helpers', () => { + it('uses the configured Design submission count', () => { + expect(getChallengeSubmissionSelectionLimit({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ count: '2', limit: 'true', unlimited: 'false' }), + }], + track: { + id: 'design-track', + name: 'Design', + }, + })) + .toBe(2) + }) + + it('retains the latest-one policy for a finite Design count of one', () => { + expect(getChallengeSubmissionSelectionLimit({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ count: 1, limit: true, unlimited: false }), + }], + track: { + id: 'design-track', + name: 'Design', + }, + })) + .toBe(1) + }) + + it('keeps explicit and default Design submission limits unlimited', () => { + const track = { + id: 'design-track', + name: 'Design', + } + + expect(getChallengeSubmissionSelectionLimit({ metadata: [], track })) + .toBeUndefined() + expect(getChallengeSubmissionSelectionLimit({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ count: '', limit: 'false', unlimited: 'true' }), + }], + track, + })) + .toBeUndefined() + }) + + it('recognizes Design from canonical track fields and keeps non-Design latest-one', () => { + expect(getChallengeSubmissionSelectionLimit({ + metadata: [], + track: { + id: 'design-track', + name: '', + track: 'DESIGN', + }, + })) + .toBeUndefined() + expect(getChallengeSubmissionSelectionLimit({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ count: '', limit: 'false', unlimited: 'true' }), + }], + track: { + id: 'development-track', + name: 'Development', + }, + })) + .toBe(1) + }) + + it.each([ + [ + 'an explicit unlimited flag with a stale count', + { count: '5', limit: 'false' }, + undefined, + ], + [ + 'contradictory flags', + { count: '2', limit: 'true', unlimited: 'true' }, + 1, + ], + [ + 'a non-integer count', + '2.5', + 1, + ], + [ + 'an invalid higher-priority count alias', + { count: 'invalid', maximum: '4' }, + 1, + ], + [ + 'an unrecognized boolean flag alias with a valid count', + { count: '3', limit: 'unlimited' }, + 3, + ], + ])('matches the backend policy for %s', (_description, value, expected) => { + expect(getChallengeSubmissionSelectionLimit({ + metadata: [{ + name: 'submissionLimit', + value: typeof value === 'string' ? value : JSON.stringify(value), + }], + track: { + id: 'design-track', + name: 'Design', + }, + })) + .toBe(expected) + }) + it('recognizes Marathon Match challenges from type metadata', () => { expect(isMarathonMatchChallenge({ type: { diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts index df58c44b9..31dceb290 100644 --- a/src/apps/review/src/lib/utils/challenge.ts +++ b/src/apps/review/src/lib/utils/challenge.ts @@ -54,6 +54,13 @@ export function isAppealsResponsePhase(challengeInfo?: ChallengeInfo): boolean { } const SUBMISSION_LIMIT_KEY = 'submissionlimit' +const SUBMISSION_LIMIT_COUNT_FIELDS = [ + 'count', + 'max', + 'maximum', + 'limitCount', + 'value', +] as const const UNLIMITED_KEYWORDS = ['unlimited', 'false', '0', 'no', 'none'] const TRUE_KEYWORDS = ['true', 'yes', '1'] @@ -87,6 +94,39 @@ function parseBooleanFlag(value: unknown): boolean | undefined { return undefined } +/** + * Parse submission-limit object flags using the backend's accepted boolean aliases. + * + * @param value - Raw `limit` or `unlimited` flag. + * @returns The recognized boolean value, or `undefined` for malformed aliases. + * @throws Does not throw. + */ +function parseSubmissionLimitFlag(value: unknown): boolean | undefined { + if (typeof value === 'boolean') { + return value + } + + if (value === 1 || value === 0) { + return value === 1 + } + + if (typeof value !== 'string') { + return undefined + } + + const normalized = value.trim() + .toLowerCase() + if (TRUE_KEYWORDS.includes(normalized)) { + return true + } + + if (['false', 'no', '0'].includes(normalized)) { + return false + } + + return undefined +} + function hasPositiveNumeric(value: unknown): boolean { if (value === undefined || value === null) { return false @@ -171,7 +211,16 @@ function evaluateObjectLimit(candidate: Record): boolean { return true } -export function challengeHasSubmissionLimit(challengeInfo?: ChallengeInfo): boolean { +/** + * Determine whether legacy submission-limit metadata represents a finite limit. + * + * @param challengeInfo - Challenge metadata containing the legacy `submissionLimit` entry. + * @returns True for finite or malformed legacy values and false for explicit unlimited values. + * @throws Does not throw; missing metadata retains the legacy finite-limit fallback. + */ +export function challengeHasSubmissionLimit( + challengeInfo?: Pick, +): boolean { const rawValue = findSubmissionLimitMetadata(challengeInfo?.metadata) if (rawValue === undefined || rawValue === null) { return true @@ -198,6 +247,104 @@ export function challengeHasSubmissionLimit(challengeInfo?: ChallengeInfo): bool return true } +/** + * Convert a numeric metadata value to a safe positive-integer submission count. + * + * @param value - Raw count value from challenge metadata. + * @returns The positive whole-number count, or `undefined` when the value is not positive numeric data. + * @throws Does not throw; invalid values are ignored. + */ +function parsePositiveSubmissionLimit(value: unknown): number | undefined { + if (typeof value !== 'number' && typeof value !== 'string') { + return undefined + } + + const numericValue = Number(value) + return Number.isSafeInteger(numericValue) && numericValue > 0 + ? numericValue + : undefined +} + +/** + * Resolve how many submissions per member and exact submission type Review should display. + * + * Design challenges with missing or explicit unlimited metadata keep every submission visible; + * a positive configured count selects that many. Flag conflicts, invalid counts, malformed Design + * data, and all non-Design challenges retain the existing latest-one behavior. Explicit unlimited + * flags take precedence over stale counts. These rules mirror the backend selection policy so the + * UI displays the same submission set for which scorecards were created. + * + * @param challengeInfo - Challenge track and submission-limit metadata. + * @returns A positive latest-submission count, or `undefined` when every submission is visible. + * @throws Does not throw; malformed limited Design metadata falls back to one. + */ +export function getChallengeSubmissionSelectionLimit( + challengeInfo?: Pick, +): number | undefined { + const trackCandidates = [ + challengeInfo?.track?.name, + challengeInfo?.track?.abbreviation, + challengeInfo?.track?.track, + ] + const isDesignChallenge = trackCandidates.some(candidate => ( + normalizeChallengeKey(candidate) === 'design' + )) + if (!isDesignChallenge) { + return 1 + } + + const rawValue = findSubmissionLimitMetadata(challengeInfo?.metadata) + if (rawValue === undefined || rawValue === null) { + return undefined + } + + const normalized = normalizeLimitMetadataValue(rawValue) + const primitiveLimit = parsePositiveSubmissionLimit(normalized) + if (primitiveLimit !== undefined) { + return primitiveLimit + } + + if (typeof normalized === 'number' && normalized === 0) { + return undefined + } + + if (typeof normalized === 'boolean') { + return normalized ? 1 : undefined + } + + if (typeof normalized === 'string') { + return UNLIMITED_KEYWORDS.includes(normalized.trim() + .toLowerCase()) + ? undefined + : 1 + } + + if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) { + const candidate = normalized as Record + const unlimitedFlag = parseSubmissionLimitFlag(candidate.unlimited) + const limitFlag = parseSubmissionLimitFlag(candidate.limit) + const countValue = SUBMISSION_LIMIT_COUNT_FIELDS + .map(fieldName => candidate[fieldName]) + .find(value => value !== undefined && value !== null && value !== '') + const count = parsePositiveSubmissionLimit(countValue) + const flagsConflict = unlimitedFlag !== undefined + && limitFlag !== undefined + && unlimitedFlag === limitFlag + + if (flagsConflict) { + return 1 + } + + if (unlimitedFlag === true || limitFlag === false) { + return undefined + } + + return count ?? 1 + } + + return 1 +} + export type PhaseLike = Pick< BackendPhase, | 'id' diff --git a/src/apps/review/src/lib/utils/screeningRows.spec.ts b/src/apps/review/src/lib/utils/screeningRows.spec.ts index 101982820..1ac1f3c22 100644 --- a/src/apps/review/src/lib/utils/screeningRows.spec.ts +++ b/src/apps/review/src/lib/utils/screeningRows.spec.ts @@ -1,47 +1,21 @@ -import type { Screening, SubmissionInfo } from '../models' +import type { Screening } from '../models' import { selectVisibleScreeningRows } from './screeningRows' const screeningRows = [ - { submissionId: 'member-one-old' }, + { submissionId: 'member-one-oldest' }, + { submissionId: 'member-one-middle' }, { submissionId: 'member-one-latest' }, - { submissionId: 'member-two-old' }, + { submissionId: 'member-two-oldest' }, + { submissionId: 'member-two-middle' }, { submissionId: 'member-two-latest' }, ] as Screening[] -const latestSubmissionIds = new Set([ - 'member-one-latest', - 'member-two-latest', -]) - describe('selectVisibleScreeningRows', () => { - it('retains every row for an unlimited challenge without latest flags', () => { - const result = selectVisibleScreeningRows({ - hasSubmissionLimit: false, - latestSubmissionIds, - screeningRows, - submissionInfos: [{}, {}, {}, {}], - }) - - expect(result.isRestrictedToLatest) - .toBe(false) - expect(result.rows) - .toBe(screeningRows) - }) - - it('retains every row for an unlimited challenge with stale latest flags', () => { - const submissionInfos: Array> = [ - { isLatest: false }, - { isLatest: true }, - { isLatest: false }, - { isLatest: true }, - ] - + it('retains every row for an unlimited challenge', () => { const result = selectVisibleScreeningRows({ - hasSubmissionLimit: false, - latestSubmissionIds, + latestSubmissionIds: new Set(), screeningRows, - submissionInfos, }) expect(result.isRestrictedToLatest) @@ -50,41 +24,45 @@ describe('selectVisibleScreeningRows', () => { .toBe(screeningRows) }) - it('retains every row for a limited challenge without explicit latest flags', () => { + it('retains the latest two selected rows per member for a finite count of two', () => { const result = selectVisibleScreeningRows({ - hasSubmissionLimit: true, - latestSubmissionIds, + latestSubmissionIds: new Set([ + 'member-one-middle', + 'member-one-latest', + 'member-two-middle', + 'member-two-latest', + ]), screeningRows, - submissionInfos: [{}, {}, {}, {}], + submissionLimit: 2, }) expect(result.isRestrictedToLatest) - .toBe(false) + .toBe(true) expect(result.rows) - .toBe(screeningRows) + .toEqual([ + screeningRows[1], + screeningRows[2], + screeningRows[4], + screeningRows[5], + ]) }) - it('retains only explicit latest submissions for a limited challenge', () => { - const submissionInfos: Array> = [ - { isLatest: false }, - { isLatest: true }, - { isLatest: false }, - { isLatest: true }, - ] - + it('retains only the selected latest row for a finite count of one', () => { const result = selectVisibleScreeningRows({ - hasSubmissionLimit: true, - latestSubmissionIds, + latestSubmissionIds: new Set([ + 'member-one-latest', + 'member-two-latest', + ]), screeningRows, - submissionInfos, + submissionLimit: 1, }) expect(result.isRestrictedToLatest) .toBe(true) expect(result.rows) .toEqual([ - screeningRows[1], - screeningRows[3], + screeningRows[2], + screeningRows[5], ]) }) }) diff --git a/src/apps/review/src/lib/utils/screeningRows.ts b/src/apps/review/src/lib/utils/screeningRows.ts index c47e6a49d..74696073f 100644 --- a/src/apps/review/src/lib/utils/screeningRows.ts +++ b/src/apps/review/src/lib/utils/screeningRows.ts @@ -1,6 +1,4 @@ -import type { Screening, SubmissionInfo } from '../models' - -import { hasIsLatestFlag } from './submissionHistory' +import type { Screening } from '../models' export interface ScreeningRowsSelection { isRestrictedToLatest: boolean @@ -8,36 +6,31 @@ export interface ScreeningRowsSelection { } export interface SelectVisibleScreeningRowsOptions { - hasSubmissionLimit: boolean latestSubmissionIds: ReadonlySet screeningRows: Screening[] - submissionInfos: Array> + submissionLimit?: number } /** * Select the Screening rows that should be displayed for a challenge. * * The Screening table uses this selection for both desktop and mobile views. - * Limited challenges collapse submission history only when the API supplies - * explicit `isLatest` flags. Unlimited challenges, or responses without those - * flags, retain every Screening row. This function performs no I/O and does - * not throw. + * Finite challenges retain the latest configured number of submission IDs selected + * independently per member and exact submission type. Unlimited challenges retain + * every Screening row. This function performs no I/O and does not throw. * * @param options visibility inputs for the challenge and its submissions - * @param options.hasSubmissionLimit whether the challenge limits submissions * @param options.latestSubmissionIds latest submission ids calculated per member * @param options.screeningRows Screening rows available for display - * @param options.submissionInfos submission metadata containing optional latest flags + * @param options.submissionLimit finite latest-submission count, or undefined for all * @returns the visible rows and whether submission history was collapsed */ export function selectVisibleScreeningRows({ - hasSubmissionLimit, latestSubmissionIds, screeningRows, - submissionInfos, + submissionLimit, }: SelectVisibleScreeningRowsOptions): ScreeningRowsSelection { - const isRestrictedToLatest = hasSubmissionLimit - && hasIsLatestFlag(submissionInfos) + const isRestrictedToLatest = submissionLimit !== undefined return { isRestrictedToLatest, diff --git a/src/apps/review/src/lib/utils/submissionHistory.spec.ts b/src/apps/review/src/lib/utils/submissionHistory.spec.ts new file mode 100644 index 000000000..6ba74773c --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionHistory.spec.ts @@ -0,0 +1,119 @@ +import type { SubmissionInfo } from '../models' + +import { + getSubmissionHistoryKey, + partitionSubmissionHistory, +} from './submissionHistory' + +/** + * Build submission metadata for history-ranking tests. + * + * @param id - Submission identifier. + * @param type - Exact submission type. + * @param submittedDate - ISO submission timestamp. + * @returns A submission owned by the shared test member. + */ +function createSubmission( + id: string, + type: string, + submittedDate: string, +): SubmissionInfo { + return { + id, + memberId: 'member-one', + submittedDate, + type, + } +} + +const submissions: SubmissionInfo[] = [ + createSubmission('contest-oldest', 'CONTEST_SUBMISSION', '2026-08-10T10:00:00Z'), + createSubmission('contest-middle', 'CONTEST_SUBMISSION', '2026-08-10T11:00:00Z'), + createSubmission('contest-newest', 'CONTEST_SUBMISSION', '2026-08-10T12:00:00Z'), + createSubmission('checkpoint-oldest', 'CHECKPOINT_SUBMISSION', '2026-08-09T10:00:00Z'), + createSubmission('checkpoint-newest', 'CHECKPOINT_SUBMISSION', '2026-08-09T11:00:00Z'), +] + +describe('partitionSubmissionHistory', () => { + it('retains the latest two submissions independently for each exact type', () => { + const result = partitionSubmissionHistory(submissions, submissions, { + visibleSubmissionCount: 2, + }) + + expect(result.latestSubmissionIds) + .toEqual(new Set([ + 'contest-newest', + 'contest-middle', + 'checkpoint-newest', + 'checkpoint-oldest', + ])) + expect(result.historyByMember.get(getSubmissionHistoryKey( + 'member-one', + 'contest-newest', + 'CONTEST_SUBMISSION', + ))) + .toEqual([submissions[0]]) + expect(result.historyByMember.has(getSubmissionHistoryKey( + 'member-one', + 'checkpoint-newest', + 'CHECKPOINT_SUBMISSION', + ))) + .toBe(false) + }) + + it('defaults finite selection to the latest one per member and type', () => { + const result = partitionSubmissionHistory(submissions, submissions) + + expect(result.latestSubmissionIds) + .toEqual(new Set([ + 'contest-newest', + 'checkpoint-newest', + ])) + expect(result.historyByMember.get(getSubmissionHistoryKey( + 'member-one', + 'contest-newest', + 'CONTEST_SUBMISSION', + ))) + .toEqual([ + submissions[1], + submissions[0], + ]) + expect(result.historyByMember.get(getSubmissionHistoryKey( + 'member-one', + 'checkpoint-newest', + 'CHECKPOINT_SUBMISSION', + ))) + .toEqual([submissions[3]]) + }) + + it('ranks complete history before retaining only eligible primary rows', () => { + const eligibleOlderSubmission = submissions[1] + const completeHistory = [ + eligibleOlderSubmission, + submissions[2], + ] + + const latestOne = partitionSubmissionHistory( + [eligibleOlderSubmission], + completeHistory, + { visibleSubmissionCount: 1 }, + ) + expect(latestOne.latestSubmissionIds) + .toEqual(new Set(['contest-newest'])) + expect(latestOne.latestSubmissions) + .toEqual([]) + + const latestTwo = partitionSubmissionHistory( + [eligibleOlderSubmission], + completeHistory, + { visibleSubmissionCount: 2 }, + ) + expect(latestTwo.latestSubmissionIds) + .toEqual(new Set([ + 'contest-newest', + 'contest-middle', + ])) + expect(latestTwo.latestSubmissions) + .toEqual([eligibleOlderSubmission]) + }) +}) diff --git a/src/apps/review/src/lib/utils/submissionHistory.ts b/src/apps/review/src/lib/utils/submissionHistory.ts index a82f29ce7..60ec3fbe9 100644 --- a/src/apps/review/src/lib/utils/submissionHistory.ts +++ b/src/apps/review/src/lib/utils/submissionHistory.ts @@ -1,26 +1,74 @@ import { SubmissionInfo } from '../models' export interface SubmissionHistoryPartition { - latestSubmissions: SubmissionInfo[] - latestSubmissionIds: Set + /** Older submissions grouped by member and exact normalized submission type. */ historyByMember: Map + /** IDs of the latest configured number of submissions in every member/type group. */ + latestSubmissionIds: Set + /** Primary submission rows associated with the latest configured IDs. */ + latestSubmissions: SubmissionInfo[] +} + +export interface PartitionSubmissionHistoryOptions { + /** Positive number of submissions retained in each member/type group. Defaults to one. */ + visibleSubmissionCount?: number +} + +/** + * Normalize the submission type used to isolate contest and checkpoint history. + * + * @param submissionType - Submission type returned by the API. + * @returns A stable normalized type key, including a fallback for missing types. + * @throws Does not throw. + */ +function normalizeSubmissionHistoryType(submissionType?: string): string { + const normalizedType = (submissionType ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + + return normalizedType || '__unknown_type__' } +/** + * Build the lookup key used for one member's history of an exact submission type. + * + * @param memberId - Submission owner ID, when available. + * @param submissionId - Submission ID used to isolate rows with no owner. + * @param submissionType - Exact submission type, normalized for legacy spelling variants. + * @returns A stable member/type history key. + * @throws Does not throw. + */ export function getSubmissionHistoryKey( memberId: string | undefined, submissionId: string, + submissionType?: string, ): string { - if (memberId && memberId.length) { - return memberId - } + const memberKey = memberId && memberId.length + ? memberId + : `__unknown__::${submissionId}` - return `__unknown__::${submissionId}` + return `${memberKey}::${normalizeSubmissionHistoryType(submissionType)}` } +/** + * Check whether any submission includes the API's explicit latest flag. + * + * @param submissions - Submission-like objects to inspect. + * @returns True when at least one object includes an `isLatest` value. + * @throws Does not throw. + */ export function hasIsLatestFlag(submissions: T[]): boolean { return submissions.some(submission => submission.isLatest !== undefined) } +/** + * Resolve a submission timestamp for newest-first ordering. + * + * @param submission - Submission metadata containing raw or formatted dates. + * @returns Milliseconds since epoch, or zero when neither date is valid. + * @throws Does not throw; invalid dates fall back to zero. + */ function getSubmissionTimestamp(submission: SubmissionInfo): number { const candidates: Array = [] @@ -44,9 +92,40 @@ function getSubmissionTimestamp(submission: SubmissionInfo): number { return 0 } +/** + * Normalize the requested number of visible submissions per member/type group. + * + * @param visibleSubmissionCount - Raw configured visible count. + * @returns A positive whole-number count, defaulting to one. + * @throws Does not throw. + */ +function normalizeVisibleSubmissionCount(visibleSubmissionCount?: number): number { + if (!Number.isFinite(visibleSubmissionCount) || Number(visibleSubmissionCount) <= 0) { + return 1 + } + + return Math.max(1, Math.floor(Number(visibleSubmissionCount))) +} + +/** + * Partition submissions into the latest configured rows and older history. + * + * Submissions are ranked independently for every member and normalized submission type. Explicit + * `isLatest` rows remain first for backward compatibility, followed by submission timestamp. A + * duplicated submission ID from the primary and complete-history inputs consumes only one slot. + * Only primary rows are returned for display, so ranking cannot reintroduce an ineligible history + * entry or promote an older eligible submission into a newer entry's configured slot. + * + * @param submissions - Primary table submissions. + * @param allSubmissions - Optional complete submission history used for ranking and history actions. + * @param options - Partition options, including the visible count per member/type group. + * @returns Latest submission rows and IDs plus older history grouped by member/type. + * @throws Does not throw; invalid visible counts default to one. + */ export function partitionSubmissionHistory( submissions: SubmissionInfo[], allSubmissions?: SubmissionInfo[], + options: PartitionSubmissionHistoryOptions = {}, ): SubmissionHistoryPartition { const byMember = new Map() const addEntry = (submission: SubmissionInfo | undefined): void => { @@ -57,6 +136,7 @@ export function partitionSubmissionHistory( const memberKey = getSubmissionHistoryKey( submission.memberId, submission.id, + submission.type, ) const list = byMember.get(memberKey) if (list) { @@ -75,56 +155,46 @@ export function partitionSubmissionHistory( const latestSubmissions: SubmissionInfo[] = [] const latestSubmissionIds = new Set() const historyByMember = new Map() - const primaryIds = new Set(primarySubmissions.map(entry => entry.id)) + const visibleSubmissionCount = normalizeVisibleSubmissionCount( + options.visibleSubmissionCount, + ) byMember.forEach((entries, memberKey) => { const sorted = entries .slice() - .sort((a, b) => getSubmissionTimestamp(b) - getSubmissionTimestamp(a)) + .sort((a, b) => { + const latestDifference = Number(Boolean(b.isLatest)) - Number(Boolean(a.isLatest)) + if (latestDifference !== 0) { + return latestDifference + } - const flaggedLatest = sorted.filter(entry => entry.isLatest) - const latestEntry = flaggedLatest.length > 0 ? flaggedLatest[0] : sorted[0] + return getSubmissionTimestamp(b) - getSubmissionTimestamp(a) + }) + const seenSubmissionIds = new Set() + const uniqueSorted = sorted.filter(entry => { + if (!entry.id || seenSubmissionIds.has(entry.id)) { + return false + } + + seenSubmissionIds.add(entry.id) + return true + }) + const visibleEntries = uniqueSorted.slice(0, visibleSubmissionCount) + const visibleIdsForGroup = new Set(visibleEntries.map(entry => entry.id)) - if (latestEntry?.id) { - const latestId = latestEntry.id + visibleEntries.forEach(visibleEntry => { + const latestId = visibleEntry.id const matchingPrimary = primarySubmissions.filter(entry => entry.id === latestId) - if (matchingPrimary.length > 0) { - matchingPrimary.forEach(entry => { - latestSubmissions.push(entry) - }) - } else { - latestSubmissions.push(latestEntry) - } + matchingPrimary.forEach(entry => { + latestSubmissions.push(entry) + }) latestSubmissionIds.add(latestId) - } else if (latestEntry) { - latestSubmissions.push(latestEntry) - } + }) - const historyEntries = sorted.filter(entry => entry.id !== latestEntry?.id) + const historyEntries = uniqueSorted.filter(entry => !visibleIdsForGroup.has(entry.id)) if (historyEntries.length > 0) { - const seenIds = new Set() - const uniqueHistory = historyEntries.filter(entry => { - const key = entry.id - if (!key) { - return false - } - - if (primaryIds.has(key) && latestSubmissionIds.has(key)) { - return false - } - - if (seenIds.has(key)) { - return false - } - - seenIds.add(key) - return true - }) - - if (uniqueHistory.length > 0) { - historyByMember.set(memberKey, uniqueHistory) - } + historyByMember.set(memberKey, historyEntries) } }) diff --git a/src/apps/support/README.md b/src/apps/support/README.md index eaad0a17d..ead5236ab 100644 --- a/src/apps/support/README.md +++ b/src/apps/support/README.md @@ -3,7 +3,8 @@ The Support subapp lets authenticated Topcoder members open requests, follow their status, and reply to the Topcoder Support Team. Users with the exact `Topcoder Support Team` role can see all tickets, assign themselves, search -closed tickets, reply to tickets assigned to them, and close resolved requests. +closed tickets, and reply to or close resolved requests assigned to them. +Closed ticket details identify the Support Team user who closed the request. ## Routes diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx index f47188cf3..79f398813 100644 --- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' +import type { ElementType } from 'react' import { readFileSync } from 'fs' import { render, screen } from '@testing-library/react' import remarkBreaks from 'remark-breaks' @@ -9,6 +10,9 @@ import { MarkdownContent } from './MarkdownContent' interface MarkdownRendererProps { children: string + components: { + a: ElementType + } remarkPlugins: unknown[] skipHtml: boolean } @@ -65,6 +69,28 @@ describe('MarkdownContent', () => { })) }) + it('opens GFM links in a safe new tab', () => { + const markdown = 'https://www.topcoder-dev.com/challenges' + + render() + const markdownProps = mockReactMarkdown.mock.calls[0][0] as MarkdownRendererProps + const MarkdownLink = markdownProps.components.a + + render( + + {markdown} + , + ) + const link = screen.getByRole('link', { name: markdown }) + + expect(link) + .toHaveAttribute('href', markdown) + expect(link) + .toHaveAttribute('target', '_blank') + expect(link) + .toHaveAttribute('rel', 'noopener noreferrer') + }) + it('keeps Markdown formatting visible after the platform style reset', () => { expect(markdownStyles) .toMatch(/a,[\s\S]*a:hover \{[\s\S]*color: \$link-blue-dark;[\s\S]*text-decoration: underline;/) diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx index 8a0d1fb31..ce0c367d7 100644 --- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx @@ -1,6 +1,6 @@ /** Safe Markdown renderer for user-authored support content. */ -import { FC } from 'react' -import ReactMarkdown from 'react-markdown' +import type { FC } from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' @@ -10,8 +10,28 @@ export interface MarkdownContentProps { markdown: string } +const markdownComponents: Components = { + /** + * Renders a Markdown link in a new tab without exposing the opener page. + * + * @param props anchor attributes and AST metadata produced by ReactMarkdown. + * @returns a safely targeted anchor used for links in support conversations. + * @throws Does not throw. + */ + a: props => ( + + {props.children} + + ), +} + /** - * Renders GFM and line breaks while dropping raw HTML. + * Renders GFM and line breaks with links opening in new tabs while dropping raw HTML. * * @param props untrusted Markdown source. * @returns safely rendered Markdown. @@ -20,6 +40,7 @@ export interface MarkdownContentProps { export const MarkdownContent: FC = props => (
diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx index cf72bf26a..1829612bf 100644 --- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx +++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx @@ -144,7 +144,7 @@ describe('OpenSupportRequestModal', () => { expect(screen.getByRole('dialog') .getAttribute('data-size')) .toBe('body') - const challengeSelect = screen.getByLabelText('Challenge (if applicable)') as HTMLSelectElement + const challengeSelect = screen.getByLabelText('Active Challenge (if applicable)') as HTMLSelectElement expect(challengeSelect.options[0].text) .toBe('Select challenge') expect(mockUseSWR.mock.calls[0][0]) @@ -155,7 +155,7 @@ describe('OpenSupportRequestModal', () => { expect(mockedGetActiveChallenges) .toHaveBeenCalledWith('12345') - fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), { + fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), { target: { value: 'challenge-2' }, }) fireEvent.change(screen.getByLabelText('Description'), { @@ -209,7 +209,7 @@ describe('OpenSupportRequestModal', () => { />, ) - fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), { + fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), { target: { value: 'challenge-2' }, }) mockUseSWR.mockReturnValue({ diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx index d3ca825f9..a24197bc3 100644 --- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx +++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx @@ -192,7 +192,7 @@ export const OpenSupportRequestModal: FC = props = >
- + aria-describedby={challengeError ? 'support-challenge-error' : undefined} className={styles.challengeSelect} diff --git a/src/apps/support/src/lib/models/support.models.ts b/src/apps/support/src/lib/models/support.models.ts index 5c346295f..f0c94d64f 100644 --- a/src/apps/support/src/lib/models/support.models.ts +++ b/src/apps/support/src/lib/models/support.models.ts @@ -33,6 +33,7 @@ export interface SupportTicketSummary { status: SupportTicketStatus openedAt: string closedAt?: string + closedByUserId?: string updatedAt: string latestActivityAt: string responseCount: number diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx index 66b321d20..0c2298da4 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -196,6 +196,49 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() }) + it('identifies the support staff member who closed the ticket', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + assignees: [{ + assignedAt: '2026-08-07T00:30:00.000Z', + handle: 'support-agent', + userId: '99999', + }], + closedByUserId: '99999', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText((_content, element) => ( + element?.tagName === 'P' + && element.textContent?.includes('Closed') === true + && element.textContent?.includes('by support-agent') === true + ))) + .toBeTruthy() + }) + + it('falls back to the stored closer user ID when no assignee snapshot matches', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + closedByUserId: 'legacy-staff-1', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText('legacy-staff-1')) + .toBeTruthy() + }) + it('preserves freshly revalidated assignees when marking a ticket read completes', async () => { const markReadRequest = createDeferred() mockedMarkRead.mockReturnValue(markReadRequest.promise) @@ -242,7 +285,48 @@ describe('TicketDetailPage reply access', () => { .toBeUndefined() }) - it('requires non-owner support staff to assign an open ticket before replying', () => { + it('identifies support team replies without labelling the ticket owner', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + responseCount: 2, + responses: [{ + createdAt: '2026-08-07T01:30:00.000Z', + id: 'response-owner', + markdown: 'Member follow-up.', + readBy: [], + userHandle: 'ticket-owner', + userId: '12345', + }, { + createdAt: '2026-08-07T01:45:00.000Z', + id: 'response-support', + markdown: 'Support follow-up.', + readBy: [], + userHandle: 'support-agent', + userId: '67890', + }], + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + const ownerReply = screen.getByText('Member follow-up.') + .closest('article') + const supportReply = screen.getByText('Support follow-up.') + .closest('article') + + expect(ownerReply?.textContent) + .toContain('ticket-owner') + expect(ownerReply?.textContent) + .not.toContain('(Support Team)') + expect(supportReply?.textContent) + .toContain('support-agent (Support Team)') + }) + + it('requires non-owner support staff to assign an open ticket before replying or closing it', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -264,9 +348,13 @@ describe('TicketDetailPage reply access', () => { .toBeNull() expect(screen.getByText('Assign this ticket to yourself before replying.')) .toBeTruthy() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(true) }) - it('lets assigned support staff reply to an open ticket', () => { + it('lets assigned support staff reply to and close an open ticket', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -293,5 +381,9 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() expect(screen.queryByText('Assign this ticket to yourself before replying.')) .toBeNull() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(false) }) }) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index 4df066db3..e0a731c86 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -43,7 +43,7 @@ import { import styles from './TicketDetailPage.module.scss' /** - * Renders the original request followed by ascending replies and authorized actions. + * Renders the original request followed by ascending, role-labelled replies and authorized actions. * * @returns support ticket detail page. * @throws Does not throw; request failures are shown with recovery actions. @@ -106,6 +106,9 @@ export const TicketDetailPage: FC = () => { assignee => String(assignee.userId) === currentUserId, )) const closed = data.status === 'CLOSED' + const closedByAssignee = data.closedByUserId + ? data.assignees.find(assignee => String(assignee.userId) === String(data.closedByUserId)) + : undefined const ticketOwner = Boolean(currentUserId && String(data.memberUserId) === currentUserId) const canReply = ticketOwner || (!closed && (!supportTeam || assignedToCurrentUser)) const replyContext = `${data.id}-reply-${replyRevision}` @@ -234,6 +237,21 @@ export const TicketDetailPage: FC = () => { Closed {' '} {formatSupportDate(data.closedAt)} + {data.closedByUserId && ( + <> + {' '} + by + {' '} + + {closedByAssignee ? ( + + ) : data.closedByUserId} + + + )}

)}
@@ -247,6 +265,7 @@ export const TicketDetailPage: FC = () => { size='md' />