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)}