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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSelectElement> {
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(
<AiReviewTab
canSwitchReviewMode
challengeId='challenge-1'
hasSubmissions
reviewers={persistedAiReviewers}
/>,
)

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(
<AiReviewTab
canSwitchReviewMode
challengeId='challenge-1'
hasSubmissions
reviewers={persistedAiReviewers}
/>,
)

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(
<AiReviewTab
canSwitchReviewMode={false}
challengeId='challenge-1'
hasSubmissions
reviewers={persistedAiReviewers}
/>,
)

expect((await findReviewModeSelect()).disabled)
.toBe(true)
expect(screen.queryByText(/You can still switch the review mode/))
.toBeNull()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void
Expand Down Expand Up @@ -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: <K extends keyof AiReviewConfigurationDraft>(
field: K,
Expand Down Expand Up @@ -418,7 +425,7 @@ const ReviewSettings: FC<ReviewSettingsProps> = (
<label className={styles.fieldGroup}>
<span>Review Mode</span>
<select
disabled={props.readOnly}
disabled={props.readOnly && props.canChangeMode !== true}
onChange={handleModeChange}
value={mode}
>
Expand Down Expand Up @@ -494,6 +501,9 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
const normalizedChallengeId = normalizeReviewerText(props.challengeId)
const onConfigRemoved = props.onConfigRemoved
const readOnly = props.hasSubmissions === true
// A locked configuration can still switch its review mode until the review phase starts.
const canSwitchReviewModeOnly = readOnly && props.canSwitchReviewMode === true
const isAutosaveLocked = readOnly && !canSwitchReviewModeOnly
const onConfigPersisted = props.onConfigPersisted
const onConfigSaveControllerReady = props.onConfigSaveControllerReady
const onSelectedModeChange = props.onSelectedModeChange
Expand Down Expand Up @@ -1041,7 +1051,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
}, [configurationMode, selectedTrackName, selectedTypeName])

const persistConfiguration = useCallback(async (): Promise<void> => {
if (!normalizedConfiguration || readOnly || validationErrors.length > 0) {
if (!normalizedConfiguration || isAutosaveLocked || validationErrors.length > 0) {
return
}

Expand Down Expand Up @@ -1075,7 +1085,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
configSavePromiseRef.current = undefined
}
}
}, [configId, normalizedConfiguration, readOnly, validationErrors])
}, [configId, isAutosaveLocked, normalizedConfiguration, validationErrors])

const flushPendingSave = useCallback(async (): Promise<void> => {
if (configSavePromiseRef.current) {
Expand Down Expand Up @@ -1122,7 +1132,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
}, [flushPendingSave, onConfigSaveControllerReady])

useEffect(() => {
if (!normalizedChallengeId || !configurationMode || !normalizedConfiguration || readOnly) {
if (!normalizedChallengeId || !configurationMode || !normalizedConfiguration || isAutosaveLocked) {
return undefined
}

Expand Down Expand Up @@ -1150,9 +1160,9 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
}, [
configId,
configurationMode,
isAutosaveLocked,
normalizedChallengeId,
normalizedConfiguration,
readOnly,
validationErrors,
])

Expand Down Expand Up @@ -1182,6 +1192,10 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
? (
<div className={styles.infoBanner}>
AI review configuration is locked because this challenge already has submissions.
{canSwitchReviewModeOnly
? ' You can still switch the review mode between AI_GATING and AI_ONLY '
+ 'until the review phase starts.'
: undefined}
</div>
)
: undefined}
Expand Down Expand Up @@ -1295,6 +1309,17 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
: undefined}
</section>

{canSwitchReviewModeOnly
? (
<ReviewSettings
canChangeMode
configuration={configuration}
onUpdate={updateConfiguration}
readOnly
/>
)
: undefined}

<section className={styles.sectionCard}>
<div className={styles.sectionHeader}>
<h3>Template Workflows</h3>
Expand Down Expand Up @@ -1332,6 +1357,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
</div>

<ReviewSettings
canChangeMode={canSwitchReviewModeOnly}
configuration={configuration}
onUpdate={updateConfiguration}
readOnly={readOnly}
Expand Down
Loading
Loading