From 3baab7acd6505aa3117146ce6efaab1df4866859 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 12 Aug 2026 16:52:02 +1000 Subject: [PATCH 01/13] PM-5858: Require assignment before ticket closure What was broken Support Team members could close an unassigned ticket, and the closed-ticket detail did not identify who closed it. Root cause The close control checked only Support Team membership, and the UI contract omitted the closer ID already stored by the API. What was changed Disable closure unless the current Support Team user is assigned, accept the optional closer ID, and display the matching assignee handle with a stored-ID fallback. Any added/updated tests Updated TicketDetailPage coverage for unassigned and assigned closure access, closer-handle display, and legacy closer-ID fallback. --- src/apps/support/README.md | 3 +- .../support/src/lib/models/support.models.ts | 1 + .../ticket-details/TicketDetailPage.spec.tsx | 55 ++++++++++++++++++- .../pages/ticket-details/TicketDetailPage.tsx | 19 +++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/apps/support/README.md b/src/apps/support/README.md index eaad0a17d..ead5236ab 100644 --- a/src/apps/support/README.md +++ b/src/apps/support/README.md @@ -3,7 +3,8 @@ The Support subapp lets authenticated Topcoder members open requests, follow their status, and reply to the Topcoder Support Team. Users with the exact `Topcoder Support Team` role can see all tickets, assign themselves, search -closed tickets, reply to tickets assigned to them, and close resolved requests. +closed tickets, and reply to or close resolved requests assigned to them. +Closed ticket details identify the Support Team user who closed the request. ## Routes diff --git a/src/apps/support/src/lib/models/support.models.ts b/src/apps/support/src/lib/models/support.models.ts index 5c346295f..f0c94d64f 100644 --- a/src/apps/support/src/lib/models/support.models.ts +++ b/src/apps/support/src/lib/models/support.models.ts @@ -33,6 +33,7 @@ export interface SupportTicketSummary { status: SupportTicketStatus openedAt: string closedAt?: string + closedByUserId?: string updatedAt: string latestActivityAt: string responseCount: number diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx index 66b321d20..9e69ac6a5 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.spec.tsx @@ -196,6 +196,49 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() }) + it('identifies the support staff member who closed the ticket', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + assignees: [{ + assignedAt: '2026-08-07T00:30:00.000Z', + handle: 'support-agent', + userId: '99999', + }], + closedByUserId: '99999', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText((_content, element) => ( + element?.tagName === 'P' + && element.textContent?.includes('Closed') === true + && element.textContent?.includes('by support-agent') === true + ))) + .toBeTruthy() + }) + + it('falls back to the stored closer user ID when no assignee snapshot matches', () => { + mockUseSWR.mockReturnValue({ + data: { + ...closedTicket, + closedByUserId: 'legacy-staff-1', + }, + error: undefined, + isValidating: false, + mutate: mockMutate, + }) + + render() + + expect(screen.getByText('legacy-staff-1')) + .toBeTruthy() + }) + it('preserves freshly revalidated assignees when marking a ticket read completes', async () => { const markReadRequest = createDeferred() mockedMarkRead.mockReturnValue(markReadRequest.promise) @@ -242,7 +285,7 @@ describe('TicketDetailPage reply access', () => { .toBeUndefined() }) - it('requires non-owner support staff to assign an open ticket before replying', () => { + it('requires non-owner support staff to assign an open ticket before replying or closing it', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -264,9 +307,13 @@ describe('TicketDetailPage reply access', () => { .toBeNull() expect(screen.getByText('Assign this ticket to yourself before replying.')) .toBeTruthy() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(true) }) - it('lets assigned support staff reply to an open ticket', () => { + it('lets assigned support staff reply to and close an open ticket', () => { mockProfile = { roles: ['Topcoder Support Team'], userId: 99999, @@ -293,5 +340,9 @@ describe('TicketDetailPage reply access', () => { .toBeTruthy() expect(screen.queryByText('Assign this ticket to yourself before replying.')) .toBeNull() + expect((screen.getByRole('button', { + name: 'Close support ticket', + }) as HTMLButtonElement).disabled) + .toBe(false) }) }) diff --git a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx index 4df066db3..8ac4bfeb5 100644 --- a/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx +++ b/src/apps/support/src/pages/ticket-details/TicketDetailPage.tsx @@ -106,6 +106,9 @@ export const TicketDetailPage: FC = () => { assignee => String(assignee.userId) === currentUserId, )) const closed = data.status === 'CLOSED' + const closedByAssignee = data.closedByUserId + ? data.assignees.find(assignee => String(assignee.userId) === String(data.closedByUserId)) + : undefined const ticketOwner = Boolean(currentUserId && String(data.memberUserId) === currentUserId) const canReply = ticketOwner || (!closed && (!supportTeam || assignedToCurrentUser)) const replyContext = `${data.id}-reply-${replyRevision}` @@ -234,6 +237,21 @@ export const TicketDetailPage: FC = () => { Closed {' '} {formatSupportDate(data.closedAt)} + {data.closedByUserId && ( + <> + {' '} + by + {' '} + + {closedByAssignee ? ( + + ) : data.closedByUserId} + + + )}

)} @@ -247,6 +265,7 @@ export const TicketDetailPage: FC = () => { size='md' /> + + + ) +} + +export default MemberNotFound diff --git a/src/apps/profiles/src/components/MemberNotFound/index.ts b/src/apps/profiles/src/components/MemberNotFound/index.ts new file mode 100644 index 000000000..e48b4584b --- /dev/null +++ b/src/apps/profiles/src/components/MemberNotFound/index.ts @@ -0,0 +1 @@ +export { default as MemberNotFound } from './MemberNotFound' diff --git a/src/apps/profiles/src/components/index.ts b/src/apps/profiles/src/components/index.ts index 8bb7e5e34..bed93b6c6 100644 --- a/src/apps/profiles/src/components/index.ts +++ b/src/apps/profiles/src/components/index.ts @@ -4,3 +4,4 @@ export * from './tc-achievements/SRMView/ChallengesGrid' export * from './EditMemberPropertyBtn' export * from './AddButton' export * from './EmptySection' +export * from './MemberNotFound' diff --git a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx index ae271f591..d55e3bac3 100644 --- a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx +++ b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx @@ -1,11 +1,12 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react' import { Params, useNavigate, useParams } from 'react-router-dom' +import { AxiosError } from 'axios' import { bind } from 'lodash' import { profileGetPublicAsync, useMemberBadges, UserBadge, UserBadgesResponse, UserProfile } from '~/libs/core' import { Button, ContentLayout, IconSolid, LoadingSpinner } from '~/libs/ui' -import { MemberBadgeModal } from '../components' +import { MemberBadgeModal, MemberNotFound } from '../components' import styles from './MemberBadgesPage.module.scss' @@ -19,6 +20,7 @@ const MemberBadgesPage: FC<{}> = () => { ] = useState() const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false) + const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false) const memberBadges: UserBadgesResponse | undefined = useMemberBadges(profile?.userId as number, { limit: 100 }) @@ -39,12 +41,21 @@ const MemberBadgesPage: FC<{}> = () => { useEffect(() => { if (routeParams.memberHandle) { + setProfileReady(false) + setNotFound(false) + setProfile(undefined) + profileGetPublicAsync(routeParams.memberHandle) .then(userProfile => { setProfile(userProfile) setProfileReady(true) }) - // TODO: NOT FOUND PAGE redirect/dispaly via catch + .catch((e: AxiosError) => { + if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) { + setNotFound(true) + setProfileReady(true) + } + }) } }, [routeParams.memberHandle]) @@ -54,9 +65,13 @@ const MemberBadgesPage: FC<{}> = () => { return ( <> - + + + {profileReady && notFound && ( + + )} - {profileReady && profile && !!memberBadges && ( + {profileReady && profile && !notFound && !!memberBadges && ( diff --git a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx index d5a85cf72..7bdead5cb 100644 --- a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx +++ b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx @@ -5,7 +5,7 @@ import { AxiosError } from 'axios' import { profileContext, ProfileContextData, profileGetPublicAsync, UserProfile } from '~/libs/core' import { LoadingSpinner } from '~/libs/ui' -import { rootRoute } from '../profiles.routes' +import { MemberNotFound } from '../components' import { notifyUniNavi } from '../lib' import { ProfilePageLayout } from './page-layout' @@ -21,6 +21,7 @@ const MemberProfilePage: FC<{}> = () => { ] = useState() const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false) + const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false) const { isTalentSearch }: MemberProfileContextValue = useMemberProfileContext() const { profile: authProfile }: ProfileContextData = useContext(profileContext) @@ -31,6 +32,10 @@ const MemberProfilePage: FC<{}> = () => { useEffect(() => { if (routeParams.memberHandle) { + setProfileReady(false) + setNotFound(false) + setProfile(undefined) + profileGetPublicAsync(routeParams.memberHandle) .then(userProfile => { setProfile({ ...userProfile } as UserProfile) @@ -38,7 +43,8 @@ const MemberProfilePage: FC<{}> = () => { }) .catch((e: AxiosError) => { if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) { - navigate(rootRoute) + setNotFound(true) + setProfileReady(true) } }) } @@ -58,7 +64,11 @@ const MemberProfilePage: FC<{}> = () => { <> - {profileReady && profile && ( + {profileReady && notFound && ( + + )} + + {profileReady && profile && !notFound && ( Date: Thu, 13 Aug 2026 16:03:21 +0530 Subject: [PATCH 11/13] PM-5879 Fix rating charts label --- .../MemberRatingInfoModal.spec.tsx | 19 +++++++++++++ .../MemberRatingInfoModal.tsx | 28 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index 549073927..bdb7fee56 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -212,4 +212,23 @@ describe('MemberRatingInfoModal', () => { expect(parseFloat(marker.style.left)) .toBeCloseTo((3.5 / 6) * 100) }) + + it('hides the 2200+ axis label when the distribution has no elite buckets', () => { + render( + , + ) + + expect(screen.getByText('1500')) + .toBeInTheDocument() + expect(screen.queryByText('2200+')) + .not + .toBeInTheDocument() + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index a2d34df9b..d3a4ca25e 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -292,6 +292,29 @@ const getAxisLabelPosition = ( return 100 } +/** + * Returns axis labels that fall within the visible distribution ranges. + * + * Hides labels such as `2200+` when the trimmed histogram has no elite buckets, + * which otherwise stack on top of the final label (e.g. `1500`). + * + * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges. + * @returns {Array<{ label: string, value: number }>} Axis labels to render. + */ +const getVisibleAxisLabels = ( + ranges: RatingDistributionRange[], +): Array<{ label: string, value: number }> => { + if (ranges.length === 0) { + return chartAxisLabels + } + + const chartEnd = ranges[ranges.length - 1].end + + return chartAxisLabels.filter((axisLabel: { label: string, value: number }) => ( + axisLabel.value <= chartEnd + )) +} + /** * Calculates a bar height for a histogram count. * @@ -359,6 +382,9 @@ const MemberRatingInfoModal: FC = (props: MemberRati const markerPosition: number = props.rating !== undefined ? getMarkerPosition(props.rating, distributionRanges) : 0 + const visibleAxisLabels: Array<{ label: string, value: number }> = useMemo(() => ( + getVisibleAxisLabels(distributionRanges) + ), [distributionRanges]) const shouldStackMarkerRating: boolean = props.rating !== undefined && ( markerPosition >= stackedMarkerPositionThreshold || props.rating >= stackedMarkerRatingThreshold @@ -476,7 +502,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati )}
- {chartAxisLabels.map((axisLabel: { label: string, value: number }) => ( + {visibleAxisLabels.map((axisLabel: { label: string, value: number }) => ( Date: Thu, 13 Aug 2026 17:24:44 +0530 Subject: [PATCH 12/13] PM-5819 Fix engagement details styling --- .../EngagementDetailsPage.module.scss | 112 +++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss index ea4ff84e3..4da57dc47 100644 --- a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss @@ -81,13 +81,119 @@ font-size: 14px; line-height: 1.5; - :global(p) { - margin: 0 0 12px; + // Element tags are not hashed by CSS Modules; avoid nested :global(...) + // which Sass can leave as a literal pseudo that never matches. + p { + margin: 0 0 15px; } - :global(p:last-child) { + + + ul, + ol { + margin: 0 0 20px; + padding-left: 24px; + list-style-position: outside; + } + + ul { + list-style-type: disc; + } + + ol { + list-style-type: decimal; + } + + li { + margin-bottom: 6px; + } + + li:last-child { margin-bottom: 0; } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: inherit; + text-transform: none; + letter-spacing: normal; + color: #2a2a2a; + } + + h1 { + margin: 28px 0 14px; + font-size: 1.4rem; + font-weight: 700; + line-height: 1.3; + } + + h2 { + margin: 24px 0 14px; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.3; + } + + h3 { + margin: 22px 0 12px; + font-size: 1.1rem; + font-weight: 600; + line-height: 1.35; + } + + h4, + h5, + h6 { + margin: 20px 0 12px; + font-size: 1rem; + font-weight: 600; + line-height: 1.4; + } + + strong { + color: #2a2a2a; + } + + a { + color: $link-blue-dark; + text-decoration: underline; + } + + blockquote { + margin: 0 0 20px; + padding-left: 12px; + border-left: 3px solid #d8dee8; + color: #5b5b5b; + } + + code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.9em; + background: #f5f7fa; + border: 1px solid #d8dee8; + border-radius: 6px; + padding: 1px 4px; + } + + pre { + margin: 0 0 20px; + background: #f5f7fa; + border: 1px solid #d8dee8; + border-radius: 12px; + padding: 12px; + overflow-x: auto; + } + + pre code { + background: transparent; + border: none; + padding: 0; + font-size: 0.9rem; + } } .skills { From 45a64961e8a1845018f27af7913c5e1732a2e5f2 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 13 Aug 2026 21:08:12 +0530 Subject: [PATCH 13/13] PM-5820 Show N/A daysLeftInEngagement for terminated assignments --- .../work/src/lib/utils/assignment-dates.utils.spec.ts | 10 +++++++++- src/apps/work/src/lib/utils/assignment-dates.utils.ts | 4 ++-- .../EngagementPaymentPage/EngagementPaymentPage.tsx | 6 +++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/lib/utils/assignment-dates.utils.spec.ts b/src/apps/work/src/lib/utils/assignment-dates.utils.spec.ts index 5d220b6a4..d8b1d6399 100644 --- a/src/apps/work/src/lib/utils/assignment-dates.utils.spec.ts +++ b/src/apps/work/src/lib/utils/assignment-dates.utils.spec.ts @@ -57,7 +57,7 @@ describe('assignment-dates.utils', () => { .toBe('-') }) - it('returns N/A for cancelled or closed engagements', () => { + it('returns N/A for cancelled or closed engagements, and terminated assignments', () => { expect(formatAssignmentDaysLeftInEngagement( '2026-01-01T12:00:00.000Z', 1, @@ -74,6 +74,14 @@ describe('assignment-dates.utils', () => { )) .toBe('N/A') + expect(formatAssignmentDaysLeftInEngagement( + '2026-01-01T12:00:00.000Z', + 1, + 'TERMINATED', + new Date('2026-01-16T12:00:00.000Z'), + )) + .toBe('N/A') + expect(formatAssignmentDaysLeftInEngagement( '2026-01-01T12:00:00.000Z', 1, diff --git a/src/apps/work/src/lib/utils/assignment-dates.utils.ts b/src/apps/work/src/lib/utils/assignment-dates.utils.ts index b4131d744..1af058d81 100644 --- a/src/apps/work/src/lib/utils/assignment-dates.utils.ts +++ b/src/apps/work/src/lib/utils/assignment-dates.utils.ts @@ -146,11 +146,11 @@ export function getAssignmentDaysLeftInEngagement( /** * Formats remaining engagement days for Assignments list display. * - * Cancelled or closed engagements show `N/A`. + * Cancelled or closed engagements, and terminated assignments, show `N/A`. * * @param startDate billing start date. * @param durationMonths assignment duration in months. - * @param engagementStatus optional engagement status. + * @param engagementStatus optional engagement or assignment status. * @param now optional "today" override for tests. * @returns display string such as `16 days`, `N/A`, or `-` when unavailable. */ diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx index 00310dd07..fc600120c 100644 --- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx @@ -1125,7 +1125,11 @@ export const EngagementPaymentPage: FC = () => { {formatAssignmentDaysLeftInEngagement( assignment.startDate, assignment.durationMonths, - engagementResult.engagement?.status, + String(assignment.status || '') + .trim() + .toUpperCase() === 'TERMINATED' + ? assignment.status + : engagementResult.engagement?.status, )}