From d3309354960f45c693e19c7e978e054996d14015 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 14:01:33 +1000 Subject: [PATCH 01/34] PM-5850: restore support rich text formatting What was broken Support ticket descriptions and replies displayed headings, emphasis, links, uploaded filenames, and lists with plain reset styling. Nested list items also inherited the conversation timeline rail and dot. Root cause The platform CSS reset removes default heading, link, and list presentation, while the Support Markdown renderer did not restore scoped styles. The ticket timeline selector targeted every descendant list item instead of only top-level messages. What was changed Added scoped Support Markdown styles for headings, bold and italic text, links, and ordered and unordered lists. Limited timeline styling to direct conversation children so Markdown lists render normally. Uploaded files remain safely stored as Markdown links and now render as visibly clickable attachments. Any added/updated tests Added MarkdownContent regression coverage for safe GFM configuration, attachment Markdown, required presentation rules, and direct-child timeline scoping. All 8 Support suites and 22 Support tests pass. The full repository suite retains the same unrelated 19 failing suites and 38 failing tests reproduced on an untouched origin/dev worktree. --- .../MarkdownContent.module.scss | 53 +++++++++++ .../MarkdownContent/MarkdownContent.spec.tsx | 91 +++++++++++++++++++ .../TicketDetailPage.module.scss | 4 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss index 739979694..f1de3bce5 100644 --- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss @@ -1,7 +1,60 @@ +@import '@libs/ui/styles/includes'; + .markdown { line-height: 1.55; overflow-wrap: anywhere; + a, + a:hover { + color: $link-blue-dark; + text-decoration: underline; + } + + strong { + font-weight: $font-weight-bold; + } + + em { + font-style: italic; + } + + h1, + h2, + h3 { + font-weight: $font-weight-bold; + margin: $sp-4 0 $sp-2; + } + + h1 { + font-size: 24px; + line-height: 28px; + } + + h2 { + font-size: 20px; + line-height: 24px; + } + + h3 { + font-size: 18px; + line-height: 22px; + } + + ol, + ul { + list-style-position: outside; + margin: $sp-3 0; + padding-left: $sp-6; + } + + ol { + list-style-type: decimal; + } + + ul { + list-style-type: disc; + } + > :first-child { margin-top: 0; } diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx new file mode 100644 index 000000000..f47188cf3 --- /dev/null +++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx @@ -0,0 +1,91 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import { render, screen } from '@testing-library/react' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' + +import { MarkdownContent } from './MarkdownContent' + +interface MarkdownRendererProps { + children: string + remarkPlugins: unknown[] + skipHtml: boolean +} + +const mockReactMarkdown = jest.fn() + +jest.mock('react-markdown', () => ({ + __esModule: true, + default: (props: MarkdownRendererProps): JSX.Element => { + mockReactMarkdown(props) + return
{props.children}
+ }, +})) + +jest.mock('remark-breaks', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('remark-gfm', () => ({ + __esModule: true, + default: jest.fn(), +})) + +const markdownStyles = readFileSync(`${__dirname}/MarkdownContent.module.scss`, 'utf8') +const ticketDetailStyles = readFileSync( + `${__dirname}/../../../pages/ticket-details/TicketDetailPage.module.scss`, + 'utf8', +) + +describe('MarkdownContent', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('passes rich formatting and uploaded-file Markdown to the safe GFM renderer', () => { + const markdown = [ + '# Request heading', + '**Bold text** and *italic text*', + '[sample.zip](https://example.test/sample.zip)', + '- First item', + '1. First step', + ].join('\n\n') + + render() + + expect(screen.getByTestId('markdown-source').textContent) + .toBe(markdown) + expect(mockReactMarkdown) + .toHaveBeenCalledWith(expect.objectContaining({ + children: markdown, + remarkPlugins: [remarkGfm, remarkBreaks], + skipHtml: true, + })) + }) + + 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;/) + expect(markdownStyles) + .toMatch(/strong \{[\s\S]*font-weight: \$font-weight-bold;/) + expect(markdownStyles) + .toMatch(/em \{[\s\S]*font-style: italic;/) + expect(markdownStyles) + .toMatch(/h1,[\s\S]*h2,[\s\S]*h3 \{[\s\S]*font-weight: \$font-weight-bold;/) + expect(markdownStyles) + .toMatch(/ol \{[\s\S]*list-style-type: decimal;/) + expect(markdownStyles) + .toMatch(/ul \{[\s\S]*list-style-type: disc;/) + }) + + it('limits conversation timeline styling to top-level messages', () => { + expect(ticketDetailStyles) + .toMatch(/\.timeline \{[\s\S]*> li \{/) + expect(ticketDetailStyles) + .toMatch(/\.timeline > li \{/) + expect(ticketDetailStyles) + .not.toMatch(/\.timeline li \{/) + }) +}) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.module.scss b/src/apps/support/src/pages/ticket-details/TicketDetailPage.module.scss index 12558e005..352f4e7b6 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.module.scss +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.module.scss @@ -107,7 +107,7 @@ margin: 0; padding: 0; - li { + > li { border-left: 3px solid #9acfd3; padding: 0 0 $sp-5 $sp-5; position: relative; @@ -182,7 +182,7 @@ flex-direction: column; } - .timeline li { + .timeline > li { padding-left: $sp-4; } From 83093cb30f2ba273bb1fc96374eb548f800b1e75 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 14:24:57 +1000 Subject: [PATCH 02/34] PM-5845: Refresh support ticket assignments What was broken Ticket raisers could keep seeing an Unassigned label and stale ticket content after support staff updated the ticket. Root cause The ticket list and detail views disabled focus revalidation, while the asynchronous mark-read completion could replace fresh SWR data with a captured stale ticket snapshot. What was changed Enabled focus revalidation for ticket lists and details, and changed mark-read cache handling to update only hasUnread on the latest cached ticket. Any added/updated tests Added ticket-list cache freshness coverage and extended ticket-detail tests to verify focus revalidation and preserve fresh assignee data during the mark-read race. --- .../ticket-details/TicketDetailPage.spec.tsx | 68 ++++++++++++++++++ .../pages/ticket-details/TicketDetailPage.tsx | 6 +- .../src/pages/tickets/TicketsPage.spec.tsx | 70 +++++++++++++++++++ .../support/src/pages/tickets/TicketsPage.tsx | 2 +- 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/apps/support/src/pages/tickets/TicketsPage.spec.tsx 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 73d305864..c8fbc818b 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -18,6 +18,25 @@ const mockMutate = jest.fn() const mockUseSWR = jest.fn() let mockProfile: { roles: string[]; userId: number | string } +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +/** + * Creates a promise whose completion can be controlled by the test. + * + * @returns deferred promise and its resolver. + * @throws Does not throw. + */ +function createDeferred(): Deferred { + let resolve: (value: T) => void = () => undefined + const promise = new Promise(promiseResolve => { + resolve = promiseResolve + }) + return { promise, resolve } +} + jest.mock('swr', () => ({ __esModule: true, default: (...args: unknown[]) => mockUseSWR(...args), @@ -138,6 +157,9 @@ describe('TicketDetailPage closed reply access', () => { async () => { render() + expect(mockUseSWR.mock.calls[0][2]) + .toEqual({ revalidateOnFocus: true, shouldRetryOnError: false }) + const challengeLink = screen.getByRole('link', { name: 'View challenge' }) expect(challengeLink.getAttribute('href')) .toBe('https://www.example.test/challenges/challenge%2Fid') @@ -173,4 +195,50 @@ describe('TicketDetailPage closed reply access', () => { expect(screen.getByText('This ticket is closed and cannot receive more replies.')) .toBeTruthy() }) + + it('preserves freshly revalidated assignees when marking a ticket read completes', async () => { + const markReadRequest = createDeferred() + mockedMarkRead.mockReturnValue(markReadRequest.promise) + mockUseSWR.mockReturnValue({ + data: { ...closedTicket, hasUnread: true }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + markReadRequest.resolve(undefined) + + await waitFor(() => { + expect(mockMutate) + .toHaveBeenCalledWith(expect.any(Function), false) + }) + + const updateCachedTicket = mockMutate.mock.calls[0][0] as ( + ticket?: SupportTicketDetail, + ) => SupportTicketDetail | undefined + const freshlyRevalidatedTicket: SupportTicketDetail = { + ...closedTicket, + assignees: [{ + assignedAt: '2026-08-07T01:30:00.000Z', + handle: 'support-staff', + userId: '67890', + }], + hasUnread: true, + responseCount: 1, + responses: [{ + createdAt: '2026-08-07T01:30:00.000Z', + id: 'response-1', + markdown: 'We are investigating.', + readBy: [], + userHandle: 'support-staff', + userId: '67890', + }], + } + + expect(updateCachedTicket(freshlyRevalidatedTicket)) + .toEqual({ ...freshlyRevalidatedTicket, hasUnread: false }) + expect(updateCachedTicket(undefined)) + .toBeUndefined() + }) }) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index d782b2063..e359bab44 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -67,7 +67,7 @@ export const TicketDetailPage: FC = () => { const { data, error, isValidating, mutate } = useSWR( requestKey, () => getSupportTicket(ticketId as string), - { revalidateOnFocus: false, shouldRetryOnError: false }, + { revalidateOnFocus: true, shouldRetryOnError: false }, ) useEffect(() => { @@ -75,7 +75,9 @@ export const TicketDetailPage: FC = () => { markedReadTicket.current = data.id markSupportTicketRead(data.id) - .then(() => mutate({ ...data, hasUnread: false }, false)) + .then(() => mutate(current => (current + ? { ...current, hasUnread: false } + : current), false)) .catch(() => undefined) }, [data, mutate]) diff --git a/src/apps/support/src/pages/tickets/TicketsPage.spec.tsx b/src/apps/support/src/pages/tickets/TicketsPage.spec.tsx new file mode 100644 index 000000000..3d2f373fb --- /dev/null +++ b/src/apps/support/src/pages/tickets/TicketsPage.spec.tsx @@ -0,0 +1,70 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render } from '@testing-library/react' + +import { OpenTicketsPage } from './TicketsPage' + +const mockUseSWR = jest.fn() + +jest.mock('swr', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseSWR(...args), +})) + +jest.mock('react-router-dom', () => ({ + useNavigate: () => jest.fn(), + useSearchParams: () => [new URLSearchParams(), jest.fn()], +})) + +jest.mock('~/config', () => ({ + EnvironmentConfig: { API: { V6: 'https://api.example.test/v6' } }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + useProfileContext: () => ({ + profile: { roles: ['Topcoder User'], userId: 12345 }, + }), + UserRole: { topcoderSupportTeam: 'Topcoder Support Team' }, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: () => <>, +}), { virtual: true }) + +jest.mock('../../config/routes.config', () => ({ + buildSupportPath: (...segments: string[]) => `/support/${segments.join('/')}`, +})) + +jest.mock('../../lib/components', () => ({ + MemberHandleAutocomplete: () => <>, + OpenSupportRequestModal: () => <>, + SupportEmpty: () => <>, + SupportError: () => <>, + SupportLoading: () => <>, + SupportTabs: () => <>, + TicketsTable: () => <>, +})) + +jest.mock('../../lib/services', () => ({ + assignSupportTicketToMe: jest.fn(), + buildTicketListUrl: () => 'support-tickets', + getSupportTickets: jest.fn(), +})) + +describe('TicketsPage cache freshness', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseSWR.mockReturnValue({ + data: undefined, + error: undefined, + isValidating: true, + mutate: jest.fn(), + }) + }) + + it('refreshes ticket assignments when the requester returns to the page', () => { + render() + + expect(mockUseSWR.mock.calls[0][2]) + .toEqual({ revalidateOnFocus: true, shouldRetryOnError: false }) + }) +}) diff --git a/src/apps/support/src/pages/tickets/TicketsPage.tsx b/src/apps/support/src/pages/tickets/TicketsPage.tsx index ca79ef94c..da3ba51fd 100644 --- a/src/apps/support/src/pages/tickets/TicketsPage.tsx +++ b/src/apps/support/src/pages/tickets/TicketsPage.tsx @@ -114,7 +114,7 @@ const TicketsPage: FC = props => { const { data, error, isValidating, mutate } = useSWR( requestKey, () => getSupportTickets(query), - { revalidateOnFocus: false, shouldRetryOnError: false }, + { revalidateOnFocus: true, shouldRetryOnError: false }, ) /** From 086224893430d92fe9b75412fe1c6b2a32d5c1f5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 14:39:27 +1000 Subject: [PATCH 03/34] PM-5844: Require assignment before support replies What was broken Support Team members could open the reply editor and submit a response without first assigning the ticket to themselves. Root cause The ticket detail page allowed replies based only on ticket status and ownership, ignoring the current support user's assignment. What was changed Hide the reply editor from unassigned non-owner support users, explain that assignment is required, and document the staff reply workflow. Any added/updated tests Added component coverage for blocked unassigned staff replies and allowed assigned staff replies while retaining the existing owner and closed-ticket cases. --- src/apps/support/README.md | 2 +- .../ticket-details/TicketDetailPage.spec.tsx | 55 ++++++++++++++++++- .../pages/ticket-details/TicketDetailPage.tsx | 8 ++- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/apps/support/README.md b/src/apps/support/README.md index be3c0eff0..eaad0a17d 100644 --- a/src/apps/support/README.md +++ b/src/apps/support/README.md @@ -3,7 +3,7 @@ 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, and close resolved requests. +closed tickets, reply to tickets assigned to them, and close resolved requests. ## Routes 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 73d305864..52b7a45fd 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -110,7 +110,7 @@ const closedTicket: SupportTicketDetail = { updatedAt: '2026-08-07T01:00:00.000Z', } -describe('TicketDetailPage closed reply access', () => { +describe('TicketDetailPage reply access', () => { beforeEach(() => { jest.clearAllMocks() mockProfile = { @@ -173,4 +173,57 @@ describe('TicketDetailPage closed reply access', () => { expect(screen.getByText('This ticket is closed and cannot receive more replies.')) .toBeTruthy() }) + + it('requires non-owner support staff to assign an open ticket before replying', () => { + mockProfile = { + roles: ['Topcoder Support Team'], + userId: 99999, + } + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + closedAt: undefined, + status: 'OPEN', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.queryByLabelText('Reply')) + .toBeNull() + expect(screen.getByText('Assign this ticket to yourself before replying.')) + .toBeTruthy() + }) + + it('lets assigned support staff reply to an open ticket', () => { + mockProfile = { + roles: ['Topcoder Support Team'], + userId: 99999, + } + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + assignees: [{ + assignedAt: '2026-08-07T00:30:00.000Z', + handle: 'support-agent', + userId: '99999', + }], + closedAt: undefined, + status: 'OPEN', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByLabelText('Reply')) + .toBeTruthy() + expect(screen.queryByText('Assign this ticket to yourself before replying.')) + .toBeNull() + }) }) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index d782b2063..8cf3701ad 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -105,7 +105,7 @@ export const TicketDetailPage: FC = () => { )) const closed = data.status === 'CLOSED' const ticketOwner = Boolean(currentUserId && String(data.memberUserId) === currentUserId) - const canReply = !closed || ticketOwner + const canReply = ticketOwner || (!closed && (!supportTeam || assignedToCurrentUser)) const replyContext = `${data.id}-reply-${replyRevision}` /** @@ -332,7 +332,11 @@ export const TicketDetailPage: FC = () => { ) : ( -

This ticket is closed and cannot receive more replies.

+

+ {closed + ? 'This ticket is closed and cannot receive more replies.' + : 'Assign this ticket to yourself before replying.'} +

)} Date: Wed, 12 Aug 2026 15:15:54 +1000 Subject: [PATCH 04/34] PM-5194: Allow Fun challenges to launch without approval What was broken Platform UI disabled both launch entry points and rejected activation while a Fun challenge was pending budget approval. Root cause The page and form launch gates applied the manual budget-approval requirement to every challenge without considering the Fun challenge flag. What was changed Exempted Fun challenges from the UI budget-approval gates while preserving the existing billing-account launch checks. Updated the challenge editor documentation to describe the exception. Any added/updated tests Added page and form regressions covering enabled launch controls and a successful Active update for a pending Fun challenge. Updated shared launch fixtures to explicitly satisfy the existing approval and billing prerequisites. --- .../ChallengeEditorPage.spec.tsx | 35 +++++++++ .../ChallengeEditorPage.tsx | 5 +- .../challenges/ChallengeEditorPage/README.md | 2 + .../components/ChallengeEditorForm.spec.tsx | 71 ++++++++++++++++++- .../components/ChallengeEditorForm.tsx | 1 + 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx index 7b75c4292..e92f03cee 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx @@ -592,6 +592,41 @@ describe('ChallengeEditorPage', () => { .toBe(true) }) + it('keeps launch enabled for a fun challenge without budget approval', async () => { + mockedUseFetchChallenge.mockReturnValue({ + challenge: { + approvalStatus: 'PENDING_APPROVAL', + discussions: [{ + url: 'https://example.com/forum/challenges/456', + }], + funChallenge: true, + id: '456', + name: 'Fun challenge', + prizeSets: [], + status: 'DRAFT', + }, + error: undefined, + isLoading: false, + mutate: jest.fn(), + }) + + renderPage( + '/projects/123/challenges/456/view', + '/projects/:projectId/challenges/:challengeId/view', + ) + + await waitFor(() => { + expect(screen.getByText('Challenge View Form')) + .toBeTruthy() + }) + + expect(screen.getAllByRole('button', { name: 'Launch' })) + .toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Launch' }) + .every(button => !(button as HTMLButtonElement).disabled)) + .toBe(true) + }) + it('keeps launch enabled when challenge budget is approved', async () => { mockedUseFetchChallenge.mockReturnValue({ challenge: { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx index 40f992eb9..14a3f0e8b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -1287,7 +1287,10 @@ export const ChallengeEditorPage: FC = () => { const isBudgetApproved = isBudgetApprovedForLaunch( challengeApprovalStatus || headerChallenge?.approvalStatus, ) - const isLaunchDisabled = isLaunching || isSavingChallenge || !isBudgetApproved + const requiresBudgetApproval = headerChallenge?.funChallenge !== true + const isLaunchDisabled = isLaunching + || isSavingChallenge + || (requiresBudgetApproval && !isBudgetApproved) const handleSavingChange = useCallback((isSaving: boolean): void => { setIsSavingChallenge(isSaving) }, []) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 177d67c46..4f38f5fdf 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -134,6 +134,8 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha ## Header Actions - `Launch` is shown on the details tab for `DRAFT` challenges in the header for both view and edit routes, and again in the footer beside `Save Challenge` while editing. +- Fun challenges bypass the manual budget-approval launch gate; other challenge types require an + approved budget before launch. - Read-only view routes repeat the available `Edit`, `Launch`, `Cancel`, and `Mark Complete` actions at the bottom of the page. Challenge quick links remain in the header only. - The work app blocks launch attempts when the parent project billing account is inactive, expired, or has insufficient remaining funds, matching the legacy work-manager launch restriction. - Task challenges cannot be launched until `Assigned Member` is set, which ensures the task is assigned before it becomes publicly visible. 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 da06806b1..19a740abf 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 @@ -838,6 +838,7 @@ describe('ChallengeEditorForm', () => { } as Challenge const validDraftChallenge = { ...draftChallenge, + approvalStatus: 'APPROVED', description: 'Valid public specification for the attachment save regression test.', prizeSets: [{ prizes: [{ @@ -959,7 +960,11 @@ describe('ChallengeEditorForm', () => { }, }) mockedUseFetchProjectBillingAccount.mockReturnValue({ - billingAccount: undefined, + billingAccount: { + active: true, + id: '80001063', + totalBudgetRemaining: 500, + }, isLoading: false, }) mockedUseFetchResourceRoles.mockImplementation(() => ({ @@ -1181,7 +1186,10 @@ describe('ChallengeEditorForm', () => { @@ -1850,6 +1858,60 @@ describe('ChallengeEditorForm', () => { }) }) + it('launches a fun challenge without budget approval', async () => { + let launchAction: (() => Promise) | undefined + + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: { + active: true, + id: '80001063', + totalBudgetRemaining: 500, + }, + isLoading: false, + }) + mockedPatchChallenge.mockResolvedValue({ + ...validDraftChallenge, + approvalStatus: 'APPROVED', + funChallenge: true, + status: 'ACTIVE', + }) + + render( + + { + launchAction = action + }} + /> + , + ) + + await waitFor(() => { + expect(launchAction) + .toEqual(expect.any(Function)) + }) + + await act(async () => { + await launchAction?.() + }) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + funChallenge: true, + status: 'ACTIVE', + })) + }) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Challenge launch is blocked until budget approval is Approved.') + }) + it('launches a design draft before a screener member is assigned', async () => { let launchAction: (() => Promise) | undefined @@ -3379,6 +3441,11 @@ describe('ChallengeEditorForm', () => { let launchAction: (() => Promise) | undefined let launchError: Error | undefined + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: undefined, + isLoading: false, + }) + mockedUseFetchChallengeTracks.mockReturnValue({ isLoading: false, tracks: [{ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index 682a8afe7..c8ceb7d9f 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -3220,6 +3220,7 @@ export const ChallengeEditorForm: FC = ( if ( isChallengeBeingActivated + && formData.funChallenge !== true && normalizeStatus(formData.approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED ) { setSaveStatus('idle') From 8f65509f1411026671769b64d4961fab3292e1a5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 15:30:41 +1000 Subject: [PATCH 05/34] PM-5851: Scope unread indicators to assigned support tickets What was broken Support Team members saw unread badges and styling for tickets that were not assigned to them, including closed tickets. Root cause The ticket table applied the API's per-viewer hasUnread value directly without considering whether a Support Team viewer was assigned to the ticket. What was changed Derive the displayed unread state from both hasUnread and current-user assignment for Support Team viewers. Apply that state consistently to desktop and mobile badges, styling, and the desktop accessibility label while preserving ordinary member behavior. Any added/updated tests Added TicketsTable regression coverage for the assigned Support Team member, another Support Team member viewing a closed ticket, and an ordinary member. --- .../TicketsTable/TicketsTable.spec.tsx | 64 +++++++++++++++++++ .../components/TicketsTable/TicketsTable.tsx | 16 +++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/apps/support/src/lib/components/TicketsTable/TicketsTable.spec.tsx b/src/apps/support/src/lib/components/TicketsTable/TicketsTable.spec.tsx index 9eb25e932..a2bd12fc9 100644 --- a/src/apps/support/src/lib/components/TicketsTable/TicketsTable.spec.tsx +++ b/src/apps/support/src/lib/components/TicketsTable/TicketsTable.spec.tsx @@ -1,4 +1,5 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' import { fireEvent, render, @@ -110,3 +111,66 @@ describe('TicketsTable challenge links', () => { .toBeNull() }) }) + +describe('TicketsTable unread indicators', () => { + const unreadTicket: SupportTicketSummary = { + ...baseTicket, + assignees: [{ + assignedAt: '2026-08-07T00:00:00.000Z', + handle: 'support-member', + userId: 'staff-1', + }], + hasUnread: true, + status: 'CLOSED', + } + + it('shows unread indicators to the assigned support team member', () => { + render( + , + ) + + expect(screen.getAllByText('Unread')) + .toHaveLength(2) + expect(screen.getByLabelText('Unread ticket: Unable to submit')) + .toHaveClass('unread') + }) + + it('hides unread indicators from support team members who are not assigned', () => { + render( + , + ) + + expect(screen.queryByText('Unread')) + .toBeNull() + expect(screen.getByLabelText('Read ticket: Unable to submit')) + .not.toHaveClass('unread') + }) + + it('preserves unread indicators for an ordinary member', () => { + render( + , + ) + + expect(screen.getAllByText('Unread')) + .toHaveLength(2) + expect(screen.getByLabelText('Unread ticket: Unable to submit')) + .toHaveClass('unread') + }) +}) diff --git a/src/apps/support/src/lib/components/TicketsTable/TicketsTable.tsx b/src/apps/support/src/lib/components/TicketsTable/TicketsTable.tsx index e3434b70c..d04d57ca6 100644 --- a/src/apps/support/src/lib/components/TicketsTable/TicketsTable.tsx +++ b/src/apps/support/src/lib/components/TicketsTable/TicketsTable.tsx @@ -70,11 +70,11 @@ const Assignees = ({ ticket }: { ticket: SupportTicketSummary }): JSX.Element => /** * Renders an accessible unread indicator that does not rely on color. * - * @param ticket ticket summary. + * @param unread whether the ticket should appear unread to the current viewer. * @returns unread badge when required. * @throws Does not throw. */ -const UnreadBadge = ({ ticket }: { ticket: SupportTicketSummary }): JSX.Element => (ticket.hasUnread +const UnreadBadge = ({ unread }: { unread: boolean }): JSX.Element => (unread ? ( @@ -189,17 +189,18 @@ export const TicketsTable: FC = props => { {props.tickets.map(ticket => { const assigned = isTicketAssignedToUser(ticket, props.currentUserId) + const unread = ticket.hasUnread && (!props.isSupportTeam || assigned) return ( props.onOpen(ticket)} onKeyDown={event => handleKeyDown(event, ticket)} tabIndex={0} > - + {preview(ticket)} {props.isSupportTeam && ( @@ -236,12 +237,13 @@ export const TicketsTable: FC = props => {
{props.tickets.map(ticket => { const assigned = isTicketAssignedToUser(ticket, props.currentUserId) + const unread = ticket.hasUnread && (!props.isSupportTeam || assigned) return (
- +

{preview(ticket)}

{props.isSupportTeam && (

From fe01311f37a1fc96de65371cc99943e96dc6aac7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 16:17:19 +1000 Subject: [PATCH 06/34] PM-5848: Refresh support ticket details after read What was broken After assigning a ticket from the Support ticket list, the detail screen could still show the ticket as unassigned and offer "Assign to me" until the page was refreshed. Root cause The asynchronous mark-read completion performed a data-bearing SWR mutation that could supersede an in-flight detail revalidation and preserve stale pre-assignment data. What was changed Changed the post-read cache update to a revalidation-only SWR mutation, ensuring the detail page reloads current server state without invalidating a concurrent assignment refresh. Any added/updated tests Updated the TicketDetailPage regression test to verify that completing the mark-read request triggers a zero-argument, revalidation-only mutation. --- .../ticket-details/TicketDetailPage.spec.tsx | 33 +++---------------- .../pages/ticket-details/TicketDetailPage.tsx | 4 +-- 2 files changed, 5 insertions(+), 32 deletions(-) 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..9567517b1 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -196,7 +196,7 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() }) - it('preserves freshly revalidated assignees when marking a ticket read completes', async () => { + it('revalidates fresh detail after marking the ticket read', async () => { const markReadRequest = createDeferred() mockedMarkRead.mockReturnValue(markReadRequest.promise) mockUseSWR.mockReturnValue({ @@ -211,35 +211,10 @@ describe('TicketDetailPage reply access', () => { await waitFor(() => { expect(mockMutate) - .toHaveBeenCalledWith(expect.any(Function), false) + .toHaveBeenCalledTimes(1) }) - - const updateCachedTicket = mockMutate.mock.calls[0][0] as ( - ticket?: SupportTicketDetail, - ) => SupportTicketDetail | undefined - const freshlyRevalidatedTicket: SupportTicketDetail = { - ...closedTicket, - assignees: [{ - assignedAt: '2026-08-07T01:30:00.000Z', - handle: 'support-staff', - userId: '67890', - }], - hasUnread: true, - responseCount: 1, - responses: [{ - createdAt: '2026-08-07T01:30:00.000Z', - id: 'response-1', - markdown: 'We are investigating.', - readBy: [], - userHandle: 'support-staff', - userId: '67890', - }], - } - - expect(updateCachedTicket(freshlyRevalidatedTicket)) - .toEqual({ ...freshlyRevalidatedTicket, hasUnread: false }) - expect(updateCachedTicket(undefined)) - .toBeUndefined() + expect(mockMutate.mock.calls[0]) + .toEqual([]) }) it('requires non-owner support staff to assign an open ticket before replying', () => { diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index 4df066db3..14eb8921a 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -75,9 +75,7 @@ export const TicketDetailPage: FC = () => { markedReadTicket.current = data.id markSupportTicketRead(data.id) - .then(() => mutate(current => (current - ? { ...current, hasUnread: false } - : current), false)) + .then(() => mutate()) .catch(() => undefined) }, [data, mutate]) From 3baab7acd6505aa3117146ce6efaab1df4866859 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 16:52:02 +1000 Subject: [PATCH 07/34] PM-5858: Require assignment before ticket closure What was broken Support Team members could close an unassigned ticket, and the closed-ticket detail did not identify who closed it. Root cause The close control checked only Support Team membership, and the UI contract omitted the closer ID already stored by the API. What was changed Disable closure unless the current Support Team user is assigned, accept the optional closer ID, and display the matching assignee handle with a stored-ID fallback. Any added/updated tests Updated TicketDetailPage coverage for unassigned and assigned closure access, closer-handle display, and legacy closer-ID fallback. --- src/apps/support/README.md | 3 +- .../support/src/lib/models/support.models.ts | 1 + .../ticket-details/TicketDetailPage.spec.tsx | 55 ++++++++++++++++++- .../pages/ticket-details/TicketDetailPage.tsx | 19 +++++++ 4 files changed, 75 insertions(+), 3 deletions(-) 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/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..9e69ac6a5 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,7 @@ describe('TicketDetailPage reply access', () => { .toBeUndefined() }) - it('requires non-owner support staff to assign an open ticket before replying', () => { + 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 +307,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 +340,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..8ac4bfeb5 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -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' /> + ), EditMemberPropertyBtn: () => , })) @@ -116,7 +119,7 @@ describe('MemberRatingCard', () => { .toBeInTheDocument() }) - it('shows the preferred roles edit action for the profile owner when rating data is unavailable', () => { + it('shows the preferred roles add action for the profile owner when rating data is unavailable', () => { mockedUseMemberStats.mockReturnValue(undefined) render() @@ -126,7 +129,8 @@ describe('MemberRatingCard', () => { .toBeInTheDocument() expect(screen.getByRole('button', { name: 'Add preferred roles' })) .toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Edit' })) + expect(screen.queryByRole('button', { name: 'Edit' })) + .not .toBeInTheDocument() }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 773b35a28..eae4d58fb 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -14,7 +14,7 @@ import { } from '~/libs/core' import { Tooltip } from '~/libs/ui' -import { EditMemberPropertyBtn } from '../../../components' +import { AddButton, EditMemberPropertyBtn } from '../../../components' import { getPreferredRolesText } from '../../../lib' import { @@ -118,35 +118,38 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp return <> } + if (preferredRoles.length === 0) { + return ( +
+ +
+ ) + } + return (
- {preferredRoles.length > 0 ? ( -
- {preferredRolesDisplay.visibleRoles.map((role: string) => ( - - {role} - - ))} - - {preferredRolesDisplay.toggleLabel && ( - - )} -
- ) : ( - - )} +
+ {preferredRolesDisplay.visibleRoles.map((role: string) => ( + + {role} + + ))} + + {preferredRolesDisplay.toggleLabel && ( + + )} +
{canEditPreferredRoles && ( = (props: Mo label='Preferred Roles' name='preferredRoles' onFetchOptions={fetchPreferredRoles} + openMenuOnClick options={preferredRoleOptions} onChange={handlePreferredRolesChange} placeholder='Select preferred roles' From 393bab7d0f9a7fd85e62c1b33fa471d5ba14a9a9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 12 Aug 2026 12:34:44 +0300 Subject: [PATCH 16/34] lint fix --- .../pages/statistics/StatisticsPage/WorldMap.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 48b0c0364..3e866d321 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx @@ -539,17 +539,22 @@ const WorldMap: FC = props => { showWinnerDetails: props.showWinnerDetails, }), padding: 0, - positioner( - this: Highcharts.Tooltip, + positioner: ( labelWidth: number, labelHeight: number, point: Highcharts.Point, - ): Highcharts.PositionObject { + ): Highcharts.PositionObject => { + const chart = chartRef.current?.chart as Highcharts.Chart | undefined + + if (!chart) { + return { x: 0, y: 0 } + } + return getFixedTooltipPosition( labelWidth, labelHeight, point, - this.chart, + chart, ) }, shadow: false, From c03b5dca9c6b852ecbb9c48e5de487559cd22a79 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 19:53:43 +1000 Subject: [PATCH 17/34] PM-5839: Render SFDC payment dates consistently What was broken Late July 31 SFDC payments could render as August 1 when the report was viewed from a browser in a positive-offset timezone. Root cause The payment table formatted timestamps in the browser's local timezone even though SFDC payment reports use America/New_York calendar dates. What was changed Render SFDC payment timestamps explicitly in America/New_York while preserving the existing browser-local formatting for billing-account profile dates. Any added/updated tests Added a Reports page regression using the reported July 31 boundary in Asia/Colombo. The test also confirms billing-account profile dates keep their existing formatting. --- .../src/pages/reports/ReportsPage.spec.tsx | 79 ++++++++++++++++++- .../reports/src/pages/reports/ReportsPage.tsx | 18 ++++- 2 files changed, 94 insertions(+), 3 deletions(-) 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') { From 8c136978b4791c4e3cd93f6f42b38909fec7ff42 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 12 Aug 2026 15:51:34 +0530 Subject: [PATCH 18/34] Fix member profile rating chart --- .../MemberRatingInfoModal.module.scss | 20 +-- .../MemberRatingInfoModal.spec.tsx | 56 +++++++- .../MemberRatingInfoModal.tsx | 123 +++++++++++++++--- 3 files changed, 169 insertions(+), 30 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 4635899c4..ad540a6fb 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -204,6 +204,7 @@ transform: translateX(-50%); width: 0; z-index: 2; + left: 50%; &::after { background: currentColor; @@ -217,11 +218,9 @@ } .markerBadge { - align-items: center; - display: flex; - gap: $sp-2; + flex-shrink: 0; position: relative; - transform: translateX(34px); + width: 32px; z-index: 1; } @@ -230,10 +229,10 @@ top: $sp-14; } - .markerBadge { - flex-direction: column; - gap: $sp-1; - transform: translateX(0); + .markerRating { + left: 50%; + top: calc(100% + #{$sp-1}); + transform: translateX(-50%); } } @@ -274,7 +273,12 @@ font-family: $font-roboto; font-size: 13px; font-weight: $font-weight-bold; + left: calc(100% + #{$sp-2}); line-height: 18px; + position: absolute; + top: 50%; + transform: translateY(-50%); + white-space: nowrap; } .axisLabels { diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index b703659fa..549073927 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -53,6 +53,18 @@ const expandedTailRatingDistribution = { }, } +const cappedRatingDistribution = { + ...ratingDistribution, + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + ratingRange2200To2999: 5, + ratingRange3000To3999: 2, + }, +} + jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(), }), { @@ -154,8 +166,50 @@ describe('MemberRatingInfoModal', () => { expect(marker) .toHaveStyle('color: #616BD5') expect(parseFloat(marker.style.left)) - .toBeLessThan(80) + .toBeCloseTo((5.5 / 6) * 100) + expect(marker) + .toHaveClass('memberMarkerStacked') + }) + + it('centers the marker on the final module bar when the rating exceeds the distribution', () => { + render( + , + ) + + const marker = screen.getByTestId('rating-member-marker') + + expect(parseFloat(marker.style.left)) + .toBeCloseTo((5.5 / 6) * 100) expect(marker) .toHaveClass('memberMarkerStacked') }) + + it('removes trailing empty buckets so the histogram spans the chart', () => { + render( + , + ) + + const marker = screen.getByTestId('rating-member-marker') + const bars = document.querySelector('[class*="bars"]') as HTMLElement + + expect(bars) + .not + .toHaveAttribute('style') + expect(parseFloat(marker.style.left)) + .toBeCloseTo((3.5 / 6) * 100) + }) }) 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 7e06eb160..05379356e 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 @@ -180,42 +180,116 @@ const getDistributionRanges = ( ) /** - * Returns the chart end rating used for marker and axis positioning. - * - * Used by MemberRatingInfoModal to align labels with the rendered distribution range. + * Trims trailing empty rating buckets so the histogram can span the chart width. * * @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges. - * @returns {number} The maximum rating represented by the chart. + * @returns {RatingDistributionRange[]} Ranges through the last populated bucket. */ -const getChartEndRating = (ranges: RatingDistributionRange[]): number => { +const getVisibleDistributionRanges = ( + ranges: RatingDistributionRange[], +): RatingDistributionRange[] => { if (ranges.length === 0) { - return 3999 + return ranges + } + + let lastPopulatedIndex = -1 + for (let index = ranges.length - 1; index >= 0; index -= 1) { + if (ranges[index].value > 0) { + lastPopulatedIndex = index + break + } + } + + if (lastPopulatedIndex < 0) { + return ranges + } + + return ranges.slice(0, lastPopulatedIndex + 1) +} + +/** + * Returns the histogram bar index for a rating value. + * + * @param {number} rating - The rating value to locate. + * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges. + * @returns {number} Zero-based bar index, clamped to the visible range. + */ +const getBarIndexForRating = ( + rating: number, + ranges: RatingDistributionRange[], +): number => { + if (ranges.length === 0) { + return 0 + } + + const matchingIndex = ranges.findIndex((range: RatingDistributionRange) => ( + rating >= range.start && rating <= range.end + )) + + if (matchingIndex >= 0) { + return matchingIndex + } + + if (rating < ranges[0].start) { + return 0 } - return ranges[ranges.length - 1].end + return ranges.length - 1 } /** - * Calculates a horizontal chart position for a rating value. + * Returns the horizontal center position of the histogram bar for a rating. * - * Used by MemberRatingInfoModal for the member marker and static x-axis labels. + * Used by MemberRatingInfoModal so the marker sits on the module bar itself + * instead of interpolating across a separate rating-percentage scale. * * @param {number} rating - The rating value to position. - * @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges. - * @returns {number} A clamped percentage from 0 to 100. + * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges. + * @returns {number} Center position of the matching bar as a percentage from 0 to 100. */ -const getChartPosition = (rating: number, ranges: RatingDistributionRange[]): number => { - const chartStart = ranges[0]?.start ?? 0 - const chartEnd = getChartEndRating(ranges) - const chartSpan = chartEnd - chartStart +const getMarkerPosition = ( + rating: number, + ranges: RatingDistributionRange[], +): number => { + if (ranges.length === 0) { + return 0 + } - if (chartSpan <= 0) { + const barIndex = getBarIndexForRating(rating, ranges) + + return ((barIndex + 0.5) / ranges.length) * 100 +} + +/** + * Returns the horizontal start position of the histogram bar for an axis label. + * + * @param {number} rating - The axis label rating value. + * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges. + * @returns {number} Start position of the matching bar as a percentage from 0 to 100. + */ +const getAxisLabelPosition = ( + rating: number, + ranges: RatingDistributionRange[], +): number => { + if (ranges.length === 0) { return 0 } - const clampedRating = Math.max(chartStart, Math.min(rating, chartEnd)) + const matchingIndex = ranges.findIndex((range: RatingDistributionRange) => ( + rating >= range.start && rating <= range.end + )) + + if (matchingIndex >= 0) { + return (matchingIndex / ranges.length) * 100 + } + + const nextIndex = ranges.findIndex((range: RatingDistributionRange) => range.start >= rating) - return ((clampedRating - chartStart) / chartSpan) * 100 + if (nextIndex >= 0) { + return (nextIndex / ranges.length) * 100 + } + + return 100 } /** @@ -268,14 +342,16 @@ const MemberRatingInfoModal: FC = (props: MemberRati const ratingColor: string = getRatingColor(props.rating) const selectedRatingTier: RatingTier = getRatingTier(props.rating) const distributionRanges: RatingDistributionRange[] = useMemo(() => ( - getDistributionRanges(props.ratingDistribution?.distribution) + getVisibleDistributionRanges( + getDistributionRanges(props.ratingDistribution?.distribution), + ) ), [props.ratingDistribution]) const maxDistributionValue: number = Math.max( 1, ...distributionRanges.map((range: RatingDistributionRange) => range.value), ) const markerPosition: number = props.rating !== undefined - ? getChartPosition(props.rating, distributionRanges) + ? getMarkerPosition(props.rating, distributionRanges) : 0 const shouldStackMarkerRating: boolean = props.rating !== undefined && ( markerPosition >= stackedMarkerPositionThreshold @@ -397,7 +473,12 @@ const MemberRatingInfoModal: FC = (props: MemberRati {chartAxisLabels.map((axisLabel: { label: string, value: number }) => ( {axisLabel.label} From 4ea6c825380c22474b9f8596f9af1e0619b80744 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 20:43:11 +1000 Subject: [PATCH 19/34] PM-5758: finish design submission limit handling What was broken Review showed only the newest submission per member when a Design challenge allowed more than one. Work Manager could also reset the visible submission-limit selection after saving a draft when the save response omitted that metadata entry. Root cause Review reduced every finite limit to the API's single isLatest flag and grouped history without using the configured count or exact submission type. The draft editor trusted sparse save-response metadata when resetting the form. What was changed Resolve the same latest-X Design policy used by the backend, rank complete member/type history before phase eligibility, and display every eligible Screening and Review row within that window. Preserve unlimited Design behavior and Development's latest-one behavior. Retain the submitted submissionLimit value when a successful draft response omits that entry. Any added/updated tests Added and updated regression coverage for finite counts, unlimited and malformed metadata, independent contest/checkpoint histories, rank-before-eligibility, Review row forwarding, Screening selection, and Work Manager draft-save metadata preservation. --- .../TabContentReview.spec.tsx | 70 +++++++- .../TabContentReview.tsx | 16 +- .../TabContentSubmissions.tsx | 11 +- .../components/TableReview/TableReview.tsx | 18 +- .../TableReviewForSubmitter.tsx | 13 +- .../TableSubmissionScreening.tsx | 30 ++-- .../src/lib/hooks/useSubmissionHistory.ts | 24 ++- .../review/src/lib/utils/challenge.spec.ts | 110 ++++++++++++ src/apps/review/src/lib/utils/challenge.ts | 149 +++++++++++++++- .../src/lib/utils/screeningRows.spec.ts | 82 ++++----- .../review/src/lib/utils/screeningRows.ts | 23 +-- .../src/lib/utils/submissionHistory.spec.ts | 119 +++++++++++++ .../review/src/lib/utils/submissionHistory.ts | 160 +++++++++++++----- .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.spec.tsx | 69 ++++++++ .../components/ChallengeEditorForm.tsx | 26 ++- 16 files changed, 773 insertions(+), 149 deletions(-) create mode 100644 src/apps/review/src/lib/utils/submissionHistory.spec.ts 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/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 4f38f5fdf..f516d3be7 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -84,7 +84,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `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, 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. 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. - `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 19a740abf..07c381caf 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 @@ -660,6 +660,12 @@ jest.mock('./MaximumSubmissionsField', () => ({ MaximumSubmissionsField: (props: { deferDirty?: boolean }) => { + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + const metadata = reactHookForm.useWatch({ + control: reactHookForm.useFormContext().control, + name: 'metadata', + }) + mockMaximumSubmissionsDeferDirtyValues.push(props.deferDirty === true) return ( @@ -667,6 +673,7 @@ jest.mock('./MaximumSubmissionsField', () => ({ data-defer-dirty={props.deferDirty === true ? 'true' : 'false'} + data-metadata={JSON.stringify(metadata || [])} data-testid='maximum-submissions-field' > Maximum Submissions Field @@ -4258,6 +4265,68 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith(expect.stringContaining('Assign all required members')) }) + it('keeps submission-limit metadata visible when the draft save response omits metadata', async () => { + const user = userEvent.setup() + const submissionLimitMetadata = [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }] + + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track-id', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'CH', + id: 'design-challenge-type-id', + name: 'Challenge', + }], + isLoading: false, + }) + mockedPatchChallenge.mockResolvedValue({ + ...designChallengeWithDeferredScreener, + metadata: [], + status: 'DRAFT', + }) + + render( + + + , + ) + + expect(screen.getByTestId('maximum-submissions-field')) + .toHaveAttribute('data-metadata', JSON.stringify(submissionLimitMetadata)) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save as Draft' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledTimes(1) + expect(mockedShowSuccessToast) + .toHaveBeenCalled() + expect(screen.getByTestId('maximum-submissions-field')) + .toHaveAttribute('data-metadata', JSON.stringify(submissionLimitMetadata)) + }) + }) + it('reports DRAFT status when task assignee sync fails after the challenge save', async () => { const user = userEvent.setup() const onChallengeStatusChange = jest.fn() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index c8ceb7d9f..68d8aff31 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -85,7 +85,11 @@ import { transformChallengeToFormData, transformFormDataToChallenge, } from '../../../../lib/utils' -import { booleanToMetadata } from '../../../../lib/utils/metadata.utils' +import { + booleanToMetadata, + getMetadataValue, + setMetadataValue, +} from '../../../../lib/utils/metadata.utils' import { isScreenerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' import { getProjectBillingAccountChallengeErrorMessage, @@ -3327,6 +3331,25 @@ export const ChallengeEditorForm: FC = ( formDataWithProjectBilling.phases, persistedFormData.phases, ) + const savedMetadata = Array.isArray(savedChallengeSnapshot.metadata) + ? persistedFormData.metadata + : formDataWithProjectBilling.metadata + const submittedSubmissionLimit = getMetadataValue( + formDataWithProjectBilling.metadata, + 'submissionLimit', + ) + const savedSubmissionLimit = getMetadataValue( + savedMetadata, + 'submissionLimit', + ) + const postSaveMetadata = submittedSubmissionLimit === undefined + || savedSubmissionLimit !== undefined + ? savedMetadata + : setMetadataValue( + savedMetadata, + 'submissionLimit', + submittedSubmissionLimit, + ) const nextValues = applySingleAssignmentFieldValues( await hydratePersistedSavedFormData( @@ -3336,6 +3359,7 @@ export const ChallengeEditorForm: FC = ( attachments: Array.isArray(persistedFormData.attachments) ? persistedFormData.attachments : formDataWithProjectBilling.attachments, + metadata: postSaveMetadata, }, ), formDataWithProjectBilling, From 9eafb1f316c29e0c68bfac8cdf52a474f814f3e5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 20:59:04 +1000 Subject: [PATCH 20/34] PM-5857: Label support team replies What was broken Support staff and ticket raiser replies displayed only their handles, making their roles difficult to distinguish. Root cause The ticket conversation rendered reply handles without using the existing author and ticket-owner IDs to identify support-authored responses. What was changed Append (Support Team) to replies authored by someone other than the ticket owner. Ticket-owner replies remain unchanged, including when the owner also holds the Support Team role. Any added/updated tests Added TicketDetailPage coverage that verifies the suffix appears for support replies and not for ticket-owner replies. --- .../ticket-details/TicketDetailPage.spec.tsx | 41 +++++++++++++++++++ .../pages/ticket-details/TicketDetailPage.tsx | 3 +- 2 files changed, 43 insertions(+), 1 deletion(-) 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..c512433e8 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -242,6 +242,47 @@ describe('TicketDetailPage reply access', () => { .toBeUndefined() }) + 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', () => { mockProfile = { roles: ['Topcoder Support Team'], diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index 4df066db3..fcf8c7aa7 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. @@ -297,6 +297,7 @@ export const TicketDetailPage: FC = () => { color={response.userHandleColor} handle={response.userHandle} /> + {response.userId !== data.memberUserId && ' (Support Team)'} From a1393b20044557304d728c2c00aa49854dabd019 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 12 Aug 2026 14:00:39 +0300 Subject: [PATCH 21/34] PM-4621 - minor updates --- .../src/pages/statistics/StatisticsPage/StatisticsPage.tsx | 2 +- .../src/pages/statistics/StatisticsPage/WorldMap.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 8e9b901e8..e02db6ffe 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx @@ -237,7 +237,7 @@ const StatisticsPage: FC = () => { -
+
{country.code && (