diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss index 7ca326a01..535c4b358 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss @@ -211,6 +211,18 @@ font-size: 12px; } +.termsErrorLinks { + display: inline-flex; + flex-wrap: wrap; + gap: 0 4px; +} + +.termsErrorLink { + color: inherit; + font-weight: 700; + text-decoration: underline; +} + .warningText { color: #7c5b1b; font-size: 12px; 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 ebcd1bef1..f74ac72b5 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 @@ -21,6 +21,7 @@ import { useFetchProjectBillingAccount, useFetchResourceRoles, useFetchResources, + useFetchTerms, useFetchTimelineTemplates, } from '../../../../lib/hooks' import { @@ -100,6 +101,7 @@ jest.mock('../../../../lib/hooks', () => ({ useFetchProjectBillingAccount: jest.fn(), useFetchResourceRoles: jest.fn(), useFetchResources: jest.fn(), + useFetchTerms: jest.fn(), useFetchTimelineTemplates: jest.fn(), })) jest.mock('../../../../lib/services', () => ({ @@ -708,6 +710,7 @@ const mockedUseFetchProject = useFetchProject as jest.Mock const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.Mock const mockedUseFetchResourceRoles = useFetchResourceRoles as jest.Mock const mockedUseFetchResources = useFetchResources as jest.Mock +const mockedUseFetchTerms = useFetchTerms as jest.Mock const mockedUseFetchTimelineTemplates = useFetchTimelineTemplates as jest.Mock const mockedCreateResource = createResource as jest.Mock const mockedCreateChallenge = createChallenge as jest.Mock @@ -853,6 +856,12 @@ describe('ChallengeEditorForm', () => { mutate: jest.fn(), resources: [], })) + mockedUseFetchTerms.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + terms: [], + }) mockedUseFetchTimelineTemplates.mockReturnValue({ timelineTemplates: [], }) @@ -1319,6 +1328,44 @@ describe('ChallengeEditorForm', () => { })) }) + it('renders selected terms as links when a member has not agreed to required terms', async () => { + const user = userEvent.setup() + const termsError = 'The user has not yet agreed to the following terms: [Standard Terms 2026]' + + mockedUseFetchTerms.mockReturnValue({ + error: undefined, + isError: false, + isLoading: false, + terms: [{ + id: 'standard-terms-2026', + title: 'Standard Terms 2026', + }], + }) + mockedPatchChallenge.mockRejectedValueOnce(new Error(termsError)) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save Challenge' })) + + expect(await screen.findByText(/The user has not yet agreed to the following terms/)) + .toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Standard Terms 2026' })) + .toHaveAttribute( + 'href', + 'https://example.com/topcoder/challenges/terms/detail/standard-terms-2026', + ) + }) + it('preserves project billing markup when fetched draft data resets the form', async () => { mockedUseFetchProjectBillingAccount.mockReturnValue({ billingAccount: { 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 89089a862..693f85e26 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -16,6 +16,7 @@ import { } from 'react-router-dom' import { yupResolver } from '@hookform/resolvers/yup' +import { EnvironmentConfig } from '~/config' import { Button } from '~/libs/ui' import { ConfirmationModal } from '../../../../lib/components' @@ -44,6 +45,7 @@ import { useFetchProjectBillingAccount, useFetchResourceRoles, useFetchResources, + useFetchTerms, useFetchTimelineTemplates, } from '../../../../lib/hooks' import { @@ -54,6 +56,7 @@ import { Resource, ResourceRole, Reviewer, + Term, } from '../../../../lib/models' import { challengeEditorSchema, @@ -314,6 +317,7 @@ const REVIEWER_REQUIRED_PHASE_KEYS = new Set( REVIEWER_REQUIRED_PHASES .map(phaseName => normalizeReviewerPhaseName(phaseName)), ) +const TERMS_NOT_AGREED_ERROR_TOKEN = 'not yet agreed to the following terms' const DESIGN_WORK_TYPE_BY_TOKEN = new Map( DESIGN_WORK_TYPES .map(workType => [ @@ -448,6 +452,148 @@ function normalizeTextValue(value: unknown): string { return value.trim() } +/** + * Normalizes challenge term form values into term ids. + * + * The challenge editor usually stores terms as string ids, but persisted challenge payloads may + * also contain term-like objects while data is being hydrated. This helper keeps save-error link + * resolution tolerant of either shape. + * + * @param value Raw `terms` form value from the challenge editor. + * @returns A de-duplicated list of normalized term ids. + * @throws Does not throw. + */ +function normalizeSelectedTermIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + const selectedTermIds = value + .map(term => { + if (typeof term === 'string') { + return normalizeTextValue(term) + } + + if (typeof term === 'object' && term) { + const termId = (term as Partial).id + + return termId === undefined || termId === null + ? '' + : normalizeTextValue(String(termId)) + } + + return '' + }) + .filter(Boolean) + + return Array.from(new Set(selectedTermIds)) +} + +/** + * Builds the public Topcoder terms detail URL for a term id. + * + * The target URL uses the configured Topcoder host so generated links point to the active + * environment while preserving the community terms-detail route expected by members. + * + * @param termId Terms API identifier to include in the detail URL. + * @returns The public terms detail URL. + * @throws Does not throw. + */ +function getTermDetailUrl(termId: string): string { + const topcoderUrl = EnvironmentConfig.TOPCODER_URL.replace(/\/$/, '') + + return `${topcoderUrl}/challenges/terms/detail/${encodeURIComponent(termId)}` +} + +/** + * Finds selected challenge terms referenced by a save error. + * + * API errors for member assignment failures currently include only the human-readable term title. + * The editor cross-references that title with the selected challenge terms so it can render the + * corresponding terms-detail link beside the existing message. + * + * @param errorMessage Save error message returned by the API. + * @param selectedTermIds Term ids currently selected in the editor. + * @param terms Terms catalog fetched from terms-api. + * @returns Selected terms whose title appears in the error message. + * @throws Does not throw. + */ +function getTermsForSaveError( + errorMessage: string | undefined, + selectedTermIds: string[], + terms: Term[], +): Term[] { + const normalizedErrorMessage = normalizeTextValue(errorMessage) + if ( + !normalizedErrorMessage + || !normalizedErrorMessage + .toLowerCase() + .includes(TERMS_NOT_AGREED_ERROR_TOKEN) + || selectedTermIds.length === 0 + ) { + return [] + } + + const selectedTermIdSet = new Set(selectedTermIds) + const normalizedErrorMessageLower = normalizedErrorMessage.toLowerCase() + + return terms + .filter(term => ( + selectedTermIdSet.has(term.id) + && normalizedErrorMessageLower.includes(term.title.toLowerCase()) + )) +} + +/** + * Renders a save error with optional terms-detail links. + * + * This keeps the two challenge-editor footers aligned while preserving the original error message + * text and adding copyable links only for matched terms errors. + * + * @param errorMessage Save error message to render. + * @param linkedTerms Terms that should be linked after the message. + * @returns The save-error element, or `undefined` when no message is available. + * @throws Does not throw. + */ +function renderSaveError( + errorMessage: string | undefined, + linkedTerms: Term[], +): JSX.Element | undefined { + if (!errorMessage) { + return undefined + } + + return ( + + {errorMessage} + {linkedTerms.length > 0 + ? ( + <> + {' '} + + Terms link: + {' '} + {linkedTerms.map((term, index) => ( + + {index > 0 ? ', ' : undefined} + + {term.title} + + + ))} + + + ) + : undefined} + + ) +} + /** * Normalizes optional display tokens from API payloads and auth context. * @@ -1704,6 +1850,7 @@ export const ChallengeEditorForm: FC = ( const values = watch() const challengeTracks = useFetchChallengeTracks().tracks const challengeTypes = useFetchChallengeTypes().challengeTypes + const terms = useFetchTerms().terms const timelineTemplates = useFetchTimelineTemplates().timelineTemplates const challengeResourcesResult = useFetchResources(currentChallengeId) const resourceRolesResult = useFetchResourceRoles() @@ -3392,12 +3539,8 @@ export const ChallengeEditorForm: FC = ( await saveChallenge(formData, { redirectToViewOnSuccess: true, }) - } catch (error) { - if (isHandledLaunchBlockError(error)) { - return - } - - throw error + } catch { + // saveChallenge already updates the visible error state for manual submissions. } }, [ @@ -3420,6 +3563,18 @@ export const ChallengeEditorForm: FC = ( () => getStatusText(isSaving ? 'saving' : saveStatus), [isSaving, saveStatus], ) + const selectedTermIds = useMemo( + () => normalizeSelectedTermIds(values.terms), + [values.terms], + ) + const linkedSaveErrorTerms = useMemo( + () => getTermsForSaveError(saveError, selectedTermIds, terms), + [ + saveError, + selectedTermIds, + terms, + ], + ) const submitButtonLabel = useMemo( () => getSubmitButtonLabel(normalizedChallengeStatus), [normalizedChallengeStatus], @@ -3569,9 +3724,7 @@ export const ChallengeEditorForm: FC = ( {saveValidationError ? {saveValidationError} : undefined} - {saveError - ? {saveError} - : undefined} + {renderSaveError(saveError, linkedSaveErrorTerms)} {isScorerBlockingChallengeActions ? ( @@ -3661,9 +3814,7 @@ export const ChallengeEditorForm: FC = ( {saveValidationError ? {saveValidationError} : undefined} - {saveError - ? {saveError} - : undefined} + {renderSaveError(saveError, linkedSaveErrorTerms)}