From c54f074cae45e271e842be1f7ba0dbb8ada93772 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 18:20:37 +1000 Subject: [PATCH] PM-5755: Simplify design screener assignment What was broken Copilot-only users editing Design Challenges saw the complete review configuration and had to assign checkpoint and final screeners independently. Root cause The Review section did not distinguish copilot-only Design Challenge editing from admin and manager configuration, and each screening reviewer synchronized only its own resource role. What was changed Show a single Screener selector for copilot-only Design Challenges while leaving the full interface for administrators and managers. Synchronize the selected member across Checkpoint Screener and Screener resources, preserve hidden reviewer defaults, and guard replacement, clearing, legacy assignments, request failures, and overlapping updates. Any added/updated tests Added coverage for role and challenge-type gating, simplified rendering, two-round and one-round assignment, replacement and clearing, legacy handle-only resources, fetch failures, in-flight locking, and preservation of reviewer configuration. --- .../challenges/ChallengeEditorPage/README.md | 4 +- .../components/ChallengeEditorForm.spec.tsx | 131 +++- .../components/ChallengeEditorForm.tsx | 10 +- .../ReviewersField/HumanReviewTab.spec.tsx | 612 +++++++++++++++++- .../ReviewersField/HumanReviewTab.tsx | 492 +++++++++++++- .../ReviewersField/ReviewersField.module.scss | 4 + .../ReviewersField/ReviewersField.spec.tsx | 42 +- .../ReviewersField/ReviewersField.tsx | 17 +- 8 files changed, 1277 insertions(+), 35 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 4f38f5fdf..30438c767 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -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. 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. 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. - `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 @@ -80,7 +80,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `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. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. Standard Screening member selectors remain available but are optional so a copilot can assign the Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. Copilot-only Design Challenge editing exposes only the shared Screener selector; selecting, replacing, or clearing it updates both the `Screener` and any `Checkpoint Screener` challenge resources. On the full 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. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. Standard Screening member selectors remain available but are optional so a copilot can assign the Screener after launch. - `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. 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..2f735e926 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 @@ -684,7 +684,10 @@ jest.mock('./ReviewCostField', () => ({ ReviewCostField: () => <>, })) jest.mock('./ReviewersField', () => ({ - ReviewersField: (props: { isReadOnly?: boolean }) => { + ReviewersField: (props: { + isReadOnly?: boolean + screenerOnly?: boolean + }) => { const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') const reviewers = reactHookForm.useWatch({ control: reactHookForm.useFormContext().control, @@ -695,6 +698,7 @@ jest.mock('./ReviewersField', () => ({
Reviewers Field @@ -3058,6 +3062,131 @@ describe('ChallengeEditorForm', () => { .toBeNull() }) + it('uses the simplified screener review for a copilot 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, + }) + + render( + + + + + , + ) + + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-screener-only', 'true') + }) + + it('uses persisted Design Challenge metadata for the copilot review gate', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [], + isLoading: false, + }) + + render( + + + + + , + ) + + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-screener-only', 'true') + }) + + it('keeps the full review configuration for an admin 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 adminContextValue: WorkAppContextModel = { + ...copilotContextValue, + isAdmin: true, + userRoles: ['administrator'], + } + + render( + + + + + , + ) + + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-screener-only', 'false') + }) + + it('keeps the full review configuration for a copilot editing a Design First2Finish', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'F2F', + id: 'design-first2finish', + name: 'First2Finish', + }], + isLoading: false, + }) + + render( + + + + + , + ) + + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-screener-only', 'false') + }) + it('does not delete manual iterative reviewer resources when saving a first2finish draft', async () => { const user = userEvent.setup() 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..ad5303a4a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -2017,17 +2017,17 @@ export const ChallengeEditorForm: FC = ( ) const isChallengeTypeSelected = useMemo( (): boolean => { - const normalizedChallengeTypeName = (selectedChallengeType?.name || '') + const normalizedChallengeTypeName = (resolvedChallengeTypeName || '') .trim() .toUpperCase() - const normalizedChallengeTypeAbbreviation = (selectedChallengeType?.abbreviation || '') + const normalizedChallengeTypeAbbreviation = (resolvedChallengeTypeAbbreviation || '') .trim() .toUpperCase() return normalizedChallengeTypeName === CHALLENGE_TYPE_CHALLENGE_NAME || normalizedChallengeTypeAbbreviation === CHALLENGE_TYPE_CHALLENGE_ABBREVIATION }, - [selectedChallengeType], + [resolvedChallengeTypeAbbreviation, resolvedChallengeTypeName], ) const isTaskChallengeSelected = useMemo( (): boolean => isTaskChallengeType(selectedChallengeType) @@ -2219,6 +2219,9 @@ export const ChallengeEditorForm: FC = ( const shouldUseCopilotBillingSummary = workAppContext.isCopilot && !workAppContext.isAdmin && !workAppContext.isManager + const shouldUseSimplifiedDesignReview = shouldUseCopilotBillingSummary + && isDesignTrackSelected + && isChallengeTypeSelected const getPersistedAssignmentValueByFields = useCallback(( fallbackValue: string | undefined, roleNames: readonly string[], @@ -3838,6 +3841,7 @@ export const ChallengeEditorForm: FC = ( onConfigSaveControllerReady={function (controller: AiReviewConfigSaveController | undefined) { aiReviewConfigSaveControllerRef.current = controller }} + screenerOnly={shouldUseSimplifiedDesignReview} />
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index ee86b0b1f..da18e1f62 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -32,6 +32,7 @@ import { MAX_MANUAL_REVIEWER_COUNT, } from '../../../../../lib/constants/challenge-editor.constants' import { + createResource, deleteResource, fetchDefaultReviewers, fetchProfile, @@ -68,6 +69,7 @@ jest.mock('../../../../../lib/components/form', () => ({ control: formContext.control, name: props.name, }) + const selectedValue = typeof controller.field.value === 'string' ? controller.field.value : '' @@ -156,8 +158,11 @@ jest.mock('../../../../../lib/components/form', () => ({ ) }, FormUserAutocomplete: (props: { + disabled?: boolean label: string name: string + onValueChange?: (value: string) => void + placeholder?: string required?: boolean }) => { const { @@ -170,13 +175,26 @@ jest.mock('../../../../../lib/components/form', () => ({ name: props.name, }) + function handleChange(event: ChangeEvent): void { + props.onValueChange?.(event.target.value) + controller.field.onChange(event.target.value) + } + return (
- {props.label} + +
) }, @@ -221,6 +239,7 @@ const mockedUseFetchChallengeTracks = useFetchChallengeTracks as jest.Mock const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock const mockedUseFetchResourceRoles = useFetchResourceRoles as jest.Mock const mockedUseFetchResources = useFetchResources as jest.Mock +const mockedCreateResource = createResource as jest.Mock const mockedDeleteResource = deleteResource as jest.Mock const mockedFetchDefaultReviewers = fetchDefaultReviewers as jest.Mock const mockedFetchProfile = fetchProfile as jest.Mock @@ -257,11 +276,14 @@ interface TestHarnessProps { restoreStaleScorecardId?: boolean showAdditionalMemberIdsValue?: boolean showMemberValue?: boolean + showMemberValueIndex?: number + showSecondMemberValue?: boolean showPublicOpportunityValue?: boolean showRoleValue?: boolean showRoleValueIndex?: number showScorecardValue?: boolean showScorecardValueIndex?: number + screenerOnly?: boolean } const baseDefaultValues: ChallengeEditorFormData = { @@ -398,6 +420,7 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { }) const roleValueIndex = props.showRoleValueIndex ?? 0 const scorecardValueIndex = props.showScorecardValueIndex ?? 0 + const memberValueIndex = props.showMemberValueIndex ?? 0 useEffect(() => { if (!props.initialScorecardErrorMessage) { @@ -415,7 +438,7 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { return ( - + {props.restoreStaleAdditionalMemberIds ? : undefined} @@ -434,7 +457,14 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { {props.showMemberValue ? (
- {formMethods.watch('reviewers.0.memberId') || ''} + {String(formMethods.watch(`reviewers.${memberValueIndex}.memberId` as never) || '')} +
+ ) + : undefined} + {props.showSecondMemberValue + ? ( +
+ {formMethods.watch('reviewers.1.memberId') || ''}
) : undefined} @@ -498,6 +528,7 @@ describe('HumanReviewTab', () => { .mockResolvedValue(undefined), resources: [], }) + mockedCreateResource.mockResolvedValue(undefined) mockedDeleteResource.mockResolvedValue(undefined) mockedFetchDefaultReviewers.mockImplementation(() => createPendingPromise()) mockedFetchProfile.mockResolvedValue(undefined) @@ -992,6 +1023,581 @@ describe('HumanReviewTab', () => { .toHaveProperty('dataset.required', 'true') }) + it('assigns one simplified screener selection to checkpoint and final screening roles', async () => { + const mutateResources = jest.fn() + .mockResolvedValue(undefined) + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [ + { + id: 'checkpoint-screener-role-id', + name: 'Checkpoint Screener', + }, + { + id: 'screener-role-id', + name: 'Screener', + }, + ], + }) + mockedUseFetchResources.mockReturnValue({ + isLoading: false, + mutate: mutateResources, + resources: [], + }) + + render( + , + ) + + expect(screen.getByLabelText('Screener') + .getAttribute('placeholder')) + .toBe('Select user') + expect(screen.queryByLabelText('Phase')) + .toBeNull() + expect(screen.queryByLabelText('Scorecard')) + .toBeNull() + + fireEvent.change(screen.getByLabelText('Screener'), { + target: { + value: 'screener-member-id', + }, + }) + + await waitFor(() => { + expect(screen.getByTestId('member-id-value').textContent) + .toBe('screener-member-id') + expect(screen.getByTestId('second-member-id-value').textContent) + .toBe('screener-member-id') + expect((screen.getByLabelText('Screener') as HTMLInputElement).value) + .toBe('screener-member-id') + expect(mockedCreateResource.mock.calls) + .toEqual(expect.arrayContaining([ + [{ + challengeId: 'challenge-1', + memberId: 'screener-member-id', + roleId: 'checkpoint-screener-role-id', + }], + [{ + challengeId: 'challenge-1', + memberId: 'screener-member-id', + roleId: 'screener-role-id', + }], + ])) + }) + expect(screen.getByTestId('scorecard-id-value').textContent) + .toBe('checkpoint-screening-scorecard-id') + expect(mockedCreateResource) + .toHaveBeenCalledTimes(2) + expect(mockedDeleteResource) + .not.toHaveBeenCalled() + await waitFor(() => { + expect(mutateResources) + .toHaveBeenCalled() + }) + }) + + it('disables simplified screener changes while both resource assignments synchronize', async () => { + const resourceCreateRequest = createDeferredPromise() + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [ + { + id: 'checkpoint-screener-role-id', + name: 'Checkpoint Screener', + }, + { + id: 'screener-role-id', + name: 'Screener', + }, + ], + }) + mockedUseFetchResources.mockReturnValue({ + isError: false, + isLoading: false, + mutate: jest.fn() + .mockResolvedValue(undefined), + resources: [], + }) + mockedCreateResource.mockReturnValue(resourceCreateRequest.promise) + + render( + , + ) + + const screenerField = screen.getByLabelText('Screener') as HTMLInputElement + + fireEvent.change(screenerField, { + target: { + value: 'first-member-id', + }, + }) + + await waitFor(() => { + expect(screenerField.disabled) + .toBe(true) + }) + fireEvent.change(screenerField, { + target: { + value: 'second-member-id', + }, + }) + expect(mockedCreateResource) + .toHaveBeenCalledTimes(2) + + await act(async () => { + resourceCreateRequest.resolve(undefined) + await resourceCreateRequest.promise + }) + await waitFor(() => { + expect(screenerField.disabled) + .toBe(false) + }) + }) + + it('disables simplified screener assignment when challenge resources fail to load', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + id: 'screener-role-id', + name: 'Screener', + }], + }) + mockedUseFetchResources.mockReturnValue({ + isError: true, + isLoading: false, + mutate: jest.fn() + .mockResolvedValue(undefined), + resources: [], + }) + + render( + , + ) + + expect((screen.getByLabelText('Screener') as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByText('Unable to load screener assignments.')) + .not.toBeNull() + }) + + it('replaces existing checkpoint and final screener resources together', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [ + { + id: 'checkpoint-screener-role-id', + name: 'Checkpoint Screener', + }, + { + id: 'screener-role-id', + name: 'Screener', + }, + ], + }) + mockedUseFetchResources.mockReturnValue({ + isLoading: false, + mutate: jest.fn() + .mockResolvedValue(undefined), + resources: [ + { + challengeId: 'challenge-1', + memberId: 'old-member-id', + roleId: 'checkpoint-screener-role-id', + roleName: 'Checkpoint Screener', + }, + { + challengeId: 'challenge-1', + memberId: 'old-member-id', + roleId: 'screener-role-id', + roleName: 'Screener', + }, + ], + }) + + render( + , + ) + + fireEvent.change(screen.getByLabelText('Screener'), { + target: { + value: 'new-member-id', + }, + }) + + await waitFor(() => { + expect(mockedDeleteResource.mock.calls) + .toEqual(expect.arrayContaining([ + [{ + challengeId: 'challenge-1', + memberHandle: undefined, + memberId: 'old-member-id', + roleId: 'checkpoint-screener-role-id', + }], + [{ + challengeId: 'challenge-1', + memberHandle: undefined, + memberId: 'old-member-id', + roleId: 'screener-role-id', + }], + ])) + }) + expect(mockedCreateResource) + .toHaveBeenCalledTimes(2) + expect(mockedDeleteResource) + .toHaveBeenCalledTimes(2) + }) + + it('clears existing checkpoint and final screener resources together', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [ + { + id: 'checkpoint-screener-role-id', + name: 'Checkpoint Screener', + }, + { + id: 'screener-role-id', + name: 'Screener', + }, + ], + }) + mockedUseFetchResources.mockReturnValue({ + isError: false, + isLoading: false, + mutate: jest.fn() + .mockResolvedValue(undefined), + resources: [ + { + challengeId: 'challenge-1', + memberId: 'old-member-id', + roleId: 'checkpoint-screener-role-id', + roleName: 'Checkpoint Screener', + }, + { + challengeId: 'challenge-1', + memberId: 'old-member-id', + roleId: 'screener-role-id', + roleName: 'Screener', + }, + ], + }) + + render( + , + ) + + fireEvent.change(screen.getByLabelText('Screener'), { + target: { + value: '', + }, + }) + + await waitFor(() => { + expect(mockedDeleteResource) + .toHaveBeenCalledTimes(2) + }) + expect(mockedCreateResource) + .not.toHaveBeenCalled() + }) + + it('does not duplicate a hydrated handle-only screener resource', async () => { + const mutateResources = jest.fn() + .mockResolvedValue(undefined) + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + id: 'screener-role-id', + name: 'Screener', + }], + }) + mockedUseFetchResources.mockReturnValue({ + isError: false, + isLoading: false, + mutate: mutateResources, + resources: [{ + challengeId: 'challenge-1', + memberHandle: 'legacy.screener', + roleId: 'screener-role-id', + roleName: 'Screener', + }], + }) + mockedFetchProfile.mockResolvedValue({ + handle: 'legacy.screener', + userId: 'legacy-member-id', + }) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByLabelText('Screener') as HTMLInputElement).value) + .toBe('legacy-member-id') + expect((screen.getByLabelText('Screener') as HTMLInputElement).disabled) + .toBe(false) + }) + + fireEvent.change(screen.getByLabelText('Screener'), { + target: { + value: ' legacy-member-id ', + }, + }) + + await waitFor(() => { + expect(mutateResources) + .toHaveBeenCalled() + }) + expect(mockedCreateResource) + .not.toHaveBeenCalled() + expect(mockedDeleteResource) + .not.toHaveBeenCalled() + }) + + it('assigns only the final Screener role for one-round design challenges', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + id: 'screener-role-id', + name: 'Screener', + }], + }) + mockedUseFetchResources.mockReturnValue({ + isLoading: false, + mutate: jest.fn() + .mockResolvedValue(undefined), + resources: [], + }) + + render( + , + ) + + fireEvent.change(screen.getByLabelText('Screener'), { + target: { + value: 'screener-member-id', + }, + }) + + await waitFor(() => { + expect(mockedCreateResource) + .toHaveBeenCalledWith({ + challengeId: 'challenge-1', + memberId: 'screener-member-id', + roleId: 'screener-role-id', + }) + }) + expect(mockedCreateResource) + .toHaveBeenCalledTimes(1) + }) + it('defaults new manual reviewer cards to regular review type', async () => { mockedFetchScorecards.mockResolvedValue([]) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index 0da18bd3e..e7f11a3af 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -36,6 +36,8 @@ import { ChallengeEditorFormData, DefaultReviewer, Resource, + ResourcePayload, + ResourceRole, Reviewer, Scorecard, } from '../../../../../lib/models' @@ -88,6 +90,10 @@ const APPEAL_PHASE_KEYS = new Set([ 'appeals', 'appealsresponse', ]) +const SCREENER_ROLE_NAME_BY_PHASE_KEY: Record = { + checkpointscreening: 'Checkpoint Screener', + screening: 'Screener', +} const REVIEW_OPPORTUNITY_TYPES = { COMPONENT_DEV_REVIEW: 'COMPONENT_DEV_REVIEW', ITERATIVE_REVIEW: 'ITERATIVE_REVIEW', @@ -595,12 +601,12 @@ function getReviewOpportunityTypeForReviewer(params: { function getRoleNameForPhaseName(phaseName: string | undefined): string { const normalizedPhaseName = normalizeKey(phaseName) - if (normalizedPhaseName === 'approval') { - return 'Approver' + if (SCREENER_ROLE_NAME_BY_PHASE_KEY[normalizedPhaseName]) { + return SCREENER_ROLE_NAME_BY_PHASE_KEY[normalizedPhaseName] } - if (normalizedPhaseName === 'checkpointscreening') { - return 'Checkpoint Screener' + if (normalizedPhaseName === 'approval') { + return 'Approver' } if (normalizedPhaseName === 'checkpointreview') { @@ -611,11 +617,153 @@ function getRoleNameForPhaseName(phaseName: string | undefined): string { return 'Iterative Reviewer' } - if (normalizedPhaseName === 'screening') { - return 'Screener' + return 'Reviewer' +} + +interface ScreenerResourceTarget { + assignedMemberIds: string[] + assignedResources: Resource[] + roleId: string + roleName: string +} + +interface ScreenerReviewerEntry { + fieldIndex: number + reviewer: Reviewer + roleName: string +} + +/** + * Resolves a screener resource member id, including a hydrated id for legacy handle-only rows. + * + * @param target screening-role resource target and its hydrated form assignments. + * @param resourceIndex persisted resource position within the target. + * @returns the normalized member id, or an empty string while a handle remains unresolved. + * @throws Does not throw. + */ +function getScreenerResourceMemberId( + target: ScreenerResourceTarget, + resourceIndex: number, +): string { + return normalizeText(target.assignedResources[resourceIndex]?.memberId) + || normalizeText(target.assignedMemberIds[resourceIndex]) +} + +/** + * Builds the resource payload used to remove or restore an existing screener assignment. + * + * @param resource persisted challenge resource for a screening role. + * @returns a resource mutation payload, or `undefined` when the resource has no member identity. + * @remarks Used while synchronizing the simplified Design Challenge screener field. + * @throws Does not throw. + */ +function getResourceMutationPayload(resource: Resource): ResourcePayload | undefined { + const memberId = normalizeText(resource.memberId) + const memberHandle = normalizeText(resource.memberHandle) + const roleId = normalizeText(resource.roleId) + + if (!roleId || (!memberId && !memberHandle)) { + return undefined + } + + return { + challengeId: resource.challengeId, + memberHandle: memberId ? undefined : memberHandle, + memberId: memberId || undefined, + roleId, } +} - return 'Reviewer' +/** + * Finds persisted challenge resources for one named screening role. + * + * @param resources challenge resources available to the editor. + * @param resourceRoles resource-role metadata used when a resource only exposes a role id. + * @param roleName screening role name to match. + * @returns all resources assigned to the requested role. + * @remarks Role names and ids are both supported for legacy resource payloads. + * @throws Does not throw. + */ +function getResourcesForRoleName( + resources: Resource[], + resourceRoles: ResourceRole[], + roleName: string, +): Resource[] { + const normalizedRoleName = normalizeKey(roleName) + const matchingRoleIds = new Set(resourceRoles + .filter(role => normalizeKey(role.name) === normalizedRoleName) + .map(role => normalizeText(role.id))) + + return resources.filter(resource => ( + normalizeKey(resource.roleName || resource.role) === normalizedRoleName + || matchingRoleIds.has(normalizeText(resource.roleId)) + )) +} + +/** + * Replaces the persisted assignments for every screening role with one selected member. + * + * @param params challenge id, selected member id, and screening-role resource targets. + * @returns a promise that resolves after all resource roles match the selected member. + * @remarks New assignments are created before old assignments are removed. Completed mutations + * are rolled back on failure so Checkpoint Screener and Screener do not remain out of sync. + * @throws Re-throws the resource service error after attempting rollback. + */ +async function syncScreenerResourceAssignments(params: { + challengeId: string + selectedMemberId: string + targets: ScreenerResourceTarget[] +}): Promise { + const createdPayloads: ResourcePayload[] = [] + const deletedPayloads: ResourcePayload[] = [] + const createPayloads = params.selectedMemberId + ? params.targets + .filter(target => !target.assignedResources.some((resource, resourceIndex) => ( + getScreenerResourceMemberId(target, resourceIndex) === params.selectedMemberId + ))) + .map(target => ({ + challengeId: params.challengeId, + memberId: params.selectedMemberId, + roleId: target.roleId, + })) + : [] + const deletePayloads = params.targets + .flatMap(target => target.assignedResources + .filter((resource, resourceIndex) => ( + !params.selectedMemberId + || getScreenerResourceMemberId(target, resourceIndex) !== params.selectedMemberId + ))) + .map(getResourceMutationPayload) + .filter((payload): payload is ResourcePayload => !!payload) + + try { + const createResults = await Promise.allSettled(createPayloads.map(async payload => { + await createResource(payload) + createdPayloads.push(payload) + })) + const failedCreate = createResults.find(result => result.status === 'rejected') as PromiseRejectedResult + | undefined + if (failedCreate) { + throw failedCreate.reason + } + + const deleteResults = await Promise.allSettled(deletePayloads.map(async payload => { + await deleteResource(payload) + deletedPayloads.push(payload) + })) + const failedDelete = deleteResults.find(result => result.status === 'rejected') as PromiseRejectedResult + | undefined + if (failedDelete) { + throw failedDelete.reason + } + } catch (error) { + await Promise.allSettled([ + ...createdPayloads.map(payload => deleteResource(payload)), + ...deletedPayloads.map(payload => createResource(payload)), + ]) + + throw error + } } /** @@ -817,7 +965,15 @@ const PublicOpportunityCheckboxField: FC = ) } -export const HumanReviewTab: FC = () => { +interface HumanReviewTabProps { + screenerOnly?: boolean +} + +/** + * Renders manual reviewer configuration, or a single synchronized screener field + * for copilot-only Design Challenge editing. + */ +export const HumanReviewTab: FC = (props: HumanReviewTabProps) => { const formContext = useFormContext() const reviewersFieldState = useController({ control: formContext.control, @@ -828,15 +984,16 @@ export const HumanReviewTab: FC = () => { const challengeTracks = challengeTracksResult.tracks const challengeTypes = challengeTypesResult.challengeTypes - const { - resourceRoles, - }: UseFetchResourceRolesResult = useFetchResourceRoles() + const resourceRolesResult: UseFetchResourceRolesResult = useFetchResourceRoles() + const resourceRoles = resourceRolesResult.resourceRoles const [defaultReviewers, setDefaultReviewers] = useState([]) const [scorecards, setScorecards] = useState([]) // Keep existing selections intact until the first scorecard fetch resolves. const [isScorecardsLoading, setIsScorecardsLoading] = useState(true) + const [isScreenerSyncing, setIsScreenerSyncing] = useState(false) const [loadError, setLoadError] = useState() + const isScreenerSyncingRef = useRef(false) const autoBackfilledReviewerTypesRef = useRef>({}) const trimmedAdditionalMemberIdsRef = useRef>({}) const reconciledScorecardSelectionsRef = useRef>({}) @@ -912,10 +1069,15 @@ export const HumanReviewTab: FC = () => { phases.forEach(phase => { const phaseName = normalizeText(phase.name) - const phaseId = normalizeText(phase.phaseId) || normalizeText(phase.id) - - if (phaseId && phaseName) { - nextPhaseNameById.set(phaseId, phaseName) + const phaseIds = toUniqueValues([ + normalizeText(phase.phaseId), + normalizeText(phase.id), + ]) + + if (phaseName) { + phaseIds.forEach(phaseId => { + nextPhaseNameById.set(phaseId, phaseName) + }) } }) @@ -975,6 +1137,106 @@ export const HumanReviewTab: FC = () => { [resolveRoleIdForPhase], ) + const screenerReviewerEntries = useMemo( + () => reviewerRows + .map((reviewer, reviewerIndex) => { + const fieldIndex = getReviewerFieldIndex(reviewerIndex) + const phaseName = phaseNameById.get(normalizeText(reviewer.phaseId)) + const roleName = SCREENER_ROLE_NAME_BY_PHASE_KEY[normalizeKey(phaseName)] + + return fieldIndex !== undefined && roleName + ? { + fieldIndex, + reviewer, + roleName, + } + : undefined + }) + .filter((entry): entry is ScreenerReviewerEntry => !!entry), + [ + getReviewerFieldIndex, + phaseNameById, + reviewerRows, + ], + ) + const screenerRoleNames = useMemo( + () => toUniqueValues([ + ...(Array.isArray(phases) + ? phases + .map(phase => SCREENER_ROLE_NAME_BY_PHASE_KEY[normalizeKey(phase.name)]) + .filter(Boolean) + : []), + ...screenerReviewerEntries.map(entry => entry.roleName), + ]), + [ + phases, + screenerReviewerEntries, + ], + ) + const screenerResourceTargets = useMemo( + () => screenerRoleNames + .map(roleName => { + const normalizedRoleName = normalizeKey(roleName) + const assignedResources = getResourcesForRoleName( + challengeResourcesResult.resources, + resourceRoles, + roleName, + ) + const reviewerRoleId = normalizeText(screenerReviewerEntries + .find(entry => entry.roleName === roleName) + ?.reviewer.roleId) + const reviewerRoleMatches = resourceRoles.some(role => ( + normalizeText(role.id) === reviewerRoleId + && normalizeKey(role.name) === normalizedRoleName + )) || assignedResources.some(resource => ( + normalizeText(resource.roleId) === reviewerRoleId + )) + const roleId = (reviewerRoleMatches ? reviewerRoleId : '') + || normalizeText(assignedResources[0]?.roleId) + || normalizeText(roleIdByName.get(normalizedRoleName)) + + return roleId + ? { + assignedMemberIds: getAssignedMemberIds(screenerReviewerEntries + .find(entry => entry.roleName === roleName) + ?.reviewer), + assignedResources, + roleId, + roleName, + } + : undefined + }) + .filter((target): target is ScreenerResourceTarget => !!target), + [ + challengeResourcesResult.resources, + resourceRoles, + roleIdByName, + screenerReviewerEntries, + screenerRoleNames, + ], + ) + const primaryScreenerReviewerEntry = useMemo(() => { + const finalScreenerEntry = screenerReviewerEntries.find(entry => entry.roleName === 'Screener') + + if (finalScreenerEntry && getAssignedMemberIds(finalScreenerEntry.reviewer) + .some(Boolean)) { + return finalScreenerEntry + } + + return screenerReviewerEntries.find(entry => getAssignedMemberIds(entry.reviewer) + .some(Boolean)) + || finalScreenerEntry + || screenerReviewerEntries[0] + }, [screenerReviewerEntries]) + const hasMissingScreenerRole = screenerResourceTargets.length < screenerRoleNames.length + const hasUnresolvedScreenerAssignment = screenerResourceTargets.some(target => ( + target.assignedResources.some((resource, resourceIndex) => ( + !normalizeText(resource.memberId) + && !!normalizeText(resource.memberHandle) + && !normalizeText(target.assignedMemberIds[resourceIndex]) + )) + )) + const phaseOptions = useMemo( () => (Array.isArray(phases) ? phases @@ -1449,12 +1711,12 @@ export const HumanReviewTab: FC = () => { }, [], ) - const refreshChallengeResources = useCallback((): void => { + const refreshChallengeResources = useCallback((): Promise | undefined => { if (!normalizedChallengeId) { - return + return undefined } - mutateChallengeResources() + return mutateChallengeResources() .catch(() => undefined) }, [ mutateChallengeResources, @@ -1682,6 +1944,153 @@ export const HumanReviewTab: FC = () => { ], ) + /** + * Applies one selected user to every configured Design Challenge screening phase. + * + * @param selectedMemberId selected member user id, or an empty string when clearing the field. + * @returns Nothing; resource mutations continue asynchronously and refresh the resource cache. + * @remarks The reviewer form rows are kept aligned with the distinct Screener resources. + * Resource failures restore the prior form assignments and surface an inline error. + * @throws Does not throw to the input component. + */ + const handleScreenerSelectionChange = useCallback( + (selectedMemberId: string): void => { + if ( + isScreenerSyncingRef.current + || resourceRolesResult.isLoading + || challengeResourcesResult.isLoading + || challengeResourcesResult.isError + || hasMissingScreenerRole + || hasUnresolvedScreenerAssignment + ) { + return + } + + const normalizedSelectedMemberId = normalizeText(selectedMemberId) + const previousAssignments = screenerReviewerEntries.map(entry => ({ + additionalMemberIds: entry.reviewer.additionalMemberIds, + fieldIndex: entry.fieldIndex, + handle: entry.reviewer.handle, + memberId: entry.reviewer.memberId, + roleId: entry.reviewer.roleId, + })) + + screenerReviewerEntries.forEach(entry => { + const targetRoleId = screenerResourceTargets + .find(target => target.roleName === entry.roleName) + ?.roleId + + formContext.setValue( + `reviewers.${entry.fieldIndex}.memberId` as any, + normalizedSelectedMemberId || undefined, + { + shouldDirty: true, + shouldValidate: true, + }, + ) + formContext.setValue( + `reviewers.${entry.fieldIndex}.additionalMemberIds` as any, + undefined, + { + shouldDirty: true, + shouldValidate: true, + }, + ) + formContext.setValue( + `reviewers.${entry.fieldIndex}.handle` as any, + undefined, + { + shouldDirty: true, + shouldValidate: false, + }, + ) + + if (targetRoleId) { + formContext.setValue( + `reviewers.${entry.fieldIndex}.roleId` as any, + targetRoleId, + { + shouldDirty: true, + shouldValidate: false, + }, + ) + } + }) + + if ( + !normalizedChallengeId + || !screenerResourceTargets.length + ) { + return + } + + isScreenerSyncingRef.current = true + setIsScreenerSyncing(true) + syncScreenerResourceAssignments({ + challengeId: normalizedChallengeId, + selectedMemberId: normalizedSelectedMemberId, + targets: screenerResourceTargets, + }) + .then(() => refreshChallengeResources()) + .catch(error => { + previousAssignments.forEach(assignment => { + const reviewerPrefix = `reviewers.${assignment.fieldIndex}` + + formContext.setValue( + `${reviewerPrefix}.memberId` as any, + assignment.memberId, + { + shouldDirty: true, + shouldValidate: true, + }, + ) + formContext.setValue( + `${reviewerPrefix}.additionalMemberIds` as any, + assignment.additionalMemberIds, + { + shouldDirty: true, + shouldValidate: true, + }, + ) + formContext.setValue( + `${reviewerPrefix}.handle` as any, + assignment.handle, + { + shouldDirty: true, + shouldValidate: false, + }, + ) + formContext.setValue( + `${reviewerPrefix}.roleId` as any, + assignment.roleId, + { + shouldDirty: true, + shouldValidate: false, + }, + ) + }) + handleResourceError(error, 'Failed to update screener assignment') + }) + .finally(() => { + isScreenerSyncingRef.current = false + setIsScreenerSyncing(false) + }) + }, + [ + challengeResourcesResult.isError, + challengeResourcesResult.isLoading, + formContext, + handleResourceError, + hasMissingScreenerRole, + hasUnresolvedScreenerAssignment, + normalizedChallengeId, + refreshChallengeResources, + resourceRolesResult.isLoading, + screenerResourceTargets, + screenerReviewerEntries, + ], + ) + const syncReviewerScorecardForPhase = useCallback( (reviewerIndex: number, reviewer: Reviewer, nextPhaseId: string): void => { const fieldIndex = getReviewerFieldIndex(reviewerIndex) @@ -1938,6 +2347,53 @@ export const HumanReviewTab: FC = () => { [handleMemberSelectionChange], ) + if (props.screenerOnly) { + const isScreenerAssignmentRequired = screenerReviewerEntries.some(entry => ( + !isScreenerAssignmentOptional(entry.reviewer, phases) + )) + const isScreenerFieldLoading = resourceRolesResult.isLoading + || challengeResourcesResult.isLoading + const screenerConfigurationError = !isScreenerFieldLoading && ( + challengeResourcesResult.isError + ? 'Unable to load screener assignments.' + : hasUnresolvedScreenerAssignment + ? 'Unable to resolve the current screener assignment.' + : !primaryScreenerReviewerEntry + ? 'Screener configuration is unavailable.' + : hasMissingScreenerRole + ? 'Unable to load all screener roles.' + : undefined + ) + + return ( +
+ {loadError + ?
{loadError}
+ : undefined} + {screenerConfigurationError + ?
{screenerConfigurationError}
+ : undefined} + {primaryScreenerReviewerEntry + ? ( + + ) + : undefined} +
+ ) + } + return (
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.module.scss index 26f1fc301..ff7e83f40 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.module.scss @@ -72,6 +72,10 @@ gap: 16px; } +.screenerOnlyContainer { + max-width: 420px; +} + .summary { color: #555; display: flex; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx index f6593b896..4ae907d2d 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx @@ -29,7 +29,14 @@ jest.mock('../../../../../lib/services', () => ({ jest.mock('./HumanReviewTab', () => ({ __esModule: true, - default: () =>
Human review content
, + default: (props: { screenerOnly?: boolean }) => ( +
+ Human review content +
+ ), })) jest.mock('./AiReviewTab', () => ({ __esModule: true, @@ -89,11 +96,13 @@ jest.mock('./ReviewContextTab', () => ({ const mockedPatchChallenge = jest.spyOn(services, 'patchChallenge') .mockResolvedValue({} as any) +const mockedFetchAiReviewConfigByChallenge = services.fetchAiReviewConfigByChallenge as jest.Mock interface TestHarnessProps { isReadOnly?: boolean numOfSubmissions?: number reviewers: Reviewer[] + screenerOnly?: boolean } const TestHarness = (props: TestHarnessProps): JSX.Element => { @@ -107,7 +116,12 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { typeId: 'type-id', }, }) - const reviewersField = + const reviewersField = ( + + ) return ( @@ -119,9 +133,33 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { describe('ReviewersField', () => { beforeEach(() => { jest.clearAllMocks() + mockedFetchAiReviewConfigByChallenge.mockResolvedValue(undefined) mockedPatchChallenge.mockResolvedValue({} as Challenge) }) + it('renders only the screener assignment in editable screener-only mode', () => { + render( + , + ) + + expect(screen.getByTestId('human-review-tab') + .getAttribute('data-screener-only')) + .toBe('true') + expect(screen.queryByRole('tablist')) + .toBeNull() + expect(screen.queryByTestId('ai-review-tab')) + .toBeNull() + expect(screen.queryByTestId('review-context-tab')) + .toBeNull() + expect(screen.queryByTestId('review-summary')) + .toBeNull() + expect(screen.queryByText('Manual review configuration is required.')) + .toBeNull() + }) + it('uses tab labels with reviewer counts and toggles between human and AI content', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx index fc65a2805..3eb5f46b9 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx @@ -42,6 +42,7 @@ type ReviewTab = typeof REVIEW_TAB[number] interface ReviewersFieldProps { isReadOnly?: boolean onConfigSaveControllerReady?: (controller: AiReviewConfigSaveController | undefined) => void + screenerOnly?: boolean } function hasReviewerChanges( @@ -160,10 +161,10 @@ export const ReviewersField: FC = (props: ReviewersFieldPro ? `Review Context (${reviewContextRequirementCount})` : 'Review Context' const aiGatingManualReviewError = useMemo( - () => (aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0 + () => (!props.screenerOnly && aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0 ? 'Manual review configuration is required.' : undefined), - [aiReviewMode, humanReviewersCount], + [aiReviewMode, humanReviewersCount, props.screenerOnly], ) useEffect(() => { @@ -324,7 +325,11 @@ export const ReviewersField: FC = (props: ReviewersFieldPro ) : undefined} - {!props.isReadOnly + {!props.isReadOnly && props.screenerOnly + ? + : undefined} + + {!props.isReadOnly && !props.screenerOnly ? ( <>
= (props: ReviewersFieldPro
- {aiGatingManualReviewError && !errors.reviewers && ( -

{aiGatingManualReviewError}

- )} + {aiGatingManualReviewError && !errors.reviewers + ?

{aiGatingManualReviewError}

+ : undefined}