Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/apps/work/src/lib/services/challenges.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import {
xhrPatchAsync,
xhrGetAsync,
xhrGetPaginatedAsync,
} from '~/libs/core'

import {
fetchChallenges,
fetchDefaultReviewers,
patchChallenge,
} from './challenges.service'

jest.mock('~/apps/review/src/lib/services/file-upload.service', () => ({
Expand Down Expand Up @@ -125,3 +127,42 @@ describe('fetchDefaultReviewers', () => {
])
})
})

describe('patchChallenge', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('preserves reviewer type when serializing manual reviewers', async () => {
const mockedPatch = xhrPatchAsync as jest.Mock

mockedPatch.mockResolvedValue({
id: 'challenge-1',
reviewers: [],
})

await patchChallenge('challenge-1', {
reviewers: [{
isMemberReview: true,
memberReviewerCount: 1,
phaseId: 'phase-1',
scorecardId: 'scorecard-1',
shouldOpenOpportunity: false,
type: 'ITERATIVE_REVIEW',
}],
})

expect(xhrPatchAsync)
.toHaveBeenCalledWith(
'https://example.com/challenges/challenge-1',
expect.objectContaining({
reviewers: [
expect.objectContaining({
type: 'ITERATIVE_REVIEW',
}),
],
}),
expect.any(Object),
)
})
})
6 changes: 6 additions & 0 deletions src/apps/work/src/lib/services/challenges.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ function serializeReviewers(reviewers: unknown): Reviewer[] | undefined {
phaseId: toOptionalTrimmedString(typedReviewer.phaseId),
scorecardId: toOptionalTrimmedString(typedReviewer.scorecardId),
shouldOpenOpportunity: toOptionalBooleanValue(typedReviewer.shouldOpenOpportunity),
type: isMemberReview
? (
toOptionalTrimmedString(typedReviewer.type)
|| toOptionalTrimmedString(typedReviewer.opportunityType)
)
: undefined,
}
})
.filter((reviewer): reviewer is Reviewer => !!reviewer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, and recalculates root phase dates when the challenge start changes. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior.
- `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags.
- `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields.
- `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 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.
- `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.
- `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls.
- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages.
- `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,50 @@ describe('HumanReviewTab', () => {
])
})

it('backfills the iterative review type for legacy manual reviewer rows', async () => {
render(<TestHarness />)

await waitFor(() => {
expect(screen.getByTestId('reviewers.0.type')
.getAttribute('data-value'))
.toBe('ITERATIVE_REVIEW')
})
})

it('upgrades auto-backfilled reviewer types when default reviewers load later', async () => {
const resolvedDefaultReviewers = [
{
isMemberReview: true,
memberReviewerCount: 1,
opportunityType: 'COMPONENT_DEV_REVIEW',
phaseId: 'phase-1',
roleId: 'role-1',
scorecardId: 'scorecard-1',
},
]
const deferredDefaultReviewers = createDeferredPromise<typeof resolvedDefaultReviewers>()

mockedFetchDefaultReviewers.mockReturnValue(deferredDefaultReviewers.promise)

render(<TestHarness />)

await waitFor(() => {
expect(screen.getByTestId('reviewers.0.type')
.getAttribute('data-value'))
.toBe('ITERATIVE_REVIEW')
})

await act(async () => {
deferredDefaultReviewers.resolve(resolvedDefaultReviewers)
})

await waitFor(() => {
expect(screen.getByTestId('reviewers.0.type')
.getAttribute('data-value'))
.toBe('COMPONENT_DEV_REVIEW')
})
})

it('restores iterative reviewer member ids from the iterative review role alias', async () => {
mockedUseFetchResourceRoles.mockReturnValue({
resourceRoles: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,42 @@ function getReviewerPhaseId(
return reviewPhase?.phaseId || reviewPhase?.id || phases[0]?.phaseId || phases[0]?.id
}

/**
* Resolves the stored review opportunity type for a manual reviewer row.
*
* Legacy drafts may omit the manual-reviewer `type` field even though work-manager
* treated iterative-review rows as `ITERATIVE_REVIEW`. Prefer the matching default
* reviewer configuration when present, then fall back to the iterative-review phase
* mapping before using the regular review default.
*/
function getReviewOpportunityTypeForReviewer(params: {
defaultReviewers: DefaultReviewer[]
phaseId: string | undefined
phaseNameById: Map<string, string>
phases: ChallengeEditorFormData['phases']
}): string {
const normalizedPhaseId = normalizeText(params.phaseId)
const matchingDefaultReviewer = normalizedPhaseId
? params.defaultReviewers.find(defaultReviewer => (
isMemberReviewer(defaultReviewer)
&& normalizeText(getReviewerPhaseId(defaultReviewer, params.phases)) === normalizedPhaseId
))
: undefined
const configuredOpportunityType = normalizeText(matchingDefaultReviewer?.opportunityType)

if (configuredOpportunityType) {
return configuredOpportunityType
}

const phaseName = normalizedPhaseId
? params.phaseNameById.get(normalizedPhaseId)
: undefined

return normalizeKey(phaseName) === 'iterativereview'
? REVIEW_OPPORTUNITY_TYPES.ITERATIVE_REVIEW
: REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW
}

function getRoleNameForPhaseName(phaseName: string | undefined): string {
const normalizedPhaseName = normalizeKey(phaseName)

Expand Down Expand Up @@ -514,6 +550,7 @@ export const HumanReviewTab: FC = () => {
// Keep existing selections intact until the first scorecard fetch resolves.
const [isScorecardsLoading, setIsScorecardsLoading] = useState<boolean>(true)
const [loadError, setLoadError] = useState<string | undefined>()
const autoBackfilledReviewerTypesRef = useRef<Record<string, string>>({})
const validatedScorecardSelectionsRef = useRef<Record<string, string>>({})

const challengeId = useWatch({
Expand Down Expand Up @@ -842,6 +879,76 @@ export const HumanReviewTab: FC = () => {
}
}, [trackId, typeId])

useEffect(() => {
const activeReviewerTypeFieldNames = new Set<string>()

reviewerRows.forEach((reviewer, reviewerIndex) => {
const fieldIndex = getReviewerFieldIndex(reviewerIndex)
if (
fieldIndex === undefined
|| !reviewer
|| reviewer.isMemberReview === false
) {
return
}

const reviewerTypeFieldName = `reviewers.${fieldIndex}.type`
const currentReviewerType = normalizeText(reviewer.type)
const autoBackfilledReviewerType = autoBackfilledReviewerTypesRef.current[reviewerTypeFieldName]

activeReviewerTypeFieldNames.add(reviewerTypeFieldName)

if (currentReviewerType && !autoBackfilledReviewerType) {
return
}

if (
currentReviewerType
&& autoBackfilledReviewerType
&& currentReviewerType !== autoBackfilledReviewerType
) {
delete autoBackfilledReviewerTypesRef.current[reviewerTypeFieldName]
return
}

const nextReviewerType = getReviewOpportunityTypeForReviewer({
defaultReviewers,
phaseId: reviewer.phaseId,
phaseNameById,
phases,
})

autoBackfilledReviewerTypesRef.current[reviewerTypeFieldName] = nextReviewerType

if (currentReviewerType === nextReviewerType) {
return
}

formContext.setValue(
reviewerTypeFieldName as any,
nextReviewerType,
{
shouldDirty: false,
shouldValidate: true,
},
)
})

Object.keys(autoBackfilledReviewerTypesRef.current)
.forEach(reviewerTypeFieldName => {
if (!activeReviewerTypeFieldNames.has(reviewerTypeFieldName)) {
delete autoBackfilledReviewerTypesRef.current[reviewerTypeFieldName]
}
})
}, [
defaultReviewers,
formContext,
getReviewerFieldIndex,
phaseNameById,
phases,
reviewerRows,
])
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

useEffect(() => {
if (isScorecardsLoading || loadError) {
return
Expand Down
Loading