Skip to content
Open
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 @@ -23,7 +23,7 @@
payload is available, so the create route can expand to the full editor immediately after the
initial draft is created.
- `components/*Field.tsx`: field-level components for each challenge section.
- `components/ReviewersField/*`: tabbed human/AI review configuration. Copilot-only users editing Design `Challenge` types see one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults; admins and managers retain the full interface. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
- `components/ReviewersField/*`: tabbed human/AI review configuration. Every user editing a Design `Challenge` sees one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults. Administrators additionally get a `Show advanced review configuration` toggle that expands the full tabbed interface on demand; copilots and managers only see the Screener selector. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
- `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them.

## Validation Rules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ jest.mock('./ReviewCostField', () => ({
}))
jest.mock('./ReviewersField', () => ({
ReviewersField: (props: {
canConfigureFullReview?: boolean
isReadOnly?: boolean
screenerOnly?: boolean
}) => {
Expand All @@ -703,6 +704,7 @@ jest.mock('./ReviewersField', () => ({

return (
<div
data-can-configure-full-review={props.canConfigureFullReview === true ? 'true' : 'false'}
data-read-only={props.isReadOnly === true ? 'true' : 'false'}
data-reviewers={JSON.stringify(reviewers || [])}
data-screener-only={props.screenerOnly === true ? 'true' : 'false'}
Expand Down Expand Up @@ -3146,7 +3148,7 @@ describe('ChallengeEditorForm', () => {
.toHaveAttribute('data-screener-only', 'true')
})

it('keeps the full review configuration for an admin editing a Design Challenge', () => {
it('uses the simplified screener review for an admin editing a Design Challenge', () => {
mockedUseFetchChallengeTracks.mockReturnValue({
isLoading: false,
tracks: [{
Expand Down Expand Up @@ -3178,7 +3180,47 @@ describe('ChallengeEditorForm', () => {
)

expect(screen.getByTestId('reviewers-field'))
.toHaveAttribute('data-screener-only', 'false')
.toHaveAttribute('data-screener-only', 'true')
expect(screen.getByTestId('reviewers-field'))
.toHaveAttribute('data-can-configure-full-review', 'true')
})

it('uses the simplified screener review for a manager editing a Design Challenge', () => {
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,
})
const managerContextValue: WorkAppContextModel = {
...copilotContextValue,
isCopilot: false,
isManager: true,
userRoles: ['project manager'],
}

render(
<MemoryRouter>
<WorkAppContext.Provider value={managerContextValue}>
<ChallengeEditorForm challenge={designChallengeWithDeferredScreeners} />
</WorkAppContext.Provider>
</MemoryRouter>,
)

expect(screen.getByTestId('reviewers-field'))
.toHaveAttribute('data-screener-only', 'true')
expect(screen.getByTestId('reviewers-field'))
.toHaveAttribute('data-can-configure-full-review', 'false')
})

it('keeps the full review configuration for a copilot editing a Design First2Finish', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2223,8 +2223,7 @@ export const ChallengeEditorForm: FC<ChallengeEditorFormProps> = (
const shouldUseCopilotBillingSummary = workAppContext.isCopilot
&& !workAppContext.isAdmin
&& !workAppContext.isManager
const shouldUseSimplifiedDesignReview = shouldUseCopilotBillingSummary
&& isDesignTrackSelected
const shouldUseSimplifiedDesignReview = isDesignTrackSelected
&& isChallengeTypeSelected
const getPersistedAssignmentValueByFields = useCallback((
fallbackValue: string | undefined,
Expand Down Expand Up @@ -3861,6 +3860,7 @@ export const ChallengeEditorForm: FC<ChallengeEditorFormProps> = (
<h3 className={styles.sectionTitle}>Review</h3>
<div className={styles.block}>
<ReviewersField
canConfigureFullReview={workAppContext.isAdmin}
isReadOnly={isReadOnly}
onConfigSaveControllerReady={function (controller: AiReviewConfigSaveController | undefined) {
aiReviewConfigSaveControllerRef.current = controller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
max-width: 420px;
}

.fullReviewToggle {
display: flex;
justify-content: flex-start;
margin-bottom: 16px;
}

.summary {
color: #555;
display: flex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ jest.mock('../../../../../lib/services', () => ({
patchChallenge: jest.fn(),
}))

jest.mock('~/libs/ui', () => ({
Button: (props: {
disabled?: boolean
label: string
onClick?: () => void
}) => (
<button
disabled={props.disabled}
onClick={props.onClick}
type='button'
>
{props.label}
</button>
),
}), {
virtual: true,
})

jest.mock('./HumanReviewTab', () => ({
__esModule: true,
default: (props: { screenerOnly?: boolean }) => (
Expand Down Expand Up @@ -99,6 +117,7 @@ const mockedPatchChallenge = jest.spyOn(services, 'patchChallenge')
const mockedFetchAiReviewConfigByChallenge = services.fetchAiReviewConfigByChallenge as jest.Mock

interface TestHarnessProps {
canConfigureFullReview?: boolean
isReadOnly?: boolean
numOfSubmissions?: number
reviewers: Reviewer[]
Expand All @@ -118,14 +137,20 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => {
})
const reviewersField = (
<ReviewersField
canConfigureFullReview={props.canConfigureFullReview}
isReadOnly={props.isReadOnly}
screenerOnly={props.screenerOnly}
/>
)

const reviewersFormError = formMethods.formState.errors.reviewers?.message

return (
<FormProvider {...formMethods}>
{reviewersField}
{reviewersFormError
? <div data-testid='reviewers-form-error'>{reviewersFormError}</div>
: undefined}
</FormProvider>
)
}
Expand Down Expand Up @@ -158,6 +183,55 @@ describe('ReviewersField', () => {
.toBeNull()
expect(screen.queryByText('Manual review configuration is required.'))
.toBeNull()
expect(screen.queryByRole('button', { name: 'Show advanced review configuration' }))
.toBeNull()
})

it('starts collapsed for administrators and reveals the full configuration on demand', async () => {
const user = userEvent.setup()

render(
<TestHarness
canConfigureFullReview
reviewers={[]}
screenerOnly
/>,
)

expect(screen.getByTestId('human-review-tab')
.getAttribute('data-screener-only'))
.toBe('true')
expect(screen.queryByRole('tablist'))
.toBeNull()

await user.click(screen.getByRole('button', { name: 'Show advanced review configuration' }))

expect(screen.getByRole('tablist')).not.toBeNull()
expect(screen.getByTestId('human-review-tab')
.getAttribute('data-screener-only'))
.toBe('false')
expect(screen.getByTestId('ai-review-tab')).not.toBeNull()

await user.click(screen.getByRole('button', { name: 'Hide advanced review configuration' }))

expect(screen.queryByRole('tablist'))
.toBeNull()
expect(screen.getByTestId('human-review-tab')
.getAttribute('data-screener-only'))
.toBe('true')
})

it('does not offer the advanced toggle outside the simplified review section', () => {
render(
<TestHarness
canConfigureFullReview
reviewers={[]}
/>,
)

expect(screen.queryByRole('button', { name: 'Show advanced review configuration' }))
.toBeNull()
expect(screen.getByRole('tablist')).not.toBeNull()
})

it('uses tab labels with reviewer counts and toggles between human and AI content', async () => {
Expand Down Expand Up @@ -319,10 +393,28 @@ describe('ReviewersField', () => {
await user.click(screen.getByRole('tab', { name: 'AI Review (0)' }))
await user.click(screen.getByRole('button', { name: 'Persist AI config' }))

expect(screen.getByText(
'Manual review configuration is required.',
))
.toBeInTheDocument()
expect(screen.getByTestId('reviewers-form-error').textContent)
.toBe('Manual review configuration is required.')
})

it('does not require manual reviewer configuration in the simplified screener view', async () => {
const user = userEvent.setup()

render(
<TestHarness
canConfigureFullReview
reviewers={[]}
screenerOnly
/>,
)

await user.click(screen.getByRole('button', { name: 'Show advanced review configuration' }))
await user.click(screen.getByRole('tab', { name: 'AI Review (0)' }))
await user.click(screen.getByRole('button', { name: 'Persist AI config' }))
await user.click(screen.getByRole('button', { name: 'Hide advanced review configuration' }))

expect(screen.queryByTestId('reviewers-form-error'))
.toBeNull()
})

it('supports keyboard navigation between review tabs', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import classNames from 'classnames'

import { ChallengeStatus } from '~/apps/admin/src/lib/models'
import { Button } from '~/libs/ui'

import * as services from '../../../../../lib/services'
import {
Expand All @@ -40,6 +41,7 @@ const REVIEW_TAB = ['ai', 'human', 'context'] as const
type ReviewTab = typeof REVIEW_TAB[number]

interface ReviewersFieldProps {
canConfigureFullReview?: boolean
isReadOnly?: boolean
onConfigSaveControllerReady?: (controller: AiReviewConfigSaveController | undefined) => void
screenerOnly?: boolean
Expand All @@ -59,6 +61,7 @@ function hasReviewerChanges(
export const ReviewersField: FC<ReviewersFieldProps> = (props: ReviewersFieldProps) => {
const formContext = useFormContext<ChallengeEditorFormData>()
const [activeTab, setActiveTab] = useState<ReviewTab>('human')
const [isFullReviewExpanded, setIsFullReviewExpanded] = useState<boolean>(false)
const [aiReviewMode, setAiReviewMode] = useState<AiReviewMode | undefined>()
const [hasLoadedAiConfig, setHasLoadedAiConfig] = useState<boolean>(false)
const [reviewContextRequirementCount, setReviewContextRequirementCount] = useState<number | undefined>(undefined)
Expand Down Expand Up @@ -160,11 +163,17 @@ export const ReviewersField: FC<ReviewersFieldProps> = (props: ReviewersFieldPro
const reviewContextLabel = reviewContextRequirementCount
? `Review Context (${reviewContextRequirementCount})`
: 'Review Context'
/**
* The simplified Design review section exposes the screener assignment only.
* Administrators keep access to the complete configuration behind a toggle.
*/
const showScreenerOnlyView = !!props.screenerOnly && !isFullReviewExpanded
const showFullReviewToggle = !!props.screenerOnly && !!props.canConfigureFullReview
const aiGatingManualReviewError = useMemo(
() => (!props.screenerOnly && aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0
() => (!showScreenerOnlyView && aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0
? 'Manual review configuration is required.'
: undefined),
[aiReviewMode, humanReviewersCount, props.screenerOnly],
[aiReviewMode, humanReviewersCount, showScreenerOnlyView],
)

useEffect(() => {
Expand All @@ -189,6 +198,9 @@ export const ReviewersField: FC<ReviewersFieldProps> = (props: ReviewersFieldPro
const handleTabChange = useCallback((tab: ReviewTab): void => {
setActiveTab(tab)
}, [])
const handleFullReviewToggle = useCallback((): void => {
setIsFullReviewExpanded(previousValue => !previousValue)
}, [])
const focusTab = useCallback((tab: ReviewTab): void => {
handleTabChange(tab)

Expand Down Expand Up @@ -325,11 +337,25 @@ export const ReviewersField: FC<ReviewersFieldProps> = (props: ReviewersFieldPro
)
: undefined}

{!props.isReadOnly && props.screenerOnly
{!props.isReadOnly && showFullReviewToggle
? (
<div className={styles.fullReviewToggle}>
<Button
label={isFullReviewExpanded
? 'Hide advanced review configuration'
: 'Show advanced review configuration'}
onClick={handleFullReviewToggle}
secondary
/>
</div>
)
: undefined}

{!props.isReadOnly && showScreenerOnlyView
? <HumanReviewTab screenerOnly />
: undefined}

{!props.isReadOnly && !props.screenerOnly
{!props.isReadOnly && !showScreenerOnlyView
? (
<>
<div
Expand Down
Loading