From 4ea6c825380c22474b9f8596f9af1e0619b80744 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 20:43:11 +1000 Subject: [PATCH] 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,