From f4adda51deeb4f84ff7b4c1e7bc477acb014e7a5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:43:16 +1000 Subject: [PATCH 1/9] PM-6012 align active and member challenge visibility --- src/apps/opportunities/README.md | 9 +++- .../src/pages/OpportunitiesPage.spec.tsx | 46 +++++++++++++++++-- .../src/pages/OpportunitiesPage.tsx | 2 +- .../services/opportunities.service.spec.ts | 28 ++++------- .../src/services/opportunities.service.ts | 42 +++++++---------- 5 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index 0b270535b..c09f4e0fb 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -82,8 +82,13 @@ subtype icons and member-facing labels. - “Open for registration” requires an `ACTIVE` challenge and an open `Registration` phase (or legacy combined `Open` phase). `ACTIVE` by itself - is not treated as an open registration window. The server-filtered “My - competitions” result marks those cards Registered without per-card calls. + is not treated as an open registration window. “Active competitions” uses + Challenge API's `hasCurrentPhase` filter so scheduled challenges remain + hidden while Submission, Review, and every other open phase remain visible. + “My competitions” uses the member's complete Challenge resource membership + so active work remains visible to Submitters, Copilots, and challenge + Managers. The separate member-registration request keeps the Registered card + state limited to actual Submitter resources. - The prize footer uses only the `PLACEMENT` prize set and preserves its API order as first, second, and third place. Checkpoint, copilot, and reviewer payments are not mixed into competitor prizes. diff --git a/src/apps/opportunities/src/pages/OpportunitiesPage.spec.tsx b/src/apps/opportunities/src/pages/OpportunitiesPage.spec.tsx index 072ace9f0..3e2d0a404 100644 --- a/src/apps/opportunities/src/pages/OpportunitiesPage.spec.tsx +++ b/src/apps/opportunities/src/pages/OpportunitiesPage.spec.tsx @@ -1,7 +1,12 @@ -/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */ import '@testing-library/jest-dom' import React from 'react' -import { render, screen, waitFor } from '@testing-library/react' +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' import { MemoryRouter, Route, @@ -40,7 +45,10 @@ jest.mock('~/libs/ui', () => { }, { virtual: true }) jest.mock('../components', () => ({ - OpportunityFiltersPanel: () => undefined, + OpportunityFiltersPanel: (props: { onAppliedChange: (checked: boolean) => void }) => { + const selectMyCompetitions = (): void => props.onAppliedChange(true) + return + }, OpportunityHero: (props: { summary?: { competitions?: { count?: number } } }) => ( {props.summary?.competitions?.count ?? 'pending'} @@ -137,6 +145,38 @@ describe('OpportunitiesPage', () => { .toHaveTextContent('true')) }) + it('does not label a non-submitter My competition as registered', async () => { + mockedGetOpportunitySummary.mockResolvedValue({ + competitions: { count: 1 }, + copilots: { count: 0 }, + engagements: { count: 0 }, + reviews: { count: 0 }, + }) + mockedGetOpportunityPage.mockResolvedValue({ + items: [{ id: 'managed-challenge', name: 'Managed challenge' }], + page: 1, + perPage: 10, + total: 1, + totalPages: 1, + }) + + render( + new Map() }}> + + + } path='/opportunities/:kind' /> + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'My competitions' })) + await waitFor(() => expect(mockedGetOpportunityPage) + .toHaveBeenLastCalledWith('competitions', expect.objectContaining({ applied: true }))) + expect(await screen.findByTestId('registration-managed-challenge')) + .toHaveTextContent('false') + }) + it('links the copilot learning card to the published Thrive article', async () => { mockedGetOpportunitySummary.mockResolvedValue({ competitions: { count: 0 }, diff --git a/src/apps/opportunities/src/pages/OpportunitiesPage.tsx b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx index 7781ae436..e94901f46 100644 --- a/src/apps/opportunities/src/pages/OpportunitiesPage.tsx +++ b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx @@ -382,7 +382,7 @@ const OpportunityListing: FC = (props: OpportunityListi memberApplied={applied} onSkillClick={updateSearch} registered={kind === 'competitions' - && (applied || registrationIds.has(item.id))} + && registrationIds.has(item.id)} view={props.view} /> ))} diff --git a/src/apps/opportunities/src/services/opportunities.service.spec.ts b/src/apps/opportunities/src/services/opportunities.service.spec.ts index 93206fb5a..c20830d75 100644 --- a/src/apps/opportunities/src/services/opportunities.service.spec.ts +++ b/src/apps/opportunities/src/services/opportunities.service.spec.ts @@ -118,8 +118,8 @@ describe('opportunities service normalization', () => { const url = new URL(String(globalGet.mock.calls[0][0])) expect(url.pathname) .toBe('/v6/challenges') - expect(url.searchParams.get('currentPhaseName')) - .toBe('Submission') + expect(url.searchParams.get('hasCurrentPhase')) + .toBe('true') expect(url.searchParams.get('perPage')) .toBe('1') const reviewUrl = new URL(String(globalGet.mock.calls[1][0])) @@ -133,16 +133,12 @@ describe('opportunities service normalization', () => { it('loads member-work totals on count-only owner pages', async () => { const get = xhrGlobalInstance.get as jest.MockedFunction - const getAsync = xhrGetAsync as jest.MockedFunction const totals: Record = { '/v6/challenges': 40, '/v6/engagements/engagements': 30, '/v6/projects/copilots/opportunities': 20, '/v6/review-opportunities/search': 15, } - getAsync.mockResolvedValueOnce([ - { id: 'submitter-role', name: 'Submitter' }, - ] as never) get.mockImplementation(async requestUrl => { const url = new URL(String(requestUrl)) return { @@ -172,7 +168,7 @@ describe('opportunities service normalization', () => { expect(byPath.get('/v6/challenges')?.searchParams.get('memberId')) .toBe('123') expect(byPath.get('/v6/challenges')?.searchParams.get('resourceRoleId')) - .toBe('submitter-role') + .toBeNull() expect(byPath.get('/v6/engagements/engagements')?.searchParams.get('perPage')) .toBe('1') expect(byPath.get('/v6/engagements/engagements')?.searchParams.get('appliedByMe')) @@ -234,19 +230,18 @@ describe('opportunities service normalization', () => { statuses: ['ACTIVE'], })) - expect(publicUrl.searchParams.get('currentPhaseName')) - .toBe('Submission') - expect(memberUrl.searchParams.has('currentPhaseName')) + expect(publicUrl.searchParams.get('hasCurrentPhase')) + .toBe('true') + expect(memberUrl.searchParams.has('hasCurrentPhase')) .toBe(false) }) - it('maps My competitions to the Challenge API member and Submitter-role filter', () => { + it('maps My competitions to every Challenge API resource role for the member', () => { const url = new URL(buildOpportunityPageUrl('competitions', { applied: true, memberId: '123', page: 2, perPage: 10, - resourceRoleId: '2425bb20-9a2c-4316-9f85-8b24f9ce43b8', search: 'design systems', sort: 'newest', tracks: ['Des'], @@ -255,7 +250,7 @@ describe('opportunities service normalization', () => { expect(url.searchParams.get('memberId')) .toBe('123') expect(url.searchParams.get('resourceRoleId')) - .toBe('2425bb20-9a2c-4316-9f85-8b24f9ce43b8') + .toBeNull() expect(url.searchParams.get('search')) .toBe('design systems') expect(url.searchParams.getAll('tracks[]')) @@ -1034,11 +1029,8 @@ describe('opportunities service normalization', () => { }) }) - it('globally filters and pages My competitions in one role-aware Challenge API request', async () => { - const get = xhrGetAsync as jest.MockedFunction + it('globally filters and pages all member-role competitions in one Challenge API request', async () => { const globalGet = xhrGlobalInstance.get as jest.MockedFunction - const submitterRoleId = '2425bb20-9a2c-4316-9f85-8b24f9ce43b8' - get.mockResolvedValueOnce([{ id: submitterRoleId, name: 'Submitter' }]) globalGet.mockResolvedValueOnce({ data: [{ id: 'challenge-b', name: 'Design challenge' }], headers: { @@ -1074,7 +1066,7 @@ describe('opportunities service normalization', () => { expect(requestUrl.searchParams.get('memberId')) .toBe('123') expect(requestUrl.searchParams.get('resourceRoleId')) - .toBe(submitterRoleId) + .toBeNull() expect(requestUrl.searchParams.get('search')) .toBe('design') expect(requestUrl.searchParams.getAll('status')) diff --git a/src/apps/opportunities/src/services/opportunities.service.ts b/src/apps/opportunities/src/services/opportunities.service.ts index bd0659fce..1baf3d00f 100644 --- a/src/apps/opportunities/src/services/opportunities.service.ts +++ b/src/apps/opportunities/src/services/opportunities.service.ts @@ -425,13 +425,14 @@ export async function getMyWorkCounts( * UI sort choices are intentionally semantic so each domain receives the * field, direction, and grouping parameters that actually implement the label. * Track values are already normalized by the filter panel for the selected - * domain; copilot tracks map to its opportunity `type` enum. A competition's - * `memberId` and `resourceRoleId` are emitted together so Challenge API can - * apply Submitter membership before filtering, sorting, and pagination. + * domain; copilot tracks map to its opportunity `type` enum. My competitions + * emit `memberId` without narrowing the challenge resource role, preserving + * Submitter, Copilot, Manager, and other challenge-resource memberships. * Competition free text is emitted only through `search`; a hidden `tags` * filter would turn the authored unified search into an unintended AND query. - * Public active competitions require an open Submission phase, while member - * competitions retain every active challenge for which the member registered. + * Public active competitions require any current phase, while member + * competitions retain every active challenge where the caller has a resource + * role, including active challenges that have moved beyond submission. * * @param kind active opportunity type. * @param filters search, facets, sorting, and pagination values. @@ -468,16 +469,18 @@ export function buildOpportunityPageUrl( if (filters.statuses?.includes('REGISTRATION')) { url.searchParams.set('currentPhaseName', 'Registration') } else if (!filters.applied && filters.statuses?.includes('ACTIVE')) { - url.searchParams.set('currentPhaseName', 'Submission') + url.searchParams.set('hasCurrentPhase', 'true') } // Challenge API's query parser only coerces bracketed keys into arrays; // even a single facet must be sent as `tracks[]=Dev` / `types[]=MM`. appendValues(url, 'tracks[]', filters.tracks) appendValues(url, 'types[]', filters.types) - if (filters.applied && filters.memberId && filters.resourceRoleId) { + if (filters.applied && filters.memberId) { url.searchParams.set('memberId', filters.memberId) - url.searchParams.set('resourceRoleId', filters.resourceRoleId) + if (filters.resourceRoleId) { + url.searchParams.set('resourceRoleId', filters.resourceRoleId) + } } } else if (kind === 'engagements') { endpoint = `${V6_URL}/engagements/engagements` @@ -890,11 +893,12 @@ async function getReviewPageWithAiTrack( } /** - * Loads one filtered page from the owning domain API. For “My competitions,” - * this first resolves the canonical Submitter role and then performs one - * globally filtered, sorted, and paginated Challenge API request. Copilot - * validation failures from an older Projects API use the bounded legacy - * fetch-and-filter fallback until that deployment supports discovery filters. + * Loads one filtered page from the owning domain API. “My competitions” uses + * the Challenge API's member filter without a resource-role restriction so + * Submitters, Copilots, and challenge Managers retain their active work. + * Copilot validation failures from an older Projects API use the bounded + * legacy fetch-and-filter fallback until that deployment supports discovery + * filters. * * @param kind active opportunity type. * @param filters search, facets, sorting, and pagination values. @@ -911,18 +915,6 @@ export async function getOpportunityPage( return getReviewPageWithAiTrack(filters) } - if (kind === 'competitions' && filters.applied && filters.memberId) { - const submitterRole = await getSubmitterRole() - const roleScopedFilters: OpportunityFilters = { - ...filters, - resourceRoleId: submitterRole.id, - } - const response = await xhrGlobalInstance.get(buildOpportunityPageUrl(kind, roleScopedFilters)) as AxiosResponse< - any[] | ApiEnvelope | ApiListResponse - > - return normalizePage(response, page, perPage) - } - try { const response = await xhrGlobalInstance.get(buildOpportunityPageUrl(kind, filters)) as AxiosResponse< any[] | ApiEnvelope | ApiListResponse From 01c1337de21e56405a2395ee08b9e959f5ff864b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:26:16 +1000 Subject: [PATCH 2/9] PM-6070 fix copilot direct-open URLs --- .../copilots/src/copilots.routes.spec.tsx | 34 +++++++++++++++- src/apps/copilots/src/copilots.routes.tsx | 39 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/apps/copilots/src/copilots.routes.spec.tsx b/src/apps/copilots/src/copilots.routes.spec.tsx index f9da7aa91..43eff4de9 100644 --- a/src/apps/copilots/src/copilots.routes.spec.tsx +++ b/src/apps/copilots/src/copilots.routes.spec.tsx @@ -1,8 +1,8 @@ -import { copilotsRoutes } from './copilots.routes' +import { copilotsRoutes, getCopilotsAbsoluteRootRoute } from './copilots.routes' jest.mock('~/config', () => ({ AppSubdomain: { copilots: 'copilots' }, - EnvironmentConfig: { SUBDOMAIN: 'topcoder-dev' }, + EnvironmentConfig: { SUBDOMAIN: 'topcoder-dev', TC_DOMAIN: 'topcoder-dev.com' }, ToolTitle: { copilots: 'Copilots' }, }), { virtual: true }) @@ -32,4 +32,34 @@ describe('copilotsRoutes', () => { expect(detailRoute?.route) .toBe('/opportunity/:opportunityId') }) + + it('targets the copilots subdomain for direct opens from Topcoder hosts', () => { + expect(getCopilotsAbsoluteRootRoute( + 'https://topcoder-dev.com', + 'topcoder-dev', + 'topcoder-dev.com', + )) + .toBe('https://copilots.topcoder-dev.com') + expect(getCopilotsAbsoluteRootRoute( + 'https://www.topcoder-dev.com', + 'www', + 'topcoder-dev.com', + )) + .toBe('https://copilots.topcoder-dev.com') + }) + + it('keeps current-origin routing on the copilots subdomain and localhost', () => { + expect(getCopilotsAbsoluteRootRoute( + 'https://copilots.topcoder-dev.com', + 'copilots', + 'topcoder-dev.com', + )) + .toBe('https://copilots.topcoder-dev.com') + expect(getCopilotsAbsoluteRootRoute( + 'http://localhost:3000', + 'localhost', + 'topcoder-dev.com', + )) + .toBe('http://localhost:3000/copilots') + }) }) diff --git a/src/apps/copilots/src/copilots.routes.tsx b/src/apps/copilots/src/copilots.routes.tsx index 439e9a221..243aa6c96 100644 --- a/src/apps/copilots/src/copilots.routes.tsx +++ b/src/apps/copilots/src/copilots.routes.tsx @@ -14,7 +14,44 @@ export const rootRoute: string = ( ) export const toolTitle: string = ToolTitle.copilots -export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}` + +/** + * Resolves the canonical absolute Copilots app root used for full-page and new-tab links. + * + * On Topcoder hosts, opening `/copilots/...` on the main site redirects to `www` and can + * render the site-level 404 instead of the Copilots SPA. To keep direct opens working, + * cross-app links target the dedicated Copilots subdomain. Non-Topcoder hosts such as + * localhost keep the current-origin path fallback so local development still works. + * + * @param origin current browser origin. + * @param subdomain active environment subdomain derived from the current host. + * @param tcDomain configured Topcoder base domain such as `topcoder-dev.com`. + * @returns canonical absolute Copilots root for the current runtime host. + */ +export function getCopilotsAbsoluteRootRoute( + origin: string, + subdomain: string, + tcDomain: string, +): string { + const currentOrigin = new URL(origin) + if (subdomain === AppSubdomain.copilots) return currentOrigin.origin + + const normalizedDomain = tcDomain.toLowerCase() + const hostname = currentOrigin.hostname.toLowerCase() + const onTopcoderHost = hostname === normalizedDomain + || hostname === `www.${normalizedDomain}` + || hostname.endsWith(`.${normalizedDomain}`) + + return onTopcoderHost + ? `${currentOrigin.protocol}//${AppSubdomain.copilots}.${normalizedDomain}` + : `${currentOrigin.origin}${rootRoute}` +} + +export const absoluteRootRoute: string = getCopilotsAbsoluteRootRoute( + window.location.origin, + EnvironmentConfig.SUBDOMAIN, + EnvironmentConfig.TC_DOMAIN, +) export const childRoutes = [ { From e474bb1fce8983d1ebb7e2e1fc00e29df994f88a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:29:09 +1000 Subject: [PATCH 3/9] PM-6080 merge review tags and skills on cards --- .../components/OpportunityListCard.spec.tsx | 10 ++++- .../src/components/OpportunityListCard.tsx | 14 +------ .../ReviewOpportunityDetailsPage.spec.tsx | 6 +++ .../pages/ReviewOpportunityDetailsPage.tsx | 22 +---------- src/apps/opportunities/src/utils/index.ts | 1 + .../utils/review-opportunity.utils.spec.ts | 28 ++++++++++++++ .../src/utils/review-opportunity.utils.ts | 38 +++++++++++++++++++ 7 files changed, 86 insertions(+), 33 deletions(-) create mode 100644 src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts create mode 100644 src/apps/opportunities/src/utils/review-opportunity.utils.ts diff --git a/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx index 4845b4a00..7dcffa6bb 100644 --- a/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx +++ b/src/apps/opportunities/src/components/OpportunityListCard.spec.tsx @@ -650,10 +650,12 @@ describe('OpportunityListCard owner-specific grid presentation', () => { .toBeInTheDocument() }) - it('falls back to review challenge skills when technologies are absent', () => { + it('merges review tags, technologies, and skills without duplicate chips', () => { const item: ReviewOpportunity = { challengeData: { skills: [{ name: 'MyTag' }, 'Test'], + tags: ['Featured', 'Test'], + technologies: [{ name: 'MyTag' }, 'React'], track: 'Development', }, challengeId: 'review-challenge', @@ -667,10 +669,16 @@ describe('OpportunityListCard owner-specific grid presentation', () => { , ) + expect(screen.getByText('Featured')) + .toBeInTheDocument() expect(screen.getByText('MyTag')) .toBeInTheDocument() expect(screen.getByText('Test')) .toBeInTheDocument() + expect(screen.getByText('React')) + .toBeInTheDocument() + expect(screen.getAllByText('Test')) + .toHaveLength(1) }) it('positions review title tooltips outside the card clipping context', () => { diff --git a/src/apps/opportunities/src/components/OpportunityListCard.tsx b/src/apps/opportunities/src/components/OpportunityListCard.tsx index 9a354a290..e0b0a9d69 100644 --- a/src/apps/opportunities/src/components/OpportunityListCard.tsx +++ b/src/apps/opportunities/src/components/OpportunityListCard.tsx @@ -54,6 +54,7 @@ import { formatChallengeTimeLeft, FUN_CHALLENGE_PRIZE_LABEL, } from './challenge-card.utils' +import { reviewOpportunityLabels } from '../utils/review-opportunity.utils' import styles from './OpportunityListCard.module.scss' interface OpportunityListCardProps { @@ -534,13 +535,6 @@ export function formatReviewPayment(value?: number): string { /** Converts review data to the shared card presentation model. */ function reviewView(item: ReviewOpportunity): CardViewModel { const track = String(item.challengeData?.track ?? item.challengeData?.trackName ?? 'Review') - const technologies = item.challengeData?.technologies - const challengeSkills = item.challengeData?.skills - const skillsSource = Array.isArray(technologies) && technologies.length - ? technologies - : Array.isArray(challengeSkills) - ? challengeSkills - : [] return { badge: track, href: `/opportunities/review/${item.id}`, @@ -558,11 +552,7 @@ function reviewView(item: ReviewOpportunity): CardViewModel { value: String(reviewApplicationTotal(item)), }, ], - skills: skillsSource.map(skill => { - if (typeof skill === 'string') return skill - if (skill && typeof skill === 'object' && 'name' in skill) return String(skill.name) - return String(skill) - }), + skills: reviewOpportunityLabels(item), state: reviewApplicationState( item, item.canApply === true || challengeCatalogKey(item.status) === 'open', diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx index d04f22b1e..108ecff7d 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.spec.tsx @@ -96,6 +96,8 @@ function reviewFixture(overrides: Partial = {}): ReviewOpport canApply: true, challengeData: { createdAt: '2026-06-19T00:00:00', + skills: ['TypeScript'], + tags: ['Featured'], technologies: ['React.js', { name: 'TypeScript' }], track: 'Development', type: 'Challenge', @@ -147,8 +149,12 @@ describe('ReviewOpportunityDetailsPage', () => { expect(screen.getByRole('heading', { name: 'Admin Challenge Curation UI Prototype' })) .toBeInTheDocument() + expect(screen.getByText('Featured')) + .toBeInTheDocument() expect(screen.getByText('React.js')) .toBeInTheDocument() + expect(screen.getAllByText('TypeScript')) + .toHaveLength(1) expect(screen.getByText('$20')) .toBeInTheDocument() expect(screen.getByText('$10')) diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx index 7152b9cb5..0047a0a27 100644 --- a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx @@ -23,7 +23,7 @@ import metricSubmissionsIcon from '../assets/metric-submissions.svg' import { ChallengeMarkdown, ReportIssueModal } from '../components' import { ReviewApplicationSummary, ReviewOpportunity } from '../models' import { applyToReviewOpportunity, getReviewOpportunity } from '../services' -import { memberProfileUrl, REVIEWER_LEARNING_URL } from '../utils' +import { memberProfileUrl, reviewOpportunityLabels, REVIEWER_LEARNING_URL } from '../utils' import styles from './ReviewOpportunityDetailsPage.module.scss' @@ -153,24 +153,6 @@ function reviewRoleLabel(value?: string): string { .join(' ') } -/** - * Extracts human-readable technology labels from embedded Challenge API data. - * - * @param opportunity Review opportunity containing an optional challenge snapshot. - * @returns unique skill labels in API order. - * @throws Does not throw. - */ -function reviewSkillLabels(opportunity: ReviewOpportunity): string[] { - const values = opportunity.challengeData?.technologies ?? opportunity.challengeData?.skills - if (!Array.isArray(values)) return [] - return Array.from(new Set(values.map(value => { - if (typeof value === 'string') return value - if (value && typeof value === 'object' && 'name' in value) return String(value.name ?? '') - return '' - }) - .filter(Boolean))) -} - /** * Formats a reviewer payment without adding insignificant decimal places. * @@ -291,7 +273,7 @@ export const ReviewOpportunityDetailsPage: FC = () => { const basePayment = selectedPayment?.payment ?? opportunity.basePayment ?? 0 const incrementalPayment = opportunity.incrementalPayment ?? 0 const hasIncrementalPayment = incrementalPayment > 0 - const skills = reviewSkillLabels(opportunity) + const skills = reviewOpportunityLabels(opportunity) const postedAt = typeof opportunity.challengeData?.createdAt === 'string' ? opportunity.challengeData.createdAt : undefined diff --git a/src/apps/opportunities/src/utils/index.ts b/src/apps/opportunities/src/utils/index.ts index 67f94ed20..0ee65a28b 100644 --- a/src/apps/opportunities/src/utils/index.ts +++ b/src/apps/opportunities/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './marathon-match.utils' export * from './opportunity-filter.utils' export * from './opportunity-learning.utils' export * from './opportunity-listing.utils' +export * from './review-opportunity.utils' diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts new file mode 100644 index 000000000..20da36e5d --- /dev/null +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.spec.ts @@ -0,0 +1,28 @@ +import { ReviewOpportunity } from '../models' + +import { reviewOpportunityLabels } from './review-opportunity.utils' + +describe('reviewOpportunityLabels', () => { + it('merges tags, technologies, and skills without duplicate chips', () => { + const opportunity: ReviewOpportunity = { + challengeData: { + skills: ['TypeScript', { name: 'React' }], + tags: ['Featured', 'TypeScript'], + technologies: [{ name: 'React' }, 'Node.js'], + }, + challengeId: 'challenge-id', + id: 'review-id', + } + + expect(reviewOpportunityLabels(opportunity)) + .toEqual(['Featured', 'TypeScript', 'React', 'Node.js']) + }) + + it('returns an empty array when the challenge snapshot has no chips', () => { + expect(reviewOpportunityLabels({ + challengeId: 'challenge-id', + id: 'review-id', + })) + .toEqual([]) + }) +}) diff --git a/src/apps/opportunities/src/utils/review-opportunity.utils.ts b/src/apps/opportunities/src/utils/review-opportunity.utils.ts new file mode 100644 index 000000000..0eae04389 --- /dev/null +++ b/src/apps/opportunities/src/utils/review-opportunity.utils.ts @@ -0,0 +1,38 @@ +import { ReviewOpportunity } from '../models' + +/** + * Normalizes one review challenge tag or skill value into a non-empty label. + * + * @param value challenge tag, technology, or skill entry from the Review API snapshot. + * @returns trimmed label, or an empty string when no label is available. + * @throws Does not throw. + */ +function reviewOpportunityLabel(value: unknown): string { + if (typeof value === 'string') return value.trim() + if (value && typeof value === 'object' && 'name' in value) { + return String(value.name ?? '') + .trim() + } + + return '' +} + +/** + * Merges review challenge tags, technologies, and skills for card and detail chips. + * + * @param opportunity review opportunity with an optional embedded challenge snapshot. + * @returns unique non-empty labels in API order. + * @throws Does not throw. + */ +export function reviewOpportunityLabels(opportunity: ReviewOpportunity): string[] { + const challengeData = opportunity.challengeData + const tags = Array.isArray(challengeData?.tags) ? challengeData.tags : [] + const technologies = Array.isArray(challengeData?.technologies) ? challengeData.technologies : [] + const skills = Array.isArray(challengeData?.skills) ? challengeData.skills : [] + + return Array.from(new Set([ + ...tags.map(reviewOpportunityLabel), + ...technologies.map(reviewOpportunityLabel), + ...skills.map(reviewOpportunityLabel), + ].filter(Boolean))) +} From 4e13368124702d7db709e7946db3fc43422e73a0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:29:16 +1000 Subject: [PATCH 4/9] PM-6095 make report issue attachments optional --- src/apps/opportunities/README.md | 12 +++-- .../src/components/ReportIssueModal.spec.tsx | 52 ++++++++++++++++++- .../src/components/ReportIssueModal.tsx | 27 +++++----- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md index c09f4e0fb..bd0aabde6 100644 --- a/src/apps/opportunities/README.md +++ b/src/apps/opportunities/README.md @@ -245,12 +245,14 @@ metadata-enabled Marathon Dashboard, and Forum, while My Submissions and upload actions remain registration-only. Administrators may create ordinary topics or official announcements and can reply throughout every challenge forum. -The Report an Issue dialog preserves the Figma subject, category, -1000-character description, and required attachment fields. Files upload +The Report an Issue dialog preserves the Figma subject, category, and +1000-character description while keeping attachments optional. Files upload through the shared Filestack support-ticket pipeline with a 2MB-per-file UI -limit. Because support-api-v6 accepts only `challengeId` and Markdown -`description`, the client serializes the subject, category, body, and uploaded -links into that description without inventing unsupported request fields. +limit, grouped under the active challenge ID when one exists or a draft upload +context before ticket creation otherwise. Because support-api-v6 accepts only +`challengeId` and Markdown `description`, the client serializes the subject, +category, body, and any uploaded links into that description without inventing +unsupported request fields. The challenge rail parses case-insensitive `fileTypes`, `submissionLimit`, `environment`, and `codeRepo` metadata, shows safe Challenge API discussions diff --git a/src/apps/opportunities/src/components/ReportIssueModal.spec.tsx b/src/apps/opportunities/src/components/ReportIssueModal.spec.tsx index b60044a94..4b7eb6528 100644 --- a/src/apps/opportunities/src/components/ReportIssueModal.spec.tsx +++ b/src/apps/opportunities/src/components/ReportIssueModal.spec.tsx @@ -85,12 +85,46 @@ describe('ReportIssueModal', () => { .toHaveAttribute('maxlength', '1000') expect(screen.getByText('Attach Files')) .toBeInTheDocument() + expect(screen.queryByText('Attach Files *')) + .not.toBeInTheDocument() expect(screen.getByText('Max. 2 MB per file')) .toBeInTheDocument() expect(screen.getByRole('button', { name: 'Send report' })) .toBeDisabled() }) + it('submits the required fields without an attachment', async () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Enter the subject of your issue'), { + target: { value: 'Submission timeout' }, + }) + fireEvent.change(screen.getByRole('combobox', { name: /Category/ }), { + target: { value: 'Submission' }, + }) + fireEvent.change(screen.getByPlaceholderText('Explain your issue'), { + target: { value: 'I tried to submit several times, but it always reaches a timeout error.' }, + }) + + const submit = screen.getByRole('button', { name: 'Send report' }) + await waitFor(() => expect(submit) + .toBeEnabled()) + fireEvent.click(submit) + + await waitFor(() => expect(mockedCreateSupportTicket) + .toHaveBeenCalledWith({ + challengeId: 'challenge-id', + description: [ + '**Subject:** Submission timeout', + '**Category:** Submission', + '', + 'I tried to submit several times, but it always reaches a timeout error.', + ].join('\n'), + })) + expect(mockedUploadAttachment) + .not.toHaveBeenCalled() + }) + it('uploads the authored file row and submits every field through the support contract', async () => { render() @@ -138,10 +172,26 @@ describe('ReportIssueModal', () => { expect(mockedUploadAttachment) .toHaveBeenCalledWith(file, expect.objectContaining({ category: 'support-ticket', - challengeId: expect.stringMatching(/^draft-/), + challengeId: 'challenge-id', })) }) + it('falls back to a draft upload context when the report is not challenge-scoped', async () => { + render() + const file = new File(['screenshot'], 'Screenshot.png', { type: 'image/png' }) + Object.defineProperty(file, 'size', { value: 1153434 }) + + fireEvent.change(screen.getByLabelText('Attach files'), { + target: { files: [file] }, + }) + + await waitFor(() => expect(mockedUploadAttachment) + .toHaveBeenCalledWith(file, expect.objectContaining({ + category: 'support-ticket', + challengeId: expect.stringMatching(/^draft-/), + }))) + }) + it('rejects files larger than the authored two-megabyte limit', async () => { render() const file = new File(['too large'], 'large.zip') diff --git a/src/apps/opportunities/src/components/ReportIssueModal.tsx b/src/apps/opportunities/src/components/ReportIssueModal.tsx index 7e7d7d7cc..6700abbf4 100644 --- a/src/apps/opportunities/src/components/ReportIssueModal.tsx +++ b/src/apps/opportunities/src/components/ReportIssueModal.tsx @@ -76,8 +76,8 @@ function formatAttachmentSize(bytes: number): string { * @param subject member-entered issue subject. * @param category selected authored issue category. * @param description member-entered issue details. - * @param attachments successfully uploaded file metadata. - * @returns Markdown description accepted by support-api-v6. + * @param attachments optional uploaded file metadata. + * @returns Markdown description accepted by support-api-v6, with attachments appended only when present. * @throws Does not throw. */ export function buildReportIssueDescription( @@ -90,21 +90,25 @@ export function buildReportIssueDescription( const label = attachment.filename.replace(/\[|\]/g, '') || 'Attachment' return `- [${label}](${attachment.url})` }) - return [ + const lines = [ `**Subject:** ${subject.trim()}`, `**Category:** ${category.trim()}`, '', description.trim(), - '', - '**Attachments:**', - ...attachmentLines, - ].join('\n') + ] + + if (attachmentLines.length) { + lines.push('', '**Attachments:**', ...attachmentLines) + } + + return lines.join('\n') } /** * Renders both authored Report an Issue states while adapting their richer * fields to support-api-v6's challenge-id plus Markdown-description contract. - * Attachments upload through the shared Filestack pipeline before submission. + * Attachments are optional and upload through the shared Filestack pipeline + * before submission. * * @param props optional challenge context and modal state. * @returns subject, category, description, attachment, and success states. @@ -130,9 +134,9 @@ export const ReportIssueModal: FC = props => { const canSubmit = !!subject.trim() && !!category && !!description.trim() - && uploaded.length > 0 && !uploading && !busy + const uploadOwnerId = props.challengeId?.trim() || uploadContext useEffect(() => { if (props.open) { @@ -200,7 +204,7 @@ export const ReportIssueModal: FC = props => { try { const result = await uploadReviewAttachment(attachment.file, { category: 'support-ticket', - challengeId: uploadContext, + challengeId: uploadOwnerId, }) setAttachments(current => current.map(item => (item.id === attachment.id ? { ...item, result, status: 'uploaded' } @@ -263,7 +267,7 @@ export const ReportIssueModal: FC = props => { */ const submit = async (): Promise => { if (!canSubmit) { - setError('Complete every required field and attach at least one file.') + setError('Complete every required field before sending the report.') return } @@ -404,7 +408,6 @@ export const ReportIssueModal: FC = props => {
{attachments.length ? 'Attach Screenshots, Files' : 'Attach Files'} - * Date: Wed, 2 Sep 2026 11:22:03 +1000 Subject: [PATCH 5/9] PM-6085 use exact design educational material labels and links --- .../src/components/ChallengeSidebar.spec.tsx | 36 ++++++++++++------- .../src/components/ChallengeSidebar.tsx | 6 ++-- .../src/utils/opportunity-learning.utils.ts | 6 ++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx index b21451bc5..968e97a8d 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx @@ -40,11 +40,12 @@ jest.mock('../services', () => ({ getChallengeTermsDetails: jest.fn(), })) jest.mock('../utils/opportunity-learning.utils', () => ({ - CHALLENGE_EXPLAINED_URL: 'https://www.topcoder.example/thrive/search?title=Topcoder%20Challenge%20Explained', + CHALLENGE_EXPLAINED_URL: + 'https://www.topcoder.example/thrive/articles/all-about-topcoder-challenges-tasks-and-gig-work-opportunities', CHECKPOINT_FEEDBACK_LEARNING_URL: - 'https://www.topcoder.example/thrive/search?title=How%20to%20Approach%20the%20Checkpoint%20Feed', + 'https://www.topcoder.example/thrive/articles/how-to-approach-the-checkpoint-feedback-to-decipher-hidden-codes', DESIGN_CHALLENGE_LEARNING_URL: - 'https://www.topcoder.example/thrive/search?title=How%20to%20Compete%20in%20Design%20Challenges', + 'https://www.topcoder.example/thrive/articles/How%20To%20Compete%20in%20Design', SCREENING_LEARNING_URL: 'https://www.topcoder.example/thrive/search?title=How%20to%20Pass%20Screening', })) @@ -140,21 +141,21 @@ describe('ChallengeSidebar Review Style', () => { it('uses published challenge-learning article links for the educational materials rail', () => { renderSidebar() - expect(screen.getByRole('link', { name: 'Topcoder Challenge Explained' })) + expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' })) .toHaveAttribute( 'href', - 'https://www.topcoder.example/thrive/search?title=Topcoder%20Challenge%20Explained', + 'https://www.topcoder.example/thrive/articles/all-about-topcoder-challenges-tasks-and-gig-work-opportunities', ) }) it('keeps design-only educational links and copy out of development challenges', () => { renderSidebar(undefined, developmentChallenge) - expect(screen.getByRole('link', { name: 'Topcoder Challenge Explained' })) + expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' })) .toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'How to Compete in Design Challenges' })) + expect(screen.queryByRole('link', { name: 'How to compete in design challenges' })) .not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: 'How to Approach the Checkpoint Feed' })) + expect(screen.queryByRole('link', { name: 'How to approach the checkpoint feedback' })) .not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Submission Format' })) .not.toBeInTheDocument() @@ -198,9 +199,20 @@ describe('ChallengeSidebar Review Style', () => { it('shows design educational links for design challenges', () => { renderSidebar(undefined, designChallenge) - expect(screen.getByRole('link', { name: 'How to Compete in Design Challenges' })) - .toBeInTheDocument() - expect(screen.getByRole('link', { name: 'How to Approach the Checkpoint Feed' })) - .toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Topcoder Challenges Explained' })) + .toHaveAttribute( + 'href', + 'https://www.topcoder.example/thrive/articles/all-about-topcoder-challenges-tasks-and-gig-work-opportunities', + ) + expect(screen.getByRole('link', { name: 'How to compete in design challenges' })) + .toHaveAttribute( + 'href', + 'https://www.topcoder.example/thrive/articles/How%20To%20Compete%20in%20Design', + ) + expect(screen.getByRole('link', { name: 'How to approach the checkpoint feedback' })) + .toHaveAttribute( + 'href', + 'https://www.topcoder.example/thrive/articles/how-to-approach-the-checkpoint-feedback-to-decipher-hidden-codes', + ) }) }) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx index 2de48a423..e780286c0 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -294,14 +294,14 @@ export const ChallengeSidebar: FC = props => {
@@ -375,10 +377,12 @@ export const ChallengeSidebar: FC = props => { {' '} Don't let your hard work go to waste. Learn more about {' '} - - how to pass screening - - . + + + how to pass screening + + . +

From 22188f7ac3807fe69a740533e0d5e9b9e0b9be7c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:26:06 +1000 Subject: [PATCH 8/9] PM-6096 match challenge terms modal spacing and typography --- .../ChallengeTermsModal.module.scss | 21 ++++++++++---- .../components/ChallengeTermsModal.spec.tsx | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss index 9bc46b5b8..09408c62b 100644 --- a/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss @@ -48,7 +48,7 @@ flex-direction: column; font-family: 'Nunito Sans', sans-serif; font-size: 16px; - gap: 16px; + gap: 12px; line-height: 22px; p, @@ -141,16 +141,16 @@ border-radius: 8px; display: flex; flex-direction: column; - gap: 24px; + gap: 16px; max-height: 532px; overflow: auto; - padding: 24px; + padding: 16px 24px 24px; } .term { display: flex; flex-direction: column; - gap: 16px; + gap: 12px; h3, h1, @@ -160,7 +160,8 @@ font-family: 'Figtree', sans-serif; font-size: 20px; line-height: 28px; - margin: 0 0 4px; + font-weight: 600; + margin: 0; text-transform: none; } @@ -169,6 +170,14 @@ font-family: 'Nunito Sans', sans-serif; font-size: 16px; line-height: 22px; + + p, + li, + a { + font-family: inherit; + font-size: inherit; + line-height: inherit; + } } > div > :first-child { @@ -178,7 +187,7 @@ p, ul, ol { - margin: 0 0 16px; + margin: 0 0 12px; } ul, diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.spec.tsx b/src/apps/opportunities/src/components/ChallengeTermsModal.spec.tsx index 1d2035d38..829fb2db5 100644 --- a/src/apps/opportunities/src/components/ChallengeTermsModal.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.spec.tsx @@ -98,4 +98,33 @@ describe('ChallengeTermsModal', () => { expect(screen.getByRole('alert')) .toHaveTextContent("We couldn't load the full challenge terms.") }) + + it('renders hydrated term headings and body copy in view mode', () => { + mockSWRResponse = { + ...mockSWRResponse, + data: [{ + id: 'standard-terms', + text: '

Acceptance of Terms and Conditions

Welcome to topcoder.com.

', + title: 'Standard Terms 2026', + }], + isValidating: false, + } + + render( + , + ) + + expect(screen.getByRole('dialog', { name: 'Standard Terms 2026' })) + .toBeInTheDocument() + expect(screen.getByText('Acceptance of Terms and Conditions')) + .toBeInTheDocument() + expect(screen.getByText('Welcome to topcoder.com.')) + .toBeInTheDocument() + }) }) From ae30c8fec51d94eb55fddbaa4dc0619b92c43f44 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 2 Sep 2026 11:42:19 +1000 Subject: [PATCH 9/9] PM-6089 keep challenge sidebar paragraph links inline --- .../components/ChallengeSidebar.module.scss | 12 ++++++- .../src/components/ChallengeSidebar.spec.tsx | 16 +++++++-- .../src/components/ChallengeSidebar.tsx | 34 ++++++++++++------- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss index cb00ea905..ba3eb180c 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss +++ b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss @@ -153,8 +153,18 @@ font-weight: 700; } -.inlineLinkText { +.card a.inlineAnchor { + display: inline; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + margin-top: 0; + text-decoration: underline; white-space: nowrap; + + &:hover { + text-decoration: none; + } } .reviewStyleList { diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx index e56da674c..fd1692697 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.spec.tsx @@ -234,9 +234,21 @@ describe('ChallengeSidebar Review Style', () => { it('keeps the policy and screening links inline with their punctuation', () => { renderSidebar(undefined, designChallenge) - expect(screen.getByRole('link', { name: 'Policy' }).parentElement) + const policyLink = screen.getByRole('link', { name: 'Policy' }) + const screeningLink = screen.getByRole('link', { name: 'how to pass screening' }) + const faqLink = screen.getByRole('link', { name: 'Read the FAQ.' }) + + expect(policyLink.className) + .toContain('inlineAnchor') + expect(screeningLink.className) + .toContain('inlineAnchor') + expect(faqLink.className) + .toContain('inlineAnchor') + expect(policyLink.parentElement) .toHaveTextContent('the Policy.') - expect(screen.getByRole('link', { name: 'how to pass screening' }).parentElement) + expect(screeningLink.parentElement) .toHaveTextContent('how to pass screening.') + expect(faqLink.parentElement) + .toHaveTextContent('Trouble formatting your submission or want to learn more? Read the FAQ.') }) }) diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx index bd0ea9006..4a2528ee8 100644 --- a/src/apps/opportunities/src/components/ChallengeSidebar.tsx +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -344,7 +344,14 @@ export const ChallengeSidebar: FC = props => {

Trouble formatting your submission or want to learn more? {' '} - Read the FAQ. + + Read the FAQ. +

@@ -358,12 +365,10 @@ export const ChallengeSidebar: FC = props => { {' '} Read about {' '} - - the - {' '} - Policy - . - + the + {' '} + Policy + .

@@ -377,12 +382,15 @@ export const ChallengeSidebar: FC = props => { {' '} Don't let your hard work go to waste. Learn more about {' '} - - - how to pass screening - - . - + + how to pass screening + + .