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 c73e03528..640b561ea 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 @@ -33,6 +33,7 @@ import { fetchChallenge, fetchProfile, fetchProjectBillingAccount, + fetchWorkflows, patchChallenge, fetchResourceRoles, fetchResources, @@ -71,6 +72,7 @@ jest.mock('../../../../lib/services', () => ({ fetchProjectBillingAccount: jest.fn(), fetchResourceRoles: jest.fn(), fetchResources: jest.fn(), + fetchWorkflows: jest.fn(), patchChallenge: jest.fn(), })) jest.mock('../../../../lib/utils', () => ({ @@ -569,6 +571,7 @@ const mockedCreateResource = createResource as jest.Mock const mockedCreateChallenge = createChallenge as jest.Mock const mockedDeleteResource = deleteResource as jest.Mock const mockedFetchChallenge = fetchChallenge as jest.Mock +const mockedFetchWorkflows = fetchWorkflows as jest.Mock const mockedFetchProfile = fetchProfile as jest.Mock const mockedFetchProjectBillingAccountService = fetchProjectBillingAccount as jest.Mock const mockedPatchChallenge = patchChallenge as jest.Mock @@ -677,6 +680,7 @@ describe('ChallengeEditorForm', () => { mockedUseFetchTimelineTemplates.mockReturnValue({ timelineTemplates: [], }) + mockedFetchWorkflows.mockResolvedValue([]) mockedFetchProjectBillingAccountService.mockResolvedValue({ billingAccount: undefined, }) @@ -2434,6 +2438,61 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith('Failed to save challenge') }) + it('blocks launching when an assigned AI workflow has been disabled', async () => { + let launchAction: (() => Promise) | undefined + let launchError: Error | undefined + + mockedFetchWorkflows.mockResolvedValue([{ + disabled: true, + id: 'workflow-disabled', + name: 'Disabled workflow', + }]) + + render( + + { + launchAction = action + }} + /> + , + ) + + await waitFor(() => { + expect(launchAction) + .toEqual(expect.any(Function)) + }) + + await act(async () => { + try { + await (launchAction as () => Promise)() + } catch (error) { + launchError = error as Error + } + }) + + expect(launchError) + .toEqual(expect.objectContaining({ + message: 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + })) + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + expect(mockedShowErrorToast) + .toHaveBeenCalledWith( + 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + ) + }) + it('does not render the attachments section while editing a draft', () => { render( @@ -2473,6 +2532,46 @@ describe('ChallengeEditorForm', () => { }) }) + it('blocks saving when an assigned AI workflow has been disabled', async () => { + const user = userEvent.setup() + + mockedFetchWorkflows.mockResolvedValue([{ + disabled: true, + id: 'workflow-disabled', + name: 'Disabled workflow', + }]) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + }) + expect(mockedShowErrorToast) + .toHaveBeenCalledWith( + 'One or more saved AI workflows were disabled. ' + + 'Update the AI workflow configuration before saving or launching this challenge.', + ) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Failed to save challenge') + }) + it('refreshes phase data when the fetched challenge updates for the same id', async () => { const initialChallenge = { ...validDraftChallenge, 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 03cc3b7ef..c5e674bbc 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -58,6 +58,7 @@ import { fetchProjectBillingAccount, fetchResourceRoles, fetchResources, + fetchWorkflows, patchChallenge, } from '../../../../lib/services' import { @@ -254,6 +255,9 @@ const SAVE_VALIDATION_ERROR_MESSAGE = 'Please fix validation errors before savin const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' +const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE + = `One or more saved AI workflows were disabled. + Update the AI workflow configuration before saving or launching this challenge.` const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH' const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE' const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F' @@ -1116,6 +1120,37 @@ function getReviewerValidationError( return getMissingRequiredPhaseCoverageError(reviewers, requiredPhases) } +async function getDisabledAiWorkflowForActionError( + formData: ChallengeEditorFormData, +): Promise { + const selectedAiWorkflowIds = Array.from(new Set((Array.isArray(formData.reviewers) + ? formData.reviewers + : []) + .map(reviewer => normalizeTextValue(reviewer?.aiWorkflowId)) + .filter(Boolean))) + + if (!selectedAiWorkflowIds.length) { + return undefined + } + + const workflows = await fetchWorkflows() + const workflowMapById = new Map( + workflows.map(workflow => [ + normalizeTextValue(workflow.id), + workflow, + ] as const), + ) + const hasDisabledWorkflow = selectedAiWorkflowIds.some(workflowId => { + const matchedWorkflow = workflowMapById.get(workflowId) + + return matchedWorkflow?.disabled === true + }) + + return hasDisabledWorkflow + ? DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE + : undefined +} + function getStatusText( saveStatus: 'error' | 'idle' | 'saved' | 'saving', ): string { @@ -2599,6 +2634,23 @@ export const ChallengeEditorForm: FC = ( throw createHandledLaunchBlockError(taskLaunchValidationError) } + const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError(formData) + + if (disabledAiWorkflowError) { + setSaveStatus('idle') + setError('reviewers', { + message: disabledAiWorkflowError, + type: 'manual', + }) + setSaveValidationError(disabledAiWorkflowError) + + if (!options.isAutosave) { + showErrorToast(disabledAiWorkflowError) + } + + throw createHandledLaunchBlockError(disabledAiWorkflowError) + } + if (!options.isAutosave) { setIsSaving(true) setSaveStatus('saving') @@ -2826,9 +2878,17 @@ export const ChallengeEditorForm: FC = ( } clearErrors('reviewers') - await saveChallenge(formData, { - redirectToViewOnSuccess: true, - }) + try { + await saveChallenge(formData, { + redirectToViewOnSuccess: true, + }) + } catch (error) { + if (isHandledLaunchBlockError(error)) { + return + } + + throw error + } }, [ clearErrors,