diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index e0a58d3ad..2f7c76c65 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -50,7 +50,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `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 Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch. Design `Challenge` reviewers additionally leave the Checkpoint Review, Review, and Approval member assignments optional because the selected copilot is assigned to those private phases during save; 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. +- `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. The only exception is the review mode: it can still switch between AI_GATING and AI_ONLY (for manual and template configs) until a review phase (AI Screening, AI Review, Review, ...) starts. When an active challenge's mode no longer matches its timeline, the editor saves the reviewers right away so challenge-api moves the challenge to the matching timeline (AI Review and Approval for AI_ONLY, AI Screening and the review phases for AI_GATING), then loads the new phases into Timeline & Schedule. ## Autosave Behavior diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx index 0de48113d..897480e1a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx @@ -585,4 +585,149 @@ describe('AiReviewTab review mode options', () => { ) .toBe(true) }) + + describe('with submissions', () => { + const lockedWorkflows = [ + { + id: 'config-workflow-1', + isGating: false, + weightPercent: 100, + workflowId: 'workflow-1', + }, + ] + + /** + * Finds the Review Mode select, whose label also wraps the mode description. + */ + async function findReviewModeSelect(): Promise { + return (await screen.findByRole('option', { name: 'AI_ONLY' })) + .closest('select') as HTMLSelectElement + } + + beforeEach(() => { + mockedFetchWorkflows.mockResolvedValue([ + { + id: 'workflow-1', + name: 'Workflow 1', + }, + ]) + mockedUpdateAiReviewConfig.mockImplementation(async (_configId, input) => ({ + ...baseConfiguration, + ...input, + id: 'config-1', + })) + }) + + it('keeps only the review mode editable until the review phase starts', async () => { + jest.useFakeTimers() + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + mode: 'AI_ONLY', + workflows: lockedWorkflows, + }) + + render( + , + ) + + const reviewModeSelect = await findReviewModeSelect() + + expect(reviewModeSelect.disabled) + .toBe(false) + expect((screen.getByRole('slider') as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByLabelText('AI Workflow') as HTMLSelectElement).disabled) + .toBe(true) + expect(screen.queryByRole('button', { name: 'Remove AI config' })) + .toBeNull() + expect(screen.getByText(/You can still switch the review mode/)).not.toBeNull() + + fireEvent.change(reviewModeSelect, { + target: { + value: 'AI_GATING', + }, + }) + + await act(async () => { + jest.advanceTimersByTime(1600) + }) + + await waitFor(() => { + expect(mockedUpdateAiReviewConfig) + .toHaveBeenCalledWith( + 'config-1', + expect.objectContaining({ + minPassingThreshold: 75, + mode: 'AI_GATING', + }), + ) + }) + }) + + it('lets template configs switch the review mode until the review phase starts', async () => { + jest.useFakeTimers() + mockedFetchAiReviewTemplates.mockResolvedValue([]) + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + templateId: 'template-1', + workflows: lockedWorkflows, + }) + + render( + , + ) + + fireEvent.change(await findReviewModeSelect(), { + target: { + value: 'AI_ONLY', + }, + }) + + await act(async () => { + jest.advanceTimersByTime(1600) + }) + + await waitFor(() => { + expect(mockedUpdateAiReviewConfig) + .toHaveBeenCalledWith( + 'config-1', + expect.objectContaining({ + mode: 'AI_ONLY', + templateId: 'template-1', + }), + ) + }) + }) + + it('locks the review mode once the review phase has started', async () => { + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + workflows: lockedWorkflows, + }) + + render( + , + ) + + expect((await findReviewModeSelect()).disabled) + .toBe(true) + expect(screen.queryByText(/You can still switch the review mode/)) + .toBeNull() + }) + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx index d6a064b21..71eec5936 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx @@ -53,6 +53,11 @@ export interface AiReviewConfigSaveController { } interface AiReviewTabProps { + /** + * Keeps the review mode (AI_GATING / AI_ONLY) editable while `hasSubmissions` locks the + * rest of the configuration. The parent sets it until the challenge's review phase starts. + */ + canSwitchReviewMode?: boolean challengeId?: string hasSubmissions?: boolean onConfigRemoved?: () => Promise | void @@ -89,6 +94,8 @@ interface ManualWorkflowEditorProps { } interface ReviewSettingsProps { + /** Keeps the review mode select enabled when `readOnly` locks the other settings. */ + canChangeMode?: boolean configuration: AiReviewConfigurationDraft onUpdate: ( field: K, @@ -418,7 +425,7 @@ const ReviewSettings: FC = (