Date: Wed, 12 Aug 2026 18:41:21 +1000
Subject: [PATCH 04/13] PM-5787: Allow deferred checkpoint screener assignment
What was broken
The previous PM-5787 changes allowed the standard Screener assignment to be deferred, but an empty Checkpoint Screener assignment still blocked draft saves and launches.
Root cause
The shared optional-assignment helper matched only the exact Screening phase name and explicitly excluded Checkpoint Screening.
What was changed
Extended the optional member-assignment policy to both Screening and Checkpoint Screening while keeping their reviewer configurations and scorecards required. Updated the Challenge Editor documentation to describe both deferred roles.
Any added/updated tests
Updated schema, required-field, save, launch, resource-allocation, and hydration coverage for an unassigned Checkpoint Screener. The four focused suites pass with 124 tests; lint and the production build pass. The full repository run matches the clean dev baseline with 222 passing suites and 18 unrelated failing suites.
---
.../schemas/challenge-editor.schema.spec.ts | 11 +++---
src/apps/work/src/lib/utils/reviewer.utils.ts | 17 +++++----
.../challenges/ChallengeEditorPage/README.md | 4 +--
.../components/ChallengeEditorForm.spec.tsx | 35 ++++++++++++++----
.../ReviewersField/HumanReviewTab.spec.tsx | 36 +++++++++++++++++--
.../reviewerAssignments.utils.spec.ts | 16 ++++++++-
6 files changed, 95 insertions(+), 24 deletions(-)
diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
index 0c0203da9..3e21f961c 100644
--- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
+++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
@@ -332,11 +332,12 @@ describe('challenge-editor schema reviewer slot assignment validation', () => {
.toBeTruthy()
})
- it('still requires an assigned checkpoint screener', async () => {
+ it('accepts an unassigned checkpoint screener for the Checkpoint Screening phase', async () => {
await expect(
challengeAdvancedOptionsSchema.validate({
...baseFormData,
phases: [{
+ id: 'checkpoint-screening-phase-instance-id',
name: 'Checkpoint Screening',
phaseId: 'checkpoint-screening-phase-id',
}],
@@ -344,17 +345,15 @@ describe('challenge-editor schema reviewer slot assignment validation', () => {
{
isMemberReview: true,
memberReviewerCount: 1,
- phaseId: 'checkpoint-screening-phase-id',
+ phaseId: 'checkpoint-screening-phase-instance-id',
scorecardId: 'checkpoint-screening-scorecard-id',
shouldOpenOpportunity: false,
},
],
}),
)
- .rejects
- .toMatchObject({
- path: 'reviewers[0].memberId',
- })
+ .resolves
+ .toBeTruthy()
})
it('accepts required reviewer slot assignments when opportunity is closed', async () => {
diff --git a/src/apps/work/src/lib/utils/reviewer.utils.ts b/src/apps/work/src/lib/utils/reviewer.utils.ts
index 3db86039c..fc1ed2cb2 100644
--- a/src/apps/work/src/lib/utils/reviewer.utils.ts
+++ b/src/apps/work/src/lib/utils/reviewer.utils.ts
@@ -8,7 +8,7 @@ import type {
*
* @param value reviewer or phase value to normalize.
* @returns trimmed text for string values, otherwise an empty string.
- * @remarks Used internally while resolving whether a reviewer belongs to standard Screening.
+ * @remarks Used internally while resolving whether a reviewer belongs to a screening phase.
* @throws Does not throw.
*/
function normalizeReviewerValue(value: unknown): string {
@@ -22,9 +22,9 @@ function normalizeReviewerValue(value: unknown): string {
*
* @param reviewer reviewer configuration whose phase should be inspected.
* @param phases challenge phases used to resolve the reviewer's phase name.
- * @returns `true` only for a human reviewer configured on the standard Screening phase.
- * @remarks Form validation and reviewer fields use this exception; Checkpoint Screening and every
- * other reviewer phase still require assignments up front.
+ * @returns `true` for a human reviewer configured on Screening or Checkpoint Screening.
+ * @remarks Form validation and reviewer fields use this exception; every other reviewer phase
+ * still requires assignments up front.
* @throws Does not throw.
*/
export function isScreenerAssignmentOptional(
@@ -46,8 +46,13 @@ export function isScreenerAssignmentOptional(
const matchesPhase = reviewerPhaseId === phaseTemplateId
|| reviewerPhaseId === phaseInstanceId
+ const normalizedPhaseName = normalizeReviewerValue(phase.name)
+ .toLowerCase()
+
return matchesPhase
- && normalizeReviewerValue(phase.name)
- .toLowerCase() === 'screening'
+ && (
+ normalizedPhaseName === 'screening'
+ || normalizedPhaseName === 'checkpoint screening'
+ )
})
}
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
index 4f38f5fdf..2df86b530 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
@@ -45,7 +45,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
- `tags`: optional string array.
- `skills`: required unless billing account is listed in `SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS`.
- `reviewer`: optional for task challenges.
-- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The standard Screening configuration and scorecard remain required, but its Screener member assignment may be left empty until after launch; other closed manual reviewer assignments remain required.
+- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch; other closed manual reviewer assignments remain required.
- `AI review configuration`: templates and manual configs autosave separately once valid, switching a template-backed config to manual mode keeps its copied settings but clears the template link on save, and the AI tab becomes read-only after the challenge has submissions.
## Autosave Behavior
@@ -80,7 +80,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test
challenges do not generate payments, and authorized modifiers can delete them after they reach
a completed or cancelled status.
-- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. Standard Screening member selectors remain available but are optional so a copilot can assign the Screener after launch.
+- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch.
- `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.
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..04777d84b 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
@@ -858,7 +858,7 @@ describe('ChallengeEditorForm', () => {
...validDraftChallenge,
status: 'NEW',
} as Challenge
- const designChallengeWithDeferredScreener = {
+ const designChallengeWithDeferredScreeners = {
...validDraftChallenge,
approvalStatus: 'APPROVED',
phases: [
@@ -867,6 +867,11 @@ describe('ChallengeEditorForm', () => {
name: 'Screening',
phaseId: 'screening-phase-id',
},
+ {
+ duration: 60,
+ name: 'Checkpoint Screening',
+ phaseId: 'checkpoint-screening-phase-id',
+ },
{
duration: 60,
name: 'Review',
@@ -882,6 +887,14 @@ describe('ChallengeEditorForm', () => {
scorecardId: 'screening-scorecard-id',
shouldOpenOpportunity: false,
},
+ {
+ additionalMemberIds: [],
+ isMemberReview: true,
+ memberReviewerCount: 1,
+ phaseId: 'checkpoint-screening-phase-id',
+ scorecardId: 'checkpoint-screening-scorecard-id',
+ shouldOpenOpportunity: false,
+ },
{
additionalMemberIds: [],
isMemberReview: true,
@@ -1912,7 +1925,7 @@ describe('ChallengeEditorForm', () => {
.not.toHaveBeenCalledWith('Challenge launch is blocked until budget approval is Approved.')
})
- it('launches a design draft before a screener member is assigned', async () => {
+ it('launches a design draft before screening members are assigned', async () => {
let launchAction: (() => Promise
) | undefined
mockedUseFetchChallengeTracks.mockReturnValue({
@@ -1940,14 +1953,14 @@ describe('ChallengeEditorForm', () => {
isLoading: false,
})
mockedPatchChallenge.mockResolvedValue({
- ...designChallengeWithDeferredScreener,
+ ...designChallengeWithDeferredScreeners,
status: 'ACTIVE',
})
render(
{
launchAction = action
@@ -1973,6 +1986,10 @@ describe('ChallengeEditorForm', () => {
phaseId: 'screening-phase-id',
scorecardId: 'screening-scorecard-id',
}),
+ expect.objectContaining({
+ phaseId: 'checkpoint-screening-phase-id',
+ scorecardId: 'checkpoint-screening-scorecard-id',
+ }),
]),
status: 'ACTIVE',
}))
@@ -4203,7 +4220,7 @@ describe('ChallengeEditorForm', () => {
})
})
- it('saves a new design draft before a screener member is assigned', async () => {
+ it('saves a new design draft before screening members are assigned', async () => {
const user = userEvent.setup()
mockedUseFetchChallengeTracks.mockReturnValue({
@@ -4223,7 +4240,7 @@ describe('ChallengeEditorForm', () => {
isLoading: false,
})
mockedPatchChallenge.mockResolvedValue({
- ...designChallengeWithDeferredScreener,
+ ...designChallengeWithDeferredScreeners,
status: 'DRAFT',
})
@@ -4231,7 +4248,7 @@ describe('ChallengeEditorForm', () => {
{
phaseId: 'screening-phase-id',
scorecardId: 'screening-scorecard-id',
}),
+ expect.objectContaining({
+ phaseId: 'checkpoint-screening-phase-id',
+ scorecardId: 'checkpoint-screening-scorecard-id',
+ }),
]),
status: 'DRAFT',
}))
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx
index ee86b0b1f..5fc1479f0 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx
@@ -631,7 +631,7 @@ describe('HumanReviewTab', () => {
})
})
- it('keeps a Reviewer resource on Review when Screening has no assigned Screener', async () => {
+ it('keeps a Reviewer resource on Review when screening phases have no assigned screeners', async () => {
mockedUseFetchResourceRoles.mockReturnValue({
resourceRoles: [
{
@@ -642,6 +642,10 @@ describe('HumanReviewTab', () => {
id: 'role-reviewer',
name: 'Reviewer',
},
+ {
+ id: 'role-checkpoint-screener',
+ name: 'Checkpoint Screener',
+ },
],
})
mockedUseFetchResources.mockReturnValue({
@@ -665,6 +669,11 @@ describe('HumanReviewTab', () => {
name: 'Screening',
phaseId: 'phase-screening',
},
+ {
+ id: 'checkpoint-screening-instance',
+ name: 'Checkpoint Screening',
+ phaseId: 'phase-checkpoint-screening',
+ },
{
id: 'review-instance',
name: 'Review',
@@ -679,6 +688,13 @@ describe('HumanReviewTab', () => {
phaseId: 'phase-screening',
shouldOpenOpportunity: false,
},
+ {
+ additionalMemberIds: [],
+ isMemberReview: true,
+ memberReviewerCount: 1,
+ phaseId: 'phase-checkpoint-screening',
+ shouldOpenOpportunity: false,
+ },
{
additionalMemberIds: [],
isMemberReview: true,
@@ -696,6 +712,9 @@ describe('HumanReviewTab', () => {
.getAttribute('data-value'))
.toBe('')
expect(screen.getByTestId('reviewers.1.memberId')
+ .getAttribute('data-value'))
+ .toBe('')
+ expect(screen.getByTestId('reviewers.2.memberId')
.getAttribute('data-value'))
.toBe('member-reviewer')
})
@@ -942,7 +961,7 @@ describe('HumanReviewTab', () => {
.not.toBeNull()
})
- it('marks only the standard Screening member assignment optional', () => {
+ it('marks Screening and Checkpoint Screening member assignments optional', () => {
mockedUseFetchChallengeTracks.mockReturnValue({
tracks: [
{
@@ -961,6 +980,10 @@ describe('HumanReviewTab', () => {
name: 'Screening',
phaseId: 'screening-phase-id',
},
+ {
+ name: 'Checkpoint Screening',
+ phaseId: 'checkpoint-screening-phase-id',
+ },
{
name: 'Review',
phaseId: 'review-phase-id',
@@ -974,6 +997,13 @@ describe('HumanReviewTab', () => {
scorecardId: 'screening-scorecard-id',
shouldOpenOpportunity: false,
},
+ {
+ isMemberReview: true,
+ memberReviewerCount: 1,
+ phaseId: 'checkpoint-screening-phase-id',
+ scorecardId: 'checkpoint-screening-scorecard-id',
+ shouldOpenOpportunity: false,
+ },
{
isMemberReview: true,
memberReviewerCount: 1,
@@ -989,6 +1019,8 @@ describe('HumanReviewTab', () => {
expect(screen.getByTestId('reviewers.0.memberId'))
.toHaveProperty('dataset.required', 'false')
expect(screen.getByTestId('reviewers.1.memberId'))
+ .toHaveProperty('dataset.required', 'false')
+ expect(screen.getByTestId('reviewers.2.memberId'))
.toHaveProperty('dataset.required', 'true')
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/reviewerAssignments.utils.spec.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/reviewerAssignments.utils.spec.ts
index fad7ff32c..5fe30605a 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/reviewerAssignments.utils.spec.ts
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/reviewerAssignments.utils.spec.ts
@@ -9,7 +9,7 @@ import {
} from './reviewerAssignments.utils'
describe('buildAssignedResourcesByReviewer', () => {
- it('reserves generic reviewer resources for Review rows before Screening fallbacks', () => {
+ it('reserves generic reviewer resources for Review rows before screening phase fallbacks', () => {
const resourceRoles: ResourceRole[] = [
{
id: 'role-screener',
@@ -19,6 +19,10 @@ describe('buildAssignedResourcesByReviewer', () => {
id: 'role-reviewer',
name: 'Reviewer',
},
+ {
+ id: 'role-checkpoint-screener',
+ name: 'Checkpoint Screener',
+ },
]
const resources: Resource[] = [
{
@@ -33,6 +37,11 @@ describe('buildAssignedResourcesByReviewer', () => {
phaseId: 'phase-screening',
roleId: 'role-reviewer',
},
+ {
+ memberReviewerCount: 1,
+ phaseId: 'phase-checkpoint-screening',
+ roleId: 'role-reviewer',
+ },
{
memberReviewerCount: 1,
phaseId: 'phase-review',
@@ -45,6 +54,10 @@ describe('buildAssignedResourcesByReviewer', () => {
'phase-screening',
'Screening',
],
+ [
+ 'phase-checkpoint-screening',
+ 'Checkpoint Screening',
+ ],
[
'phase-review',
'Review',
@@ -58,6 +71,7 @@ describe('buildAssignedResourcesByReviewer', () => {
expect(assignedResourcesByReviewer.map(assignedResources => assignedResources
.map(resource => resource.memberHandle)))
.toEqual([
+ [],
[],
['reviewer-one'],
])
From c03b5dca9c6b852ecbb9c48e5de487559cd22a79 Mon Sep 17 00:00:00 2001
From: Justin Gasper
Date: Wed, 12 Aug 2026 19:53:43 +1000
Subject: [PATCH 05/13] 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 4ea6c825380c22474b9f8596f9af1e0619b80744 Mon Sep 17 00:00:00 2001
From: Justin Gasper
Date: Wed, 12 Aug 2026 20:43:11 +1000
Subject: [PATCH 06/13] 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 07/13] 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 e6ad41979fc25fd47b422479fd2fe264cb49a534 Mon Sep 17 00:00:00 2001
From: Justin Gasper
Date: Wed, 12 Aug 2026 21:31:49 +1000
Subject: [PATCH 08/13] PM-5853: Rename active challenge label
What was broken
The Open support request modal labeled its challenge picker "Challenge (if applicable)" even though only active challenges are available.
Root cause
The picker label did not reflect the active-status filter already applied by the challenge service.
What was changed
Renamed the picker label to "Active Challenge (if applicable)".
Any added/updated tests
Updated the existing modal tests to query and verify the new accessible label.
---
.../OpenSupportRequestModal.spec.tsx | 6 +++---
.../OpenSupportRequestModal/OpenSupportRequestModal.tsx | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
index cf72bf26a..1829612bf 100644
--- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
+++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
@@ -144,7 +144,7 @@ describe('OpenSupportRequestModal', () => {
expect(screen.getByRole('dialog')
.getAttribute('data-size'))
.toBe('body')
- const challengeSelect = screen.getByLabelText('Challenge (if applicable)') as HTMLSelectElement
+ const challengeSelect = screen.getByLabelText('Active Challenge (if applicable)') as HTMLSelectElement
expect(challengeSelect.options[0].text)
.toBe('Select challenge')
expect(mockUseSWR.mock.calls[0][0])
@@ -155,7 +155,7 @@ describe('OpenSupportRequestModal', () => {
expect(mockedGetActiveChallenges)
.toHaveBeenCalledWith('12345')
- fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), {
+ fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), {
target: { value: 'challenge-2' },
})
fireEvent.change(screen.getByLabelText('Description'), {
@@ -209,7 +209,7 @@ describe('OpenSupportRequestModal', () => {
/>,
)
- fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), {
+ fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), {
target: { value: 'challenge-2' },
})
mockUseSWR.mockReturnValue({
diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
index d3ca825f9..a24197bc3 100644
--- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
+++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
@@ -192,7 +192,7 @@ export const OpenSupportRequestModal: FC = props =
>
-
+