From dda59ad8c39d35d2dae3b4225a48cea55c4ebed6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 14 Aug 2026 16:33:27 +1000 Subject: [PATCH 01/44] PM-5755: Show the simplified Design review section for every role What was broken QA re-tested the Design challenge Review section and still saw every reviewer role (Checkpoint Review, Review, Approval, Checkpoint Screening, Screening) instead of the single Screener selector requested on the ticket. Root cause The first fix gated the simplified Review section on the copilot-only billing role check (isCopilot && !isAdmin && !isManager). QA and most Topcoder staff accounts carry the administrator role, so the gate never evaluated to true for them and the full tabbed configuration kept rendering. The Design track and Challenge type parts of the gate were correct; only the role restriction was wrong. What was changed The simplified Screener-only Review section now renders for every user editing a Design Challenge, matching the ticket ("for the entire Review section we need Screener + dropdown: Select user"). Administrators no longer lose the detailed configuration: ReviewersField receives a canConfigureFullReview flag and, when set, renders a "Show advanced review configuration" toggle that expands the existing Human Review / AI Review / Review Context tabs on demand and collapses back to the Screener selector. Copilots and managers only see the Screener selector. The AI-gating "Manual review configuration is required" validation stays suppressed while the simplified view is showing and re-applies as soon as an administrator expands the advanced configuration. Challenge Editor documentation was updated to describe the new behavior. Any added/updated tests ChallengeEditorForm.spec now asserts that administrators and managers also get the simplified Design Challenge review section and that only administrators receive the advanced-configuration capability. ReviewersField.spec adds cases for the collapsed-by-default admin view, expanding and collapsing the advanced configuration, the absence of the toggle for non-admins and non-Design sections, and the suppressed AI-gating error in the simplified view. The pre-existing AI-gating assertion in ReviewersField.spec was failing on dev because the inline message is replaced by the registered form error once the effect runs; it now asserts the registered reviewers form error instead, so the suite is green. Co-Authored-By: Claude Opus 5 (1M context) --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.spec.tsx | 46 +++++++- .../components/ChallengeEditorForm.tsx | 4 +- .../ReviewersField/ReviewersField.module.scss | 6 ++ .../ReviewersField/ReviewersField.spec.tsx | 100 +++++++++++++++++- .../ReviewersField/ReviewersField.tsx | 34 +++++- 6 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 6fe2ac84e..95b889af1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -23,7 +23,7 @@ payload is available, so the create route can expand to the full editor immediately after the initial draft is created. - `components/*Field.tsx`: field-level components for each challenge section. -- `components/ReviewersField/*`: tabbed human/AI review configuration. Copilot-only users editing Design `Challenge` types see one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults; admins and managers retain the full interface. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. +- `components/ReviewersField/*`: tabbed human/AI review configuration. Every user editing a Design `Challenge` sees one Screener selector that synchronizes the selected member across final Screening and, for two-round challenges, Checkpoint Screening while preserving all hidden phase, scorecard, and reviewer defaults. Administrators additionally get a `Show advanced review configuration` toggle that expands the full tabbed interface on demand; copilots and managers only see the Screener selector. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs. - `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them. ## Validation Rules 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 978510484..16939b81a 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 @@ -692,6 +692,7 @@ jest.mock('./ReviewCostField', () => ({ })) jest.mock('./ReviewersField', () => ({ ReviewersField: (props: { + canConfigureFullReview?: boolean isReadOnly?: boolean screenerOnly?: boolean }) => { @@ -703,6 +704,7 @@ jest.mock('./ReviewersField', () => ({ return (
{ .toHaveAttribute('data-screener-only', 'true') }) - it('keeps the full review configuration for an admin editing a Design Challenge', () => { + it('uses the simplified screener review for an admin editing a Design Challenge', () => { mockedUseFetchChallengeTracks.mockReturnValue({ isLoading: false, tracks: [{ @@ -3178,7 +3180,47 @@ describe('ChallengeEditorForm', () => { ) expect(screen.getByTestId('reviewers-field')) - .toHaveAttribute('data-screener-only', 'false') + .toHaveAttribute('data-screener-only', 'true') + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-can-configure-full-review', 'true') + }) + + it('uses the simplified screener review for a manager editing a Design Challenge', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track-id', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'CH', + id: 'design-challenge-type-id', + name: 'Challenge', + }], + isLoading: false, + }) + const managerContextValue: WorkAppContextModel = { + ...copilotContextValue, + isCopilot: false, + isManager: true, + userRoles: ['project manager'], + } + + render( + + + + + , + ) + + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-screener-only', 'true') + expect(screen.getByTestId('reviewers-field')) + .toHaveAttribute('data-can-configure-full-review', 'false') }) it('keeps the full review configuration for a copilot editing a Design First2Finish', () => { 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 9a8a20273..f14c12715 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -2223,8 +2223,7 @@ export const ChallengeEditorForm: FC = ( const shouldUseCopilotBillingSummary = workAppContext.isCopilot && !workAppContext.isAdmin && !workAppContext.isManager - const shouldUseSimplifiedDesignReview = shouldUseCopilotBillingSummary - && isDesignTrackSelected + const shouldUseSimplifiedDesignReview = isDesignTrackSelected && isChallengeTypeSelected const getPersistedAssignmentValueByFields = useCallback(( fallbackValue: string | undefined, @@ -3861,6 +3860,7 @@ export const ChallengeEditorForm: FC = (

Review

({ patchChallenge: jest.fn(), })) +jest.mock('~/libs/ui', () => ({ + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + }) => ( + + ), +}), { + virtual: true, +}) + jest.mock('./HumanReviewTab', () => ({ __esModule: true, default: (props: { screenerOnly?: boolean }) => ( @@ -99,6 +117,7 @@ const mockedPatchChallenge = jest.spyOn(services, 'patchChallenge') const mockedFetchAiReviewConfigByChallenge = services.fetchAiReviewConfigByChallenge as jest.Mock interface TestHarnessProps { + canConfigureFullReview?: boolean isReadOnly?: boolean numOfSubmissions?: number reviewers: Reviewer[] @@ -118,14 +137,20 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { }) const reviewersField = ( ) + const reviewersFormError = formMethods.formState.errors.reviewers?.message + return ( {reviewersField} + {reviewersFormError + ?
{reviewersFormError}
+ : undefined}
) } @@ -158,6 +183,55 @@ describe('ReviewersField', () => { .toBeNull() expect(screen.queryByText('Manual review configuration is required.')) .toBeNull() + expect(screen.queryByRole('button', { name: 'Show advanced review configuration' })) + .toBeNull() + }) + + it('starts collapsed for administrators and reveals the full configuration on demand', async () => { + const user = userEvent.setup() + + render( + , + ) + + expect(screen.getByTestId('human-review-tab') + .getAttribute('data-screener-only')) + .toBe('true') + expect(screen.queryByRole('tablist')) + .toBeNull() + + await user.click(screen.getByRole('button', { name: 'Show advanced review configuration' })) + + expect(screen.getByRole('tablist')).not.toBeNull() + expect(screen.getByTestId('human-review-tab') + .getAttribute('data-screener-only')) + .toBe('false') + expect(screen.getByTestId('ai-review-tab')).not.toBeNull() + + await user.click(screen.getByRole('button', { name: 'Hide advanced review configuration' })) + + expect(screen.queryByRole('tablist')) + .toBeNull() + expect(screen.getByTestId('human-review-tab') + .getAttribute('data-screener-only')) + .toBe('true') + }) + + it('does not offer the advanced toggle outside the simplified review section', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: 'Show advanced review configuration' })) + .toBeNull() + expect(screen.getByRole('tablist')).not.toBeNull() }) it('uses tab labels with reviewer counts and toggles between human and AI content', async () => { @@ -319,10 +393,28 @@ describe('ReviewersField', () => { await user.click(screen.getByRole('tab', { name: 'AI Review (0)' })) await user.click(screen.getByRole('button', { name: 'Persist AI config' })) - expect(screen.getByText( - 'Manual review configuration is required.', - )) - .toBeInTheDocument() + expect(screen.getByTestId('reviewers-form-error').textContent) + .toBe('Manual review configuration is required.') + }) + + it('does not require manual reviewer configuration in the simplified screener view', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Show advanced review configuration' })) + await user.click(screen.getByRole('tab', { name: 'AI Review (0)' })) + await user.click(screen.getByRole('button', { name: 'Persist AI config' })) + await user.click(screen.getByRole('button', { name: 'Hide advanced review configuration' })) + + expect(screen.queryByTestId('reviewers-form-error')) + .toBeNull() }) it('supports keyboard navigation between review tabs', async () => { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx index 3eb5f46b9..cf23869d1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx @@ -17,6 +17,7 @@ import { import classNames from 'classnames' import { ChallengeStatus } from '~/apps/admin/src/lib/models' +import { Button } from '~/libs/ui' import * as services from '../../../../../lib/services' import { @@ -40,6 +41,7 @@ const REVIEW_TAB = ['ai', 'human', 'context'] as const type ReviewTab = typeof REVIEW_TAB[number] interface ReviewersFieldProps { + canConfigureFullReview?: boolean isReadOnly?: boolean onConfigSaveControllerReady?: (controller: AiReviewConfigSaveController | undefined) => void screenerOnly?: boolean @@ -59,6 +61,7 @@ function hasReviewerChanges( export const ReviewersField: FC = (props: ReviewersFieldProps) => { const formContext = useFormContext() const [activeTab, setActiveTab] = useState('human') + const [isFullReviewExpanded, setIsFullReviewExpanded] = useState(false) const [aiReviewMode, setAiReviewMode] = useState() const [hasLoadedAiConfig, setHasLoadedAiConfig] = useState(false) const [reviewContextRequirementCount, setReviewContextRequirementCount] = useState(undefined) @@ -160,11 +163,17 @@ export const ReviewersField: FC = (props: ReviewersFieldPro const reviewContextLabel = reviewContextRequirementCount ? `Review Context (${reviewContextRequirementCount})` : 'Review Context' + /** + * The simplified Design review section exposes the screener assignment only. + * Administrators keep access to the complete configuration behind a toggle. + */ + const showScreenerOnlyView = !!props.screenerOnly && !isFullReviewExpanded + const showFullReviewToggle = !!props.screenerOnly && !!props.canConfigureFullReview const aiGatingManualReviewError = useMemo( - () => (!props.screenerOnly && aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0 + () => (!showScreenerOnlyView && aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0 ? 'Manual review configuration is required.' : undefined), - [aiReviewMode, humanReviewersCount, props.screenerOnly], + [aiReviewMode, humanReviewersCount, showScreenerOnlyView], ) useEffect(() => { @@ -189,6 +198,9 @@ export const ReviewersField: FC = (props: ReviewersFieldPro const handleTabChange = useCallback((tab: ReviewTab): void => { setActiveTab(tab) }, []) + const handleFullReviewToggle = useCallback((): void => { + setIsFullReviewExpanded(previousValue => !previousValue) + }, []) const focusTab = useCallback((tab: ReviewTab): void => { handleTabChange(tab) @@ -325,11 +337,25 @@ export const ReviewersField: FC = (props: ReviewersFieldPro ) : undefined} - {!props.isReadOnly && props.screenerOnly + {!props.isReadOnly && showFullReviewToggle + ? ( +
+
+ ) + : undefined} + + {!props.isReadOnly && showScreenerOnlyView ? : undefined} - {!props.isReadOnly && !props.screenerOnly + {!props.isReadOnly && !showScreenerOnlyView ? ( <>
Date: Fri, 14 Aug 2026 16:49:43 +1000 Subject: [PATCH 02/44] PM-5878: Add Show Dashboard flag to Marathon Match advanced settings What was broken The Work app in platform-ui had no control for the show_data_dashboard challenge metadata flag, so copilots could not enable the data dashboard graph on a Marathon Match challenge from the challenge editor. MM 166 launched without the dashboard because the flag had to be set manually against challenge-api-v6. Root cause The flag was only ever consumed, never authored. community-app reads the show_data_dashboard metadata entry to decide whether to render the challenge dashboard tab, and challenge-api-v6 stores it as a generic key/value metadata entry, but no editor field ever wrote it. What was changed - Added ShowDashboardField, a "Show Dashboard" checkbox that reads and writes the exact string-valued show_data_dashboard challenge metadata entry, following the existing StockArtsField/RegisteredMemberDownloadField metadata patterns. - Rendered the checkbox in the challenge editor's Advanced Options section only for Marathon Match challenge types. - Defaulted the checkbox to checked for fun challenges that have no saved show_data_dashboard value yet, and persisted that implied value so a save keeps the dashboard enabled. A saved value always wins, so the dashboard can be turned back off. - Seeded show_data_dashboard during Marathon Match creation as true for fun challenges and false otherwise, so the default takes effect as soon as the challenge is set up. - Documented the new field in the ChallengeEditorPage README. No changes were needed in challenge-api-v6 or community-app: challenge metadata is a generic name/value collection and community-app already renders the dashboard tab from this flag. Added/updated tests - New ShowDashboardField.spec.tsx covering the standard Marathon Match default (unchecked, no metadata written), the fun-challenge default (checked and persisted as "true"), saved metadata winning over the fun-challenge default, and toggling persisting exact string booleans. - New parameterized ChallengeEditorForm.spec.tsx case asserting that creating a Marathon Match sends show_data_dashboard "true" for fun challenges and "false" otherwise; the FunChallengeField mock now binds to the form so the fun flag can be toggled in tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../challenges/ChallengeEditorPage/README.md | 5 + .../components/ChallengeEditorForm.spec.tsx | 88 ++++++++- .../components/ChallengeEditorForm.tsx | 19 +- .../ShowDashboardField.spec.tsx | 179 ++++++++++++++++++ .../ShowDashboardField/ShowDashboardField.tsx | 144 ++++++++++++++ .../components/ShowDashboardField/index.ts | 1 + .../ChallengeEditorPage/components/index.ts | 1 + 7 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.spec.tsx create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.tsx create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/index.ts diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 6fe2ac84e..f7fa1bd01 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -75,6 +75,11 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, keeps outside-label date-picker controls visible and interactive when the shared input wrapper omits an empty internal label, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, initializes missing challenge start dates from existing phase starts or the current date before calculating blank phase rows, recalculates root phase dates when the challenge start changes, and upgrades populated legacy non-task schedules to the scheduling API during serialization even if asynchronous hydration restores a stale disabled flag. It honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, reports rejected schedule edits to the form so manual saves show the relevant validation error and autosave pauses until the edit is corrected, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior and retain a disabled legacy scheduling flag. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. +- `ShowDashboardField`: `Show Dashboard` checkbox shown in Advanced Options only for `Marathon Match` + type challenges. It reloads from the exact string-valued `show_data_dashboard` challenge metadata + entry consumed by the challenge details page, and defaults to checked for fun challenges that have + no saved value yet. Creating a Marathon Match persists `show_data_dashboard` as `true` for fun + challenges and `false` otherwise. - `Test Challenge` checkbox: shown only in Advanced Options after the challenge has been created; it is omitted from Basic Information during initial creation. It defaults unchecked, reloads from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test 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 978510484..bd3a42efb 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 @@ -651,7 +651,22 @@ jest.mock('./FinalDeliverablesField', () => ({ FinalDeliverablesField: () => <>Final Deliverables Field, })) jest.mock('./FunChallengeField', () => ({ - FunChallengeField: () => <>, + FunChallengeField: function MockFunChallengeField() { + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + const controller = reactHookForm.useController({ + control: reactHookForm.useFormContext().control, + name: 'funChallenge', + }) + + return ( + controller.field.onChange(event.target.checked)} + type='checkbox' + /> + ) + }, })) jest.mock('./GroupsField', () => ({ GroupsField: () => <>, @@ -719,6 +734,10 @@ jest.mock('./ReviewTypeField', () => ({ jest.mock('./RoundTypeField', () => ({ RoundTypeField: () => <>, })) +jest.mock('./ShowDashboardField', () => ({ + SHOW_DATA_DASHBOARD_METADATA_FIELD: 'show_data_dashboard', + ShowDashboardField: () => <>Show Dashboard Field, +})) jest.mock('./StockArtsField', () => ({ StockArtsField: function StockArtsField() { const React: typeof import('react') = jest.requireActual('react') @@ -4810,6 +4829,73 @@ describe('ChallengeEditorForm', () => { }) }) + it.each([ + { + expectedShowDataDashboard: 'true', + isFunChallenge: true, + }, + { + expectedShowDataDashboard: 'false', + isFunChallenge: false, + }, + ])('creates a Marathon Match with show_data_dashboard $expectedShowDataDashboard', async ({ + expectedShowDataDashboard, + isFunChallenge, + }: { + expectedShowDataDashboard: string + isFunChallenge: boolean + }) => { + const user = userEvent.setup() + + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'MM', + id: 'marathon-match-id', + isActive: true, + isTask: false, + name: 'Marathon Match', + }], + isLoading: false, + }) + mockedCreateChallenge.mockResolvedValue({ + id: 'created-challenge-id', + name: 'Marathon Match challenge', + status: 'NEW', + }) + mockedFetchChallenge.mockResolvedValue({ + id: 'created-challenge-id', + name: 'Marathon Match challenge', + status: 'NEW', + }) + + render( + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), 'Marathon Match challenge') + await user.type(screen.getByLabelText('Challenge Track'), 'track-id') + await user.type(screen.getByLabelText('Challenge Type'), 'marathon-match-id') + + if (isFunChallenge) { + await user.click(screen.getByRole('checkbox', { name: 'Fun Challenge' })) + } + + await user.click(screen.getByRole('button', { name: 'New' })) + + await waitFor(() => { + expect(mockedCreateChallenge) + .toHaveBeenCalledWith(expect.objectContaining({ + funChallenge: isFunChallenge, + metadata: expect.arrayContaining([{ + name: 'show_data_dashboard', + value: expectedShowDataDashboard, + }]), + })) + }) + }) + it('creates a forum discussion for forum-enabled challenge types', async () => { const user = userEvent.setup() 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 9a8a20273..bdde72b2f 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -188,6 +188,10 @@ import { import { RoundTypeField, } from './RoundTypeField' +import { + ShowDashboardField, + SHOW_DATA_DASHBOARD_METADATA_FIELD, +} from './ShowDashboardField' import { StockArtsField, } from './StockArtsField' @@ -2192,6 +2196,7 @@ export const ChallengeEditorForm: FC = ( const isChallengeCreated = !!currentChallengeId const isFunChallengeSelected = values.funChallenge === true const showFunChallengeField = isMarathonMatchChallengeSelected + const showDashboardField = isMarathonMatchChallengeSelected const showMarathonMatchScorerSection = isMarathonMatchChallengeSelected && isChallengeCreated const showRateChallengeField = isMarathonMatchChallengeSelected || isDevelopmentChallengeSelected const showPrizesAndBillingSection = !isFunChallengeSelected @@ -3071,7 +3076,7 @@ export const ChallengeEditorForm: FC = ( discussionForum: formData.discussionForum, selectedChallengeType, }) - const metadata = booleanToMetadata( + const baseMetadata = booleanToMetadata( booleanToMetadata( formData.metadata, REGISTERED_MEMBER_DOWNLOAD_METADATA_FIELD, @@ -3080,6 +3085,14 @@ export const ChallengeEditorForm: FC = ( IS_TEST_CHALLENGE_METADATA_FIELD, formData.isTestChallenge === true, ) + // Marathon Match fun challenges are created with the data dashboard enabled. + const metadata = isMarathonMatchChallengeSelected + ? booleanToMetadata( + baseMetadata, + SHOW_DATA_DASHBOARD_METADATA_FIELD, + formData.funChallenge === true, + ) + : baseMetadata const createdChallenge = await createChallenge({ discussions, funChallenge: formData.funChallenge === true, @@ -3167,6 +3180,7 @@ export const ChallengeEditorForm: FC = ( fallbackProjectId, getValues, isDevelopmentTrackSelected, + isMarathonMatchChallengeSelected, isTaskSingleAssignmentChallenge, reset, onChallengeCreated, @@ -4211,6 +4225,9 @@ export const ChallengeEditorForm: FC = ( {showRateChallengeField ? : undefined} + {showDashboardField + ? + : undefined}
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.spec.tsx new file mode 100644 index 000000000..f8053a4a1 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.spec.tsx @@ -0,0 +1,179 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { FC } from 'react' +import { + render, + screen, + waitFor, +} from '@testing-library/react' +import '@testing-library/jest-dom' +import userEvent from '@testing-library/user-event' +import { + FormProvider, + useForm, + useWatch, +} from 'react-hook-form' + +import { + ChallengeEditorFormData, + ChallengeMetadata, +} from '../../../../../lib/models' + +import { ShowDashboardField } from './ShowDashboardField' + +interface MockFormCheckboxFieldProps { + label: string + name: string + onChange?: (checked: boolean) => void +} + +jest.mock('../../../../../lib/components/form', () => ({ + FormCheckboxField: function MockFormCheckboxField(props: MockFormCheckboxFieldProps) { + const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + const formContext = reactHookForm.useFormContext() + const controller = reactHookForm.useController({ + control: formContext.control, + name: props.name, + }) + + function handleChange(event: { target: { checked: boolean } }): void { + controller.field.onChange(event.target.checked) + props.onChange?.(event.target.checked) + } + + return ( + + ) + }, +})) + +interface TestHarnessProps { + defaultMetadata?: ChallengeMetadata[] + funChallenge?: boolean +} + +const MetadataWatcher: FC = () => { + const metadata = useWatch({ + name: 'metadata', + }) + + return {JSON.stringify(metadata || [])} +} + +const TestHarness: FC = (props: TestHarnessProps) => { + const formMethods = useForm({ + defaultValues: { + description: 'Public challenge specification', + funChallenge: props.funChallenge, + metadata: props.defaultMetadata, + name: 'Challenge', + skills: [], + tags: [], + trackId: 'track-id', + typeId: 'type-id', + }, + }) + + return ( + + + + + ) +} + +const SHOW_DASHBOARD_LABEL = 'Show Dashboard' +const SHOW_DATA_DASHBOARD_METADATA_FIELD = 'show_data_dashboard' + +describe('ShowDashboardField', () => { + it('leaves the dashboard disabled for standard marathon matches', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('checkbox', { name: SHOW_DASHBOARD_LABEL })) + .not + .toBeChecked() + }) + expect(screen.getByTestId('metadata-value').textContent) + .toBe('[]') + }) + + it('defaults fun challenges to an enabled dashboard and persists the metadata', async () => { + render( + , + ) + + await waitFor(() => { + expect(screen.getByRole('checkbox', { name: SHOW_DASHBOARD_LABEL })) + .toBeChecked() + }) + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify([ + { + name: 'existingMetadata', + value: 'keep-me', + }, + { + name: SHOW_DATA_DASHBOARD_METADATA_FIELD, + value: 'true', + }, + ])) + }) + + it('restores the saved value instead of the fun-challenge default', async () => { + const metadata = [{ + name: SHOW_DATA_DASHBOARD_METADATA_FIELD, + value: 'false', + }] + + render() + + await waitFor(() => { + expect(screen.getByRole('checkbox', { name: SHOW_DASHBOARD_LABEL })) + .not + .toBeChecked() + }) + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify(metadata)) + }) + + it('persists exact string booleans when the checkbox is toggled', async () => { + const user = userEvent.setup() + + render() + + const showDashboardCheckbox = screen.getByRole('checkbox', { name: SHOW_DASHBOARD_LABEL }) + + await user.click(showDashboardCheckbox) + + expect(showDashboardCheckbox) + .toBeChecked() + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify([{ + name: SHOW_DATA_DASHBOARD_METADATA_FIELD, + value: 'true', + }])) + + await user.click(showDashboardCheckbox) + + expect(showDashboardCheckbox) + .not + .toBeChecked() + expect(screen.getByTestId('metadata-value').textContent) + .toBe(JSON.stringify([{ + name: SHOW_DATA_DASHBOARD_METADATA_FIELD, + value: 'false', + }])) + }) +}) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.tsx new file mode 100644 index 000000000..29ace116a --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/ShowDashboardField.tsx @@ -0,0 +1,144 @@ +import { + FC, + useCallback, + useEffect, +} from 'react' +import { + useFormContext, + useWatch, +} from 'react-hook-form' + +import { FormCheckboxField } from '../../../../../lib/components/form' +import { + ChallengeEditorFormData, + ChallengeMetadata, +} from '../../../../../lib/models' +import { + booleanToMetadata, + getMetadataValue, + metadataToBoolean, +} from '../../../../../lib/utils/metadata.utils' + +export const SHOW_DATA_DASHBOARD_METADATA_FIELD = 'show_data_dashboard' +const SHOW_DATA_DASHBOARD_TOGGLE_FIELD = 'showDataDashboardToggle' + +interface ShowDashboardFieldProps { + disabled?: boolean +} + +/** + * Renders the Marathon Match data dashboard toggle in the Advanced Options section. + * + * Fun challenges default to an enabled dashboard when the challenge has no saved + * `show_data_dashboard` metadata, matching how Marathon Matches are normally set up. Saved metadata + * always wins so a copilot can turn the dashboard off again. + * + * @param props field state supplied by the challenge editor, including read-only disablement. + * @returns A checkbox that persists the `show_data_dashboard` challenge metadata flag. + * @throws Does not throw. + */ +export const ShowDashboardField: FC = (props: ShowDashboardFieldProps) => { + const formContext = useFormContext() + const dynamicFormControl = formContext.control as any + const metadata = useWatch({ + control: dynamicFormControl, + name: 'metadata', + }) as ChallengeMetadata[] | undefined + const funChallenge = useWatch({ + control: dynamicFormControl, + name: 'funChallenge', + }) as boolean | undefined + const showDataDashboardToggle = useWatch({ + control: dynamicFormControl, + name: SHOW_DATA_DASHBOARD_TOGGLE_FIELD, + }) as boolean | undefined + + const hasSavedDashboardFlag = getMetadataValue( + metadata, + SHOW_DATA_DASHBOARD_METADATA_FIELD, + ) !== undefined + const isDashboardShown = hasSavedDashboardFlag + ? metadataToBoolean(metadata, SHOW_DATA_DASHBOARD_METADATA_FIELD) + : funChallenge === true + + // Persist the fun-challenge default so saving the challenge keeps the dashboard enabled. + useEffect(() => { + if (hasSavedDashboardFlag || !isDashboardShown) { + return + } + + formContext.setValue( + 'metadata', + booleanToMetadata( + metadata, + SHOW_DATA_DASHBOARD_METADATA_FIELD, + true, + ), + { + shouldDirty: false, + shouldValidate: false, + }, + ) + }, [ + formContext, + hasSavedDashboardFlag, + isDashboardShown, + metadata, + ]) + + useEffect(() => { + if (showDataDashboardToggle !== undefined) { + return + } + + formContext.setValue( + SHOW_DATA_DASHBOARD_TOGGLE_FIELD as never, + isDashboardShown as never, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + }, [ + formContext, + isDashboardShown, + showDataDashboardToggle, + ]) + + const handleShowDashboardChange = useCallback((checked: boolean): void => { + if (hasSavedDashboardFlag && checked === isDashboardShown) { + return + } + + formContext.setValue( + 'metadata', + booleanToMetadata( + metadata, + SHOW_DATA_DASHBOARD_METADATA_FIELD, + checked, + ), + { + shouldDirty: true, + shouldValidate: true, + }, + ) + }, [ + formContext, + hasSavedDashboardFlag, + isDashboardShown, + metadata, + ]) + + return ( + + ) +} + +export default ShowDashboardField diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/index.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/index.ts new file mode 100644 index 000000000..76c760683 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ShowDashboardField/index.ts @@ -0,0 +1 @@ +export * from './ShowDashboardField' diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/index.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/index.ts index 8154d1983..a70e4f2ae 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/index.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/index.ts @@ -30,6 +30,7 @@ export * from './ReviewersField' export * from './ReviewTypeField' export * from './ResourcesSection' export * from './RoundTypeField' +export * from './ShowDashboardField' export * from './StockArtsField' export * from './SubmissionsSection' export * from './SubmissionTypeField' From 2cc6317c62468eea74382e0805a34e15be9043ca Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 17 Aug 2026 07:42:57 +0300 Subject: [PATCH 03/44] PM-5370 - campus app setup --- src/apps/campus/index.ts | 1 + src/apps/campus/src/CampusApp.tsx | 20 ++++++++++++++ src/apps/campus/src/campus.routes.tsx | 33 +++++++++++++++++++++++ src/apps/campus/src/home.tsx | 10 +++++++ src/apps/campus/src/index.ts | 1 + src/apps/platform/src/platform.routes.tsx | 2 ++ src/config/constants.ts | 2 ++ 7 files changed, 69 insertions(+) create mode 100644 src/apps/campus/index.ts create mode 100644 src/apps/campus/src/CampusApp.tsx create mode 100644 src/apps/campus/src/campus.routes.tsx create mode 100644 src/apps/campus/src/home.tsx create mode 100644 src/apps/campus/src/index.ts diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/campus/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/campus/src/CampusApp.tsx b/src/apps/campus/src/CampusApp.tsx new file mode 100644 index 000000000..931de6a3f --- /dev/null +++ b/src/apps/campus/src/CampusApp.tsx @@ -0,0 +1,20 @@ +import { FC, useContext, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { toolTitle } from './campus.routes' + +const CampusApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + return ( + <> + + {childRoutes} + + ) +} + +export default CampusApp diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx new file mode 100644 index 000000000..0f891d648 --- /dev/null +++ b/src/apps/campus/src/campus.routes.tsx @@ -0,0 +1,33 @@ +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' + +const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp')) +const CampusHomePage: LazyLoadedComponent = lazyLoad( + () => import('./home'), + 'CampusHomePage', +) + +export const rootRoute: string = ( + EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}` +) + +export const toolTitle: string = ToolTitle.campus + +export const campusRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + { + children: [], + element: , + id: 'Campus Home', + route: '', + }, + ], + domain: AppSubdomain.campus, + element: , + id: toolTitle, + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/campus/src/home.tsx b/src/apps/campus/src/home.tsx new file mode 100644 index 000000000..93568a303 --- /dev/null +++ b/src/apps/campus/src/home.tsx @@ -0,0 +1,10 @@ +import { FC } from 'react' + +const CampusHomePage: FC = () => ( +
+

Campus

+

Welcome to the Campus application.

+
+) + +export default CampusHomePage diff --git a/src/apps/campus/src/index.ts b/src/apps/campus/src/index.ts new file mode 100644 index 000000000..903dee652 --- /dev/null +++ b/src/apps/campus/src/index.ts @@ -0,0 +1 @@ +export { campusRoutes } from './campus.routes' diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 87fc44c02..a39f1f82e 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -2,6 +2,7 @@ import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' import { learnRoutes } from '~/apps/learn' import { devCenterRoutes } from '~/apps/dev-center' +import { campusRoutes } from '~/apps/campus' import { profilesRoutes } from '~/apps/profiles' import { accountsRoutes } from '~/apps/accounts' import { onboardingRoutes } from '~/apps/onboarding' @@ -38,6 +39,7 @@ export const platformRoutes: Array = [ // that matches the current path ...onboardingRoutes, ...devCenterRoutes, + ...campusRoutes, ...copilotsRoutes, ...learnRoutes, ...profilesRoutes, diff --git a/src/config/constants.ts b/src/config/constants.ts index 62a460111..2c4f94c4e 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,6 +9,7 @@ export enum AppSubdomain { wallet = 'wallet', walletAdmin = 'wallet-admin', copilots = 'copilots', + campus = 'campus', admin = 'system-admin', review = 'review', calendar = 'calendar', @@ -32,6 +33,7 @@ export enum ToolTitle { wallet = 'Wallet', walletAdmin = 'Wallet Admin', copilots = 'Copilots', + campus = 'Campus', admin = 'Admin', review = 'Review', calendar = 'Calendar', From 6d9dcf1097e5950c1f904842c47366993bec5714 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 18 Aug 2026 12:52:19 +1000 Subject: [PATCH 04/44] PM-5755: Preserve hidden reviewer defaults --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.spec.tsx | 150 ++++++++++++++++++ .../ReviewersField/HumanReviewTab.spec.tsx | 59 +++++++ .../ReviewersField/HumanReviewTab.tsx | 3 +- 4 files changed, 212 insertions(+), 2 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 95b889af1..0eb64abd8 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -80,7 +80,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section preserves hidden public-review defaults used to defer reviewer assignments while exposing the Screening and Checkpoint Screening member selectors. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked; the simplified view does not rewrite hidden defaults. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. 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 16939b81a..51f506103 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 @@ -925,6 +925,81 @@ describe('ChallengeEditorForm', () => { }, typeId: 'design-challenge-type-id', } as Challenge + const twoRoundDesignChallengeWithDeferredReviewers = { + ...validDraftChallenge, + phases: [ + { + duration: 60, + name: 'Checkpoint Screening', + phaseId: 'checkpoint-screening-phase-id', + }, + { + duration: 60, + name: 'Checkpoint Review', + phaseId: 'checkpoint-review-phase-id', + }, + { + duration: 60, + name: 'Screening', + phaseId: 'screening-phase-id', + }, + { + duration: 60, + name: 'Review', + phaseId: 'review-phase-id', + }, + { + duration: 60, + name: 'Approval', + phaseId: 'approval-phase-id', + }, + ], + reviewers: [ + { + isMemberReview: true, + memberId: 'screener-member-id', + memberReviewerCount: 1, + phaseId: 'checkpoint-screening-phase-id', + scorecardId: 'checkpoint-screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-review-phase-id', + scorecardId: 'checkpoint-review-scorecard-id', + shouldOpenOpportunity: true, + }, + { + isMemberReview: true, + memberId: 'screener-member-id', + memberReviewerCount: 1, + phaseId: 'screening-phase-id', + scorecardId: 'screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: true, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: true, + }, + ], + trackId: 'design-track-id', + type: { + abbreviation: 'CH', + name: 'Challenge', + }, + typeId: 'design-challenge-type-id', + } as Challenge const taskDraftChallenge = { ...draftChallenge, task: { @@ -4457,6 +4532,81 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith(expect.stringContaining('Assign all required members')) }) + it('saves a two-round design draft with shared screeners and deferred hidden reviewers', async () => { + const user = userEvent.setup() + + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track-id', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'CH', + id: 'design-challenge-type-id', + name: 'Challenge', + }], + isLoading: false, + }) + mockedPatchChallenge.mockResolvedValue({ + ...twoRoundDesignChallengeWithDeferredReviewers, + status: 'DRAFT', + }) + + render( + + + + + , + ) + + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Save as Draft' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + reviewers: expect.arrayContaining([ + expect.objectContaining({ + memberId: 'screener-member-id', + phaseId: 'checkpoint-screening-phase-id', + }), + expect.objectContaining({ + phaseId: 'checkpoint-review-phase-id', + shouldOpenOpportunity: true, + }), + expect.objectContaining({ + memberId: 'screener-member-id', + phaseId: 'screening-phase-id', + }), + expect.objectContaining({ + phaseId: 'review-phase-id', + shouldOpenOpportunity: true, + }), + expect.objectContaining({ + phaseId: 'approval-phase-id', + shouldOpenOpportunity: true, + }), + ]), + status: 'DRAFT', + })) + }) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith(expect.stringContaining('validation')) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith(expect.stringContaining('Assign all required members')) + }) + it('keeps submission-limit metadata visible when the draft save response omits metadata', async () => { const user = userEvent.setup() const submissionLimitMetadata = [{ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index 3ad2a5146..46de2ff19 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -992,6 +992,65 @@ describe('HumanReviewTab', () => { .not.toBeNull() }) + it('preserves hidden public reviewer defaults in simplified design review', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + id: 'screener-role-id', + name: 'Screener', + }], + }) + + render( + , + ) + + expect(screen.getByLabelText('Screener')) + .not.toBeNull() + await waitFor(() => { + expect(screen.getByTestId('public-opportunity-value').textContent) + .toBe('true') + }) + }) + it('marks Screening and Checkpoint Screening member assignments optional', () => { mockedUseFetchChallengeTracks.mockReturnValue({ tracks: [ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index e7f11a3af..04af6d12d 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -1499,7 +1499,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro ]) useEffect(() => { - if (!isDesignTrackSelected) { + if (!isDesignTrackSelected || props.screenerOnly) { return } @@ -1526,6 +1526,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro formContext, getReviewerFieldIndex, isDesignTrackSelected, + props.screenerOnly, reviewerRows, ]) From ba8339b45acd41305c5c3889401b9194f03df17b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 18 Aug 2026 13:37:57 +1000 Subject: [PATCH 05/44] PM-5755: Assign private reviewers to copilot --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.spec.tsx | 153 ++++++++++- .../components/ChallengeEditorForm.tsx | 260 +++++++++++++++++- .../ReviewersField/HumanReviewTab.spec.tsx | 167 ++++++++++- .../ReviewersField/HumanReviewTab.tsx | 214 +++++++++++++- 5 files changed, 766 insertions(+), 30 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 0eb64abd8..2746e5aee 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -80,7 +80,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section preserves hidden public-review defaults used to defer reviewer assignments while exposing the Screening and Checkpoint Screening member selectors. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked; the simplified view does not rewrite hidden defaults. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section repairs missing, duplicate, and stale hidden reviewer rows from the active defaults while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. 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 51f506103..38d2f29e9 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 @@ -613,6 +613,9 @@ jest.mock('./CopilotField', () => ({ value={controller.field.value || ''} /> + {controller.fieldState.error?.message + ? {controller.fieldState.error.message} + : undefined}
) : undefined} + {props.showReviewersValue + ? ( +
+ {JSON.stringify(formMethods.watch('reviewers'))} +
+ ) + : undefined} {props.showScorecardValue ? (
@@ -992,7 +1001,7 @@ describe('HumanReviewTab', () => { .not.toBeNull() }) - it('preserves hidden public reviewer defaults in simplified design review', async () => { + it('repairs hidden legacy rows and assigns private reviewers to the copilot', async () => { mockedUseFetchChallengeTracks.mockReturnValue({ tracks: [{ id: 'track-1', @@ -1002,52 +1011,184 @@ describe('HumanReviewTab', () => { }) mockedUseFetchResourceRoles.mockReturnValue({ isLoading: false, - resourceRoles: [{ - id: 'screener-role-id', - name: 'Screener', - }], + resourceRoles: [], }) + mockedFetchDefaultReviewers.mockResolvedValue([ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-review-phase-id', + scorecardId: 'checkpoint-review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-screening-phase-id', + scorecardId: 'checkpoint-screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'screening-phase-id', + scorecardId: 'screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: false, + }, + ]) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'checkpoint-review-scorecard-id', + name: 'Checkpoint Review Scorecard', + phaseId: 'checkpoint-review-phase-id', + }, + { + id: 'review-scorecard-id', + name: 'Review Scorecard', + phaseId: 'review-phase-id', + }, + { + id: 'checkpoint-screening-scorecard-id', + name: 'Checkpoint Screening Scorecard', + phaseId: 'checkpoint-screening-phase-id', + }, + { + id: 'screening-scorecard-id', + name: 'Screening Scorecard', + phaseId: 'screening-phase-id', + }, + { + id: 'approval-scorecard-id', + name: 'Approval Scorecard', + phaseId: 'approval-phase-id', + }, + ]) render( , ) expect(screen.getByLabelText('Screener')) .not.toBeNull() await waitFor(() => { - expect(screen.getByTestId('public-opportunity-value').textContent) - .toBe('true') + const reconciledReviewers = JSON.parse( + screen.getByTestId('reviewers-value').textContent || '[]', + ) as Reviewer[] + + expect(reconciledReviewers) + .toHaveLength(5) + expect(reconciledReviewers.map(reviewer => reviewer.phaseId)) + .toEqual([ + 'checkpoint-screening-phase-id', + 'checkpoint-review-phase-id', + 'screening-phase-id', + 'review-phase-id', + 'approval-phase-id', + ]) + expect(reconciledReviewers.filter(reviewer => [ + 'checkpoint-review-phase-id', + 'review-phase-id', + 'approval-phase-id', + ].includes(reviewer.phaseId || ''))) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + handle: 'TCConnCopilot', + phaseId: 'checkpoint-review-phase-id', + scorecardId: 'checkpoint-review-scorecard-id', + shouldOpenOpportunity: false, + }), + expect.objectContaining({ + handle: 'TCConnCopilot', + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }), + expect.objectContaining({ + handle: 'TCConnCopilot', + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: false, + }), + ])) }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index 04af6d12d..f74401bba 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -94,6 +94,11 @@ const SCREENER_ROLE_NAME_BY_PHASE_KEY: Record = { checkpointscreening: 'Checkpoint Screener', screening: 'Screener', } +const DESIGN_COPILOT_REVIEW_PHASE_KEYS = new Set([ + 'approval', + 'checkpointreview', + 'review', +]) const REVIEW_OPPORTUNITY_TYPES = { COMPONENT_DEV_REVIEW: 'COMPONENT_DEV_REVIEW', ITERATIVE_REVIEW: 'ITERATIVE_REVIEW', @@ -814,6 +819,162 @@ function mapDefaultReviewerToReviewer( } } +/** + * Reconciles the hidden reviewer matrix used by the simplified Design Challenge editor. + * + * @param params current manual reviewer rows, active defaults and scorecards, challenge phases, + * and the selected copilot identity. + * @returns one valid reviewer row for every configured reviewer phase, preserving screener + * assignments and valid custom selections while repairing missing, duplicate, or stale rows. + * @remarks Checkpoint Review, Review, and Approval are always private and assigned to the selected + * copilot. The helper is used before save validation because those rows are hidden in simplified + * mode and cannot be repaired manually by the user. + * @throws Does not throw. + */ +function reconcileSimplifiedDesignReviewerDefaults(params: { + copilot?: string + defaultReviewers: DefaultReviewer[] + phases: ChallengeEditorFormData['phases'] + reviewers: Reviewer[] + scorecards: Scorecard[] +}): Reviewer[] { + const phaseRows = Array.isArray(params.phases) + ? params.phases + : [] + const expectedPhaseIds = phaseRows + .filter(phase => isSelectableReviewerPhaseName(phase.name, false)) + .map(phase => getChallengePhaseId(phase)) + .filter((phaseId): phaseId is string => !!phaseId) + const phaseNameById = new Map( + phaseRows + .map(phase => { + const phaseId = getChallengePhaseId(phase) + const phaseName = normalizeText(phase.name) + + return phaseId && phaseName + ? [phaseId, phaseName] as const + : undefined + }) + .filter((entry): entry is readonly [string, string] => !!entry), + ) + const defaultReviewerByPhaseId = new Map() + + params.defaultReviewers + .filter(defaultReviewer => isMemberReviewer(defaultReviewer)) + .forEach(defaultReviewer => { + const reviewer = mapDefaultReviewerToReviewer(defaultReviewer, params.phases) + const phaseId = normalizeText(reviewer.phaseId) + const phaseName = phaseNameById.get(phaseId) + const scorecardId = normalizeText(reviewer.scorecardId) + const hasValidDefaultScorecard = getPhaseMatchedScorecards( + params.scorecards, + phaseId, + phaseNameById, + ) + .some(scorecard => hasSameNormalizedText(scorecard.id, scorecardId)) + + if ( + !phaseId + || !phaseName + || !isSelectableReviewerPhaseName(phaseName, false) + || !scorecardId + || !hasValidDefaultScorecard + || defaultReviewerByPhaseId.has(phaseId) + ) { + return + } + + defaultReviewerByPhaseId.set(phaseId, reviewer) + }) + + if ( + !expectedPhaseIds.length + || expectedPhaseIds.some(phaseId => !defaultReviewerByPhaseId.has(phaseId)) + ) { + return params.reviewers + } + + const selectedCopilot = normalizeText(params.copilot) + const selectedCopilotIsMemberId = /^\d+$/.test(selectedCopilot) + const reconciledReviewers = expectedPhaseIds.map(phaseId => { + const defaultReviewer = defaultReviewerByPhaseId.get(phaseId) as Reviewer + const phaseScorecardIds = new Set( + getPhaseMatchedScorecards(params.scorecards, phaseId, phaseNameById) + .map(scorecard => normalizeText(scorecard.id)) + .filter(Boolean), + ) + const candidates = params.reviewers.filter(reviewer => ( + normalizeText(reviewer.phaseId) === phaseId + )) + const candidate = candidates.find(reviewer => ( + phaseScorecardIds.has(normalizeText(reviewer.scorecardId)) + && getAssignedMemberIds(reviewer) + .some(Boolean) + )) + || candidates.find(reviewer => ( + phaseScorecardIds.has(normalizeText(reviewer.scorecardId)) + && isPublicOpportunityOpen(reviewer) + )) + || candidates.find(reviewer => ( + phaseScorecardIds.has(normalizeText(reviewer.scorecardId)) + )) + || candidates.find(reviewer => getAssignedMemberIds(reviewer) + .some(Boolean)) + || candidates[0] + const candidateHasValidScorecard = !!candidate + && phaseScorecardIds.has(normalizeText(candidate.scorecardId)) + const nextReviewer: Reviewer = candidateHasValidScorecard + ? { + ...candidate, + } + : { + ...defaultReviewer, + additionalMemberIds: candidate?.additionalMemberIds, + handle: candidate?.handle, + memberId: candidate?.memberId, + resourceId: candidate?.resourceId, + roleId: candidate?.roleId || defaultReviewer.roleId, + } + + nextReviewer.phaseId = phaseId + nextReviewer.scorecardId = candidateHasValidScorecard + ? candidate?.scorecardId + : defaultReviewer.scorecardId + nextReviewer.isMemberReview = true + nextReviewer.memberReviewerCount = Math.max( + 1, + getAssignedMemberIds(nextReviewer) + .filter(Boolean).length, + ) + nextReviewer.type = normalizeText(nextReviewer.type) + || defaultReviewer.type + || REVIEW_OPPORTUNITY_TYPES.REGULAR_REVIEW + + if (DESIGN_COPILOT_REVIEW_PHASE_KEYS.has(normalizeKey(phaseNameById.get(phaseId)))) { + nextReviewer.additionalMemberIds = undefined + nextReviewer.handle = selectedCopilot && !selectedCopilotIsMemberId + ? selectedCopilot + : undefined + nextReviewer.memberId = selectedCopilotIsMemberId + ? selectedCopilot + : undefined + nextReviewer.memberReviewerCount = 1 + nextReviewer.shouldOpenOpportunity = false + } + + return nextReviewer + }) + const expectedPhaseIdSet = new Set(expectedPhaseIds) + const unmatchedReviewers = params.reviewers.filter(reviewer => ( + !expectedPhaseIdSet.has(normalizeText(reviewer.phaseId)) + )) + + return [ + ...reconciledReviewers, + ...unmatchedReviewers, + ] +} + /** * Selects the default reviewer metadata used when adding the next manual * reviewer card. @@ -1002,6 +1163,10 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro control: formContext.control, name: 'id', }) as string | undefined + const copilot = useWatch({ + control: formContext.control, + name: 'copilot', + }) as string | undefined const normalizedChallengeId = normalizeText(challengeId) const challengeResourcesResult = useFetchResources(normalizedChallengeId || undefined) const mutateChallengeResources = challengeResourcesResult.mutate @@ -1499,7 +1664,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro ]) useEffect(() => { - if (!isDesignTrackSelected || props.screenerOnly) { + if (!isDesignTrackSelected) { return } @@ -1526,7 +1691,6 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro formContext, getReviewerFieldIndex, isDesignTrackSelected, - props.screenerOnly, reviewerRows, ]) @@ -1595,6 +1759,52 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro reviewerRows, ]) + useEffect(() => { + if ( + !props.screenerOnly + || !isDesignTrackSelected + || isScorecardsLoading + || loadError + || !defaultReviewers.length + ) { + return + } + + const reconciledManualReviewers = reconcileSimplifiedDesignReviewerDefaults({ + copilot, + defaultReviewers, + phases, + reviewers: reviewerRows, + scorecards, + }) + const aiReviewers = allReviewerRows.filter(reviewer => isAiReviewer(reviewer)) + const nextReviewers = [ + ...reconciledManualReviewers, + ...aiReviewers, + ] + + if (JSON.stringify(nextReviewers) === JSON.stringify(allReviewerRows)) { + return + } + + formContext.setValue('reviewers', nextReviewers, { + shouldDirty: false, + shouldValidate: true, + }) + }, [ + allReviewerRows, + copilot, + defaultReviewers, + formContext, + isDesignTrackSelected, + isScorecardsLoading, + loadError, + phases, + props.screenerOnly, + reviewerRows, + scorecards, + ]) + useEffect(() => { if ( !normalizedChallengeId From 0850969407b3ddc9fa17dfe2a9dda882a5583b4d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 18 Aug 2026 14:16:27 +1000 Subject: [PATCH 06/44] PM-5755: Allow copilot handles through validation --- .../schemas/challenge-editor.schema.spec.ts | 19 ++++++++ .../lib/schemas/challenge-editor.schema.ts | 5 +-- .../components/ChallengeEditorForm.spec.tsx | 30 +++++++++++-- .../components/ChallengeEditorForm.tsx | 45 ++++++++++++++++++- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts index 3e21f961c..5918ca0d8 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts @@ -376,6 +376,25 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toBeTruthy() }) + it('accepts a persisted member handle while its member id is resolved during save', async () => { + await expect( + challengeAdvancedOptionsSchema.validate({ + ...baseFormData, + reviewers: [ + { + handle: 'TCConnCopilot', + isMemberReview: true, + memberReviewerCount: 1, + scorecardId: 'scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }), + ) + .resolves + .toBeTruthy() + }) + it('rejects reviewer counts above the manual reviewer limit', async () => { await expect( challengeAdvancedOptionsSchema.validate({ diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts index f44aa3cfe..e90090c07 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts @@ -441,11 +441,10 @@ export const challengeAdvancedOptionsSchema = yup.object({ ? reviewer.additionalMemberIds : [] const normalizedAssignedMemberSlots = [ - reviewer.memberId, - ...additionalMemberIds, + toNormalizedText(reviewer.memberId) || toNormalizedText(reviewer.handle), + ...additionalMemberIds.map(memberId => toNormalizedText(memberId)), ] .slice(0, reviewerSlots) - .map(memberId => toNormalizedText(memberId)) const missingSlotIndex = normalizedAssignedMemberSlots.findIndex(memberId => !memberId) const hasAllAssignments = normalizedAssignedMemberSlots.length === reviewerSlots && missingSlotIndex === -1 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 5a089f1e4..154691d68 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 @@ -988,8 +988,8 @@ describe('ChallengeEditorForm', () => { shouldOpenOpportunity: false, }, { + handle: 'TCConnCopilot', isMemberReview: true, - memberId: '40158994', memberReviewerCount: 1, phaseId: 'checkpoint-review-phase-id', scorecardId: 'checkpoint-review-scorecard-id', @@ -1004,16 +1004,16 @@ describe('ChallengeEditorForm', () => { shouldOpenOpportunity: false, }, { + handle: 'TCConnCopilot', isMemberReview: true, - memberId: '40158994', memberReviewerCount: 1, phaseId: 'review-phase-id', scorecardId: 'review-scorecard-id', shouldOpenOpportunity: false, }, { + handle: 'TCConnCopilot', isMemberReview: true, - memberId: '40158994', memberReviewerCount: 1, phaseId: 'approval-phase-id', scorecardId: 'approval-scorecard-id', @@ -4520,6 +4520,30 @@ describe('ChallengeEditorForm', () => { }) }) + it('shows the concrete schema error when saving an invalid draft', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Save as Draft' })) + + expect(await screen.findByText('Public specification must be at least 10 characters')) + .toBeInTheDocument() + expect(screen.queryByText('Please fix validation errors before saving.')) + .not.toBeInTheDocument() + expect(mockedPatchChallenge) + .not.toHaveBeenCalled() + }) + it('saves a new design draft before screening members are assigned', async () => { const user = userEvent.setup() 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 8c730e93c..b71d0acf1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -494,6 +494,45 @@ function normalizeTextValue(value: unknown): string { return value.trim() } +/** + * Finds the first user-facing message in React Hook Form's nested validation errors. + * + * @param errors field-error tree supplied to the invalid-submit callback. + * @returns the first non-empty validation message, or `undefined` when none is available. + * @remarks Nested and array fields, including hidden reviewer rows, do not expose a root-level + * message. The save footer uses this helper so every rejected submit explains what must be fixed. + * @throws Does not throw; cyclic field references are ignored. + */ +function getFirstFormValidationMessage(errors: unknown): string | undefined { + const visitedValues = new Set() + + function findMessage(value: unknown): string | undefined { + if (!value || typeof value !== 'object' || visitedValues.has(value)) { + return undefined + } + + visitedValues.add(value) + + const message = normalizeTextValue((value as { message?: unknown }).message) + if (message) { + return message + } + + for (const [key, childValue] of Object.entries(value)) { + if (key !== 'ref') { + const childMessage = findMessage(childValue) + if (childMessage) { + return childMessage + } + } + } + + return undefined + } + + return findMessage(errors) +} + /** * Normalizes challenge term form values into term ids. * @@ -3967,13 +4006,15 @@ export const ChallengeEditorForm: FC = ( ], ) - const onInvalidSubmit = useCallback((): void => { + const onInvalidSubmit = useCallback((errors: unknown): void => { if (!validateDesignReviewCopilotSelection(getValues())) { return } setSaveStatus('idle') - setSaveValidationError(SAVE_VALIDATION_ERROR_MESSAGE) + setSaveValidationError( + getFirstFormValidationMessage(errors) || SAVE_VALIDATION_ERROR_MESSAGE, + ) }, [ getValues, validateDesignReviewCopilotSelection, From e39b1e640cb0d2dd993c19dc87a3739e5d1b92d6 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 09:29:12 +0300 Subject: [PATCH 07/44] PM-5370 - campus leaderboard page --- src/apps/campus/index.ts | 2 +- src/apps/campus/src/campus.routes.tsx | 11 + src/apps/campus/src/lib/hooks/index.ts | 1 + .../src/lib/hooks/use-campus-leaderboard.ts | 38 +++ .../lib/models/campus-leaderboard.model.ts | 63 ++++ src/apps/campus/src/lib/models/index.ts | 1 + .../services/campus-leaderboard.service.ts | 32 ++ src/apps/campus/src/lib/services/index.ts | 1 + .../CampusLeaderboardPage.module.scss | 172 ++++++++++ .../CampusLeaderboardPage.spec.tsx | 222 +++++++++++++ .../leaderboard/CampusLeaderboardPage.tsx | 301 ++++++++++++++++++ .../ParticipationHistoryModal.module.scss | 23 ++ .../leaderboard/ParticipationHistoryModal.tsx | 124 ++++++++ .../leaderboard/RankingRulesModal.module.scss | 14 + .../pages/leaderboard/RankingRulesModal.tsx | 43 +++ .../campus/src/pages/leaderboard/index.ts | 3 + tsconfig.paths.json | 3 + 17 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 src/apps/campus/src/lib/hooks/index.ts create mode 100644 src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts create mode 100644 src/apps/campus/src/lib/models/campus-leaderboard.model.ts create mode 100644 src/apps/campus/src/lib/models/index.ts create mode 100644 src/apps/campus/src/lib/services/campus-leaderboard.service.ts create mode 100644 src/apps/campus/src/lib/services/index.ts create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx create mode 100644 src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx create mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss create mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx create mode 100644 src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss create mode 100644 src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx create mode 100644 src/apps/campus/src/pages/leaderboard/index.ts diff --git a/src/apps/campus/index.ts b/src/apps/campus/index.ts index 6f39cd49b..3c2923024 100644 --- a/src/apps/campus/index.ts +++ b/src/apps/campus/index.ts @@ -1 +1 @@ -export * from './src' +export { campusRoutes } from './src' diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx index 0f891d648..61efd6028 100644 --- a/src/apps/campus/src/campus.routes.tsx +++ b/src/apps/campus/src/campus.routes.tsx @@ -6,6 +6,10 @@ const CampusHomePage: LazyLoadedComponent = lazyLoad( () => import('./home'), 'CampusHomePage', ) +const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/leaderboard'), + 'CampusLeaderboardPage', +) export const rootRoute: string = ( EnvironmentConfig.SUBDOMAIN === AppSubdomain.campus ? '' : `/${AppSubdomain.campus}` @@ -23,6 +27,13 @@ export const campusRoutes: ReadonlyArray = [ id: 'Campus Home', route: '', }, + { + // Campus program leaderboard, eg. https://campus.topcoder-dev.com/mecw + children: [], + element: , + id: 'Campus Leaderboard', + route: ':groupName', + }, ], domain: AppSubdomain.campus, element: , diff --git a/src/apps/campus/src/lib/hooks/index.ts b/src/apps/campus/src/lib/hooks/index.ts new file mode 100644 index 000000000..55f29ec92 --- /dev/null +++ b/src/apps/campus/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-campus-leaderboard' diff --git a/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts new file mode 100644 index 000000000..7a4b1a954 --- /dev/null +++ b/src/apps/campus/src/lib/hooks/use-campus-leaderboard.ts @@ -0,0 +1,38 @@ +import useSWR, { SWRResponse } from 'swr' + +import { CampusChallengeFilter, CampusLeaderboard } from '../models' +import { campusLeaderboardUrl, fetchCampusLeaderboard } from '../services' + +export interface CampusLeaderboardResource { + data?: CampusLeaderboard + error?: Error & { response?: { status?: number } } + isLoading: boolean +} + +/** + * Loads the campus leaderboard for a group, re-fetching when the filter changes. + * + * @param groupName group name from the route, when available. + * @param challengeFilter selected challenge visibility filter. + * @returns leaderboard resource state. + */ +export function useCampusLeaderboard( + groupName: string | undefined, + challengeFilter: CampusChallengeFilter, +): CampusLeaderboardResource { + const url: string | undefined = groupName + ? campusLeaderboardUrl(groupName, challengeFilter) + : undefined + + const { data, error }: SWRResponse = useSWR( + url, + fetchCampusLeaderboard, + { revalidateOnFocus: false }, + ) + + return { + data, + error, + isLoading: !!url && !data && !error, + } +} diff --git a/src/apps/campus/src/lib/models/campus-leaderboard.model.ts b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts new file mode 100644 index 000000000..b450c0379 --- /dev/null +++ b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts @@ -0,0 +1,63 @@ +/** + * Shapes returned by the campus leaderboard report endpoint. + */ + +export type CampusChallengeFilter = 'all' | 'public' | 'campus' + +export interface CampusParticipation { + challengeEndDate: string | null + challengeId: string + challengeName: string | null + challengeStatus: string | null + challengeTrack: string | null + challengeType: string | null + isCampusChallenge: boolean + isPublicChallenge: boolean + passedReview: boolean + placement: number | null + registered: boolean + registeredAt: string | null + score: number | null + submitted: boolean + submittedDate: string | null + won: boolean +} + +export interface CampusLeaderboardMember { + challenges: CampusParticipation[] + firstName: string | null + handle: string | null + hasActivity: boolean + lastName: string | null + memberSince: string | null + passingSubmissions: number + photoURL: string | null + rank: number + rating: number | null + ratingColor: string | null + registrations: number + signupDate: string | null + submissions: number + userId: string + wins: number +} + +export interface CampusLeaderboardSummary { + membersRegistered: number + membersSubmitted: number + totalMembers: number +} + +export interface CampusLeaderboardGroup { + id: string + name: string + oldId: string | null + privateGroup: boolean +} + +export interface CampusLeaderboard { + challengeFilter: CampusChallengeFilter + group: CampusLeaderboardGroup + members: CampusLeaderboardMember[] + summary: CampusLeaderboardSummary +} diff --git a/src/apps/campus/src/lib/models/index.ts b/src/apps/campus/src/lib/models/index.ts new file mode 100644 index 000000000..d4bcf47dd --- /dev/null +++ b/src/apps/campus/src/lib/models/index.ts @@ -0,0 +1 @@ +export * from './campus-leaderboard.model' diff --git a/src/apps/campus/src/lib/services/campus-leaderboard.service.ts b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts new file mode 100644 index 000000000..50e3e59b5 --- /dev/null +++ b/src/apps/campus/src/lib/services/campus-leaderboard.service.ts @@ -0,0 +1,32 @@ +/** + * Read-only client for the campus leaderboard report. + */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { CampusChallengeFilter, CampusLeaderboard } from '../models' + +/** + * Builds the campus leaderboard report url for a group and challenge filter. + * + * @param groupName group name taken from the route. + * @param challengeFilter challenge visibility filter. + * @returns absolute reports api url. + */ +export function campusLeaderboardUrl( + groupName: string, + challengeFilter: CampusChallengeFilter, +): string { + const params: URLSearchParams = new URLSearchParams({ challengeFilter, groupName }) + return `${EnvironmentConfig.REPORTS_API}/topcoder/leaderboard/campus?${params.toString()}` +} + +/** + * Fetches the campus leaderboard for a group. + * + * @param url campus leaderboard report url. + * @returns leaderboard payload. + */ +export async function fetchCampusLeaderboard(url: string): Promise { + return xhrGetAsync(url) +} diff --git a/src/apps/campus/src/lib/services/index.ts b/src/apps/campus/src/lib/services/index.ts new file mode 100644 index 000000000..cb7a5a248 --- /dev/null +++ b/src/apps/campus/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './campus-leaderboard.service' diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss new file mode 100644 index 000000000..3451b2d1a --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -0,0 +1,172 @@ +@import '@libs/ui/styles/includes'; + +.header { + margin-top: $sp-8; + margin-bottom: $sp-6; + + h1 { + margin-bottom: $sp-2; + } +} + +.subtitle { + color: $black-60; +} + +.stats { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + margin-bottom: $sp-6; +} + +.statCard { + align-items: center; + background: $tc-white; + border: 1px solid $black-10; + border-radius: 8px; + display: flex; + gap: $sp-4; + padding: $sp-4; +} + +.statIcon { + align-items: center; + border-radius: 50%; + display: flex; + flex: 0 0 auto; + height: 48px; + justify-content: center; + width: 48px; + + svg { + height: 24px; + width: 24px; + } +} + +.statIconMembers { + background: #e6f7f0; + color: #0ab88a; +} + +.statIconRegistered { + background: #e9f2fe; + color: #2a8ded; +} + +.statIconSubmitted { + background: #f0eafc; + color: #7b61ff; +} + +.statLabel { + color: $black-80; + margin-bottom: $sp-1; +} + +.statValue { + @include font-barlow-condensed; + + font-size: 28px; + font-weight: 500; +} + +.toolbar { + align-items: center; + display: flex; + justify-content: space-between; + gap: $sp-4; + margin-bottom: $sp-4; +} + +.filter { + max-width: 320px; + min-width: 220px; + width: 100%; +} + +.lbTable { + tbody td { + vertical-align: middle; + } +} + +.rulesLink { + align-items: center; + background: none; + border: none; + color: $turq-160; + cursor: pointer; + display: flex; + gap: $sp-2; + padding: 0; + + svg { + height: 20px; + width: 20px; + } +} + +.rank { + align-items: center; + border-radius: 50%; + display: inline-flex; + font-weight: 700; + height: 28px; + justify-content: center; + width: 28px; +} + +.gold { + background: #f5c344; + color: $tc-white; +} + +.silver { + background: $black-20; + color: $black-100; +} + +.bronze { + background: #d9a48f; + color: $tc-white; +} + +.handleCell { + align-items: center; + display: flex; + gap: $sp-3; +} + +.avatar { + flex: 0 0 auto; + height: 40px; + width: 40px; +} + +.handle { + font-weight: 500; +} + +.wins { + color: #0ab88a; + font-weight: 500; +} + +.chevron { + color: $black-60; + height: 20px; + width: 20px; +} + +.clickableRow { + cursor: pointer; +} + +.empty, +.error { + color: $black-60; + padding: $sp-6 0; + text-align: center; +} diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx new file mode 100644 index 000000000..0335d3135 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -0,0 +1,222 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind, + react/no-unused-prop-types, react/no-array-index-key, unicorn/no-null */ +import '@testing-library/jest-dom' +import type { ChangeEvent, PropsWithChildren, ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { CampusLeaderboard, CampusLeaderboardMember, CampusParticipation } from '../../lib/models' + +import { CampusLeaderboardPage } from './CampusLeaderboardPage' + +interface StubColumn { + columnId?: string + label?: string + propertyName?: string + renderer?: (data: T) => ReactNode +} + +interface StubTableProps { + columns: ReadonlyArray> + data: ReadonlyArray + moreToLoad?: boolean + onLoadMoreClick?: () => void + onRowClick?: (data: T) => void +} + +interface StubSelectProps { + onChange: (event: ChangeEvent) => void + options: ReadonlyArray<{ label?: ReactNode, value: string }> + value?: string +} + +jest.mock('~/config', () => ({ + AppSubdomain: { campus: 'campus' }, + EnvironmentConfig: { REPORTS_API: 'https://api.example.com/v6/reports', SUBDOMAIN: 'campus' }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + ProfilePicture: (): JSX.Element => , + textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + + return { + BaseModal: (props: PropsWithChildren<{ open?: boolean, title?: ReactNode }>): JSX.Element => ( + props.open ? ( +
+

{props.title}

+ {props.children} +
+ ) : <> + ), + ContentLayout: (props: PropsWithChildren<{}>): JSX.Element =>
{props.children}
, + IconOutline: new Proxy({}, { get: () => Icon }), + InputSelect: (props: StubSelectProps): JSX.Element => ( + + ), + LoadingSpinner: (props: { hide?: boolean }): JSX.Element => ( + props.hide ? <> :
Loading
+ ), + PageTitle: (): JSX.Element => <>, + Table: (props: StubTableProps): JSX.Element => ( + + + {props.data.map((row, rowIndex) => ( + props.onRowClick?.(row)}> + {props.columns.map(column => ( + + ))} + + ))} + +
+ {column.renderer + ? column.renderer(row) + : String((row as Record)[column.propertyName ?? ''])} +
+ ), + } +}, { virtual: true }) + +const mockUseCampusLeaderboard = jest.fn() + +jest.mock('../../lib/hooks', () => ({ + useCampusLeaderboard: (...args: unknown[]) => mockUseCampusLeaderboard(...args), +})) + +const participation = (overrides: Partial = {}): CampusParticipation => ({ + challengeEndDate: '2026-02-01T00:00:00.000Z', + challengeId: 'c1', + challengeName: 'Campus Sprint', + challengeStatus: 'COMPLETED', + challengeTrack: 'Development', + challengeType: 'Challenge', + isCampusChallenge: true, + isPublicChallenge: false, + passedReview: true, + placement: 1, + registered: true, + registeredAt: '2026-01-05T00:00:00.000Z', + score: 95, + submitted: true, + submittedDate: '2026-01-20T00:00:00.000Z', + won: true, + ...overrides, +}) + +const member = (overrides: Partial = {}): CampusLeaderboardMember => ({ + challenges: [participation()], + firstName: 'Ada', + handle: 'testaws1', + hasActivity: true, + lastName: 'Lovelace', + memberSince: '2025-01-01T00:00:00.000Z', + passingSubmissions: 1, + photoURL: null, + rank: 1, + rating: 1500, + ratingColor: '#3f3', + registrations: 1, + signupDate: '2026-01-01T00:00:00.000Z', + submissions: 1, + userId: '1', + wins: 1, + ...overrides, +}) + +const leaderboard = (): CampusLeaderboard => ({ + challengeFilter: 'all', + group: { id: 'group-1', name: 'MECW', oldId: null, privateGroup: false }, + members: [ + member(), + member({ + challenges: [], + handle: 'quiet_member', + hasActivity: false, + passingSubmissions: 0, + rank: 2, + registrations: 0, + submissions: 0, + userId: '2', + wins: 0, + }), + ], + summary: { membersRegistered: 842, membersSubmitted: 623, totalMembers: 1248 }, +}) + +function renderPage(): void { + render( + + + } path='/:groupName' /> + + , + ) +} + +describe('CampusLeaderboardPage', () => { + beforeEach(() => { + mockUseCampusLeaderboard.mockReturnValue({ data: leaderboard(), isLoading: false }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('requests the leaderboard for the group in the route', () => { + renderPage() + + expect(mockUseCampusLeaderboard) + .toHaveBeenCalledWith('mecw', 'all') + }) + + it('renders the participation summary and every group member', () => { + renderPage() + + expect(screen.getByText('1,248')) + .toBeInTheDocument() + expect(screen.getByText('842')) + .toBeInTheDocument() + expect(screen.getByText('623')) + .toBeInTheDocument() + expect(screen.getByText('testaws1')) + .toBeInTheDocument() + expect(screen.getByText('quiet_member')) + .toBeInTheDocument() + }) + + it('opens the participation history only for members with activity', () => { + renderPage() + + fireEvent.click(screen.getByText('quiet_member')) + expect(screen.queryByText(/Participation History/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('testaws1')) + expect(screen.getByText(/testaws1 — Participation History/)) + .toBeInTheDocument() + expect(screen.getByText('Campus Sprint')) + .toBeInTheDocument() + expect(screen.getByText('Won (place 1)')) + .toBeInTheDocument() + }) + + it('re-requests the leaderboard when the challenge filter changes', () => { + renderPage() + + fireEvent.change(screen.getByTestId('challenge-filter'), { target: { value: 'campus' } }) + + expect(mockUseCampusLeaderboard) + .toHaveBeenLastCalledWith('mecw', 'campus') + }) +}) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx new file mode 100644 index 000000000..64ac94a98 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -0,0 +1,301 @@ +/** + * Campus program leaderboard for a single group (`/:groupName`). + */ +import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import classNames from 'classnames' + +import { + ContentLayout, + IconOutline, + InputSelect, + InputSelectOption, + LoadingSpinner, + PageTitle, + Table, + TableColumn, +} from '~/libs/ui' +import { ProfilePicture } from '~/libs/shared' + +import { + CampusChallengeFilter, + CampusLeaderboardMember, +} from '../../lib/models' +import { CampusLeaderboardResource, useCampusLeaderboard } from '../../lib/hooks' + +import { ParticipationHistoryModal } from './ParticipationHistoryModal' +import { RankingRulesModal } from './RankingRulesModal' +import styles from './CampusLeaderboardPage.module.scss' + +const PAGE_SIZE: number = 50 + +const CHALLENGE_FILTER_OPTIONS: ReadonlyArray = [ + { label: 'All Challenges', value: 'all' }, + { label: 'Public Challenges', value: 'public' }, + { label: 'Campus Challenges', value: 'campus' }, +] + +const RANK_MEDAL_CLASSES: { [rank: number]: string } = { + 1: styles.gold, + 2: styles.silver, + 3: styles.bronze, +} + +/** + * Marks rows that open the participation history modal. + * + * @param member leaderboard row. + * @returns row class name, when the row is clickable. + */ +function rowClassName(member: CampusLeaderboardMember): string | undefined { + return member.hasActivity ? styles.clickableRow : undefined +} + +/** + * Renders a rank badge, medal-styled for the top three ranks. + * + * @param member leaderboard row. + * @returns rank cell. + */ +function renderRank(member: CampusLeaderboardMember): JSX.Element { + return ( + + {member.rank} + + ) +} + +/** + * Renders the member avatar and rating-colored handle. + * + * @param member leaderboard row. + * @returns handle cell. + */ +function renderHandle(member: CampusLeaderboardMember): JSX.Element { + return ( +
+ + + {member.handle ?? member.userId} + +
+ ) +} + +export const CampusLeaderboardPage: FC = () => { + const groupName: string | undefined = useParams<{ groupName: string }>().groupName + const [challengeFilter, setChallengeFilter] = useState('all') + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const [selectedMember, setSelectedMember] = useState() + const [rulesVisible, setRulesVisible] = useState(false) + + const { data, error, isLoading }: CampusLeaderboardResource + = useCampusLeaderboard(groupName, challengeFilter) + + const displayGroupName: string = data?.group.name ?? groupName ?? '' + + const onFilterChange = useCallback((event: ChangeEvent): void => { + setChallengeFilter(event.target.value as CampusChallengeFilter) + setVisibleCount(PAGE_SIZE) + }, []) + + const onRowClick = useCallback((member: CampusLeaderboardMember): void => { + if (!member.hasActivity) { + return + } + + setSelectedMember(member) + }, []) + + const columns = useMemo>>(() => [ + { + columnId: 'rank', + label: 'Rank', + renderer: renderRank, + type: 'element', + }, + { + columnId: 'handle', + label: 'Handle', + renderer: renderHandle, + type: 'element', + }, + { + columnId: 'registrations', + label: 'Number of Registrations', + propertyName: 'registrations', + tooltip: 'Challenges the member registered for.', + type: 'number', + }, + { + columnId: 'submissions', + label: 'Number of Submissions', + propertyName: 'submissions', + tooltip: 'Challenges the member submitted to. At most one submission is counted per challenge.', + type: 'number', + }, + { + columnId: 'passingSubmissions', + label: 'Number of Passing Submissions', + propertyName: 'passingSubmissions', + tooltip: 'Challenges where a submission passed review. ' + + 'At most one passing submission is counted per challenge.', + type: 'number', + }, + { + columnId: 'wins', + label: 'Number of Wins', + renderer: (member: CampusLeaderboardMember) => ( + {member.wins} + ), + type: 'numberElement', + }, + { + columnId: 'open', + label: '', + renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? ( + + ) : ), + type: 'element', + }, + ], []) + + const members: ReadonlyArray = data?.members ?? [] + const visibleMembers = useMemo( + () => members.slice(0, visibleCount), + [members, visibleCount], + ) + + const onLoadMoreClick = useCallback((): void => { + setVisibleCount(count => count + PAGE_SIZE) + }, []) + + return ( + + Campus Program Leaderboard + +
+

Campus Program Leaderboard

+

+ {`Track participation and performance of members in the ${displayGroupName} `} + group across challenges. +

+
+ + + + {!!error && ( +
+ {error.response?.status === 403 + ? 'You do not have access to this leaderboard.' + : `The leaderboard for "${displayGroupName}" could not be loaded.`} +
+ )} + + {!!data && ( + <> +
+
+ + + +
+
Total Members in Group
+
+ {data.summary.totalMembers.toLocaleString()} +
+
+
+
+ + + +
+
+ Members + {' Registered to Any Challenge'} +
+
+ {data.summary.membersRegistered.toLocaleString()} +
+
+
+
+ + + +
+
+ Members + {' Submitted to Any Challenge'} +
+
+ {data.summary.membersSubmitted.toLocaleString()} +
+
+
+
+ +
+
+ +
+ +
+ + + + {!members.length && ( +
+ {`No members were found in the ${displayGroupName} group.`} +
+ )} + + )} + + + + + + ) +} + +export default CampusLeaderboardPage diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss new file mode 100644 index 000000000..d21d0279d --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss @@ -0,0 +1,23 @@ +@import '@libs/ui/styles/includes'; + +.summary { + color: $black-80; + display: flex; + flex-wrap: wrap; + gap: $sp-5; + margin-bottom: $sp-4; +} + +.challengeCell { + display: flex; + flex-direction: column; +} + +.challengeName { + font-weight: 500; +} + +.challengeMeta { + color: $black-60; + font-size: 12px; +} diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx new file mode 100644 index 000000000..f372c13f2 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -0,0 +1,124 @@ +/** + * Participation history for one leaderboard member. + */ +import { FC, useMemo } from 'react' + +import { BaseModal, Table, TableColumn } from '~/libs/ui' +import { textFormatDateLocaleShortString } from '~/libs/shared' + +import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' + +import styles from './ParticipationHistoryModal.module.scss' + +interface ParticipationHistoryModalProps { + member?: CampusLeaderboardMember + onClose: () => void +} + +/** + * Formats an api date as a short local date. + * + * @param value iso date string. + * @returns formatted date or an em dash. + */ +function formatDate(value: string | null): string { + return (value ? textFormatDateLocaleShortString(new Date(value)) : undefined) ?? '—' +} + +/** + * Describes the outcome of a member's participation in a challenge. + * + * @param entry participation entry. + * @returns human readable result. + */ +function formatResult(entry: CampusParticipation): string { + if (entry.won) { + return entry.placement ? `Won (place ${entry.placement})` : 'Won' + } + + if (entry.passedReview) { + return 'Passed review' + } + + if (entry.submitted) { + return 'Did not pass review' + } + + return 'No submission' +} + +export const ParticipationHistoryModal: FC = props => { + const member: CampusLeaderboardMember | undefined = props.member + + const columns = useMemo>>(() => [ + { + columnId: 'challenge', + label: 'Challenge', + renderer: (entry: CampusParticipation) => ( +
+ {entry.challengeName ?? entry.challengeId} + + {[entry.challengeTrack, entry.challengeType].filter(Boolean) + .join(' • ')} + +
+ ), + type: 'element', + }, + { + columnId: 'registeredAt', + label: 'Registered', + renderer: (entry: CampusParticipation) => {formatDate(entry.registeredAt)}, + type: 'element', + }, + { + columnId: 'submittedDate', + label: 'Submitted', + renderer: (entry: CampusParticipation) => {formatDate(entry.submittedDate)}, + type: 'element', + }, + { + columnId: 'result', + label: 'Result', + renderer: (entry: CampusParticipation) => {formatResult(entry)}, + type: 'element', + }, + ], []) + + if (!member) { + return <> + } + + return ( + +
+ + {`${member.registrations} registrations`} + + + {`${member.submissions} submissions`} + + + {`${member.passingSubmissions} passing`} + + + {`${member.wins} wins`} + +
+ +
+ + ) +} + +export default ParticipationHistoryModal diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss new file mode 100644 index 000000000..3163ddb50 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss @@ -0,0 +1,14 @@ +@import '@libs/ui/styles/includes'; + +.rules { + list-style: decimal outside; + margin: $sp-3 0 $sp-4 $sp-5; + + li { + margin-bottom: $sp-1; + } +} + +.note { + color: $black-60; +} diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx new file mode 100644 index 000000000..d7bebd976 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx @@ -0,0 +1,43 @@ +/** + * Explains the leaderboard ranking criteria. + */ +import { FC } from 'react' + +import { BaseModal } from '~/libs/ui' + +import styles from './RankingRulesModal.module.scss' + +interface RankingRulesModalProps { + onClose: () => void + open: boolean +} + +export const RankingRulesModal: FC = props => { + if (!props.open) { + return <> + } + + return ( + +

Members are ranked by the following criteria, in order:

+
    +
  1. Number of wins, highest first
  2. +
  3. Number of passing submissions, highest first
  4. +
  5. Number of registrations, highest first
  6. +
  7. Signup time, earliest first
  8. +
+

+ At most one submission and one passing submission are counted per member per + challenge. Every member of the group is listed, including members with no + challenge activity. +

+
+ ) +} + +export default RankingRulesModal diff --git a/src/apps/campus/src/pages/leaderboard/index.ts b/src/apps/campus/src/pages/leaderboard/index.ts new file mode 100644 index 000000000..e6be4c579 --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/index.ts @@ -0,0 +1,3 @@ +export { default as CampusLeaderboardPage } from './CampusLeaderboardPage' +export { default as ParticipationHistoryModal } from './ParticipationHistoryModal' +export { default as RankingRulesModal } from './RankingRulesModal' diff --git a/tsconfig.paths.json b/tsconfig.paths.json index 54e6a260e..809bc6b7c 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -27,6 +27,9 @@ "@wallet/*": [ "./src/apps/wallet/src/*" ], + "@campus/*": [ + "./src/apps/campus/src/*" + ], "@walletAdmin/*": [ "./src/apps/wallet-admin/src/*" ], From a0b327a3ed757d38d546b6cf12ba3f2bf3808c11 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 10:23:49 +0300 Subject: [PATCH 08/44] PM-4621 - tooltip fixes --- .../statistics/StatisticsPage/StatisticsPage.module.scss | 1 + .../src/pages/statistics/StatisticsPage/StatisticsPage.tsx | 4 ++-- .../src/pages/statistics/StatisticsPage/WorldMap.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss index 087ba9099..75f247a55 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss @@ -283,6 +283,7 @@ :global(.highcharts-container), :global(.highcharts-root) { height: 370px !important; + z-index: unset !important; } } diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx index e02db6ffe..22abc7001 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx @@ -73,7 +73,7 @@ const StatisticsPage: FC = () => { ? !generalStatistics && !generalStatisticsError : !winners && !winnersError const contentError = activeTab === 'countries' ? generalStatisticsError : winnersError - const valueLabel = activeTab === 'countries' ? 'Members' : 'Winners' + const valueLabel = activeTab === 'countries' ? 'Members' : '1st Places' const selectTab = useCallback((tab: StatisticsTab) => { setActiveTab(tab) @@ -184,7 +184,7 @@ const StatisticsPage: FC = () => { tabIndex={activeTab === 'winners' ? 0 : -1} type='button' > - Winners by Country + First place by Country diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx index 7ce5e8092..8eae64042 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx @@ -113,7 +113,7 @@ function renderWinnersTooltip(point: StatisticsMapPoint): string { ${flag} ${escapeHtml(point.name)} - Winners: ${NUMBER_FORMATTER.format(point.value)} + # of 1st place Wins: ${NUMBER_FORMATTER.format(point.value)} Top Winners
From 9c8673ff499bbcbb3fe5874b41a4dda46450237c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 18 Aug 2026 17:53:03 +1000 Subject: [PATCH 09/44] HOTFIX-PM5755: Fetch timeline-specific reviewer defaults --- .../lib/services/challenges.service.spec.ts | 19 ++++ .../src/lib/services/challenges.service.ts | 15 ++- .../challenges/ChallengeEditorPage/README.md | 2 +- .../ReviewersField/HumanReviewTab.spec.tsx | 94 +++++++++++-------- .../ReviewersField/HumanReviewTab.tsx | 13 ++- 5 files changed, 101 insertions(+), 42 deletions(-) diff --git a/src/apps/work/src/lib/services/challenges.service.spec.ts b/src/apps/work/src/lib/services/challenges.service.spec.ts index ef70a3131..48fa46836 100644 --- a/src/apps/work/src/lib/services/challenges.service.spec.ts +++ b/src/apps/work/src/lib/services/challenges.service.spec.ts @@ -126,6 +126,25 @@ describe('fetchDefaultReviewers', () => { }, ]) }) + + it('requests defaults for the selected timeline template', async () => { + const mockedGet = xhrGetAsync as jest.Mock + + mockedGet.mockResolvedValue([]) + + await fetchDefaultReviewers({ + timelineTemplateId: ' timeline-template-1 ', + trackId: ' track-1 ', + typeId: ' type-1 ', + }) + + expect(mockedGet) + .toHaveBeenCalledWith( + 'https://example.com/default-reviewers' + + '?typeId=type-1&trackId=track-1&timelineTemplateId=timeline-template-1', + expect.any(Object), + ) + }) }) describe('patchChallenge', () => { diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts index fad44bfea..e178f1c6f 100644 --- a/src/apps/work/src/lib/services/challenges.service.ts +++ b/src/apps/work/src/lib/services/challenges.service.ts @@ -688,10 +688,19 @@ export async function deleteChallenge(challengeId: string): Promise { } /** - * Fetch default reviewers metadata. + * Fetches default reviewer metadata for a challenge configuration. + * + * @param typeIdOrFilters challenge type id for the legacy positional call, or type, track, + * and timeline-template filters for a template-specific lookup. + * @param trackId challenge track id used with the legacy positional call. + * @returns the normalized default reviewer rows matching the supplied configuration. + * @remarks The reviewer editor uses the filter-object form so challenges with multiple timeline + * templates receive the scorecards and reviewer phases configured for the selected template. + * @throws a normalized request error when the default-reviewer endpoint cannot be reached. */ export async function fetchDefaultReviewers( typeIdOrFilters: string | { + timelineTemplateId?: string trackId?: string typeId?: string } | undefined, @@ -713,6 +722,10 @@ export async function fetchDefaultReviewers( query.set('trackId', filters.trackId.trim()) } + if (filters.timelineTemplateId?.trim()) { + query.set('timelineTemplateId', filters.timelineTemplateId.trim()) + } + try { const queryString = query.toString() const response = await xhrGetAsync( diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index ef6c0948c..e4f0beb54 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -85,7 +85,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section repairs missing, duplicate, and stale hidden reviewer rows from the active defaults while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index da8966dfe..b99deb345 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -918,13 +918,18 @@ describe('HumanReviewTab', () => { , ) await waitFor(() => { expect(mockedFetchDefaultReviewers) - .toHaveBeenCalledWith('type-1', 'track-1') + .toHaveBeenCalledWith({ + timelineTemplateId: 'timeline-template-1', + trackId: 'track-1', + typeId: 'type-1', + }) }) await waitFor(() => { expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) @@ -1013,43 +1018,47 @@ describe('HumanReviewTab', () => { isLoading: false, resourceRoles: [], }) - mockedFetchDefaultReviewers.mockResolvedValue([ - { - isMemberReview: true, - memberReviewerCount: 1, - phaseId: 'checkpoint-review-phase-id', - scorecardId: 'checkpoint-review-scorecard-id', - shouldOpenOpportunity: false, - }, - { - isMemberReview: true, - memberReviewerCount: 1, - phaseId: 'review-phase-id', - scorecardId: 'review-scorecard-id', - shouldOpenOpportunity: false, - }, - { - isMemberReview: true, - memberReviewerCount: 1, - phaseId: 'checkpoint-screening-phase-id', - scorecardId: 'checkpoint-screening-scorecard-id', - shouldOpenOpportunity: false, - }, - { - isMemberReview: true, - memberReviewerCount: 1, - phaseId: 'screening-phase-id', - scorecardId: 'screening-scorecard-id', - shouldOpenOpportunity: false, - }, - { - isMemberReview: true, - memberReviewerCount: 1, - phaseId: 'approval-phase-id', - scorecardId: 'approval-scorecard-id', - shouldOpenOpportunity: false, - }, - ]) + mockedFetchDefaultReviewers.mockImplementation(filters => Promise.resolve( + filters?.timelineTemplateId === 'two-round-timeline-template-id' + ? [ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-review-phase-id', + scorecardId: 'checkpoint-review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-screening-phase-id', + scorecardId: 'checkpoint-screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'screening-phase-id', + scorecardId: 'screening-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: false, + }, + ] + : [], + )) mockedFetchScorecards.mockResolvedValue([ { id: 'checkpoint-review-scorecard-id', @@ -1141,6 +1150,7 @@ describe('HumanReviewTab', () => { shouldOpenOpportunity: true, }, ], + timelineTemplateId: 'two-round-timeline-template-id', }} screenerOnly showReviewersValue @@ -1149,6 +1159,14 @@ describe('HumanReviewTab', () => { expect(screen.getByLabelText('Screener')) .not.toBeNull() + await waitFor(() => { + expect(mockedFetchDefaultReviewers) + .toHaveBeenCalledWith({ + timelineTemplateId: 'two-round-timeline-template-id', + trackId: 'track-1', + typeId: 'type-1', + }) + }) await waitFor(() => { const reconciledReviewers = JSON.parse( screen.getByTestId('reviewers-value').textContent || '[]', diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index f74401bba..f0be18574 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -1186,6 +1186,10 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro control: formContext.control, name: 'typeId', }) as string | undefined + const timelineTemplateId = useWatch({ + control: formContext.control, + name: 'timelineTemplateId', + }) as string | undefined const prizeSets = useWatch({ control: formContext.control, name: 'prizeSets', @@ -1564,6 +1568,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro }, [selectedScorecardTrack, selectedScorecardType]) useEffect(() => { + const selectedTimelineTemplateId = timelineTemplateId?.trim() || '' const selectedTypeId = typeId?.trim() || '' const selectedTrackId = trackId?.trim() || '' @@ -1574,7 +1579,11 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro let mounted = true - fetchDefaultReviewers(selectedTypeId, selectedTrackId) + fetchDefaultReviewers({ + timelineTemplateId: selectedTimelineTemplateId || undefined, + trackId: selectedTrackId, + typeId: selectedTypeId, + }) .then(fetchedDefaultReviewers => { if (!mounted) { return @@ -1591,7 +1600,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro return () => { mounted = false } - }, [trackId, typeId]) + }, [timelineTemplateId, trackId, typeId]) useEffect(() => { const activeReviewerTypeFieldNames = new Set() From 0d09bb538959a3848718c417c34a05fc90a3cc56 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 13:27:05 +0300 Subject: [PATCH 10/44] handle colors --- .../pages/statistics/StatisticsPage/StatisticsPage.module.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss index 75f247a55..782957589 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss @@ -503,6 +503,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + color: #3877EA!important; } .tooltipWins { From 115599f162e47bddd41373607ff925b2c5245804 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 14:35:50 +0300 Subject: [PATCH 11/44] PM-5370 - campus data loading --- .../CampusLeaderboardPage.module.scss | 4 ++ .../leaderboard/CampusLeaderboardPage.tsx | 56 +++++++++++-------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index 3451b2d1a..947620fba 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -86,6 +86,10 @@ width: 100%; } +.tableWrapper { + position: relative; +} + .lbTable { tbody td { vertical-align: middle; diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index 64ac94a98..f00d69160 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -2,7 +2,7 @@ * Campus program leaderboard for a single group (`/:groupName`). */ import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react' -import { useParams } from 'react-router-dom' +import { useParams, useSearchParams } from 'react-router-dom' import classNames from 'classnames' import { @@ -94,7 +94,12 @@ function renderHandle(member: CampusLeaderboardMember): JSX.Element { export const CampusLeaderboardPage: FC = () => { const groupName: string | undefined = useParams<{ groupName: string }>().groupName - const [challengeFilter, setChallengeFilter] = useState('all') + const [searchParams, setSearchParams] = useSearchParams() + const searchChallengeFilter: string | null = searchParams.get('type') + const challengeFilter: CampusChallengeFilter = ( + searchChallengeFilter === 'public' || searchChallengeFilter === 'campus' + ) ? searchChallengeFilter : 'all' + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) const [selectedMember, setSelectedMember] = useState() const [rulesVisible, setRulesVisible] = useState(false) @@ -105,9 +110,15 @@ export const CampusLeaderboardPage: FC = () => { const displayGroupName: string = data?.group.name ?? groupName ?? '' const onFilterChange = useCallback((event: ChangeEvent): void => { - setChallengeFilter(event.target.value as CampusChallengeFilter) + const value = event.target.value as CampusChallengeFilter + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + type: value, + }, { replace: true }) + setVisibleCount(PAGE_SIZE) - }, []) + }, [searchParams, setSearchParams]) const onRowClick = useCallback((member: CampusLeaderboardMember): void => { if (!member.hasActivity) { @@ -192,8 +203,6 @@ export const CampusLeaderboardPage: FC = () => {

- - {!!error && (
{error.response?.status === 403 @@ -202,7 +211,7 @@ export const CampusLeaderboardPage: FC = () => {
)} - {!!data && ( + {(!!data || isLoading) && ( <>
@@ -212,7 +221,7 @@ export const CampusLeaderboardPage: FC = () => {
Total Members in Group
- {data.summary.totalMembers.toLocaleString()} + {data?.summary.totalMembers.toLocaleString() ?? '-'}
@@ -226,7 +235,7 @@ export const CampusLeaderboardPage: FC = () => { {' Registered to Any Challenge'}
- {data.summary.membersRegistered.toLocaleString()} + {data?.summary.membersRegistered.toLocaleString() ?? '-'}
@@ -240,7 +249,7 @@ export const CampusLeaderboardPage: FC = () => { {' Submitted to Any Challenge'}
- {data.summary.membersSubmitted.toLocaleString()} + {data?.summary.membersSubmitted.toLocaleString() ?? '-'}
@@ -265,19 +274,22 @@ export const CampusLeaderboardPage: FC = () => { -
+
+ +
+ - {!members.length && ( + {!isLoading && !members.length && (
{`No members were found in the ${displayGroupName} group.`}
From 824dd183999aac97344848bc0b5f2683bb639725 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 14:57:00 +0300 Subject: [PATCH 12/44] PM-5370 - clear out old code --- src/apps/campus/src/campus.routes.spec.tsx | 56 ++++++++++++++++++++++ src/apps/campus/src/campus.routes.tsx | 9 +--- src/apps/campus/src/home.tsx | 10 ---- 3 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 src/apps/campus/src/campus.routes.spec.tsx delete mode 100644 src/apps/campus/src/home.tsx diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx new file mode 100644 index 000000000..c5db10f66 --- /dev/null +++ b/src/apps/campus/src/campus.routes.spec.tsx @@ -0,0 +1,56 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' + +import { campusRoutes, rootRoute } from './campus.routes' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + SUBDOMAIN: 'campus', + }, +}), { + virtual: true, +}) + +jest.mock('~/config/constants', () => ({ + AppSubdomain: { + campus: 'campus', + }, + ToolTitle: { + campus: 'Campus', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): undefined => undefined, +}), { + virtual: true, +}) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +describe('campus routes', () => { + it('redirects the campus root to /mecw when groupName is missing', async () => { + const campusAppRoute = campusRoutes[0] + const campusChildRoutes = campusAppRoute.children || [] + const fallbackRoute = campusChildRoutes.find(route => route.route === '') + + render( + + + } path={`${rootRoute}/mecw`} /> + + + , + ) + + expect((await screen.findByTestId('location-pathname')).textContent) + .toBe('/mecw') + }) +}) diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx index 61efd6028..3bfb76248 100644 --- a/src/apps/campus/src/campus.routes.tsx +++ b/src/apps/campus/src/campus.routes.tsx @@ -1,11 +1,8 @@ import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { Navigate } from 'react-router-dom' import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp')) -const CampusHomePage: LazyLoadedComponent = lazyLoad( - () => import('./home'), - 'CampusHomePage', -) const CampusLeaderboardPage: LazyLoadedComponent = lazyLoad( () => import('./pages/leaderboard'), 'CampusLeaderboardPage', @@ -22,9 +19,7 @@ export const campusRoutes: ReadonlyArray = [ authRequired: true, children: [ { - children: [], - element: , - id: 'Campus Home', + element: , route: '', }, { diff --git a/src/apps/campus/src/home.tsx b/src/apps/campus/src/home.tsx deleted file mode 100644 index 93568a303..000000000 --- a/src/apps/campus/src/home.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { FC } from 'react' - -const CampusHomePage: FC = () => ( -
-

Campus

-

Welcome to the Campus application.

-
-) - -export default CampusHomePage From e2f296d1739a250a30e370bb6c7f3cf47432743b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 15:19:14 +0300 Subject: [PATCH 13/44] PM-5370 - clickable row --- src/apps/campus/src/campus.routes.spec.tsx | 2 +- .../CampusLeaderboardPage.module.scss | 15 +++++++++++---- .../CampusLeaderboardPage.spec.tsx | 11 +++++++---- .../leaderboard/CampusLeaderboardPage.tsx | 19 ++++++++++--------- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/apps/campus/src/campus.routes.spec.tsx b/src/apps/campus/src/campus.routes.spec.tsx index c5db10f66..b42d5d4f3 100644 --- a/src/apps/campus/src/campus.routes.spec.tsx +++ b/src/apps/campus/src/campus.routes.spec.tsx @@ -42,7 +42,7 @@ describe('campus routes', () => { const fallbackRoute = campusChildRoutes.find(route => route.route === '') render( - + } path={`${rootRoute}/mecw`} /> diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index 947620fba..3505c8520 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -158,16 +158,23 @@ font-weight: 500; } +.chevronButton { + align-items: center; + background: transparent; + border: none; + color: inherit; + cursor: pointer; + display: inline-flex; + justify-content: center; + padding: 0; +} + .chevron { color: $black-60; height: 20px; width: 20px; } -.clickableRow { - cursor: pointer; -} - .empty, .error { color: $black-60; diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx index 0335d3135..a520a79c9 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -196,13 +196,16 @@ describe('CampusLeaderboardPage', () => { .toBeInTheDocument() }) - it('opens the participation history only for members with activity', () => { + it('opens the participation history only when the chevron is clicked for active members', () => { renderPage() - fireEvent.click(screen.getByText('quiet_member')) - expect(screen.queryByText(/Participation History/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { + name: /View participation history for quiet_member/i, + })).not.toBeInTheDocument() - fireEvent.click(screen.getByText('testaws1')) + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) expect(screen.getByText(/testaws1 — Participation History/)) .toBeInTheDocument() expect(screen.getByText('Campus Sprint')) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index f00d69160..ef71516f8 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -47,10 +47,6 @@ const RANK_MEDAL_CLASSES: { [rank: number]: string } = { * @param member leaderboard row. * @returns row class name, when the row is clickable. */ -function rowClassName(member: CampusLeaderboardMember): string | undefined { - return member.hasActivity ? styles.clickableRow : undefined -} - /** * Renders a rank badge, medal-styled for the top three ranks. * @@ -120,7 +116,7 @@ export const CampusLeaderboardPage: FC = () => { setVisibleCount(PAGE_SIZE) }, [searchParams, setSearchParams]) - const onRowClick = useCallback((member: CampusLeaderboardMember): void => { + const openParticipationHistory = useCallback((member: CampusLeaderboardMember): void => { if (!member.hasActivity) { return } @@ -175,11 +171,18 @@ export const CampusLeaderboardPage: FC = () => { columnId: 'open', label: '', renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? ( - + ) : ), type: 'element', }, - ], []) + ], [openParticipationHistory]) const members: ReadonlyArray = data?.members ?? [] const visibleMembers = useMemo( @@ -283,9 +286,7 @@ export const CampusLeaderboardPage: FC = () => { disableSorting moreToLoad={visibleCount < members.length} onLoadMoreClick={onLoadMoreClick} - onRowClick={onRowClick} removeDefaultSort - rowClassName={rowClassName} /> From 6340c2c2749acb687aec66ed669fa8f285fce53c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 18 Aug 2026 15:34:17 +0300 Subject: [PATCH 14/44] lint --- src/apps/campus/src/campus.routes.tsx | 3 ++- .../campus/src/pages/leaderboard/CampusLeaderboardPage.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apps/campus/src/campus.routes.tsx b/src/apps/campus/src/campus.routes.tsx index 3bfb76248..6426a9b61 100644 --- a/src/apps/campus/src/campus.routes.tsx +++ b/src/apps/campus/src/campus.routes.tsx @@ -1,5 +1,6 @@ -import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' import { Navigate } from 'react-router-dom' + +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' const CampusApp: LazyLoadedComponent = lazyLoad(() => import('./CampusApp')) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index ef71516f8..8e9023c41 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -173,7 +173,7 @@ export const CampusLeaderboardPage: FC = () => { renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? ( + ) + }, + } +}) jest.mock('./ChallengeSkillsField', () => ({ ChallengeSkillsField: () => <>, })) @@ -1430,6 +1457,59 @@ describe('ChallengeEditorForm', () => { .toBeNull() }) + it('shows budget approval actions after saving a new challenge without persisted prizes', async () => { + const user = userEvent.setup() + const managerContextValue: WorkAppContextModel = { + ...copilotContextValue, + isManager: true, + userRoles: ['manager'], + } + // Challenges in 'New' status are created before the prizes section is available, so the + // fetched challenge snapshot still has no persisted prize sets while the form is edited. + const newChallengeWithoutPrizes = { + ...validNewChallenge, + approvalStatus: 'PENDING_APPROVAL', + prizeSets: undefined, + } as Challenge + const renderForm = (isReadOnly: boolean): React.ReactElement => ( + + + + + + ) + + mockedPatchChallenge.mockResolvedValue({ + ...validNewChallenge, + approvalStatus: 'PENDING_APPROVAL', + status: 'DRAFT', + }) + + const renderResult = render(renderForm(false)) + + await user.click(screen.getByRole('button', { name: 'Mock Set Placement Prize' })) + await user.click(screen.getByRole('button', { name: 'Save as Draft' })) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + status: 'DRAFT', + })) + }) + + // The saved form stays mounted while the successful save redirects to the read-only view. + renderResult.rerender(renderForm(true)) + + expect(screen.getByRole('button', { name: 'Approve Budget' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reject Budget' })) + .toBeInTheDocument() + }) + it('hides the editable timeline section for task challenges in edit mode', () => { mockedUseFetchChallengeTypes.mockReturnValue({ challengeTypes: [{ 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 b71d0acf1..8963cb2ab 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -54,6 +54,7 @@ import { ChallengeEditorFormData, ChallengePhase, ChallengeType, + PrizeSet, Resource, ResourceRole, Reviewer, @@ -1887,6 +1888,19 @@ function getApprovalStatusText(approvalStatus: string | undefined): string { return 'Pending Approval' } +/** + * Detects whether a persisted challenge snapshot already stores at least one prize. + * + * @param prizeSets prize sets returned by challenge-api for the challenge. + * @returns `true` when any prize set contains at least one prize. + * @remarks Used by the budget approval actions so approvers only act on prizes + * that challenge-api already persisted. + */ +function hasPrizeSetWithPrizes(prizeSets: PrizeSet[] | undefined): boolean { + return Array.isArray(prizeSets) + && prizeSets.some(prizeSet => Array.isArray(prizeSet?.prizes) && prizeSet.prizes.length > 0) +} + interface TaskLaunchValidationParams { assignedMemberId?: unknown currentStatus?: unknown @@ -2056,6 +2070,11 @@ export const ChallengeEditorForm: FC = ( const [scorerHasError, setScorerHasError] = useState(false) const [isUpdatingApproval, setIsUpdatingApproval] = useState(false) const [rejectionReasonInput, setRejectionReasonInput] = useState('') + // Tracks the prize sets challenge-api has stored so budget approval actions can appear + // right after a save instead of waiting for the challenge prop to be refetched. + const [persistedPrizeSets, setPersistedPrizeSets] = useState( + props.challenge?.prizeSets, + ) const [showApproveBudgetModal, setShowApproveBudgetModal] = useState(false) const [showRejectBudgetModal, setShowRejectBudgetModal] = useState(false) const [resolvedPaymentCreator, setResolvedPaymentCreator] = useState() @@ -2316,10 +2335,8 @@ export const ChallengeEditorForm: FC = ( projectResult.project, ) const hasPersistedPrizeSets = useMemo( - () => Array.isArray(props.challenge?.prizeSets) - && props.challenge?.prizeSets - .some(prizeSet => Array.isArray(prizeSet?.prizes) && prizeSet.prizes.length > 0), - [props.challenge?.prizeSets], + () => hasPrizeSetWithPrizes(persistedPrizeSets), + [persistedPrizeSets], ) const hasUnsavedPrizeSetChanges = useMemo( () => { @@ -2909,6 +2926,10 @@ export const ChallengeEditorForm: FC = ( challengeRef.current = props.challenge }, [props.challenge]) + useEffect(() => { + setPersistedPrizeSets(props.challenge?.prizeSets) + }, [props.challenge?.prizeSets]) + useEffect(() => { currentChallengeIdRef.current = currentChallengeId }, [currentChallengeId]) @@ -3612,6 +3633,8 @@ export const ChallengeEditorForm: FC = ( } } + setPersistedPrizeSets(savedChallengeSnapshot.prizeSets) + const persistedFormData = applyProjectBillingToChallengeFormData( transformChallengeToFormData(savedChallengeSnapshot), resolvedProjectBillingAccount, From 6251debc7535011bd6267d3ba6f2ceda29145694 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Aug 2026 18:08:12 +1000 Subject: [PATCH 23/44] PM-5562: Count rating-only history subtrack wins in track totals What was broken The "Development Stats" win total on the member profile stats page did not match the sum of the wins shown on the Development subtrack cards. On prod, sdgun showed 321 wins while the cards summed to 329, and standlove showed 738 wins while the cards summed to 978. The total was correct on first paint and then changed to the wrong value a few seconds later, once the stats history request resolved. Root cause getTrackSummaryStats aggregates the parent track totals from stats history so that a challenge appearing under two subtracks is only counted once. A subtrack card falls back to the aggregate `wins` counter when its history rows carry no placement data (rating-only rows), but the parent total only counted history rows with `placement === 1`. Any subtrack that had history without placements was therefore treated as having zero wins in the total while its card still displayed the aggregate count, so those wins silently disappeared from the summary once stats history loaded. For sdgun, CONTENT_CREATION has a single rating-only history row and 8 aggregate wins, which is exactly the 329 - 321 = 8 difference. For standlove, ARCHITECTURE (68), DESIGN (147) and DEVELOPMENT (25) are all rating-only and account for the 978 - 738 = 240 difference. What was changed - src/apps/profiles/src/hooks/useFetchActiveTracks.tsx: added a `hasPlacementHistory` helper and split the history summaries into placement-bearing rows and rating-only rows. Placement-bearing rows keep the existing de-duplicated unique-history win count, while rating-only rows now contribute their aggregate wins to the parent total, the same way subtracks with no history at all already did. The `historyStatsWins` fallback is now computed from placement-bearing summaries only so those wins are not counted twice, and its `Math.max` is seeded with 0 to avoid `-Infinity`. - Challenge and submission totals are untouched; only the win aggregation changed. Any added/updated tests - src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx: new `getTrackSummaryStats` suite with two cases, modeled on the sdgun payload: one asserting the Development total equals the sum of the subtrack card wins when a subtrack has rating-only history (fails with 77 vs 85 before this change), and one asserting aggregate wins are preserved when no subtrack history has placements. - Verified against the live prod payloads for both members reported in the ticket: Development totals now come out as 329 for sdgun and 978 for standlove, matching the sum of the subtrack cards in both cases, with challenge and submission totals unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/hooks/useFetchActiveTracks.spec.tsx | 143 ++++++++++++++++++ .../src/hooks/useFetchActiveTracks.tsx | 31 +++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 89853c324..e38686cc0 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -5,7 +5,9 @@ import { getMemberChallengePoints, getSubTrackDisplaySubmissionCount, getSubTrackSummaryStats, + getTrackSummaryStats, MemberStatsTrack, + SubTrackSummaryStats, } from './useFetchActiveTracks' jest.mock('~/libs/core', () => ({ @@ -594,3 +596,144 @@ describe('getSubTrackSummaryStats', () => { }) }) }) + +describe('getTrackSummaryStats', () => { + const statsOnlyHistoryStats = { + DEVELOP: { + subTracks: [ + { + challenges: 231, + name: 'Task', + submissions: { + submissions: 231, + }, + wins: 231, + }, + { + challenges: 18, + name: 'Challenge', + submissions: { + submissions: 18, + }, + wins: 17, + }, + { + challenges: 21, + name: 'CONTENT_CREATION', + submissions: { + submissions: 17, + }, + wins: 8, + }, + { + challenges: 71, + name: 'First2Finish', + submissions: { + submissions: 66, + }, + wins: 64, + }, + { + challenges: 37, + name: 'CODE', + submissions: { + submissions: 24, + }, + wins: 10, + }, + ], + }, + } as unknown as UserStats + const statsOnlyHistory = { + DEVELOP: { + subTracks: [ + { + history: [ + { + challengeId: 'task-1', + challengeName: 'Task 1', + placement: 1, + ratingDate: 1781237773026, + }, + { + challengeId: 'task-2', + challengeName: 'Task 2', + placement: 1, + ratingDate: 1781237773027, + }, + ], + name: 'Task', + }, + { + history: [ + { + challengeId: 'challenge-1', + challengeName: 'Challenge 1', + placement: 1, + ratingDate: 1781237773028, + }, + { + challengeId: 'challenge-2', + challengeName: 'Challenge 2', + placement: 36, + ratingDate: 1781237773029, + }, + ], + name: 'Challenge', + }, + { + history: [ + { + challengeId: 30040681, + newRating: 1333, + ratingDate: 1393405500000, + }, + ], + name: 'CONTENT_CREATION', + }, + ], + }, + } as unknown as UserStatsHistory + + it('adds aggregate wins for subtracks whose history has no placements', () => { + const developmentTrack: MemberStatsTrack | undefined = getActiveTracks( + statsOnlyHistoryStats, + statsOnlyHistory, + ) + .find(track => track.name === 'Development') + const subTrackWins: number = developmentTrack?.subTracks.reduce((wins, subTrack) => { + const trackHistory = statsOnlyHistory.DEVELOP?.subTracks + ?.find(historyEntry => historyEntry.name === subTrack.name) + ?.history ?? [] + const summaryStats: SubTrackSummaryStats = getSubTrackSummaryStats(subTrack, trackHistory) + + return wins + summaryStats.wins + }, 0) ?? 0 + + // 2 Task placements + 1 Challenge placement + 8 CONTENT_CREATION + 64 First2Finish + 10 CODE + expect(developmentTrack?.wins) + .toEqual(85) + expect(developmentTrack?.wins) + .toEqual(subTrackWins) + }) + + it('keeps aggregate wins when no subtrack history has placements', () => { + const summaryStats = getTrackSummaryStats( + [ + { + challenges: 21, + name: 'CONTENT_CREATION', + path: 'DEVELOP.subTracks', + submissions: { + submissions: 17, + }, + wins: 8, + } as unknown as MemberStats, + ], + statsOnlyHistory, + ) + + expect(summaryStats.wins) + .toEqual(8) + }) +}) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 6d2adadeb..37f1adb46 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -189,6 +189,19 @@ interface SubTrackHistorySummary { subTrack: MemberStats } +/** + * Checks whether the subtrack history carries placement information. + * + * Some history rows only record rating changes, so their win counts still come + * from the aggregate stats payload instead of placement rows. + * + * @param {SubTrackHistorySummary} summary - The subtrack history summary to inspect. + * @returns {boolean} Whether any history row includes a placement. + */ +const hasPlacementHistory = (summary: SubTrackHistorySummary): boolean => ( + summary.history.some(history => getFiniteNumber(history.placement) !== undefined) +) + const getSubTrackHistorySummaries = ( subTracks: MemberStats[], statsHistory?: UserStatsHistory, @@ -217,6 +230,11 @@ const getFallbackTrackSummaryStats = (summaries: SubTrackHistorySummary[]): Trac * `AI Engineering`. When history rows are available, duplicate challenge ids are * counted once for the parent totals while each child card keeps its own stats. * + * Subtracks whose history only carries rating changes have no placement rows, so + * their cards fall back to the aggregate win counter. Those wins are added to the + * parent total as well, otherwise the summary drops them and no longer matches the + * sum of the subtrack cards. + * * @param {MemberStats[]} subTracks - Active subtracks included in the parent track. * @param {UserStatsHistory | undefined} statsHistory - Optional stats-history payload for the same member. * @returns {TrackSummaryStats} Parent challenge, win, and submission totals for display. @@ -267,15 +285,22 @@ export const getTrackSummaryStats = ( 0, summary.stats.submissions - summary.history.length, )) + const placementHistorySummaries = historySummaries.filter(hasPlacementHistory) const uniqueHistoryWins = uniqueHistory.filter(history => history.placement === 1).length const historyStatsWins = hasDuplicateHistory - ? Math.max(...historySummaries.map(summary => summary.stats.wins)) - : sumBy(historySummaries, summary => summary.stats.wins) + ? Math.max(0, ...placementHistorySummaries.map(summary => summary.stats.wins)) + : sumBy(placementHistorySummaries, summary => summary.stats.wins) + const statsOnlyHistoryWins = sumBy( + historySummaries.filter(summary => !hasPlacementHistory(summary)), + summary => summary.stats.wins, + ) return { challenges: uniqueHistory.length + historyChallengeExtras + noHistorySummaryStats.challenges, submissions: uniqueHistory.length + historySubmissionExtras + noHistorySummaryStats.submissions, - wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + noHistorySummaryStats.wins, + wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + + statsOnlyHistoryWins + + noHistorySummaryStats.wins, } } From ca8c8f8de2f4981a5acbb54057aa1a94990db8cc Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 20 Aug 2026 11:32:49 +0300 Subject: [PATCH 24/44] litn --- .../src/pages/statistics/StatisticsPage/WorldMap.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx index 8feff4fe9..c93033069 100644 --- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx +++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/WorldMap.tsx @@ -16,7 +16,6 @@ import fullscreenIcon from './assets/fullscreen.svg' import memberGroupIcon from './assets/member-group.svg' import skillCognitionIcon from './assets/skill-cognition.svg' import styles from './StatisticsPage.module.scss' -import { getName } from 'i18n-iso-countries' interface WorldMapProps { countries: StatisticsCountry[] From 76af27fb265dad747914ff4a8b94e9cb900a6ca3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 20 Aug 2026 18:50:54 +1000 Subject: [PATCH 25/44] PM-5758: show and lock the design submission limit in Work Manager What was broken Work Manager rendered the design Submission limit control with no option selected, so a saved Unlimited or Limited value was invisible on the challenge view page and after saving a draft, even though the value was stored in challenge metadata. Copilots could also still raise or lower the limit after members had uploaded submissions, and the Review application does not retroactively create scorecards for submissions that a later limit would have allowed. Root cause The radio selection and the count are display-only form fields that are not part of the persisted challenge payload. The editor resets the form when challenge data arrives and after each save, which drops both fields. The seeding effect only ran while those fields were undefined and its dependencies did not change when a reset cleared them, so the fields were never re-seeded and the radio group rendered with no selection. Nothing in the editor tied the control to existing submissions. What was changed The submission-limit selection and count are now re-seeded from the current challenge metadata whenever they do not match it, so the persisted limit stays visible after the challenge loads and after a draft save. The mode and count become read-only, with an explanatory hint, once the challenge has at least one contest or checkpoint submission. Challenge form data now carries numOfCheckpointSubmissions alongside numOfSubmissions so two-round design challenges lock on checkpoint uploads too, and FormRadioGroup accepts the optional hint already supported by FormFieldWrapper. Any added/updated tests Added MaximumSubmissionsField coverage for restoring the persisted limit after a form reset, replacing a stale selection with the persisted metadata, locking on a contest submission, locking on a checkpoint submission, and staying editable while no submission exists. Added a challenge-editor utils test that numOfCheckpointSubmissions is kept in form data. --- .../form/FormRadioGroup/FormRadioGroup.tsx | 2 + .../lib/utils/challenge-editor.utils.spec.ts | 16 ++ .../src/lib/utils/challenge-editor.utils.ts | 1 + .../challenges/ChallengeEditorPage/README.md | 2 +- .../MaximumSubmissionsField.spec.tsx | 169 +++++++++++++++++- .../MaximumSubmissionsField.tsx | 76 ++++---- 6 files changed, 233 insertions(+), 33 deletions(-) diff --git a/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx b/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx index 16a103d38..288ab42d3 100644 --- a/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx +++ b/src/apps/work/src/lib/components/form/FormRadioGroup/FormRadioGroup.tsx @@ -21,6 +21,7 @@ export interface FormRadioOption { interface FormRadioGroupProps { disabled?: boolean + hint?: string label: string name: string onChange?: (value: boolean | string) => void @@ -66,6 +67,7 @@ export const FormRadioGroup: FC = (props: FormRadioGroupPro return ( { .toBe(1) }) + it('keeps numOfCheckpointSubmissions in form data so submission-limit locking can use it', () => { + const result = transformChallengeToFormData({ + description: 'Public specification', + name: 'Checkpoint submission challenge', + numOfCheckpointSubmissions: 2, + numOfSubmissions: 0, + trackId: 'track-id', + typeId: 'type-id', + }) + + expect(result.numOfCheckpointSubmissions) + .toBe(2) + expect(result.numOfSubmissions) + .toBe(0) + }) + it('keeps phase completion dates in form data so completed schedule rows stay locked', () => { const result = transformChallengeToFormData({ description: 'Public specification', diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index 496c2f607..2d894f0d9 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -1046,6 +1046,7 @@ export function transformChallengeToFormData( milestoneDurationDays: normalizeOptionalNumber(milestoneConfiguration.milestoneDurationDays), }, name, + numOfCheckpointSubmissions: normalizeOptionalNumber(challenge?.numOfCheckpointSubmissions), numOfSubmissions: normalizeOptionalNumber(challenge?.numOfSubmissions), phases, privateDescription, diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index c1dce85ea..b9960934d 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -89,7 +89,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. -- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. +- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. - `ChallengeDescriptionField`: public markdown spec editor with a `Copy spec` action that copies the current Markdown in both edit and read-only view modes. - `ChallengePrivateDescriptionField`: optional private markdown spec editor. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx index 29219fa53..f84e7ea2d 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx @@ -46,7 +46,10 @@ interface TestHarnessProps { value: string }> deferDirty?: boolean + numOfCheckpointSubmissions?: number + numOfSubmissions?: number onMetadataWrite?: () => void + staleSubmissionLimitMode?: string } const TestHarness: FC = (props: TestHarnessProps) => { @@ -55,12 +58,30 @@ const TestHarness: FC = (props: TestHarnessProps) => { description: 'Public challenge specification', metadata: props.defaultMetadata, name: 'Design challenge', + numOfCheckpointSubmissions: props.numOfCheckpointSubmissions, + numOfSubmissions: props.numOfSubmissions, skills: [], tags: [], trackId: 'design-track', typeId: 'design-type', - }, + ...(props.staleSubmissionLimitMode + ? { submissionLimitCount: '', submissionLimitMode: props.staleSubmissionLimitMode } + : {}), + } as ChallengeEditorFormData, }) + const resetToPersistedValues = useCallback(() => { + // Mirrors the editor resetting the form from saved challenge data, which drops the + // display-only submission-limit fields. + formMethods.reset({ + description: 'Public challenge specification', + metadata: props.defaultMetadata, + name: 'Design challenge', + skills: [], + tags: [], + trackId: 'design-track', + typeId: 'design-type', + } as ChallengeEditorFormData) + }, [formMethods, props.defaultMetadata]) const setValue = useCallback(( name, value, @@ -83,6 +104,7 @@ const TestHarness: FC = (props: TestHarnessProps) => { setValue={setValue} > + {String(formMethods.formState.isDirty)} {JSON.stringify(values.metadata || [])} @@ -361,4 +383,149 @@ describe('MaximumSubmissionsField', () => { expect(onMetadataWrite) .toHaveBeenCalledTimes(1) }) + it('restores the persisted limit when the editor resets the form', async () => { + const user = userEvent.setup() + const limitedMetadata = [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }] + + render() + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('button', { name: 'Reset form' })) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).value) + .toBe('2') + }) + }) + + it('replaces a stale selection with the persisted submission limit', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).value) + .toBe('3') + }) + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(false) + }) + + it('locks the submission limit once a submission has been uploaded', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByRole('spinbutton', { name: 'Limit count' }) as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeTruthy() + }) + + it('locks the submission limit once a checkpoint submission has been uploaded', async () => { + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(true) + expect(screen.getByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeTruthy() + }) + + it('keeps the submission limit editable while no submission exists', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).disabled) + .toBe(false) + expect(screen.queryByText( + 'The submission limit cannot be changed after the first submission is uploaded.', + )) + .toBeNull() + + await user.click(screen.getByRole('radio', { name: 'Limited' })) + + expect(await screen.findByRole('spinbutton', { name: 'Limit count' })) + .toBeTruthy() + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx index 0f95c69c2..18c05a852 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx @@ -30,6 +30,8 @@ const SUBMISSION_LIMIT_COUNT_FIELD = 'submissionLimitCount' const SUBMISSION_LIMIT_MODE_FIELD = 'submissionLimitMode' const LIMITED_MODE = 'limited' const UNLIMITED_MODE = 'unlimited' +const SUBMITTED_LIMIT_LOCK_HINT + = 'The submission limit cannot be changed after the first submission is uploaded.' type SubmissionLimitMode = typeof LIMITED_MODE | typeof UNLIMITED_MODE @@ -196,7 +198,16 @@ export const MaximumSubmissionsField: FC = ( control, name: SUBMISSION_LIMIT_COUNT_FIELD, }) as string | undefined + const numOfSubmissions = useWatch({ + control, + name: 'numOfSubmissions', + }) as number | string | undefined + const numOfCheckpointSubmissions = useWatch({ + control, + name: 'numOfCheckpointSubmissions', + }) as number | string | undefined const submissionLimitValue = getMetadataValue(metadata, SUBMISSION_LIMIT_FIELD) + const isLocked = Number(numOfSubmissions || 0) + Number(numOfCheckpointSubmissions || 0) > 0 const persistSubmissionLimitMetadata = useCallback(( mode: SubmissionLimitMode, @@ -226,43 +237,41 @@ export const MaximumSubmissionsField: FC = ( setValue, ]) + /* + * The selection and count are display-only fields, so every editor form reset drops them + * without changing any value this component watches. Running on each render re-seeds them + * from the current challenge metadata, which keeps the saved limit visible after the + * challenge loads and after a draft save resets the form. + */ useEffect(() => { - if (submissionLimitMode !== undefined && submissionLimitCount !== undefined) { - return - } - - const currentSubmissionLimitMetadata = parseSubmissionLimitMetadata( + const currentSubmissionLimit = parseSubmissionLimitMetadata( getMetadataValue(getValues('metadata'), SUBMISSION_LIMIT_FIELD), ) - if (submissionLimitMode === undefined) { - setValue( - SUBMISSION_LIMIT_MODE_FIELD, - currentSubmissionLimitMetadata.mode, - { - shouldDirty: false, - shouldValidate: false, - }, - ) + if ( + submissionLimitMode === currentSubmissionLimit.mode + && (submissionLimitCount || '') === currentSubmissionLimit.count + ) { + return } - if (submissionLimitCount === undefined) { - setValue( - SUBMISSION_LIMIT_COUNT_FIELD, - currentSubmissionLimitMetadata.count, - { - shouldDirty: false, - shouldValidate: false, - }, - ) - } - }, [ - getValues, - setValue, - submissionLimitCount, - submissionLimitMode, - submissionLimitValue, - ]) + setValue( + SUBMISSION_LIMIT_MODE_FIELD, + currentSubmissionLimit.mode, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + setValue( + SUBMISSION_LIMIT_COUNT_FIELD, + currentSubmissionLimit.count, + { + shouldDirty: false, + shouldValidate: false, + }, + ) + }) useEffect(() => { if (props.deferDirty) { @@ -332,6 +341,10 @@ export const MaximumSubmissionsField: FC = ( return (
= ( ? ( Date: Thu, 20 Aug 2026 19:24:23 +1000 Subject: [PATCH 26/44] PM-5913: Drop the required marker from Design review member fields What was broken In the Admin advanced review configuration for Design challenges, the Checkpoint Review, Review, and Approval "Member N" selectors were rendered with the red required asterisk, and closed-opportunity validation refused to save or launch until a member was picked for each of them. Those assignments are no longer something an admin has to make, because the selected copilot is assigned to those private phases automatically. Root cause PM-5755 made the editor assign the selected copilot to the Design Checkpoint Review, Review, and Approval reviewer rows during save, but the reviewer assignment exception was never widened past Screening and Checkpoint Screening. `isScreenerAssignmentOptional` therefore still reported those Design review phases as requiring an up-front member, which drove both the `required` flag on the member autocomplete and the closed-opportunity slot validation in the yup schema and the draft-save reviewer check. What was changed Renamed `isScreenerAssignmentOptional` to `isReviewerAssignmentOptional` and gave it an `isDesignChallenge` argument that also treats Checkpoint Review, Review, and Approval as deferrable, matching the phases the copilot is assigned to during save. HumanReviewTab passes that flag when the selected track is Design and the selected type is Challenge, so the advanced view no longer marks those member fields required. The challenge editor schema reads the same flag from a new resolver validation context supplied by ChallengeEditorForm, which also re-triggers reviewer validation when the track or type selection changes, and the draft-save reviewer check receives it through its existing options object. Every other track, challenge type, and reviewer phase keeps the previous required behavior. The ChallengeEditorPage README was updated to match. Any added/updated tests Added a reviewer.utils unit spec covering the screening exception, the new Design copilot phase exception, and the unchanged required cases for other tracks, AI reviewer rows, and unknown phases. Added schema coverage for accepting unassigned Design Review and Approval rows with the design validation context and for still rejecting them without it. Added a HumanReviewTab test asserting the Design Challenge Checkpoint Review, Review, and Approval member fields render as optional, and a ChallengeEditorForm test that launches a Design draft whose copilot-assigned review rows have no member. Co-Authored-By: Claude Opus 5 (1M context) --- .../schemas/challenge-editor.schema.spec.ts | 75 ++++++++++++++++++ .../lib/schemas/challenge-editor.schema.ts | 19 ++++- .../work/src/lib/utils/reviewer.utils.spec.ts | 59 ++++++++++++++ src/apps/work/src/lib/utils/reviewer.utils.ts | 25 +++++- .../challenges/ChallengeEditorPage/README.md | 4 +- .../components/ChallengeEditorForm.spec.tsx | 76 +++++++++++++++++++ .../components/ChallengeEditorForm.tsx | 31 +++++++- .../ReviewersField/HumanReviewTab.spec.tsx | 71 +++++++++++++++++ .../ReviewersField/HumanReviewTab.tsx | 13 +++- 9 files changed, 359 insertions(+), 14 deletions(-) create mode 100644 src/apps/work/src/lib/utils/reviewer.utils.spec.ts diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts index 5918ca0d8..970d00aa9 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts @@ -356,6 +356,81 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toBeTruthy() }) + it('accepts unassigned Design copilot review phases', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + phases: [ + { + name: 'Review', + phaseId: 'review-phase-id', + }, + { + name: 'Approval', + phaseId: 'approval-phase-id', + }, + ], + reviewers: [ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'approval-phase-id', + scorecardId: 'approval-scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }, + { + context: { + isDesignChallenge: true, + }, + }, + ), + ) + .resolves + .toBeTruthy() + }) + + it('still requires review assignments outside Design challenges', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + phases: [{ + name: 'Review', + phaseId: 'review-phase-id', + }], + reviewers: [ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + shouldOpenOpportunity: false, + }, + ], + }, + { + context: { + isDesignChallenge: false, + }, + }, + ), + ) + .rejects + .toMatchObject({ + path: 'reviewers[0].memberId', + }) + }) + it('accepts required reviewer slot assignments when opportunity is closed', async () => { await expect( challengeAdvancedOptionsSchema.validate({ diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts index e90090c07..ba83b3883 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts @@ -18,7 +18,19 @@ import { import { isSkillsRequired, } from '../utils/challenge-editor.utils' -import { isScreenerAssignmentOptional } from '../utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../utils/reviewer.utils' + +/** + * Validation context supplied to the challenge editor schema by the challenge editor form. + * + * @remarks The schema only receives form values, so track and type driven rules such as the + * Design `Challenge` reviewer assignment exception are provided through the resolver context. + */ +export interface ChallengeEditorValidationContext { + /** Whether the edited challenge is a Design `Challenge`, whose private reviewers are + * automatically assigned to the selected copilot during save. */ + isDesignChallenge?: boolean +} function isSchedulingApiEnabled(value: unknown): boolean { return value !== false @@ -425,6 +437,9 @@ export const challengeAdvancedOptionsSchema = yup.object({ } const phases = (this.parent as Partial)?.phases + const isDesignChallenge = ( + this.options.context as ChallengeEditorValidationContext | undefined + )?.isDesignChallenge === true for (let reviewerIndex = 0; reviewerIndex < value.length; reviewerIndex += 1) { const reviewer = value[reviewerIndex] as ChallengeReviewer | undefined @@ -433,7 +448,7 @@ export const challengeAdvancedOptionsSchema = yup.object({ const requiresMemberAssignments = !!reviewer && isMemberReview && !shouldOpenOpportunity - && !isScreenerAssignmentOptional(reviewer, phases) + && !isReviewerAssignmentOptional(reviewer, phases, isDesignChallenge) if (requiresMemberAssignments) { const reviewerSlots = getRequiredReviewerSlots(reviewer.memberReviewerCount) diff --git a/src/apps/work/src/lib/utils/reviewer.utils.spec.ts b/src/apps/work/src/lib/utils/reviewer.utils.spec.ts new file mode 100644 index 000000000..494088a2b --- /dev/null +++ b/src/apps/work/src/lib/utils/reviewer.utils.spec.ts @@ -0,0 +1,59 @@ +import { + isReviewerAssignmentOptional, +} from './reviewer.utils' + +describe('isReviewerAssignmentOptional', () => { + const phases = [ + { + id: 'screening-instance-id', + name: 'Screening', + phaseId: 'screening-phase-id', + }, + { + id: 'review-instance-id', + name: 'Review', + phaseId: 'review-phase-id', + }, + { + id: 'approval-instance-id', + name: 'Approval', + phaseId: 'approval-phase-id', + }, + { + id: 'checkpoint-review-instance-id', + name: 'Checkpoint Review', + phaseId: 'checkpoint-review-phase-id', + }, + ] + + it('defers screening assignments for every track', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'screening-phase-id' }, phases)) + .toBe(true) + }) + + it('requires review assignments outside Design challenges', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'review-phase-id' }, phases)) + .toBe(false) + expect(isReviewerAssignmentOptional({ phaseId: 'approval-instance-id' }, phases)) + .toBe(false) + }) + + it('defers copilot assigned review phases for Design challenges', () => { + expect(isReviewerAssignmentOptional({ phaseId: 'review-phase-id' }, phases, true)) + .toBe(true) + expect(isReviewerAssignmentOptional({ phaseId: 'approval-instance-id' }, phases, true)) + .toBe(true) + expect(isReviewerAssignmentOptional({ phaseId: 'checkpoint-review-phase-id' }, phases, true)) + .toBe(true) + }) + + it('keeps AI reviewer rows and unknown phases required', () => { + expect(isReviewerAssignmentOptional({ + isMemberReview: false, + phaseId: 'review-phase-id', + }, phases, true)) + .toBe(false) + expect(isReviewerAssignmentOptional({ phaseId: 'unknown-phase-id' }, phases, true)) + .toBe(false) + }) +}) diff --git a/src/apps/work/src/lib/utils/reviewer.utils.ts b/src/apps/work/src/lib/utils/reviewer.utils.ts index fc1ed2cb2..707dd8710 100644 --- a/src/apps/work/src/lib/utils/reviewer.utils.ts +++ b/src/apps/work/src/lib/utils/reviewer.utils.ts @@ -3,6 +3,16 @@ import type { ChallengeReviewer, } from '../models' +const SCREENER_PHASE_NAMES = new Set([ + 'checkpoint screening', + 'screening', +]) +const DESIGN_COPILOT_ASSIGNED_PHASE_NAMES = new Set([ + 'approval', + 'checkpoint review', + 'review', +]) + /** * Normalizes a reviewer or phase value for exact identifier and name comparisons. * @@ -22,14 +32,18 @@ function normalizeReviewerValue(value: unknown): string { * * @param reviewer reviewer configuration whose phase should be inspected. * @param phases challenge phases used to resolve the reviewer's phase name. - * @returns `true` for a human reviewer configured on Screening or Checkpoint Screening. + * @param isDesignChallenge whether the editor is configuring a Design `Challenge`, where the + * selected copilot is assigned to the private review phases during save. + * @returns `true` for a human reviewer configured on Screening or Checkpoint Screening, and for a + * human reviewer configured on Checkpoint Review, Review, or Approval of a Design `Challenge`. * @remarks Form validation and reviewer fields use this exception; every other reviewer phase * still requires assignments up front. * @throws Does not throw. */ -export function isScreenerAssignmentOptional( +export function isReviewerAssignmentOptional( reviewer: ChallengeReviewer | undefined, phases: ChallengePhase[] | undefined, + isDesignChallenge: boolean = false, ): boolean { if (reviewer?.isMemberReview === false || !Array.isArray(phases)) { return false @@ -51,8 +65,11 @@ export function isScreenerAssignmentOptional( return matchesPhase && ( - normalizedPhaseName === 'screening' - || normalizedPhaseName === 'checkpoint screening' + SCREENER_PHASE_NAMES.has(normalizedPhaseName) + || ( + isDesignChallenge + && DESIGN_COPILOT_ASSIGNED_PHASE_NAMES.has(normalizedPhaseName) + ) ) }) } diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index c1dce85ea..9d423c3df 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -45,7 +45,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `tags`: optional string array. - `skills`: required unless billing account is listed in `SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS`. - `reviewer`: optional for task challenges. -- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch; other closed manual reviewer assignments remain required. +- `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch. Design `Challenge` reviewers additionally leave the Checkpoint Review, Review, and Approval member assignments optional because the selected copilot is assigned to those private phases during save; other closed manual reviewer assignments remain required. - `AI review configuration`: templates and manual configs autosave separately once valid, switching a template-backed config to manual mode keeps its copied settings but clears the template link on save, and the AI tab becomes read-only after the challenge has submissions. ## Autosave Behavior @@ -85,7 +85,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha from `is_test_challenge`, and explicitly persists metadata value `true` or `false`. Test challenges do not generate payments, and authorized modifiers can delete them after they reach a completed or cancelled status. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. The simplified Design Challenge review section fetches defaults for the selected timeline template, resolves the API's phase-name-only defaults against the challenge phases, then repairs missing, duplicate, and stale hidden reviewer rows while exposing the Screening and Checkpoint Screening member selectors. Checkpoint Review, Review, and Approval are private and automatically assigned to the selected copilot during save; Design Challenge creation and saving highlight the Copilot field when no copilot is selected. On the full human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. The full Design reviewer editor keeps the public review opportunity checkbox disabled and unchecked. Screening and Checkpoint Screening member selectors remain available but are optional so a copilot can assign the Screener or Checkpoint Screener after launch. For Design `Challenge` challenges the advanced view also drops the required marker from the Checkpoint Review, Review, and Approval member selectors, because those private phases are assigned to the selected copilot during save. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. 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 154691d68..883228045 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 @@ -948,6 +948,13 @@ describe('ChallengeEditorForm', () => { }, typeId: 'design-challenge-type-id', } as Challenge + const designChallengeWithDeferredReviewers = { + ...designChallengeWithDeferredScreeners, + reviewers: designChallengeWithDeferredScreeners.reviewers?.map(reviewer => ({ + ...reviewer, + memberId: undefined, + })), + } as Challenge const twoRoundDesignChallengeWithCopilotReviewers = { ...validDraftChallenge, copilot: 'TCConnCopilot', @@ -2133,6 +2140,75 @@ describe('ChallengeEditorForm', () => { .not.toHaveBeenCalledWith('Please fix validation errors before launching') }) + it('launches a design draft before copilot assigned review members exist', async () => { + let launchAction: (() => Promise) | undefined + + mockedUseFetchChallengeTracks.mockReturnValue({ + isLoading: false, + tracks: [{ + id: 'design-track-id', + name: 'Design', + track: 'DESIGN', + }], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [{ + abbreviation: 'CH', + id: 'design-challenge-type-id', + name: 'Challenge', + }], + isLoading: false, + }) + mockedUseFetchProjectBillingAccount.mockReturnValue({ + billingAccount: { + active: true, + id: '80001063', + totalBudgetRemaining: 500, + }, + isLoading: false, + }) + mockedPatchChallenge.mockResolvedValue({ + ...designChallengeWithDeferredReviewers, + status: 'ACTIVE', + }) + + render( + + { + launchAction = action + }} + /> + , + ) + + await waitFor(() => { + expect(launchAction) + .toEqual(expect.any(Function)) + }) + + await act(async () => { + await launchAction?.() + }) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + reviewers: expect.arrayContaining([ + expect.objectContaining({ + phaseId: 'review-phase-id', + scorecardId: 'review-scorecard-id', + }), + ]), + status: 'ACTIVE', + })) + }) + expect(mockedShowErrorToast) + .not.toHaveBeenCalledWith('Please fix validation errors before launching') + }) + it('launches a read-only draft when manual reviewer assignments exist only in resources', async () => { let launchAction: (() => Promise) | undefined 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 b71d0acf1..8479a371f 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -61,6 +61,7 @@ import { } from '../../../../lib/models' import { challengeEditorSchema, + ChallengeEditorValidationContext, } from '../../../../lib/schemas/challenge-editor.schema' import { createChallenge, @@ -90,7 +91,7 @@ import { getMetadataValue, setMetadataValue, } from '../../../../lib/utils/metadata.utils' -import { isScreenerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../../../../lib/utils/reviewer.utils' import { getProjectBillingAccountChallengeErrorMessage, getProjectBillingAccountChallengeIssue, @@ -1401,6 +1402,7 @@ async function hydratePersistedManualReviewerAssignments( function getReviewerEntryValidationError( reviewer: Reviewer | undefined, phases: ChallengeEditorFormData['phases'], + isDesignChallenge: boolean, ): string | undefined { if (!reviewer) { return undefined @@ -1423,7 +1425,7 @@ function getReviewerEntryValidationError( if ( reviewer.shouldOpenOpportunity !== true - && !isScreenerAssignmentOptional(reviewer, phases) + && !isReviewerAssignmentOptional(reviewer, phases, isDesignChallenge) ) { const requiredAssignedMembers = getAssignedMemberReviewerValidationSlots(reviewer) .slice(0, reviewerCount) @@ -1465,6 +1467,7 @@ interface ReviewerValidationOptions { challengeTypeAbbreviation?: string challengeTypeName?: string requiredReviewersErrorMessage: string + isDesignChallenge: boolean isTaskChallenge: boolean } @@ -1493,7 +1496,11 @@ function getReviewerValidationError( } const invalidReviewer = reviewers - .map(reviewer => getReviewerEntryValidationError(reviewer, formData.phases)) + .map(reviewer => getReviewerEntryValidationError( + reviewer, + formData.phases, + options.isDesignChallenge, + )) .find(Boolean) if (invalidReviewer) { return invalidReviewer @@ -2060,7 +2067,11 @@ export const ChallengeEditorForm: FC = ( const [showRejectBudgetModal, setShowRejectBudgetModal] = useState(false) const [resolvedPaymentCreator, setResolvedPaymentCreator] = useState() + const validationContextRef = useRef({ + isDesignChallenge: false, + }) const formMethods = useForm({ + context: validationContextRef.current, defaultValues: applyProjectBillingToChallengeFormData( transformChallengeToFormData(props.challenge), projectBillingAccount, @@ -2386,6 +2397,19 @@ export const ChallengeEditorForm: FC = ( && !workAppContext.isManager const shouldUseSimplifiedDesignReview = isDesignTrackSelected && isChallengeTypeSelected + + useEffect(() => { + if (validationContextRef.current.isDesignChallenge === shouldUseSimplifiedDesignReview) { + return + } + + validationContextRef.current.isDesignChallenge = shouldUseSimplifiedDesignReview + trigger('reviewers') + .catch(() => undefined) + }, [ + shouldUseSimplifiedDesignReview, + trigger, + ]) /** * Validates the copilot required for hidden private Design reviewer assignments. * @@ -3967,6 +3991,7 @@ export const ChallengeEditorForm: FC = ( const reviewerValidationError = getReviewerValidationError(formData, { challengeTypeAbbreviation: resolvedChallengeTypeAbbreviation, challengeTypeName: resolvedChallengeTypeName, + isDesignChallenge: shouldUseSimplifiedDesignReview, isTaskChallenge, requiredReviewersErrorMessage: 'Reviewers are required for configured review phases before saving as draft.', diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index 9f7ebbe3e..1996bd4e9 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -1273,6 +1273,77 @@ describe('HumanReviewTab', () => { .toHaveProperty('dataset.required', 'true') }) + it('marks copilot assigned Design Challenge review assignments optional', () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [ + { + id: 'type-1', + name: 'Challenge', + }, + ], + }) + + render( + , + ) + + expect(screen.getByTestId('reviewers.0.memberId')) + .toHaveProperty('dataset.required', 'false') + expect(screen.getByTestId('reviewers.1.memberId')) + .toHaveProperty('dataset.required', 'false') + expect(screen.getByTestId('reviewers.2.memberId')) + .toHaveProperty('dataset.required', 'false') + }) + it('assigns one simplified screener selection to checkpoint and final screening roles', async () => { const mutateResources = jest.fn() .mockResolvedValue(undefined) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index a34561357..e0e1e1659 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -53,7 +53,7 @@ import { calculateEstimatedReviewerCost, getFirstPlacePrizeValue, } from '../../../../../lib/utils' -import { isScreenerAssignmentOptional } from '../../../../../lib/utils/reviewer.utils' +import { isReviewerAssignmentOptional } from '../../../../../lib/utils/reviewer.utils' import { isAiReviewer } from './reviewers-field.utils' import { @@ -94,6 +94,7 @@ const SCREENER_ROLE_NAME_BY_PHASE_KEY: Record = { checkpointscreening: 'Checkpoint Screener', screening: 'Screener', } +const CHALLENGE_TYPE_CHALLENGE_KEY = 'challenge' const DESIGN_COPILOT_REVIEW_PHASE_KEYS = new Set([ 'approval', 'checkpointreview', @@ -1493,6 +1494,8 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro }, [challengeTypes, normalizedTypeId], ) + const isDesignChallengeSelected = isDesignTrackSelected + && normalizeKey(selectedScorecardType) === CHALLENGE_TYPE_CHALLENGE_KEY const isLoading = isScorecardsLoading const reviewersValidationError = typeof reviewersFieldState.error?.message === 'string' ? reviewersFieldState.error.message @@ -2591,7 +2594,7 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro if (props.screenerOnly) { const isScreenerAssignmentRequired = screenerReviewerEntries.some(entry => ( - !isScreenerAssignmentOptional(entry.reviewer, phases) + !isReviewerAssignmentOptional(entry.reviewer, phases, isDesignChallengeSelected) )) const isScreenerFieldLoading = resourceRolesResult.isLoading || challengeResourcesResult.isLoading @@ -2682,7 +2685,11 @@ export const HumanReviewTab: FC = (props: HumanReviewTabPro || index const reviewerKey = `${reviewerPrefix}-${reviewerIdentity}` const shouldDisablePublicOpportunity = isDesignTrackSelected - const isMemberAssignmentOptional = isScreenerAssignmentOptional(reviewer, phases) + const isMemberAssignmentOptional = isReviewerAssignmentOptional( + reviewer, + phases, + isDesignChallengeSelected, + ) return (
Date: Thu, 20 Aug 2026 18:12:36 +0300 Subject: [PATCH 27/44] PM-5370 - show placement for 2nd and 3rd place --- .../ParticipationHistoryModal.spec.tsx | 115 ++++++++++++++++++ .../leaderboard/ParticipationHistoryModal.tsx | 20 ++- 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx new file mode 100644 index 000000000..d69dd755e --- /dev/null +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx @@ -0,0 +1,115 @@ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' + +import { ParticipationHistoryModal } from './ParticipationHistoryModal' +import type { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + REVIEW: { CHALLENGE_PAGE_URL: 'https://review.example.test' }, + }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => ({ + textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => { + const Icon = (): JSX.Element => + + return { + BaseModal: (props: PropsWithChildren<{ open?: boolean; title?: string }>): JSX.Element => ( + props.open ?
{props.children}
: <> + ), + IconOutline: { ExternalLinkIcon: Icon }, + Table: (props: { columns: ReadonlyArray<{ columnId?: string; renderer?: (data: T) => React.ReactNode }>; data: ReadonlyArray }): JSX.Element => ( +
+ + {props.data.map((row, rowIndex) => ( + + {props.columns.map(column => ( + + ))} + + ))} + +
+ {column.renderer?.(row)} +
+ ), + } +}, { virtual: true }) + +const baseParticipation = (): CampusParticipation => ({ + challengeEndDate: '2026-02-01T00:00:00.000Z', + challengeId: 'c1', + challengeName: 'Campus Sprint', + challengeStatus: 'COMPLETED', + challengeTrack: 'Development', + challengeType: 'Challenge', + isCampusChallenge: true, + isPublicChallenge: false, + passedReview: true, + placement: null, + registered: true, + registeredAt: '2026-01-05T00:00:00.000Z', + score: 95, + submitted: true, + submittedDate: '2026-01-20T00:00:00.000Z', + won: false, +}) + +const baseMember = (overrides: Partial = {}): CampusLeaderboardMember => ({ + challenges: [baseParticipation()], + firstName: 'Ada', + handle: 'testaws1', + hasActivity: true, + lastName: 'Lovelace', + memberSince: '2025-01-01T00:00:00.000Z', + passingSubmissions: 1, + photoURL: null, + rank: 1, + rating: 1500, + ratingColor: '#3f3', + registrations: 1, + signupDate: '2026-01-01T00:00:00.000Z', + submissions: 1, + userId: '1', + wins: 0, + ...overrides, +}) + +describe('ParticipationHistoryModal', () => { + it('shows 2nd place for a second-place finish', () => { + const member = baseMember({ + challenges: [ + { + ...baseParticipation(), + passedReview: true, + placement: 2, + }, + ], + }) + + render() + + expect(screen.getByText('2nd place')).toBeInTheDocument() + }) + + it('shows 3rd place for a third-place finish', () => { + const member = baseMember({ + challenges: [ + { + ...baseParticipation(), + passedReview: true, + placement: 3, + }, + ], + }) + + render() + + expect(screen.getByText('3rd place')).toBeInTheDocument() + }) +}) diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index bfd158ac8..0b23ce1ef 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -32,11 +32,28 @@ function formatDate(value: string | null): string { * @param entry participation entry. * @returns human readable result. */ +function formatPlacement(placement: number | null): string | undefined { + if (placement === 2) { + return '2nd place' + } + + if (placement === 3) { + return '3rd place' + } + + return placement && placement > 1 ? `Place ${placement}` : undefined +} + function formatResult(entry: CampusParticipation): string { if (entry.won) { return entry.placement ? `Won (place ${entry.placement})` : 'Won' } + const placement = formatPlacement(entry.placement) + if (placement) { + return placement + } + if (entry.challengeStatus !== 'COMPLETED') { if (entry.challengeStatus === 'ACTIVE') { return 'Challenge is in progress' @@ -62,8 +79,7 @@ export const ParticipationHistoryModal: FC = pro columnId: 'challenge', label: 'Challenge', renderer: (entry: CampusParticipation) => { - const challengePath - = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` + const challengePath = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` return (
From 8ba5f0e1f96dc4b78e6975eff5aea7f9a4abcafa Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 20 Aug 2026 18:23:59 +0300 Subject: [PATCH 28/44] PM-5370 - lint --- .../ParticipationHistoryModal.spec.tsx | 115 ------------------ .../leaderboard/ParticipationHistoryModal.tsx | 3 +- 2 files changed, 2 insertions(+), 116 deletions(-) delete mode 100644 src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx deleted file mode 100644 index d69dd755e..000000000 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.spec.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import '@testing-library/jest-dom' -import type { PropsWithChildren } from 'react' -import { render, screen } from '@testing-library/react' - -import { ParticipationHistoryModal } from './ParticipationHistoryModal' -import type { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' - -jest.mock('~/config', () => ({ - EnvironmentConfig: { - REVIEW: { CHALLENGE_PAGE_URL: 'https://review.example.test' }, - }, -}), { virtual: true }) - -jest.mock('~/libs/shared', () => ({ - textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), -}), { virtual: true }) - -jest.mock('~/libs/ui', () => { - const Icon = (): JSX.Element => - - return { - BaseModal: (props: PropsWithChildren<{ open?: boolean; title?: string }>): JSX.Element => ( - props.open ?
{props.children}
: <> - ), - IconOutline: { ExternalLinkIcon: Icon }, - Table: (props: { columns: ReadonlyArray<{ columnId?: string; renderer?: (data: T) => React.ReactNode }>; data: ReadonlyArray }): JSX.Element => ( - - - {props.data.map((row, rowIndex) => ( - - {props.columns.map(column => ( - - ))} - - ))} - -
- {column.renderer?.(row)} -
- ), - } -}, { virtual: true }) - -const baseParticipation = (): CampusParticipation => ({ - challengeEndDate: '2026-02-01T00:00:00.000Z', - challengeId: 'c1', - challengeName: 'Campus Sprint', - challengeStatus: 'COMPLETED', - challengeTrack: 'Development', - challengeType: 'Challenge', - isCampusChallenge: true, - isPublicChallenge: false, - passedReview: true, - placement: null, - registered: true, - registeredAt: '2026-01-05T00:00:00.000Z', - score: 95, - submitted: true, - submittedDate: '2026-01-20T00:00:00.000Z', - won: false, -}) - -const baseMember = (overrides: Partial = {}): CampusLeaderboardMember => ({ - challenges: [baseParticipation()], - firstName: 'Ada', - handle: 'testaws1', - hasActivity: true, - lastName: 'Lovelace', - memberSince: '2025-01-01T00:00:00.000Z', - passingSubmissions: 1, - photoURL: null, - rank: 1, - rating: 1500, - ratingColor: '#3f3', - registrations: 1, - signupDate: '2026-01-01T00:00:00.000Z', - submissions: 1, - userId: '1', - wins: 0, - ...overrides, -}) - -describe('ParticipationHistoryModal', () => { - it('shows 2nd place for a second-place finish', () => { - const member = baseMember({ - challenges: [ - { - ...baseParticipation(), - passedReview: true, - placement: 2, - }, - ], - }) - - render() - - expect(screen.getByText('2nd place')).toBeInTheDocument() - }) - - it('shows 3rd place for a third-place finish', () => { - const member = baseMember({ - challenges: [ - { - ...baseParticipation(), - passedReview: true, - placement: 3, - }, - ], - }) - - render() - - expect(screen.getByText('3rd place')).toBeInTheDocument() - }) -}) diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index 0b23ce1ef..c5b8d2d40 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -79,7 +79,8 @@ export const ParticipationHistoryModal: FC = pro columnId: 'challenge', label: 'Challenge', renderer: (entry: CampusParticipation) => { - const challengePath = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` + const challengePath + = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` return (
From 6f43cea3d0fd4bbf310b527eb665faa8455a96af Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 21 Aug 2026 07:32:25 +0530 Subject: [PATCH 29/44] PM-4699 Add bubble skill statistics UI with mock data --- .../src/config/routes.config.ts | 1 + .../src/customer-portal.routes.tsx | 2 + .../components/NavTabs/config/tabs-config.ts | 4 + .../SkillBubblesChart.module.scss | 300 ++++++++++++ .../SkillStatisticsPage/SkillBubblesChart.tsx | 463 ++++++++++++++++++ .../SkillMembersPanel.module.scss | 268 ++++++++++ .../SkillStatisticsPage/SkillMembersPanel.tsx | 202 ++++++++ .../SkillStatisticsPage.module.scss | 50 ++ .../SkillStatisticsPage.spec.tsx | 170 +++++++ .../SkillStatisticsPage.tsx | 61 +++ .../SkillStatisticsPage/index.ts | 1 + .../SkillStatisticsPage/mock/index.ts | 2 + .../mock/skill-categories.mock.ts | 377 ++++++++++++++ .../mock/skill-members.mock.ts | 192 ++++++++ .../SkillStatisticsPage/packCircles.ts | 137 ++++++ .../skill-statistics.routes.tsx | 26 + 16 files changed, 2256 insertions(+) create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index c45b3e81e..0e5a652a0 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -12,3 +12,4 @@ export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' export const statisticsRouteId = 'statistics' +export const skillStatisticsRouteId = 'skill-statistics' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 3e9cb2249..d0033c8be 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -17,6 +17,7 @@ import { import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes' +import { customerPortalSkillStatisticsRoutes } from './pages/skill-statistics/skill-statistics.routes' import { customerPortalStatisticsRoutes } from './pages/statistics/statistics.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -34,6 +35,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalStatisticsRoutes, + ...customerPortalSkillStatisticsRoutes, ...customerPortalTalentSearchRoutes, ...customerPortalProjectShowcaseRoutes, ...customerPortalFlexiTalentRoutes, diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index c01e6ff0e..75c25f041 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -4,6 +4,7 @@ import { TabsNavItem } from '~/libs/ui' import { flexiTalentRouteId, showcaseSearchRouteId, + skillStatisticsRouteId, statisticsRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -14,6 +15,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: statisticsRouteId, title: 'General Statistics', + }, { + id: skillStatisticsRouteId, + title: 'Skill Statistics', }, { id: talentSearchRouteId, title: 'Talent Search', diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss new file mode 100644 index 000000000..da0416245 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -0,0 +1,300 @@ +@import '@libs/ui/styles/includes'; + +.chart { + height: 560px; + min-height: 560px; + overflow: visible; + position: relative; + width: 100%; + + @include ltemd { + height: 420px; + min-height: 420px; + } +} + +.bubble { + align-items: center; + border: 0; + border-radius: 50%; + color: #fff; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding: 8px; + position: absolute; + text-align: center; + transform: translate(-50%, -50%); + transition: box-shadow 160ms ease, transform 160ms ease; + z-index: 1; + + svg { + color: #fff; + flex: 0 0 auto; + margin-bottom: 4px; + } + + &:hover, + &:focus-visible, + &.hovered { + box-shadow: 0 10px 28px rgba(10, 10, 10, 0.35); + z-index: 3; + } + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 3px; + } +} + +.selected { + box-shadow: 0 12px 32px rgba(10, 10, 10, 0.4); + z-index: 4; +} + +.label { + display: -webkit-box; + font-family: 'Nunito Sans', sans-serif; + font-weight: 700; + line-height: 1.15; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.popover { + background: #0f172a; + border-radius: 8px; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + font-family: 'Figtree', sans-serif; + gap: 16px; + padding: 24px; + pointer-events: none; + position: fixed; + transform: translate(-50%, calc(-100% - 8px)); + width: 320px; + z-index: 2147483647; + + &::after { + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 8px solid #0f172a; + content: ''; + height: 0; + left: 50%; + position: absolute; + top: 100%; + transform: translateX(-50%); + width: 0; + } + + &.below { + transform: translate(-50%, 8px); + + &::after { + border-bottom: 8px solid #0f172a; + border-top: 0; + bottom: 100%; + top: auto; + } + } + + &.left { + transform: translate(calc(-100% - 8px), 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 8px solid #0f172a; + border-right: 0; + border-top: 9px solid transparent; + left: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } + + &.right { + transform: translate(8px, 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 0; + border-right: 8px solid #0f172a; + border-top: 9px solid transparent; + left: auto; + right: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } +} + +.popoverTitle { + font-size: 18px; + font-weight: 700; + line-height: normal; +} + +.metrics { + display: grid; + gap: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 3px; +} + +.metricValue { + align-items: center; + display: flex; + gap: 5px; + + img { + flex: 0 0 24px; + height: 24px; + width: 24px; + } + + strong { + font-size: 24px; + font-weight: 600; + line-height: normal; + white-space: nowrap; + } +} + +.breakdown { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 10px; +} + +.bar { + border-radius: 4px; + display: flex; + height: 24px; + overflow: hidden; + width: 100%; +} + +.segment { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.legend { + align-items: center; + display: flex; + justify-content: space-between; +} + +.legendItem { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + > span:last-child { + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.dot { + border-radius: 50%; + flex: 0 0 9px; + height: 9px; + width: 9px; +} + +.topMember { + display: flex; + flex-direction: column; + gap: 8px; +} + +.topMemberContent { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; +} + +.avatar { + background-color: #d9d9d9; + background-position: center; + background-size: cover; + border-radius: 50%; + display: block; + flex: 0 0 40px; + height: 40px; + overflow: hidden; + position: relative; + width: 40px; +} + +.avatarHead { + background: #aab6c2; + border-radius: 50%; + height: 14px; + left: 13px; + position: absolute; + top: 7px; + width: 14px; +} + +.avatarBody { + background: #aab6c2; + border-radius: 16px 16px 8px 8px; + bottom: -2px; + height: 18px; + left: 7px; + position: absolute; + width: 26px; +} + +.handle { + font-size: 16px; + font-weight: 600; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #3877EA; +} + +.memberStats { + align-items: center; + display: flex; + font-size: 12px; + gap: 5px; + line-height: normal; + white-space: nowrap; +} + +.flag { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.divider { + margin: 0 5px; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx new file mode 100644 index 000000000..a4e6b022f --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -0,0 +1,463 @@ +/* eslint-disable react/jsx-no-bind, no-use-before-define */ +import { + CSSProperties, + FC, + KeyboardEvent, + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import classNames from 'classnames' + +import { getRatingColor } from '~/libs/core' + +import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg' +import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg' + +import { + getTopMemberForCategory, + SkillCategoryMock, + SkillMemberMock, +} from './mock' +import { packCircles, PackedCircle } from './packCircles' +import styles from './SkillBubblesChart.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') +const SKILL_COLORS = ['#c1294f', '#00797a', '#fdc220', '#a6a6a6'] +const POPOVER_GAP = 12 +const POPOVER_ESTIMATED_HEIGHT = 340 +const POPOVER_WIDTH = 320 +const VIEW_PAD = 8 +const MIN_BUBBLE_FONT_SIZE = 12 +const MAX_BUBBLE_FONT_SIZE = 20 + +type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' + +type PopoverLayout = { + arrowOffset?: number + left: number + placement: PopoverPlacement + top: number +} + +type ChartRect = { + height: number + left: number + top: number + width: number +} + +function getPopoverLayout( + circle: PackedCircle, + chartRect: ChartRect, + popoverWidth: number, + popoverHeight: number, +): PopoverLayout { + const viewWidth = typeof window === 'undefined' ? chartRect.width : window.innerWidth + const viewHeight = typeof window === 'undefined' ? chartRect.height : window.innerHeight + const centerX = chartRect.left + circle.x + const centerY = chartRect.top + circle.y + const bubbleTop = centerY - circle.r + const bubbleBottom = centerY + circle.r + const bubbleLeft = centerX - circle.r + const bubbleRight = centerX + circle.r + const spaceLeft = bubbleLeft - VIEW_PAD + const spaceRight = viewWidth - VIEW_PAD - bubbleRight + const fitsTop = bubbleTop - POPOVER_GAP - popoverHeight >= VIEW_PAD + const fitsBottom = bubbleBottom + POPOVER_GAP + popoverHeight <= viewHeight - VIEW_PAD + const fitsLeft = spaceLeft >= popoverWidth + POPOVER_GAP + const fitsRight = spaceRight >= popoverWidth + POPOVER_GAP + + let placement: PopoverPlacement = 'top' + if (fitsTop) { + placement = 'top' + } else if (fitsLeft && fitsRight) { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } else if (fitsRight) { + placement = 'right' + } else if (fitsLeft) { + placement = 'left' + } else if (fitsBottom) { + placement = 'bottom' + } else { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } + + if (placement === 'top') { + return { + left: centerX, + placement, + top: bubbleTop, + } + } + + if (placement === 'bottom') { + return { + left: centerX, + placement, + top: bubbleBottom, + } + } + + const desiredTop = centerY - (popoverHeight / 2) + const clampedTop = Math.min( + Math.max(desiredTop, VIEW_PAD), + viewHeight - VIEW_PAD - popoverHeight, + ) + + return { + arrowOffset: centerY - clampedTop, + left: placement === 'right' ? bubbleRight : bubbleLeft, + placement, + top: clampedTop, + } +} + +interface SkillBubblesChartProps { + categories: SkillCategoryMock[] + onSelect: (categoryId: string) => void + selectedCategoryId?: string +} + +function radiusForSize(size: number): number { + return 28 + (size * 9) +} + +function fontSizeForRadius( + radius: number, + minRadius: number, + maxRadius: number, +): number { + if (maxRadius <= minRadius) { + return (MIN_BUBBLE_FONT_SIZE + MAX_BUBBLE_FONT_SIZE) / 2 + } + + const t = (radius - minRadius) / (maxRadius - minRadius) + + return MIN_BUBBLE_FONT_SIZE + (t * (MAX_BUBBLE_FONT_SIZE - MIN_BUBBLE_FONT_SIZE)) +} + +const SkillBubblesChart: FC = props => { + const chartRef = useRef(null) + const [hoveredCategoryId, setHoveredCategoryId] = useState() + const [viewport, setViewport] = useState({ height: 560, width: 960 }) + + useEffect(() => { + const node = chartRef.current + if (!node) { + return undefined + } + + const measure = (): void => { + setViewport(current => { + const height = Math.max(node.clientHeight, 1) + const width = Math.max(node.clientWidth, 1) + + return current.width === width && current.height === height + ? current + : { height, width } + }) + } + + measure() + window.addEventListener('resize', measure) + + const observer = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(measure) + observer?.observe(node) + + return () => { + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, []) + + const packed = useMemo( + () => packCircles( + props.categories.map(category => ({ + id: category.id, + r: radiusForSize(category.size), + })), + viewport.width, + viewport.height, + ), + [props.categories, viewport.height, viewport.width], + ) + + const packedById = useMemo( + () => new Map(packed.map(circle => [circle.id, circle])), + [packed], + ) + const packedRadii = useMemo( + () => packed.map(circle => circle.r), + [packed], + ) + const minPackedRadius = packedRadii.length ? Math.min(...packedRadii) : 0 + const maxPackedRadius = packedRadii.length ? Math.max(...packedRadii) : 0 + + const hoveredCategory = props.categories.find( + category => category.id === hoveredCategoryId, + ) + const hoveredCircle = hoveredCategory + ? packedById.get(hoveredCategory.id) + : undefined + const topMember = hoveredCategory + ? getTopMemberForCategory(hoveredCategory.id) + : undefined + + const handleKeyDown = useCallback(( + event: KeyboardEvent, + categoryId: string, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + props.onSelect(categoryId) + } + }, [props]) + + return ( +
+ {props.categories.map(category => { + const circle = packedById.get(category.id) + if (!circle) { + return undefined + } + + const Icon = category.icon + const isSelected = category.id === props.selectedCategoryId + const fontSize = fontSizeForRadius( + circle.r, + minPackedRadius, + maxPackedRadius, + ) + const iconSize = Math.max(14, Math.min(28, circle.r / 4.6)) + + return ( + + ) + })} + {hoveredCategory && hoveredCircle && ( + + )} +
+ ) +} + +interface SkillCategoryPopoverProps { + category: SkillCategoryMock + chartHeight: number + chartRef: RefObject + chartWidth: number + circle: PackedCircle + topMember?: SkillMemberMock +} + +const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => { + const popoverRef = useRef(null) + const [layout, setLayout] = useState({ + left: props.circle.x, + placement: 'top', + top: props.circle.y - props.circle.r, + }) + + useLayoutEffect(() => { + const update = (): void => { + const chartNode = props.chartRef.current + const chartRect = chartNode?.getBoundingClientRect() + const measured: ChartRect = chartRect && chartRect.width > 0 + ? chartRect + : { + height: props.chartHeight, + left: 0, + top: 0, + width: props.chartWidth, + } + const height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT + const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH + setLayout(getPopoverLayout(props.circle, measured, width, height)) + } + + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [props.category.id, props.chartHeight, props.chartRef, props.chartWidth, props.circle]) + + const topSkillsPercentage = props.category.skillsBreakdown.reduce( + (total, skill) => total + skill.percentage, + 0, + ) + const skills = [ + ...props.category.skillsBreakdown, + { + name: 'Others', + percentage: Math.max(100 - topSkillsPercentage, 0), + }, + ].filter(skill => skill.percentage > 0) + const countryCode = /^[A-Z]{2}$/.test(props.topMember?.countryCode || '') + ? props.topMember?.countryCode.toLowerCase() + : '' + const popoverStyle: CSSProperties = { + left: layout.left, + top: layout.top, + } + + if (layout.arrowOffset !== undefined) { + Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + } + + const popover = ( +
+ {props.category.name} +
+
+ Total Members + + + {NUMBER_FORMATTER.format(props.category.totalMembers)} + +
+
+ Total Skills + + + {NUMBER_FORMATTER.format(props.category.totalSkills)} + +
+
+
+ Sub-Skill Breakdown +
+ {skills.map((skill, index) => ( + + {`${skill.percentage}%`} + + ))} +
+
+ {skills.map((skill, index) => ( + + + {skill.name} + + ))} +
+
+ {props.topMember && ( +
+ Top Member +
+ + + + + + + {props.topMember.handle} + + + {countryCode && ( + + +
+
+ )} +
+ ) + + return typeof document === 'undefined' + ? popover + : createPortal(popover, document.body) +} + +export default SkillBubblesChart diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss new file mode 100644 index 000000000..35b950ec8 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss @@ -0,0 +1,268 @@ +@import '@libs/ui/styles/includes'; + +.section { + margin-top: 40px; +} + +.header { + h2 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; + margin: 0; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.body { + display: grid; + gap: 32px; + grid-template-columns: minmax(220px, 240px) minmax(0, 1fr); + margin-top: 24px; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.search { + position: relative; + + input { + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 40px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } + + svg { + color: #767676; + height: 20px; + pointer-events: none; + position: absolute; + right: 12px; + top: 10px; + width: 20px; + } +} + +.filter { + display: flex; + flex-direction: column; + gap: 6px; + + span { + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 16px; + } + + select { + appearance: none; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%230a0a0a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E") no-repeat right 12px center; + background-size: 18px; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 36px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } +} + +$row-height: 64px; +$visible-member-rows: 10; + +.tableWrap { + max-height: $row-height * ($visible-member-rows + 1); + overflow: auto; + + table { + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + width: 100%; + } + + thead { + position: sticky; + top: 0; + z-index: 3; + } + + th, + td { + border-bottom: 1px solid #e2e2e2; + color: #1a1a1a; + font-size: 14px; + height: $row-height; + line-height: 20px; + padding: 12px 16px; + text-align: left; + vertical-align: middle; + } + + th { + background: #fff; + border-bottom-color: #a8a8a8; + font-weight: 700; + position: sticky; + top: 0; + z-index: 3; + } + + tbody td { + background: #fff; + position: relative; + z-index: 0; + } + + .avatar { + z-index: 0; + } + + th:first-child, + td:first-child { + text-align: center; + width: 64px; + } + + th:nth-child(3), + td:nth-child(3) { + width: 88px; + } + + th:nth-child(4), + td:nth-child(4) { + width: 140px; + } + + th:last-child, + td:last-child { + text-align: right; + width: 110px; + } +} + +.memberCell { + align-items: center; + display: flex; + gap: 12px; + min-width: 0; +} + +.avatar { + flex: 0 0 40px; + height: 40px; + width: 40px; + + :global(span) { + font-size: 14px !important; + } +} + +.memberText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.handle { + font-weight: 700; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.memberName { + color: #767676; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.countryCell { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.flag { + display: inline-flex; + flex: 0 0 auto; + height: 14px; + width: 22px; +} + +.rank1, +.rank2, +.rank3 { + align-items: center; + display: inline-flex; + height: 20px; + justify-content: center; + width: 20px; +} + +.rank4 { + display: inline-block; + font-size: 14px; + line-height: 20px; + min-width: 19px; + text-align: center; +} + +.empty { + color: #545f71; + font-size: 14px; + padding: 24px 16px; + text-align: center; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx new file mode 100644 index 000000000..a927bab44 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -0,0 +1,202 @@ +/* eslint-disable react/jsx-no-bind */ +import { ChangeEvent, FC, useMemo } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { getRatingColor } from '~/libs/core' +import { ProfilePicture } from '~/libs/shared' +import { IconOutline } from '~/libs/ui' + +import { + IconFirstPlace, + IconSecondPlace, + IconThirdPlace, +} from '../../statistics/StatisticsPage/assets' + +import { SkillCategoryMock, SkillMemberMock } from './mock' +import styles from './SkillMembersPanel.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +interface SkillMembersPanelProps { + category: SkillCategoryMock + countryFilter: string + members: SkillMemberMock[] + onCountryChange: (countryCode: string) => void + onSearchChange: (value: string) => void + search: string +} + +const SkillMembersPanel: FC = props => { + const countryOptions = useMemo(() => { + const unique = new Map() + props.members.forEach(member => { + if (member.countryCode && member.countryName) { + unique.set(member.countryCode, member.countryName) + } + }) + + return Array.from(unique.entries()) + .map(([code, name]) => ({ code, name })) + .sort((left, right) => left.name.localeCompare(right.name)) + }, [props.members]) + + const visibleMembers = useMemo(() => { + const query = props.search.trim() + .toLowerCase() + + return props.members + .filter(member => { + const matchesSearch = !query + || member.handle.toLowerCase() + .includes(query) + || member.name.toLowerCase() + .includes(query) + const matchesCountry = !props.countryFilter + || member.countryCode === props.countryFilter + + return matchesSearch && matchesCountry + }) + .sort((left, right) => right.wins - left.wins) + }, [props.countryFilter, props.members, props.search]) + + return ( +
+
+

{`Members for ${props.category.name}`}

+

+ Browse top 100 talent by skills and numbers of wins. +

+
+
+ +
+ + + + + + + + + + + + {visibleMembers.length === 0 && ( + + + + )} + {visibleMembers.map((member, index) => { + const rankIcon = index === 0 + ? + + + + + + + ) + })} + +
RankMemberRatingCountry# of Wins
+ No members match the current filters. +
+ + {rankIcon} + + +
+ +
+ + {member.handle} + + {member.name} +
+
+
+ + {member.rating} + + +
+ {countryCode && ( +
+
{NUMBER_FORMATTER.format(member.wins)}
+
+
+
+ ) +} + +export default SkillMembersPanel diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss new file mode 100644 index 000000000..f55ffd6be --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -0,0 +1,50 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0a0a0a; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + overflow-x: hidden; + padding: 8px 0 0; +} + +.header { + h1 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 38px; + margin-top: 20px; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.chartPanel { + box-sizing: border-box; + display: flex; + flex-direction: column; + left: 50%; + margin-top: 16px; + max-width: 100vw; + padding: 0 32px; + position: relative; + transform: translateX(-50%); + width: 100vw; +} + +.hint { + color: #0a0a0a; + font-size: 16px; + line-height: 22px; + margin: 20px 0 8px; + text-align: center; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx new file mode 100644 index 000000000..afedc26e3 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -0,0 +1,170 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen, within } from '@testing-library/react' + +import { getTabIdFromPathName, getTabsConfig } from '../../../lib/components/NavTabs/config/tabs-config' +import { SKILL_CATEGORIES } from './mock' +import SkillStatisticsPage from './SkillStatisticsPage' + +jest.mock('~/config', () => ({ + AppSubdomain: { + customer: 'customer', + }, + EnvironmentConfig: { + SUBDOMAIN: 'customer', + USER_PROFILE_URL: 'https://profiles.example.com', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + getRatingColor: (rating?: number) => (rating && rating >= 2200 ? '#EF3A3A' : '#F2C900'), +}), { + virtual: true, +}) + +jest.mock('~/libs/shared', () => ({ + ProfilePicture: () => , +}), { + virtual: true, +}) + +const DummyIcon = (): JSX.Element => + +jest.mock('~/libs/ui', () => ({ + IconOutline: new Proxy({}, { + get: () => DummyIcon, + }), + TabsNavItem: {}, +}), { + virtual: true, +}) + +jest.mock('~/apps/customer-portal/src/config/routes.config', () => ({ + flexiTalentRouteId: 'flexi-talent', + showcaseSearchRouteId: 'showcase', + skillStatisticsRouteId: 'skill-statistics', + statisticsRouteId: 'statistics', + talentSearchRouteId: 'talent-search', +}), { + virtual: true, +}) + +jest.mock('flag-icons/css/flag-icons.min.css', () => ({}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets', () => ({ + IconFirstPlace: () => 1st, + IconSecondPlace: () => 2nd, + IconThirdPlace: () => 3rd, +}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/member-group.svg', () => 'member-group.svg', { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/skill-cognition.svg', () => 'skill-cognition.svg', { + virtual: true, +}) + +describe('Customer Portal Skill Statistics tabs', () => { + it('adds Skill Statistics beside General Statistics', () => { + const tabs = getTabsConfig(['administrator'], false, false) + + expect(tabs.map(tab => tab.title)) + .toEqual([ + 'General Statistics', + 'Skill Statistics', + 'Talent Search', + 'Showcase', + 'Flexi-Talent', + ]) + expect(getTabIdFromPathName('/skill-statistics', ['administrator'], false, false)) + .toBe('skill-statistics') + expect(getTabIdFromPathName('/statistics', ['administrator'], false, false)) + .toBe('statistics') + }) +}) + +describe('SkillStatisticsPage', () => { + it('renders all 23 skill categories', () => { + render() + + expect(SKILL_CATEGORIES) + .toHaveLength(23) + expect(screen.queryByRole('button', { name: 'Bar' })) + .not.toBeInTheDocument() + SKILL_CATEGORIES.forEach(category => { + expect(screen.getByRole('button', { name: category.name })) + .toBeInTheDocument() + }) + }) + + it('shows the category popover on hover and the members UI on click', () => { + render() + + const bubble = screen.getByRole('button', { name: 'Programming & Development' }) + fireEvent.mouseEnter(bubble) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + expect(screen.getByText('banerjeesourish')) + .toBeInTheDocument() + expect(screen.getByText('Total Members') + .closest('[data-placement]')) + .toHaveAttribute('data-placement', expect.stringMatching(/^(top|bottom|left|right)$/)) + expect(screen.queryByRole('heading', { name: 'Members for Programming & Development' })) + .not.toBeInTheDocument() + + fireEvent.click(bubble) + + expect(screen.getByRole('heading', { name: 'Members for Programming & Development' })) + .toBeInTheDocument() + expect(screen.getByText('billzedison')) + .toBeInTheDocument() + }) + + it('filters members from in-memory state when searching', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + + fireEvent.change(screen.getByLabelText('Search members'), { + target: { value: 'Ghostar' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('Ghostar')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) + + it('reranks members when filtering by country', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + + fireEvent.change(screen.getByLabelText('Filter By'), { + target: { value: 'GB' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('diazx')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx new file mode 100644 index 000000000..a85e28e49 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -0,0 +1,61 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import 'flag-icons/css/flag-icons.min.css' + +import { getMembersForCategory, SKILL_CATEGORIES } from './mock' +import SkillBubblesChart from './SkillBubblesChart' +import SkillMembersPanel from './SkillMembersPanel' +import styles from './SkillStatisticsPage.module.scss' + +const SkillStatisticsPage: FC = () => { + const [selectedCategoryId, setSelectedCategoryId] = useState() + const [search, setSearch] = useState('') + const [countryFilter, setCountryFilter] = useState('') + + const selectedCategory = useMemo( + () => SKILL_CATEGORIES.find(category => category.id === selectedCategoryId), + [selectedCategoryId], + ) + const selectedMembers = useMemo( + () => (selectedCategoryId ? getMembersForCategory(selectedCategoryId) : []), + [selectedCategoryId], + ) + + const selectCategory = useCallback((categoryId: string) => { + setSelectedCategoryId(categoryId) + setSearch('') + setCountryFilter('') + }, []) + + return ( +
+
+

Skill Statistics

+

+ Browse and connect with verified experts across 23 skill categories. +

+
+ +
+

Select a skill category to see additional details

+ +
+ + {selectedCategory && ( + + )} +
+ ) +} + +export default SkillStatisticsPage diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts new file mode 100644 index 000000000..4e9fd8917 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts @@ -0,0 +1 @@ +export { default as SkillStatisticsPage } from './SkillStatisticsPage' diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts new file mode 100644 index 000000000..e21177854 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts @@ -0,0 +1,2 @@ +export * from './skill-categories.mock' +export * from './skill-members.mock' diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts new file mode 100644 index 000000000..47e2bb333 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts @@ -0,0 +1,377 @@ +import { FC, SVGProps } from 'react' + +import { IconOutline } from '~/libs/ui' + +export type SkillCategoryIcon = FC> + +export type SkillCategoryMock = { + color: string + icon: SkillCategoryIcon + id: string + name: string + officialName: string + size: number + skillsBreakdown: Array<{ name: string; percentage: number }> + totalMembers: number + totalSkills: number +} + +export type SkillMemberMock = { + countryCode: string + countryName: string + handle: string + name: string + photoURL?: string + rating: number + wins: number +} + +export const PROGRAMMING_CATEGORY_ID = '481b5ebc-2fe6-45ed-a90c-736936d458d7' + +export const SKILL_CATEGORIES: SkillCategoryMock[] = [ + { + color: '#1B4F72', + icon: IconOutline.TerminalIcon, + id: PROGRAMMING_CATEGORY_ID, + name: 'Programming & Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [ + { name: 'JavaScript', percentage: 40 }, + { name: 'Python', percentage: 30 }, + { name: 'Swift', percentage: 15 }, + ], + totalMembers: 1012928, + totalSkills: 1059, + }, + { + color: '#3D8B8F', + icon: IconOutline.RssIcon, + id: 'cfb17211-2abd-41e1-b169-e90cf038c6a7', + name: 'Networking and Telecommunications', + officialName: 'Networking and Telecommunications', + size: 8.4, + skillsBreakdown: [ + { name: 'TCP/IP', percentage: 35 }, + { name: 'Routing', percentage: 28 }, + { name: '5G', percentage: 22 }, + ], + totalMembers: 412800, + totalSkills: 286, + }, + { + color: '#5B9BD5', + icon: IconOutline.GlobeAltIcon, + id: '5aadafad-da63-488e-8499-32b596215789', + name: 'Web Development', + officialName: 'Web Development', + size: 7.8, + skillsBreakdown: [ + { name: 'React', percentage: 38 }, + { name: 'Node.js', percentage: 27 }, + { name: 'CSS', percentage: 20 }, + ], + totalMembers: 388420, + totalSkills: 412, + }, + { + color: '#5EB3C4', + icon: IconOutline.ShieldCheckIcon, + id: '221f4e3f-1ac8-438b-9dc1-977e30656789', + name: 'Cybersecurity', + officialName: 'Cybersecurity', + size: 7.2, + skillsBreakdown: [ + { name: 'Pen Testing', percentage: 32 }, + { name: 'SIEM', percentage: 26 }, + { name: 'IAM', percentage: 24 }, + ], + totalMembers: 276540, + totalSkills: 198, + }, + { + color: '#1A3D3D', + icon: IconOutline.CloudIcon, + id: 'cc346829-c9e4-44a9-996b-34054cf20fec', + name: 'Cloud Computing', + officialName: 'Cloud Computing', + size: 6.4, + skillsBreakdown: [ + { name: 'AWS', percentage: 42 }, + { name: 'Azure', percentage: 28 }, + { name: 'GCP', percentage: 18 }, + ], + totalMembers: 241100, + totalSkills: 176, + }, + { + color: '#2D4A3E', + icon: IconOutline.RefreshIcon, + id: 'aa495f25-2f2d-4334-9b6f-2ffe11d835d2', + name: 'Software Development Lifecycle', + officialName: 'Software Development Lifecycle (SDLC)', + size: 6.2, + skillsBreakdown: [ + { name: 'Agile', percentage: 40 }, + { name: 'CI/CD', percentage: 30 }, + { name: 'Scrum', percentage: 18 }, + ], + totalMembers: 198760, + totalSkills: 94, + }, + { + color: '#4A5D4A', + icon: IconOutline.DuplicateIcon, + id: 'e2429c1b-7609-49e0-93cc-341a89e12269', + name: 'DevOps & Automation', + officialName: 'DevOps and Automation', + size: 5.8, + skillsBreakdown: [ + { name: 'Kubernetes', percentage: 34 }, + { name: 'Terraform', percentage: 28 }, + { name: 'Jenkins', percentage: 22 }, + ], + totalMembers: 176430, + totalSkills: 142, + }, + { + color: '#3D7EA6', + icon: IconOutline.ChipIcon, + id: '185f4bf3-50de-46af-aaa6-9011872395cf', + name: 'Operating Systems', + officialName: 'Operating Systems', + size: 5.5, + skillsBreakdown: [ + { name: 'Linux', percentage: 48 }, + { name: 'Windows', percentage: 22 }, + { name: 'macOS', percentage: 16 }, + ], + totalMembers: 154220, + totalSkills: 88, + }, + { + color: '#2C5F8A', + icon: IconOutline.ChartBarIcon, + id: '4064574c-befa-4fb3-a8e2-34038d7f845b', + name: 'Data Analysis & Big Data', + officialName: 'Data Analysis and Big Data', + size: 5.4, + skillsBreakdown: [ + { name: 'SQL', percentage: 36 }, + { name: 'Spark', percentage: 26 }, + { name: 'Tableau', percentage: 20 }, + ], + totalMembers: 148900, + totalSkills: 164, + }, + { + color: '#7EB8C4', + icon: IconOutline.SparklesIcon, + id: 'a1289278-a734-4523-918f-ea0f05667e24', + name: 'Machine Learning & AI', + officialName: 'Machine Learning and AI', + size: 5.1, + skillsBreakdown: [ + { name: 'PyTorch', percentage: 33 }, + { name: 'TensorFlow', percentage: 29 }, + { name: 'NLP', percentage: 21 }, + ], + totalMembers: 132450, + totalSkills: 210, + }, + { + color: '#2C4A6E', + icon: IconOutline.ServerIcon, + id: 'e50b1794-e08d-4dc3-a4b1-6b5213c7da8e', + name: 'Databases & Data Warehousing', + officialName: 'Databases and Data Warehousing', + size: 5, + skillsBreakdown: [ + { name: 'PostgreSQL', percentage: 34 }, + { name: 'Snowflake', percentage: 28 }, + { name: 'Redshift', percentage: 20 }, + ], + totalMembers: 121800, + totalSkills: 118, + }, + { + color: '#3D5C5C', + icon: IconOutline.PencilAltIcon, + id: '3eb5163c-cd3f-4c7d-b059-95c901bc2066', + name: 'UX Design & Multimedia', + officialName: 'User Experience Design and Multimedia', + size: 4.6, + skillsBreakdown: [ + { name: 'Figma', percentage: 42 }, + { name: 'UX Research', percentage: 24 }, + { name: 'Motion', percentage: 16 }, + ], + totalMembers: 98600, + totalSkills: 76, + }, + { + color: '#6B7C4A', + icon: IconOutline.CalculatorIcon, + id: 'b3c8970d-79e8-4f97-a84e-841aebaa890f', + name: 'Mathematics & Statistics', + officialName: 'Mathematics and Statistics', + size: 4.5, + skillsBreakdown: [ + { name: 'Statistics', percentage: 38 }, + { name: 'Linear Algebra', percentage: 27 }, + { name: 'R', percentage: 19 }, + ], + totalMembers: 87400, + totalSkills: 64, + }, + { + color: '#2D5A8A', + icon: IconOutline.CubeTransparentIcon, + id: '35e9e3c6-3480-4fdb-9f77-91e667923a01', + name: 'Virtualization', + officialName: 'Virtualization', + size: 4.4, + skillsBreakdown: [ + { name: 'VMware', percentage: 36 }, + { name: 'Hyper-V', percentage: 28 }, + { name: 'KVM', percentage: 20 }, + ], + totalMembers: 76210, + totalSkills: 41, + }, + { + color: '#5A8A8A', + icon: IconOutline.MapIcon, + id: '6b9717ef-9520-4507-9039-2acedbec002d', + name: 'Geospatial Information Systems', + officialName: 'Geospatial Information Systems (GIS)', + size: 4, + skillsBreakdown: [ + { name: 'ArcGIS', percentage: 40 }, + { name: 'QGIS', percentage: 28 }, + { name: 'GeoJSON', percentage: 18 }, + ], + totalMembers: 54120, + totalSkills: 52, + }, + { + color: '#4EC4C4', + icon: IconOutline.DesktopComputerIcon, + id: '831ed28d-c20f-40c8-a348-d1d3739e9046', + name: 'Hardware & Systems Administration', + officialName: 'Hardware and Systems Administration', + size: 3.9, + skillsBreakdown: [ + { name: 'Linux Admin', percentage: 36 }, + { name: 'Networking', percentage: 27 }, + { name: 'Hardware', percentage: 21 }, + ], + totalMembers: 49880, + totalSkills: 58, + }, + { + color: '#2C4A6E', + icon: IconOutline.ClipboardCheckIcon, + id: 'c5f83f60-4dcf-4305-b55e-b38fe5afec60', + name: 'Software Testing & QA', + officialName: 'Software Testing and Quality Assurance', + size: 3.8, + skillsBreakdown: [ + { name: 'Selenium', percentage: 34 }, + { name: 'Cypress', percentage: 28 }, + { name: 'JMeter', percentage: 20 }, + ], + totalMembers: 46750, + totalSkills: 72, + }, + { + color: '#5A8A9A', + icon: IconOutline.DatabaseIcon, + id: '38fadd80-8721-4ce0-9387-cb6ad3ce48da', + name: 'Database Management', + officialName: 'Database Management', + size: 3.7, + skillsBreakdown: [ + { name: 'MySQL', percentage: 38 }, + { name: 'Oracle', percentage: 26 }, + { name: 'MongoDB', percentage: 20 }, + ], + totalMembers: 43210, + totalSkills: 81, + }, + { + color: '#4A9A9A', + icon: IconOutline.DeviceMobileIcon, + id: '0ae22576-48ed-4ffd-9319-058b6fd80675', + name: 'Mobile App Development', + officialName: 'Mobile App Development', + size: 3.5, + skillsBreakdown: [ + { name: 'Swift', percentage: 32 }, + { name: 'Kotlin', percentage: 30 }, + { name: 'React Native', percentage: 22 }, + ], + totalMembers: 38940, + totalSkills: 96, + }, + { + color: '#3D6A8A', + icon: IconOutline.ShareIcon, + id: 'f1daa100-b63b-45c1-a638-90fbfc817200', + name: 'Blockchain', + officialName: 'Blockchain', + size: 3.3, + skillsBreakdown: [ + { name: 'Solidity', percentage: 40 }, + { name: 'Ethereum', percentage: 28 }, + { name: 'Web3', percentage: 18 }, + ], + totalMembers: 27650, + totalSkills: 44, + }, + { + color: '#5EB8B0', + icon: IconOutline.WifiIcon, + id: 'e4a51b10-ecba-46eb-89e2-5908bb324a8c', + name: 'IoT (Internet of Things)', + officialName: 'IoT (Internet of Things)', + size: 3.2, + skillsBreakdown: [ + { name: 'MQTT', percentage: 34 }, + { name: 'Embedded C', percentage: 28 }, + { name: 'Arduino', percentage: 22 }, + ], + totalMembers: 24180, + totalSkills: 39, + }, + { + color: '#3D5A6E', + icon: IconOutline.ClipboardListIcon, + id: '07a0abe3-2791-4068-b5b5-be48fefa3551', + name: 'Project Management', + officialName: 'Project Management', + size: 3.1, + skillsBreakdown: [ + { name: 'Jira', percentage: 36 }, + { name: 'PMP', percentage: 26 }, + { name: 'Kanban', percentage: 22 }, + ], + totalMembers: 21890, + totalSkills: 27, + }, + { + color: '#4A6A7A', + icon: IconOutline.CodeIcon, + id: '1f5ed3e8-8d22-44ea-b75d-ea85147a04da', + name: 'Scripting & Automation', + officialName: 'Scripting and Automation', + size: 3, + skillsBreakdown: [ + { name: 'Bash', percentage: 36 }, + { name: 'Python', percentage: 32 }, + { name: 'PowerShell', percentage: 18 }, + ], + totalMembers: 19640, + totalSkills: 33, + }, +] diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts new file mode 100644 index 000000000..9001c5cf1 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts @@ -0,0 +1,192 @@ +import { + PROGRAMMING_CATEGORY_ID, + SkillMemberMock, + SKILL_CATEGORIES, +} from './skill-categories.mock' + +const COUNTRIES: Array<{ code: string; name: string }> = [ + { code: 'IN', name: 'India' }, + { code: 'US', name: 'USA' }, + { code: 'CN', name: 'China' }, + { code: 'GB', name: 'UK' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'CA', name: 'Canada' }, + { code: 'BR', name: 'Brazil' }, + { code: 'DE', name: 'Germany' }, + { code: 'JP', name: 'Japan' }, + { code: 'AU', name: 'Australia' }, +] + +const HANDLES = [ + 'skywalker', 'bytecraft', 'codecat', 'pixelhawk', 'algomind', + 'devnova', 'stackpilot', 'nimbusdev', 'qubitron', 'hashlane', + 'loopsmith', 'gridfox', 'nullwave', 'bitforge', 'cloudnest', + 'syntaxio', 'datapath', 'kernelfox', 'vectorly', 'modulin', +] + +const FIRST_NAMES = [ + 'Alex', 'Jordan', 'Priya', 'Wei', 'Sofia', 'Noah', 'Amina', 'Lucas', 'Mei', 'Omar', +] + +const LAST_NAMES = [ + 'Chen', 'Patel', 'Nguyen', 'Garcia', 'Khan', 'Silva', 'Ivanov', 'Kim', 'Brown', 'Rossi', +] + +const RATINGS = [780, 950, 1100, 1350, 1480, 1600, 1800, 1900, 2000, 2100, 2200, 2300, 2400] + +const PROGRAMMING_TOP_MEMBERS: SkillMemberMock[] = [ + { + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Ghostar', + name: 'Justin G', + rating: 1900, + wins: 322, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'stevenfrog', + name: 'Steven', + rating: 2100, + wins: 280, + }, + { + countryCode: 'CN', + countryName: 'China', + handle: 'ergolite', + name: 'Michael P', + rating: 2200, + wins: 262, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'jiangliwu', + name: 'Jiang L', + rating: 950, + wins: 210, + }, + { + countryCode: 'GB', + countryName: 'UK', + handle: 'diazx', + name: 'DAT N', + rating: 2300, + wins: 200, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Standlove', + name: 'GuanZhao I', + rating: 1800, + wins: 188, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'soso0574', + name: 'Jianchang S', + rating: 1600, + wins: 176, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'vasilica.olaru', + name: 'vasilica.olaru', + rating: 2400, + wins: 132, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'ngoctay', + name: 'Minh Ngoc P', + rating: 780, + wins: 90, + }, +] + +function hashString(value: string): number { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash * 31) + value.charCodeAt(index)) % 2147483647 + } + + return Math.abs(hash) +} + +function buildGeneratedMembers(categoryId: string, startWins: number): SkillMemberMock[] { + const members: SkillMemberMock[] = [] + + for (let index = 0; index < 90; index += 1) { + const seed = hashString(`${categoryId}-${index}`) + const country = COUNTRIES[seed % COUNTRIES.length] + const firstName = FIRST_NAMES[seed % FIRST_NAMES.length] + const lastName = LAST_NAMES[hashString(`${categoryId}-last-${index}`) % LAST_NAMES.length] + const handleBase = HANDLES[hashString(`${categoryId}-handle-${index}`) % HANDLES.length] + + members.push({ + countryCode: country.code, + countryName: country.name, + handle: `${handleBase}${index + 1}`, + name: `${firstName} ${lastName.charAt(0)}`, + rating: RATINGS[seed % RATINGS.length], + wins: Math.max(1, startWins - index), + }) + } + + return members +} + +function buildMembersForCategory(categoryId: string): SkillMemberMock[] { + if (categoryId === PROGRAMMING_CATEGORY_ID) { + return [ + ...PROGRAMMING_TOP_MEMBERS, + ...buildGeneratedMembers(categoryId, 89), + ] + } + + const seed = hashString(categoryId) + const startWins = 120 + (seed % 80) + + return buildGeneratedMembers(categoryId, startWins) + .slice(0, 100) + .sort((left, right) => right.wins - left.wins) +} + +export const SKILL_MEMBERS_BY_CATEGORY: Record = Object.fromEntries( + SKILL_CATEGORIES.map(category => [ + category.id, + buildMembersForCategory(category.id), + ]), +) + +export function getMembersForCategory(categoryId: string): SkillMemberMock[] { + return SKILL_MEMBERS_BY_CATEGORY[categoryId] || [] +} + +export function getTopMemberForCategory(categoryId: string): SkillMemberMock | undefined { + if (categoryId === PROGRAMMING_CATEGORY_ID) { + return { + countryCode: 'IN', + countryName: 'India', + handle: 'banerjeesourish', + name: 'Sourish Banerjee', + rating: 1400, + wins: 1768, + } + } + + return getMembersForCategory(categoryId)[0] +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts new file mode 100644 index 000000000..c9cceac3a --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts @@ -0,0 +1,137 @@ +export type PackedCircle = { + id: string + r: number + x: number + y: number +} + +function overlaps( + a: PackedCircle, + b: PackedCircle, + padding: number, +): boolean { + const dx = a.x - b.x + const dy = a.y - b.y + const minDist = a.r + b.r + padding + + return (dx * dx) + (dy * dy) < minDist * minDist +} + +function hashString(value: string): number { + let hash = 0 + + for (let i = 0; i < value.length; i += 1) { + hash = ((hash * 31) + value.charCodeAt(i)) % 2147483647 + } + + return hash + 1 +} + +function createRng(seed: number): () => number { + let state = (seed % 2147483646) + 1 + + return () => { + state = (state * 16807) % 2147483647 + return (state - 1) / 2147483646 + } +} + +function shuffleItems(items: T[], rng: () => number): T[] { + const shuffled = [...items] + + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)) + const current = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = current + } + + return shuffled +} + +/** + * Place circles in a tight non-overlapping cluster, then scale uniformly + * so the pack fits inside the given viewport while staying close together. + * Placement order is shuffled so the largest bubble is not always centered. + */ +export function packCircles( + items: Array<{ id: string; r: number }>, + width: number, + height: number, + padding: number = 6, +): PackedCircle[] { + const rng = createRng(items.reduce((seed, item) => seed + hashString(item.id), 1)) + const ordered = shuffleItems(items, rng) + const placed: PackedCircle[] = [] + const aspect = Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) + const angleOffset = rng() * Math.PI * 2 + + ordered.forEach(item => { + if (placed.length === 0) { + placed.push({ + id: item.id, + r: item.r, + x: 0, + y: 0, + }) + return + } + + let found: PackedCircle | undefined + const maxReach = placed.reduce( + (reach, circle) => Math.max( + reach, + Math.hypot(circle.x / aspect, circle.y) + circle.r, + ), + 0, + ) + item.r + padding + 8 + + for (let dist = item.r; dist <= maxReach && !found; dist += 3) { + const steps = Math.max(16, Math.ceil((2 * Math.PI * dist) / 10)) + for (let step = 0; step < steps && !found; step += 1) { + const angle = ((step / steps) * 2 * Math.PI) + angleOffset + (placed.length * 0.37) + const candidate: PackedCircle = { + id: item.id, + r: item.r, + x: Math.cos(angle) * dist * aspect, + y: Math.sin(angle) * dist, + } + + if (!placed.some(circle => overlaps(candidate, circle, padding))) { + found = candidate + } + } + } + + placed.push(found || { + id: item.id, + r: item.r, + x: (maxReach + item.r) * aspect, + y: 0, + }) + }) + + if (!placed.length || width <= 0 || height <= 0) { + return placed + } + + const minX = Math.min(...placed.map(circle => circle.x - circle.r)) + const maxX = Math.max(...placed.map(circle => circle.x + circle.r)) + const minY = Math.min(...placed.map(circle => circle.y - circle.r)) + const maxY = Math.max(...placed.map(circle => circle.y + circle.r)) + const packWidth = Math.max(maxX - minX, 1) + const packHeight = Math.max(maxY - minY, 1) + const inset = 16 + const availableWidth = Math.max(width - (inset * 2), 1) + const availableHeight = Math.max(height - (inset * 2), 1) + const scale = Math.min(availableWidth / packWidth, availableHeight / packHeight) + const offsetX = inset + ((availableWidth - (packWidth * scale)) / 2) + const offsetY = inset + ((availableHeight - (packHeight * scale)) / 2) + + return placed.map(circle => ({ + id: circle.id, + r: circle.r * scale, + x: ((circle.x - minX) * scale) + offsetX, + y: ((circle.y - minY) * scale) + offsetY, + })) +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx new file mode 100644 index 000000000..49948f976 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx @@ -0,0 +1,26 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { skillStatisticsRouteId } from '../../config/routes.config' + +const SkillStatisticsPage: LazyLoadedComponent = lazyLoad( + () => import('./SkillStatisticsPage'), + 'SkillStatisticsPage', +) + +export const skillStatisticsChildRoutes = [ + { + authRequired: true, + element: , + id: 'skill-statistics-page', + route: '', + }, +] + +export const customerPortalSkillStatisticsRoutes = [ + { + children: [...skillStatisticsChildRoutes], + element: getRoutesContainer(skillStatisticsChildRoutes), + id: skillStatisticsRouteId, + route: skillStatisticsRouteId, + }, +] From 171df04024b7c36880f956af49747cce4a3d93c9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 21 Aug 2026 08:36:52 +0300 Subject: [PATCH 30/44] Fixes to campus leaderboard --- .../src/pages/leaderboard/ParticipationHistoryModal.tsx | 1 + .../member-profile/profile-header/ProfileHeader.module.scss | 6 +++++- .../components/profile-picture/ProfilePicture.module.scss | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index c5b8d2d40..ea06998f4 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -89,6 +89,7 @@ export const ParticipationHistoryModal: FC = pro href={challengePath} rel='noopener noreferrer' target='_blank' + onClick={function (event: any) { event.stopPropagation() }} > {entry.challengeName ?? entry.challengeId} diff --git a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss index b33247b7a..c82440b31 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss +++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss @@ -29,6 +29,10 @@ border: 12px solid $tc-white; border-radius: 50%; + img { + min-width: 150px; + } + @include ltelg { width: 250px; height: 250px; @@ -166,7 +170,7 @@ white-space: normal; max-width: 240px; word-wrap: break-word; - font-size: 14px; + font-size: 14px; } .statusRow { diff --git a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss index 3e2b3d54e..8e67b7d7d 100644 --- a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss +++ b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss @@ -63,7 +63,6 @@ max-width: 100%; max-height: 100%; object-fit: cover; - min-width: 150px; aspect-ratio: 1 / 1; border-radius: 50%; From a563fc2c04e25fb64a7e6c2612a0bf8928088fda Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 21 Aug 2026 14:58:24 +0300 Subject: [PATCH 31/44] PM-5954 - show actions in review tab for copilots & reviewers --- .../TabContentReview.tsx | 5 +-- .../iterativeReviewFiltering.ts | 9 ++--- .../components/TableReview/TableReview.tsx | 28 +++++++------- .../src/lib/utils/reviewPhaseGuards.spec.ts | 37 ++++++++++++++++++- .../review/src/lib/utils/reviewPhaseGuards.ts | 13 +++++++ 5 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index 46bbebb96..05bfa1572 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -42,6 +42,7 @@ import { SUBMITTER, } from '../../../config/index.config' import { + isAiFailedReviewSubmission, isContestReviewPhaseSubmission, shouldIncludeInReviewPhase, } from '../../utils/reviewPhaseGuards' @@ -172,10 +173,6 @@ const sortSubmissionsByReviewScoreDesc = ( return entries.map(entry => entry.submission) } -const isAiFailedReviewSubmission = (submission?: SubmissionInfo): boolean => ( - (submission?.status || '').toUpperCase() === 'AI_FAILED_REVIEW' -) - const mergeSubmissionsById = ( primary: SubmissionInfo[], additional: SubmissionInfo[], diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts index d6ee96bb6..261cf4acf 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts @@ -4,7 +4,10 @@ import { BackendResource, SubmissionInfo, } from '../../models' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' interface FilterIterativeReviewRowsArgs { aiReviewDecisionsBySubmissionId?: Record @@ -28,10 +31,6 @@ interface LimitFirst2FinishIterativeRowsOptions { forceSingleRow?: boolean } -function isAiFailedReviewSubmission(submission: SubmissionInfo): boolean { - return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' -} - function isAiLockedByDecision( submission: SubmissionInfo, aiReviewDecisionsBySubmissionId?: Record, diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index a06faab00..1745b6e67 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -83,7 +83,10 @@ import { isSubmissionReviewerActionRow, resolveSubmissionReviewResult, } from '../common/reviewResult' -import { shouldIncludeInReviewPhase } from '../../utils/reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + shouldIncludeInReviewPhase, +} from '../../utils/reviewPhaseGuards' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' import { EscalationModals } from './EscalationModals' @@ -149,9 +152,11 @@ export const TableReview: FC = (props: TableReviewProps) => { const isTablet = useMemo(() => screenWidth <= 744, [screenWidth]) const reviewPhaseDatas = useMemo( - () => datas.filter(submission => shouldIncludeInReviewPhase( - submission, - challengeInfo?.phases, + () => datas.filter(submission => ( + // AI-locked submissions may carry no Review-phase review yet, but reviewers and + // copilots still need the row to escalate, verify, or unlock them. + isAiFailedReviewSubmission(submission) + || shouldIncludeInReviewPhase(submission, challengeInfo?.phases) )), [challengeInfo?.phases, datas], ) @@ -278,7 +283,7 @@ export const TableReview: FC = (props: TableReviewProps) => { return true } - return (submission.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' + return isAiFailedReviewSubmission(submission) }, ), [props.screeningOutcome.failingSubmissionIds], @@ -370,7 +375,7 @@ export const TableReview: FC = (props: TableReviewProps) => { submission: SubmissionReviewerRow, decision?: AiReviewEscalationDecision, ): boolean => { - if (submission.status !== 'AI_FAILED_REVIEW') { + if (!isAiFailedReviewSubmission(submission)) { return false } @@ -840,13 +845,10 @@ export const TableReview: FC = (props: TableReviewProps) => { } appendAction(buildPrimaryAction(), 'primary') - if (submission.isFirstReviewerRow) { - appendAction(buildEscalateAction(), 'escalate') - appendAction(buildVerifyAction(), 'verify') - appendAction(buildUnlockAction(), 'unlock') - appendAction(buildHistoryAction(), 'history') - } - + appendAction(buildEscalateAction(), 'escalate') + appendAction(buildVerifyAction(), 'verify') + appendAction(buildUnlockAction(), 'unlock') + appendAction(buildHistoryAction(), 'history') appendAction(buildReopenAction(), 'reopen') if (!actionEntries.length) { diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts index 6071363ec..fcb0983bf 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.spec.ts @@ -1,6 +1,10 @@ import type { BackendPhase, SubmissionInfo } from '../models' -import { isContestReviewPhaseSubmission } from './reviewPhaseGuards' +import { + isAiFailedReviewSubmission, + isContestReviewPhaseSubmission, + shouldIncludeInReviewPhase, +} from './reviewPhaseGuards' const reviewPhase: BackendPhase = { constraints: [], @@ -93,3 +97,34 @@ describe('isContestReviewPhaseSubmission', () => { .toBe(false) }) }) + +describe('isAiFailedReviewSubmission', () => { + it('detects AI-locked submissions regardless of status casing', () => { + expect(isAiFailedReviewSubmission({ status: 'AI_FAILED_REVIEW' } as SubmissionInfo)) + .toBe(true) + expect(isAiFailedReviewSubmission({ status: 'ai_failed_review' } as SubmissionInfo)) + .toBe(true) + }) + + it('ignores other submission statuses', () => { + expect(isAiFailedReviewSubmission({ status: 'ACTIVE' } as SubmissionInfo)) + .toBe(false) + expect(isAiFailedReviewSubmission(undefined)) + .toBe(false) + }) + + it('keeps AI-failed submissions visible even when the phase guard excludes them', () => { + const aiFailedSubmission = { + id: 'submission-ai-failed', + memberId: '1001', + status: 'AI_FAILED_REVIEW', + type: 'Contest Submission', + } as SubmissionInfo + + // No review-phase hints, so the phase guard alone would drop the row. + expect(shouldIncludeInReviewPhase(aiFailedSubmission, [reviewPhase])) + .toBe(false) + expect(isAiFailedReviewSubmission(aiFailedSubmission)) + .toBe(true) + }) +}) diff --git a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts index 0e93dcf14..e027dd3fc 100644 --- a/src/apps/review/src/lib/utils/reviewPhaseGuards.ts +++ b/src/apps/review/src/lib/utils/reviewPhaseGuards.ts @@ -159,6 +159,19 @@ export const isContestReviewPhaseSubmission = ( return normalizedCandidates.has(normalizeReviewPhaseKey(targetPhaseName)) } +/** + * Detects submissions the AI reviewer failed and locked. + * + * @param submission - Submission candidate. + * @returns True when the submission status marks an AI review failure. + * @throws This helper does not throw. + * Such submissions must stay visible on the Review tab so reviewers and copilots + * can escalate, verify, or unlock them even without a Review-phase review record. + */ +export const isAiFailedReviewSubmission = (submission?: SubmissionInfo): boolean => ( + (submission?.status ?? '').toUpperCase() === 'AI_FAILED_REVIEW' +) + export const shouldIncludeInReviewPhase = ( submission?: SubmissionInfo, phases?: BackendPhase[], From ab65f787032e739bd1bf2c16a714bea7123c66aa Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 21 Aug 2026 15:26:47 +0300 Subject: [PATCH 32/44] restore check --- .../src/lib/components/TableReview/TableReview.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index 1745b6e67..40af73fe0 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -844,12 +844,14 @@ export const TableReview: FC = (props: TableReviewProps) => { ) } - appendAction(buildPrimaryAction(), 'primary') - appendAction(buildEscalateAction(), 'escalate') - appendAction(buildVerifyAction(), 'verify') - appendAction(buildUnlockAction(), 'unlock') - appendAction(buildHistoryAction(), 'history') - appendAction(buildReopenAction(), 'reopen') + if (submission.isFirstReviewerRow) { + appendAction(buildPrimaryAction(), 'primary') + appendAction(buildEscalateAction(), 'escalate') + appendAction(buildVerifyAction(), 'verify') + appendAction(buildUnlockAction(), 'unlock') + appendAction(buildHistoryAction(), 'history') + appendAction(buildReopenAction(), 'reopen') + } if (!actionEntries.length) { return ( From 34f59eb301d96123e83422ffc004a0e1a7ae7c1c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 24 Aug 2026 07:04:22 +1000 Subject: [PATCH 33/44] PM-5562: Preserve aggregate Marathon Match wins What was broken The previous PM-5562 follow-up made Development totals include aggregate wins for subtracks whose history had no placement data. QA still found that the profile totals for pops and wleite showed 76 instead of 152 and 2 instead of 36. Root cause Legacy Marathon Match history is partial but contains placement fields. The shared subtrack summary therefore treated those incomplete rows as the source of truth and replaced 76 and 34 valid aggregate wins with zero placement wins. The prior fix only covered histories with no placement fields at all. What was changed Keep an explicit aggregate wins value authoritative for Marathon Match while retaining placement-derived wins for modern tracks. This preserves the earlier Development deduplication and rating-only history fixes. Any added/updated tests Added a regression case modeled on the pops payload, proving that 76 aggregate Marathon Match wins survive a partial placement history with no first-place rows. The existing placement-history and Development aggregation tests remain passing. --- .../src/hooks/useFetchActiveTracks.spec.tsx | 32 +++++++++++++++++++ .../src/hooks/useFetchActiveTracks.tsx | 12 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index e38686cc0..0623555e8 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -595,6 +595,38 @@ describe('getSubTrackSummaryStats', () => { wins: 2, }) }) + + it('keeps aggregate Marathon Match wins when legacy placement history is partial', () => { + const summaryStats = getSubTrackSummaryStats({ + challenges: 225, + name: 'MARATHON_MATCH', + submissions: { + submissions: 7, + }, + wins: 76, + } as MemberStats, [ + { + challengeId: 'legacy-mm-1', + challengeName: 'Legacy Marathon Match 1', + newRating: 1210, + placement: 122, + ratingDate: 1301961600000, + }, + { + challengeId: 'legacy-mm-2', + challengeName: 'Legacy Marathon Match 2', + newRating: 1218, + placement: 145, + ratingDate: 1302652800000, + }, + ]) + + expect(summaryStats) + .toEqual({ + submissions: 7, + wins: 76, + }) + }) }) describe('getTrackSummaryStats', () => { diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 37f1adb46..e8a53610c 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -120,7 +120,8 @@ export const getSubTrackDisplaySubmissionCount = (subTrack?: MemberStats): numbe * Some unified stats rows currently include challenge/rating history while the * aggregate win or submission counters are stale, omitted, or left at zero. In * that case, placement-bearing history is used for wins and history/challenge - * count is used as the minimum visible submission count. + * count is used as the minimum visible submission count. Legacy Marathon Match + * history is partial, so its explicit aggregate win counter remains authoritative. * * @param {MemberStats | undefined} subTrack - The subtrack to summarize. * @param {StatsHistory[]} trackHistory - Optional history rows for the same subtrack. @@ -130,16 +131,21 @@ export const getSubTrackSummaryStats = ( subTrack?: MemberStats, trackHistory: StatsHistory[] = [], ): SubTrackSummaryStats => { - const statWins = getFiniteNumber(subTrack?.wins) ?? 0 + const aggregateWins = getFiniteNumber(subTrack?.wins) + const statWins = aggregateWins ?? 0 const historyWithPlacements = trackHistory .filter(history => getFiniteNumber(history.placement) !== undefined) const historyWins = historyWithPlacements.filter(history => history.placement === 1).length const displaySubmissions = getSubTrackDisplaySubmissionCount(subTrack) ?? 0 const historySubmissions = trackHistory.length + const hasAuthoritativeAggregateWins = subTrack?.name === 'MARATHON_MATCH' + && aggregateWins !== undefined return { submissions: Math.max(displaySubmissions, historySubmissions), - wins: historyWithPlacements.length > 0 ? historyWins : statWins, + wins: historyWithPlacements.length > 0 && !hasAuthoritativeAggregateWins + ? historyWins + : statWins, } } From 7d71cb8e4da77b4f8507f2902e1aec73bafef68e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 24 Aug 2026 12:37:19 +1000 Subject: [PATCH 34/44] Mirror code to AWS code commit for backup purposes --- .github/workflows/codecommit-mirror.yml | 142 ++++++++++++++++++ README.md | 5 + docs/github-codecommit-mirror.md | 184 ++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 .github/workflows/codecommit-mirror.yml create mode 100644 docs/github-codecommit-mirror.md diff --git a/.github/workflows/codecommit-mirror.yml b/.github/workflows/codecommit-mirror.yml new file mode 100644 index 000000000..6b485cc9c --- /dev/null +++ b/.github/workflows/codecommit-mirror.yml @@ -0,0 +1,142 @@ +name: Mirror GitHub to AWS CodeCommit + +on: + push: + branches: + - '**' + tags: + - '**' + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: codecommit-mirror-${{ github.repository }} + cancel-in-progress: false + +jobs: + mirror: + name: Synchronize Git refs + if: ${{ vars.CODECOMMIT_MIRROR_ENABLED == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + MIRROR_AWS_REGION: ${{ vars.CODECOMMIT_MIRROR_AWS_REGION }} + MIRROR_REPOSITORY: ${{ vars.CODECOMMIT_MIRROR_REPOSITORY }} + MIRROR_ROLE_ARN: ${{ vars.CODECOMMIT_MIRROR_ROLE_ARN }} + SOURCE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + + steps: + - name: Validate mirror configuration + shell: bash + run: | + set -euo pipefail + + configuration_is_valid=true + + if [[ -z "${MIRROR_AWS_REGION}" ]]; then + echo "::error::Repository variable CODECOMMIT_MIRROR_AWS_REGION is not configured." + configuration_is_valid=false + fi + + if [[ -z "${MIRROR_REPOSITORY}" ]]; then + echo "::error::Repository variable CODECOMMIT_MIRROR_REPOSITORY is not configured." + configuration_is_valid=false + fi + + if [[ -z "${MIRROR_ROLE_ARN}" ]]; then + echo "::error::Repository variable CODECOMMIT_MIRROR_ROLE_ARN is not configured." + configuration_is_valid=false + fi + + if [[ -z "${SOURCE_DEFAULT_BRANCH}" ]]; then + echo "::error::The GitHub event did not identify the repository's default branch." + configuration_is_valid=false + fi + + if [[ "${configuration_is_valid}" != "true" ]]; then + exit 1 + fi + + if [[ ! "${MIRROR_AWS_REGION}" =~ ^[a-z]{2}(-[a-z0-9]+)+-[0-9]+$ ]]; then + echo "::error::CODECOMMIT_MIRROR_AWS_REGION is not a valid AWS Region name." + exit 1 + fi + + if [[ ! "${MIRROR_REPOSITORY}" =~ ^[A-Za-z0-9._-]{1,100}$ ]]; then + echo "::error::CODECOMMIT_MIRROR_REPOSITORY is not a valid CodeCommit repository name." + exit 1 + fi + + if [[ ! "${MIRROR_ROLE_ARN}" =~ ^arn:(aws|aws-us-gov|aws-cn):iam::[0-9]{12}:role/.+$ ]]; then + echo "::error::CODECOMMIT_MIRROR_ROLE_ARN is not a valid IAM role ARN." + exit 1 + fi + + - name: Check out every branch and tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ env.SOURCE_DEFAULT_BRANCH }} + fetch-depth: 0 + persist-credentials: false + + - name: Configure short-lived AWS credentials + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ env.MIRROR_ROLE_ARN }} + aws-region: ${{ env.MIRROR_AWS_REGION }} + role-session-name: GitHubActions-platform-ui-mirror + + - name: Synchronize branches, tags, and the default branch + shell: bash + run: | + set -euo pipefail + + codecommit_url="https://git-codecommit.${MIRROR_AWS_REGION}.amazonaws.com/v1/repos/${MIRROR_REPOSITORY}" + git remote add codecommit "${codecommit_url}" + git config --local credential.helper '!aws codecommit credential-helper $@' + git config --local credential.UseHttpPath true + + while read -r object_name remote_ref; do + if [[ "${remote_ref}" == "refs/remotes/origin/HEAD" ]]; then + continue + fi + + branch_name="${remote_ref#refs/remotes/origin/}" + git update-ref "refs/heads/${branch_name}" "${object_name}" + done < <(git for-each-ref --format='%(objectname) %(refname)' refs/remotes/origin) + + # Create/update source refs first so the GitHub default branch exists in CodeCommit. + git push --force codecommit \ + 'refs/heads/*:refs/heads/*' \ + 'refs/tags/*:refs/tags/*' + + aws codecommit update-default-branch \ + --region "${MIRROR_AWS_REGION}" \ + --repository-name "${MIRROR_REPOSITORY}" \ + --default-branch-name "${SOURCE_DEFAULT_BRANCH}" + + # Prune only branches and tags; provider-specific internal refs are left untouched. + git push --force --prune codecommit \ + 'refs/heads/*:refs/heads/*' \ + 'refs/tags/*:refs/tags/*' + + - name: Verify mirrored ref object IDs + shell: bash + run: | + set -euo pipefail + + source_refs="${RUNNER_TEMP}/platform-ui-source-refs" + destination_refs="${RUNNER_TEMP}/platform-ui-codecommit-refs" + + git for-each-ref --format='%(objectname) %(refname)' refs/heads refs/tags \ + | sort > "${source_refs}" + git ls-remote --refs codecommit 'refs/heads/*' 'refs/tags/*' \ + | awk '{ print $1, $2 }' \ + | sort > "${destination_refs}" + + diff -u "${source_refs}" "${destination_refs}" diff --git a/README.md b/README.md index b52243e1c..bec2b803e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ All future user interfaces at Topcoder will be implemented here. Pre-existing us # Source Control & CI/CD - [Deployments](#deployments) +- [Repository Backup](#repository-backup) - [Pull Requests](#pull-requests) - [Branching](#branching) - [Commits](#commits) @@ -29,6 +30,10 @@ The `dev` branch is auto-deployed to the dev environment: https://platform-mvp.t The `master` branch is auto-deployed to the production environment: https://platform-ui.topcoder.com. +## Repository Backup + +Git branches, tags, and their reachable history can be mirrored from GitHub to AWS CodeCommit by the `Mirror GitHub to AWS CodeCommit` workflow. See the [CodeCommit mirror setup and operations guide](docs/github-codecommit-mirror.md) for its behavior, limitations, and the one-time AWS and GitHub administrator setup. + ## Pull Requests If a Jira ticket requires any code changes, it should have its own pull request. diff --git a/docs/github-codecommit-mirror.md b/docs/github-codecommit-mirror.md new file mode 100644 index 000000000..eb2bb47b0 --- /dev/null +++ b/docs/github-codecommit-mirror.md @@ -0,0 +1,184 @@ +# GitHub backup mirror to AWS CodeCommit + +The [`codecommit-mirror.yml`](../.github/workflows/codecommit-mirror.yml) workflow maintains a secondary Git copy of `topcoder-platform/platform-ui` in a dedicated AWS CodeCommit repository. It is disabled until the repository variable `CODECOMMIT_MIRROR_ENABLED` is set to `true`. + +AWS returned CodeCommit to full general availability, including new customers, on 24 November 2025. See [The Future of AWS CodeCommit](https://aws.amazon.com/blogs/devops/aws-codecommit-returns-to-general-availability/). + +## Provisioned status + +The tenant setup was completed on 24 August 2026: + +- The dedicated `platform-ui` CodeCommit repository exists in `us-east-1` with `dev` as its default branch. +- The AWS account has the GitHub Actions OIDC provider and the `GitHubActionsPlatformUiCodeCommitMirror` role with the repository-scoped `PlatformUiCodeCommitMirrorAccess` policy. +- All four GitHub repository variables are configured, including `CODECOMMIT_MIRROR_ENABLED=true`. +- The initial mirror was seeded directly from GitHub and all branch and tag object IDs were verified. + +The remaining activation step is to commit this workflow and merge it into GitHub's `dev` default branch. The enabled variable is inert until GitHub contains the workflow file. + +## What the workflow does + +For every GitHub push to a branch or tag, the workflow: + +1. Fetches the complete GitHub history for all branches and tags. +2. Uses GitHub OpenID Connect (OIDC) to assume a narrowly scoped AWS IAM role. No long-lived AWS access key is stored in GitHub. +3. Force-updates all CodeCommit branch and tag refs to the exact Git object IDs held by GitHub. +4. Sets the CodeCommit default branch to the GitHub default branch. +5. Removes CodeCommit branches and tags that no longer exist in GitHub. +6. Fails the run if the final branch or tag object IDs differ between the two repositories. + +Runs are serialized so that an older run cannot finish after a newer run and move the mirror backwards. A full reconciliation also runs every six hours and can be started manually. This catches current refs after skipped Actions, pushes made with GitHub's workflow token, or pushes to an older branch that does not contain the workflow file. + +The first successful run seeds the entire repository. Later runs still fetch the complete GitHub repository on a fresh runner, but Git sends CodeCommit only objects that the destination does not already have. + +## One-time setup reference + +These steps are complete for the current deployment. Use them only when auditing or rebuilding the integration. The target is destructive by design: it must be a dedicated mirror, because branches and tags that exist only in CodeCommit will be deleted. + +### 1. Create the CodeCommit repository + +Choose the AWS account, supported AWS Region, and repository name. `platform-ui` is the recommended repository name. Create it without an initial README or other content where possible. + +For example, with an authenticated AWS CLI: + +```bash +aws codecommit create-repository \ + --region \ + --repository-name platform-ui \ + --repository-description "Read-only mirror of topcoder-platform/platform-ui" +``` + +Do not use this CodeCommit repository for development or CodeCommit pull requests. GitHub is the source of truth and the next synchronization can overwrite or delete CodeCommit refs. + +### 2. Add GitHub as an AWS OIDC identity provider + +If the AWS account already has the GitHub Actions OIDC provider, reuse it. Otherwise, in IAM add an OpenID Connect provider with: + +- Provider URL: `https://token.actions.githubusercontent.com` +- Audience: `sts.amazonaws.com` + +GitHub documents the AWS setup in [Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws). + +### 3. Create the IAM mirror role + +Create a role such as `GitHubActionsPlatformUiCodeCommitMirror`. Replace `` in this trust policy. The policy accepts both GitHub's legacy repository-name subject and its immutable repository-ID subject. The IDs shown are the stable IDs for the existing `topcoder-platform/platform-ui` repository and its owner. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "TrustPlatformUiGitHubActions", + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": [ + "repo:topcoder-platform/platform-ui:ref:refs/*", + "repo:topcoder-platform@25333036/platform-ui@462526084:ref:refs/*" + ] + } + } + } + ] +} +``` + +Attach the following permissions policy after replacing ``, ``, and ``. `GitPull` is required for the final object-ID verification, `GitPush` synchronizes Git data, and `UpdateDefaultBranch` keeps the repository metadata aligned. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SynchronizePlatformUiMirror", + "Effect": "Allow", + "Action": [ + "codecommit:GitPull", + "codecommit:GitPush", + "codecommit:UpdateDefaultBranch" + ], + "Resource": "arn:aws:codecommit:::" + } + ] +} +``` + +Do not attach a broad managed CodeCommit policy. This role needs access to only the single mirror repository. Anyone able to modify and push a workflow on an allowed `platform-ui` ref can request this role, so its permissions must remain mirror-only. + +### 4. Configure GitHub repository variables + +In `topcoder-platform/platform-ui`, open **Settings > Secrets and variables > Actions > Variables** and create these repository variables: + +| Variable | Example | Purpose | +| ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------- | +| `CODECOMMIT_MIRROR_AWS_REGION` | `us-east-1` | Region containing the CodeCommit repository | +| `CODECOMMIT_MIRROR_REPOSITORY` | `platform-ui` | CodeCommit repository name | +| `CODECOMMIT_MIRROR_ROLE_ARN` | `arn:aws:iam::123456789012:role/GitHubActionsPlatformUiCodeCommitMirror` | OIDC role assumed by the workflow | + +These values identify AWS resources but are not credentials, so repository variables are appropriate. No GitHub Actions secrets are required. + +For a new deployment, leave `CODECOMMIT_MIRROR_ENABLED` unset while creating and reviewing the infrastructure. After this workflow is present on the GitHub default branch and the three variables above are correct, add: + +| Variable | Value | +| --------------------------- | ------ | +| `CODECOMMIT_MIRROR_ENABLED` | `true` | + +Setting this last prevents expected failures while setup is incomplete. The value is case-sensitive. + +The same setup can be performed with GitHub CLI by an administrator: + +```bash +gh variable set CODECOMMIT_MIRROR_AWS_REGION \ + --repo topcoder-platform/platform-ui \ + --body +gh variable set CODECOMMIT_MIRROR_REPOSITORY \ + --repo topcoder-platform/platform-ui \ + --body platform-ui +gh variable set CODECOMMIT_MIRROR_ROLE_ARN \ + --repo topcoder-platform/platform-ui \ + --body arn:aws:iam:::role/GitHubActionsPlatformUiCodeCommitMirror +gh variable set CODECOMMIT_MIRROR_ENABLED \ + --repo topcoder-platform/platform-ui \ + --body true +``` + +### 5. Seed and verify the mirror + +After enabling, open **Actions > Mirror GitHub to AWS CodeCommit > Run workflow** and run it from the default branch. The first run may take longer because it transfers the existing history and all current refs. + +A successful run verifies every `refs/heads/*` and `refs/tags/*` object ID itself. Also confirm in CodeCommit that: + +- the default branch matches GitHub; +- expected active branches and tags are present; and +- the latest default-branch commit ID matches GitHub. + +The push that adds this workflow can run it automatically if the variables were enabled first. A manual run is still recommended as the explicit acceptance check. + +## Ongoing operation + +- Treat a failed **Mirror GitHub to AWS CodeCommit** run as a backup warning. It does not roll back or block the original GitHub push. +- The next push or six-hour reconciliation attempts a complete repair, so retries are safe. +- Use **Run workflow** for an immediate repair or after changing AWS configuration. +- Keep the two third-party Actions pinned to reviewed commit SHAs. Review and update those pins deliberately when upgrading. +- If the repository is renamed, transferred, or opts into GitHub immutable OIDC subjects, verify the IAM role's `sub` conditions before the next run. The current trust policy already includes the present immutable owner and repository IDs. +- If the GitHub default branch changes, no manual CodeCommit change is needed; the next successful run updates it. + +## Scope and recovery limitations + +This is an exact secondary Git repository, not a point-in-time archive: + +- Branch deletion, tag deletion, tag movement, and force-pushes are reproduced in CodeCommit. Commits left unreachable by all mirrored refs are not guaranteed to remain recoverable indefinitely. +- A commit pushed and then made unreachable before any workflow or reconciliation fetches it can be missed. Protect important branches against deletion and force-pushes if historical retention is required. +- Only Git branches, Git tags, commits, trees, and ordinary Git blobs are mirrored. GitHub pull requests, issues, discussions, releases, Actions logs/artifacts, branch protections, repository settings, and secrets are outside the scope. +- Git LFS pointer files are Git blobs, but their external LFS objects are not copied by a normal Git push. Add a separate LFS/object-storage backup before adopting LFS. +- The workflow does not copy provider-specific internal refs such as GitHub pull-request refs. + +If the requirement changes from a warm Git replica to immutable, point-in-time retention, add a separate scheduled `git bundle` archive in versioned/object-locked storage rather than changing this mirror's destructive synchronization semantics. + +For disaster recovery, an administrator with CodeCommit read access can clone the CodeCommit repository and push its branches and tags to a new GitHub repository. Keep the CodeCommit repository and the mirror IAM role under the normal AWS backup-access and break-glass procedures. From 2a3cf060965205788a80670662afb6717b96df30 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 24 Aug 2026 09:27:13 +0530 Subject: [PATCH 35/44] Integrate UI with skill statistics api --- .../lib/services/statistics.service.spec.ts | 73 ++++ .../src/lib/services/statistics.service.ts | 55 +++ .../SkillStatisticsPage/SkillBubblesChart.tsx | 41 +- .../SkillStatisticsPage/SkillMembersPanel.tsx | 6 +- .../SkillStatisticsPage.module.scss | 14 + .../SkillStatisticsPage.spec.tsx | 156 +++++++- .../SkillStatisticsPage.tsx | 98 ++++- .../SkillStatisticsPage/mock/index.ts | 2 - .../mock/skill-categories.mock.ts | 377 ------------------ .../mock/skill-members.mock.ts | 192 --------- 10 files changed, 390 insertions(+), 624 deletions(-) create mode 100644 src/apps/customer-portal/src/lib/services/statistics.service.spec.ts delete mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts delete mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts delete mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts b/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts new file mode 100644 index 000000000..d48d22b27 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/statistics.service.spec.ts @@ -0,0 +1,73 @@ +import { xhrGetAsync } from '~/libs/core' + +import { + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from './statistics.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { V6: 'https://api.example.com/v6' }, + REPORTS_API: 'https://reports.example.com', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { + virtual: true, +}) + +const mockedXhrGetAsync = xhrGetAsync as jest.MockedFunction + +describe('statistics.service expert-skills', () => { + beforeEach(() => { + mockedXhrGetAsync.mockReset() + }) + + it('loads skill categories from statistics/expert-skills', async () => { + const categories = [{ + color: '#1B4F72', + icon: 'TerminalIcon', + id: '481b5ebc-2fe6-45ed-a90c-736936d458d7', + name: 'Programming and Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [{ name: 'JavaScript', percentage: 40 }], + totalMembers: 101, + totalSkills: 50, + }] + mockedXhrGetAsync.mockResolvedValueOnce(categories) + + await expect(fetchExpertSkillCategories()) + .resolves + .toEqual(categories) + expect(mockedXhrGetAsync) + .toHaveBeenCalledWith( + 'https://reports.example.com/statistics/expert-skills/categories', + ) + }) + + it('loads category members from statistics/expert-skills', async () => { + const members = [{ + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }] + mockedXhrGetAsync.mockResolvedValueOnce(members) + + await expect(fetchExpertSkillCategoryMembers('Programming and Development')) + .resolves + .toEqual(members) + expect(mockedXhrGetAsync) + .toHaveBeenCalledWith( + 'https://reports.example.com/statistics/expert-skills/category-members' + + '?selectedcategory=Programming+and+Development', + ) + }) +}) diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.ts b/src/apps/customer-portal/src/lib/services/statistics.service.ts index e15a59c15..c00df244e 100644 --- a/src/apps/customer-portal/src/lib/services/statistics.service.ts +++ b/src/apps/customer-portal/src/lib/services/statistics.service.ts @@ -92,6 +92,7 @@ type CountryLookupResponse = { } const GENERAL_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/general` +const EXPERT_SKILLS_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/expert-skills` const COUNTRY_LOOKUP_URL = `${EnvironmentConfig.API.V6}/lookups/countries?page=1&perPage=9999` const COUNTRY_NAME_ALIASES: Record = { @@ -263,3 +264,57 @@ export async function fetchGeneralStatistics(): Promise { totalPrizes: Number(totalPrizesResponse.total || 0), } } + +export type ExpertSkillBreakdown = { + name: string + percentage: number +} + +export type ExpertSkillCategory = { + color: string + icon: string + id: string + name: string + officialName: string + size: number + skillsBreakdown: ExpertSkillBreakdown[] + totalMembers: number + totalSkills: number +} + +export type ExpertSkillCategoryMember = { + countryCode: string + countryName: string + handle: string + name: string + photoURL?: string | null + rating: number + wins: number +} + +export const EXPERT_SKILL_CATEGORIES_CACHE_KEY = 'customer-portal-expert-skill-categories' + +export function expertSkillCategoryMembersCacheKey(selectedCategory: string): string { + return `customer-portal-expert-skill-category-members:${selectedCategory}` +} + +export async function fetchExpertSkillCategories(): Promise { + const response = await xhrGetAsync( + `${EXPERT_SKILLS_STATISTICS_URL}/categories`, + ) + + return Array.isArray(response) ? response : [] +} + +export async function fetchExpertSkillCategoryMembers( + selectedCategory: string, +): Promise { + const query = new URLSearchParams({ + selectedcategory: selectedCategory, + }) + const response = await xhrGetAsync( + `${EXPERT_SKILLS_STATISTICS_URL}/category-members?${query.toString()}`, + ) + + return Array.isArray(response) ? response : [] +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx index a4e6b022f..09f395788 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -4,6 +4,7 @@ import { FC, KeyboardEvent, RefObject, + SVGProps, useCallback, useEffect, useLayoutEffect, @@ -13,17 +14,20 @@ import { } from 'react' import { createPortal } from 'react-dom' import classNames from 'classnames' +import useSWR, { SWRResponse } from 'swr' import { getRatingColor } from '~/libs/core' +import { IconOutline } from '~/libs/ui' +import { + ExpertSkillCategory, + ExpertSkillCategoryMember, + expertSkillCategoryMembersCacheKey, + fetchExpertSkillCategoryMembers, +} from '../../../lib' import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg' import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg' -import { - getTopMemberForCategory, - SkillCategoryMock, - SkillMemberMock, -} from './mock' import { packCircles, PackedCircle } from './packCircles' import styles from './SkillBubblesChart.module.scss' @@ -118,12 +122,21 @@ function getPopoverLayout( } } +type SkillCategoryIcon = FC> + interface SkillBubblesChartProps { - categories: SkillCategoryMock[] + categories: ExpertSkillCategory[] onSelect: (categoryId: string) => void selectedCategoryId?: string } +function getCategoryIcon(iconName?: string): SkillCategoryIcon { + const icons = IconOutline as Record + const icon = iconName ? icons[iconName] : undefined + + return icon || IconOutline.CodeIcon +} + function radiusForSize(size: number): number { return 28 + (size * 9) } @@ -207,9 +220,13 @@ const SkillBubblesChart: FC = props => { const hoveredCircle = hoveredCategory ? packedById.get(hoveredCategory.id) : undefined - const topMember = hoveredCategory - ? getTopMemberForCategory(hoveredCategory.id) - : undefined + const { data: hoveredMembers }: SWRResponse = useSWR( + hoveredCategory + ? expertSkillCategoryMembersCacheKey(hoveredCategory.name) + : undefined, + () => fetchExpertSkillCategoryMembers(hoveredCategory?.name || ''), + ) + const topMember = hoveredMembers?.[0] const handleKeyDown = useCallback(( event: KeyboardEvent, @@ -234,7 +251,7 @@ const SkillBubblesChart: FC = props => { return undefined } - const Icon = category.icon + const Icon = getCategoryIcon(category.icon) const isSelected = category.id === props.selectedCategoryId const fontSize = fontSizeForRadius( circle.r, @@ -291,12 +308,12 @@ const SkillBubblesChart: FC = props => { } interface SkillCategoryPopoverProps { - category: SkillCategoryMock + category: ExpertSkillCategory chartHeight: number chartRef: RefObject chartWidth: number circle: PackedCircle - topMember?: SkillMemberMock + topMember?: ExpertSkillCategoryMember } const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => { diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx index a927bab44..8776462d1 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -7,21 +7,21 @@ import { getRatingColor } from '~/libs/core' import { ProfilePicture } from '~/libs/shared' import { IconOutline } from '~/libs/ui' +import { ExpertSkillCategory, ExpertSkillCategoryMember } from '../../../lib' import { IconFirstPlace, IconSecondPlace, IconThirdPlace, } from '../../statistics/StatisticsPage/assets' -import { SkillCategoryMock, SkillMemberMock } from './mock' import styles from './SkillMembersPanel.module.scss' const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') interface SkillMembersPanelProps { - category: SkillCategoryMock + category: ExpertSkillCategory countryFilter: string - members: SkillMemberMock[] + members: ExpertSkillCategoryMember[] onCountryChange: (countryCode: string) => void onSearchChange: (value: string) => void search: string diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss index f55ffd6be..b7a1e854d 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -48,3 +48,17 @@ margin: 20px 0 8px; text-align: center; } + +.status { + align-items: center; + color: #545f71; + display: flex; + gap: 8px; + justify-content: center; + min-height: 240px; + + button { + color: #0d61bf; + text-decoration: underline; + } +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx index afedc26e3..3636316a1 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -1,9 +1,13 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import { fireEvent, render, screen, within } from '@testing-library/react' +import { SWRConfig } from 'swr' import { getTabIdFromPathName, getTabsConfig } from '../../../lib/components/NavTabs/config/tabs-config' -import { SKILL_CATEGORIES } from './mock' +import { + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from '../../../lib' import SkillStatisticsPage from './SkillStatisticsPage' jest.mock('~/config', () => ({ @@ -71,6 +75,87 @@ jest.mock('../../statistics/StatisticsPage/assets/skill-cognition.svg', () => 's virtual: true, }) +jest.mock('../../../lib', () => ({ + EXPERT_SKILL_CATEGORIES_CACHE_KEY: 'customer-portal-expert-skill-categories', + expertSkillCategoryMembersCacheKey: (selectedCategory: string) => ( + `customer-portal-expert-skill-category-members:${selectedCategory}` + ), + fetchExpertSkillCategories: jest.fn(), + fetchExpertSkillCategoryMembers: jest.fn(), +})) + +const mockedFetchCategories = fetchExpertSkillCategories as jest.MockedFunction< + typeof fetchExpertSkillCategories +> +const mockedFetchMembers = fetchExpertSkillCategoryMembers as jest.MockedFunction< + typeof fetchExpertSkillCategoryMembers +> + +const CATEGORIES = [ + { + color: '#1B4F72', + icon: 'TerminalIcon', + id: '481b5ebc-2fe6-45ed-a90c-736936d458d7', + name: 'Programming and Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [{ name: 'JavaScript', percentage: 40 }], + totalMembers: 101, + totalSkills: 50, + }, + { + color: '#4A6A7A', + icon: 'CodeIcon', + id: '1f5ed3e8-8d22-44ea-b75d-ea85147a04da', + name: 'Scripting and Automation', + officialName: 'Scripting and Automation', + size: 3, + skillsBreakdown: [], + totalMembers: 10, + totalSkills: 4, + }, +] + +const MEMBERS = [ + { + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Ghostar', + name: 'Justin G', + rating: 1900, + wins: 322, + }, + { + countryCode: 'GB', + countryName: 'UK', + handle: 'diazx', + name: 'DAT N', + rating: 2300, + wins: 200, + }, +] + +function renderPage(): ReturnType { + return render( + new Map(), + }} + > + + , + ) +} + describe('Customer Portal Skill Statistics tabs', () => { it('adds Skill Statistics beside General Statistics', () => { const tabs = getTabsConfig(['administrator'], false, false) @@ -91,46 +176,60 @@ describe('Customer Portal Skill Statistics tabs', () => { }) describe('SkillStatisticsPage', () => { - it('renders all 23 skill categories', () => { - render() + beforeEach(() => { + mockedFetchCategories.mockReset() + mockedFetchMembers.mockReset() + mockedFetchCategories.mockResolvedValue(CATEGORIES) + mockedFetchMembers.mockImplementation(async selectedCategory => ( + selectedCategory === 'Programming and Development' ? MEMBERS : [] + )) + }) + + it('renders skill categories from the reports API', async () => { + renderPage() - expect(SKILL_CATEGORIES) - .toHaveLength(23) + expect(await screen.findByRole('button', { name: 'Programming and Development' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Scripting and Automation' })) + .toBeInTheDocument() + expect(screen.getByText('Browse and connect with verified experts across 2 skill categories.')) + .toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Bar' })) .not.toBeInTheDocument() - SKILL_CATEGORIES.forEach(category => { - expect(screen.getByRole('button', { name: category.name })) - .toBeInTheDocument() - }) + expect(mockedFetchCategories) + .toHaveBeenCalledTimes(1) }) - it('shows the category popover on hover and the members UI on click', () => { - render() + it('shows the category popover on hover and the members UI on click', async () => { + renderPage() - const bubble = screen.getByRole('button', { name: 'Programming & Development' }) + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) fireEvent.mouseEnter(bubble) expect(screen.getByText('Total Members')) .toBeInTheDocument() - expect(screen.getByText('banerjeesourish')) + expect(await screen.findByText('billzedison')) .toBeInTheDocument() expect(screen.getByText('Total Members') .closest('[data-placement]')) .toHaveAttribute('data-placement', expect.stringMatching(/^(top|bottom|left|right)$/)) - expect(screen.queryByRole('heading', { name: 'Members for Programming & Development' })) + expect(screen.queryByRole('heading', { name: 'Members for Programming and Development' })) .not.toBeInTheDocument() fireEvent.click(bubble) - expect(screen.getByRole('heading', { name: 'Members for Programming & Development' })) + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) .toBeInTheDocument() - expect(screen.getByText('billzedison')) + expect(screen.getByText('Ghostar')) .toBeInTheDocument() }) - it('filters members from in-memory state when searching', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + it('filters members from in-memory state when searching', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByText('billzedison')) + .toBeInTheDocument() fireEvent.change(screen.getByLabelText('Search members'), { target: { value: 'Ghostar' }, @@ -148,9 +247,12 @@ describe('SkillStatisticsPage', () => { .toBeInTheDocument() }) - it('reranks members when filtering by country', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + it('reranks members when filtering by country', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByText('billzedison')) + .toBeInTheDocument() fireEvent.change(screen.getByLabelText('Filter By'), { target: { value: 'GB' }, @@ -167,4 +269,14 @@ describe('SkillStatisticsPage', () => { .getByText('1st')) .toBeInTheDocument() }) + + it('shows an error when skill categories fail to load', async () => { + mockedFetchCategories.mockRejectedValueOnce(new Error('failed')) + renderPage() + + expect(await screen.findByRole('alert')) + .toHaveTextContent('Skill categories could not be loaded.') + expect(screen.queryByRole('button', { name: 'Programming and Development' })) + .not.toBeInTheDocument() + }) }) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx index a85e28e49..53fb628b2 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -1,54 +1,120 @@ import { FC, useCallback, useMemo, useState } from 'react' +import useSWR, { SWRResponse } from 'swr' import 'flag-icons/css/flag-icons.min.css' -import { getMembersForCategory, SKILL_CATEGORIES } from './mock' +import { + ExpertSkillCategory, + ExpertSkillCategoryMember, + expertSkillCategoryMembersCacheKey, + EXPERT_SKILL_CATEGORIES_CACHE_KEY, + fetchExpertSkillCategories, + fetchExpertSkillCategoryMembers, +} from '../../../lib' + import SkillBubblesChart from './SkillBubblesChart' import SkillMembersPanel from './SkillMembersPanel' import styles from './SkillStatisticsPage.module.scss' +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +function getPageSubtitle(categoryCount?: number): string { + if (!categoryCount) { + return 'Browse and connect with verified experts.' + } + + return `Browse and connect with verified experts across ${NUMBER_FORMATTER.format(categoryCount)} skill categories.` +} + const SkillStatisticsPage: FC = () => { const [selectedCategoryId, setSelectedCategoryId] = useState() const [search, setSearch] = useState('') const [countryFilter, setCountryFilter] = useState('') + const { + data: categories, + error: categoriesError, + mutate: reloadCategories, + }: SWRResponse = useSWR( + EXPERT_SKILL_CATEGORIES_CACHE_KEY, + fetchExpertSkillCategories, + ) const selectedCategory = useMemo( - () => SKILL_CATEGORIES.find(category => category.id === selectedCategoryId), - [selectedCategoryId], + () => categories?.find(category => category.id === selectedCategoryId), + [categories, selectedCategoryId], ) - const selectedMembers = useMemo( - () => (selectedCategoryId ? getMembersForCategory(selectedCategoryId) : []), - [selectedCategoryId], + const { + data: members, + error: membersError, + mutate: reloadMembers, + }: SWRResponse = useSWR( + selectedCategory + ? expertSkillCategoryMembersCacheKey(selectedCategory.name) + : undefined, + () => fetchExpertSkillCategoryMembers(selectedCategory?.name || ''), ) + const isLoadingCategories = !categories && !categoriesError + const isLoadingMembers = Boolean(selectedCategory && !members && !membersError) + const selectCategory = useCallback((categoryId: string) => { setSelectedCategoryId(categoryId) setSearch('') setCountryFilter('') }, []) + const retryCategories = useCallback(() => { + reloadCategories() + }, [reloadCategories]) + + const retryMembers = useCallback(() => { + reloadMembers() + }, [reloadMembers]) + return (

Skill Statistics

-

- Browse and connect with verified experts across 23 skill categories. -

+

{getPageSubtitle(categories?.length)}

Select a skill category to see additional details

- + {isLoadingCategories && ( +
Loading skill categories…
+ )} + {categoriesError && ( +
+ Skill categories could not be loaded. + +
+ )} + {!isLoadingCategories && !categoriesError && ( + + )}
- {selectedCategory && ( + {selectedCategory && isLoadingMembers && ( +
Loading members…
+ )} + {selectedCategory && membersError && ( +
+ Members could not be loaded. + +
+ )} + {selectedCategory && !isLoadingMembers && !membersError && ( > - -export type SkillCategoryMock = { - color: string - icon: SkillCategoryIcon - id: string - name: string - officialName: string - size: number - skillsBreakdown: Array<{ name: string; percentage: number }> - totalMembers: number - totalSkills: number -} - -export type SkillMemberMock = { - countryCode: string - countryName: string - handle: string - name: string - photoURL?: string - rating: number - wins: number -} - -export const PROGRAMMING_CATEGORY_ID = '481b5ebc-2fe6-45ed-a90c-736936d458d7' - -export const SKILL_CATEGORIES: SkillCategoryMock[] = [ - { - color: '#1B4F72', - icon: IconOutline.TerminalIcon, - id: PROGRAMMING_CATEGORY_ID, - name: 'Programming & Development', - officialName: 'Programming and Development', - size: 10, - skillsBreakdown: [ - { name: 'JavaScript', percentage: 40 }, - { name: 'Python', percentage: 30 }, - { name: 'Swift', percentage: 15 }, - ], - totalMembers: 1012928, - totalSkills: 1059, - }, - { - color: '#3D8B8F', - icon: IconOutline.RssIcon, - id: 'cfb17211-2abd-41e1-b169-e90cf038c6a7', - name: 'Networking and Telecommunications', - officialName: 'Networking and Telecommunications', - size: 8.4, - skillsBreakdown: [ - { name: 'TCP/IP', percentage: 35 }, - { name: 'Routing', percentage: 28 }, - { name: '5G', percentage: 22 }, - ], - totalMembers: 412800, - totalSkills: 286, - }, - { - color: '#5B9BD5', - icon: IconOutline.GlobeAltIcon, - id: '5aadafad-da63-488e-8499-32b596215789', - name: 'Web Development', - officialName: 'Web Development', - size: 7.8, - skillsBreakdown: [ - { name: 'React', percentage: 38 }, - { name: 'Node.js', percentage: 27 }, - { name: 'CSS', percentage: 20 }, - ], - totalMembers: 388420, - totalSkills: 412, - }, - { - color: '#5EB3C4', - icon: IconOutline.ShieldCheckIcon, - id: '221f4e3f-1ac8-438b-9dc1-977e30656789', - name: 'Cybersecurity', - officialName: 'Cybersecurity', - size: 7.2, - skillsBreakdown: [ - { name: 'Pen Testing', percentage: 32 }, - { name: 'SIEM', percentage: 26 }, - { name: 'IAM', percentage: 24 }, - ], - totalMembers: 276540, - totalSkills: 198, - }, - { - color: '#1A3D3D', - icon: IconOutline.CloudIcon, - id: 'cc346829-c9e4-44a9-996b-34054cf20fec', - name: 'Cloud Computing', - officialName: 'Cloud Computing', - size: 6.4, - skillsBreakdown: [ - { name: 'AWS', percentage: 42 }, - { name: 'Azure', percentage: 28 }, - { name: 'GCP', percentage: 18 }, - ], - totalMembers: 241100, - totalSkills: 176, - }, - { - color: '#2D4A3E', - icon: IconOutline.RefreshIcon, - id: 'aa495f25-2f2d-4334-9b6f-2ffe11d835d2', - name: 'Software Development Lifecycle', - officialName: 'Software Development Lifecycle (SDLC)', - size: 6.2, - skillsBreakdown: [ - { name: 'Agile', percentage: 40 }, - { name: 'CI/CD', percentage: 30 }, - { name: 'Scrum', percentage: 18 }, - ], - totalMembers: 198760, - totalSkills: 94, - }, - { - color: '#4A5D4A', - icon: IconOutline.DuplicateIcon, - id: 'e2429c1b-7609-49e0-93cc-341a89e12269', - name: 'DevOps & Automation', - officialName: 'DevOps and Automation', - size: 5.8, - skillsBreakdown: [ - { name: 'Kubernetes', percentage: 34 }, - { name: 'Terraform', percentage: 28 }, - { name: 'Jenkins', percentage: 22 }, - ], - totalMembers: 176430, - totalSkills: 142, - }, - { - color: '#3D7EA6', - icon: IconOutline.ChipIcon, - id: '185f4bf3-50de-46af-aaa6-9011872395cf', - name: 'Operating Systems', - officialName: 'Operating Systems', - size: 5.5, - skillsBreakdown: [ - { name: 'Linux', percentage: 48 }, - { name: 'Windows', percentage: 22 }, - { name: 'macOS', percentage: 16 }, - ], - totalMembers: 154220, - totalSkills: 88, - }, - { - color: '#2C5F8A', - icon: IconOutline.ChartBarIcon, - id: '4064574c-befa-4fb3-a8e2-34038d7f845b', - name: 'Data Analysis & Big Data', - officialName: 'Data Analysis and Big Data', - size: 5.4, - skillsBreakdown: [ - { name: 'SQL', percentage: 36 }, - { name: 'Spark', percentage: 26 }, - { name: 'Tableau', percentage: 20 }, - ], - totalMembers: 148900, - totalSkills: 164, - }, - { - color: '#7EB8C4', - icon: IconOutline.SparklesIcon, - id: 'a1289278-a734-4523-918f-ea0f05667e24', - name: 'Machine Learning & AI', - officialName: 'Machine Learning and AI', - size: 5.1, - skillsBreakdown: [ - { name: 'PyTorch', percentage: 33 }, - { name: 'TensorFlow', percentage: 29 }, - { name: 'NLP', percentage: 21 }, - ], - totalMembers: 132450, - totalSkills: 210, - }, - { - color: '#2C4A6E', - icon: IconOutline.ServerIcon, - id: 'e50b1794-e08d-4dc3-a4b1-6b5213c7da8e', - name: 'Databases & Data Warehousing', - officialName: 'Databases and Data Warehousing', - size: 5, - skillsBreakdown: [ - { name: 'PostgreSQL', percentage: 34 }, - { name: 'Snowflake', percentage: 28 }, - { name: 'Redshift', percentage: 20 }, - ], - totalMembers: 121800, - totalSkills: 118, - }, - { - color: '#3D5C5C', - icon: IconOutline.PencilAltIcon, - id: '3eb5163c-cd3f-4c7d-b059-95c901bc2066', - name: 'UX Design & Multimedia', - officialName: 'User Experience Design and Multimedia', - size: 4.6, - skillsBreakdown: [ - { name: 'Figma', percentage: 42 }, - { name: 'UX Research', percentage: 24 }, - { name: 'Motion', percentage: 16 }, - ], - totalMembers: 98600, - totalSkills: 76, - }, - { - color: '#6B7C4A', - icon: IconOutline.CalculatorIcon, - id: 'b3c8970d-79e8-4f97-a84e-841aebaa890f', - name: 'Mathematics & Statistics', - officialName: 'Mathematics and Statistics', - size: 4.5, - skillsBreakdown: [ - { name: 'Statistics', percentage: 38 }, - { name: 'Linear Algebra', percentage: 27 }, - { name: 'R', percentage: 19 }, - ], - totalMembers: 87400, - totalSkills: 64, - }, - { - color: '#2D5A8A', - icon: IconOutline.CubeTransparentIcon, - id: '35e9e3c6-3480-4fdb-9f77-91e667923a01', - name: 'Virtualization', - officialName: 'Virtualization', - size: 4.4, - skillsBreakdown: [ - { name: 'VMware', percentage: 36 }, - { name: 'Hyper-V', percentage: 28 }, - { name: 'KVM', percentage: 20 }, - ], - totalMembers: 76210, - totalSkills: 41, - }, - { - color: '#5A8A8A', - icon: IconOutline.MapIcon, - id: '6b9717ef-9520-4507-9039-2acedbec002d', - name: 'Geospatial Information Systems', - officialName: 'Geospatial Information Systems (GIS)', - size: 4, - skillsBreakdown: [ - { name: 'ArcGIS', percentage: 40 }, - { name: 'QGIS', percentage: 28 }, - { name: 'GeoJSON', percentage: 18 }, - ], - totalMembers: 54120, - totalSkills: 52, - }, - { - color: '#4EC4C4', - icon: IconOutline.DesktopComputerIcon, - id: '831ed28d-c20f-40c8-a348-d1d3739e9046', - name: 'Hardware & Systems Administration', - officialName: 'Hardware and Systems Administration', - size: 3.9, - skillsBreakdown: [ - { name: 'Linux Admin', percentage: 36 }, - { name: 'Networking', percentage: 27 }, - { name: 'Hardware', percentage: 21 }, - ], - totalMembers: 49880, - totalSkills: 58, - }, - { - color: '#2C4A6E', - icon: IconOutline.ClipboardCheckIcon, - id: 'c5f83f60-4dcf-4305-b55e-b38fe5afec60', - name: 'Software Testing & QA', - officialName: 'Software Testing and Quality Assurance', - size: 3.8, - skillsBreakdown: [ - { name: 'Selenium', percentage: 34 }, - { name: 'Cypress', percentage: 28 }, - { name: 'JMeter', percentage: 20 }, - ], - totalMembers: 46750, - totalSkills: 72, - }, - { - color: '#5A8A9A', - icon: IconOutline.DatabaseIcon, - id: '38fadd80-8721-4ce0-9387-cb6ad3ce48da', - name: 'Database Management', - officialName: 'Database Management', - size: 3.7, - skillsBreakdown: [ - { name: 'MySQL', percentage: 38 }, - { name: 'Oracle', percentage: 26 }, - { name: 'MongoDB', percentage: 20 }, - ], - totalMembers: 43210, - totalSkills: 81, - }, - { - color: '#4A9A9A', - icon: IconOutline.DeviceMobileIcon, - id: '0ae22576-48ed-4ffd-9319-058b6fd80675', - name: 'Mobile App Development', - officialName: 'Mobile App Development', - size: 3.5, - skillsBreakdown: [ - { name: 'Swift', percentage: 32 }, - { name: 'Kotlin', percentage: 30 }, - { name: 'React Native', percentage: 22 }, - ], - totalMembers: 38940, - totalSkills: 96, - }, - { - color: '#3D6A8A', - icon: IconOutline.ShareIcon, - id: 'f1daa100-b63b-45c1-a638-90fbfc817200', - name: 'Blockchain', - officialName: 'Blockchain', - size: 3.3, - skillsBreakdown: [ - { name: 'Solidity', percentage: 40 }, - { name: 'Ethereum', percentage: 28 }, - { name: 'Web3', percentage: 18 }, - ], - totalMembers: 27650, - totalSkills: 44, - }, - { - color: '#5EB8B0', - icon: IconOutline.WifiIcon, - id: 'e4a51b10-ecba-46eb-89e2-5908bb324a8c', - name: 'IoT (Internet of Things)', - officialName: 'IoT (Internet of Things)', - size: 3.2, - skillsBreakdown: [ - { name: 'MQTT', percentage: 34 }, - { name: 'Embedded C', percentage: 28 }, - { name: 'Arduino', percentage: 22 }, - ], - totalMembers: 24180, - totalSkills: 39, - }, - { - color: '#3D5A6E', - icon: IconOutline.ClipboardListIcon, - id: '07a0abe3-2791-4068-b5b5-be48fefa3551', - name: 'Project Management', - officialName: 'Project Management', - size: 3.1, - skillsBreakdown: [ - { name: 'Jira', percentage: 36 }, - { name: 'PMP', percentage: 26 }, - { name: 'Kanban', percentage: 22 }, - ], - totalMembers: 21890, - totalSkills: 27, - }, - { - color: '#4A6A7A', - icon: IconOutline.CodeIcon, - id: '1f5ed3e8-8d22-44ea-b75d-ea85147a04da', - name: 'Scripting & Automation', - officialName: 'Scripting and Automation', - size: 3, - skillsBreakdown: [ - { name: 'Bash', percentage: 36 }, - { name: 'Python', percentage: 32 }, - { name: 'PowerShell', percentage: 18 }, - ], - totalMembers: 19640, - totalSkills: 33, - }, -] diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts deleted file mode 100644 index 9001c5cf1..000000000 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - PROGRAMMING_CATEGORY_ID, - SkillMemberMock, - SKILL_CATEGORIES, -} from './skill-categories.mock' - -const COUNTRIES: Array<{ code: string; name: string }> = [ - { code: 'IN', name: 'India' }, - { code: 'US', name: 'USA' }, - { code: 'CN', name: 'China' }, - { code: 'GB', name: 'UK' }, - { code: 'UA', name: 'Ukraine' }, - { code: 'CA', name: 'Canada' }, - { code: 'BR', name: 'Brazil' }, - { code: 'DE', name: 'Germany' }, - { code: 'JP', name: 'Japan' }, - { code: 'AU', name: 'Australia' }, -] - -const HANDLES = [ - 'skywalker', 'bytecraft', 'codecat', 'pixelhawk', 'algomind', - 'devnova', 'stackpilot', 'nimbusdev', 'qubitron', 'hashlane', - 'loopsmith', 'gridfox', 'nullwave', 'bitforge', 'cloudnest', - 'syntaxio', 'datapath', 'kernelfox', 'vectorly', 'modulin', -] - -const FIRST_NAMES = [ - 'Alex', 'Jordan', 'Priya', 'Wei', 'Sofia', 'Noah', 'Amina', 'Lucas', 'Mei', 'Omar', -] - -const LAST_NAMES = [ - 'Chen', 'Patel', 'Nguyen', 'Garcia', 'Khan', 'Silva', 'Ivanov', 'Kim', 'Brown', 'Rossi', -] - -const RATINGS = [780, 950, 1100, 1350, 1480, 1600, 1800, 1900, 2000, 2100, 2200, 2300, 2400] - -const PROGRAMMING_TOP_MEMBERS: SkillMemberMock[] = [ - { - countryCode: 'IN', - countryName: 'India', - handle: 'billzedison', - name: 'Honghan W', - rating: 2000, - wins: 376, - }, - { - countryCode: 'US', - countryName: 'USA', - handle: 'Ghostar', - name: 'Justin G', - rating: 1900, - wins: 322, - }, - { - countryCode: 'IN', - countryName: 'India', - handle: 'stevenfrog', - name: 'Steven', - rating: 2100, - wins: 280, - }, - { - countryCode: 'CN', - countryName: 'China', - handle: 'ergolite', - name: 'Michael P', - rating: 2200, - wins: 262, - }, - { - countryCode: 'IN', - countryName: 'India', - handle: 'jiangliwu', - name: 'Jiang L', - rating: 950, - wins: 210, - }, - { - countryCode: 'GB', - countryName: 'UK', - handle: 'diazx', - name: 'DAT N', - rating: 2300, - wins: 200, - }, - { - countryCode: 'US', - countryName: 'USA', - handle: 'Standlove', - name: 'GuanZhao I', - rating: 1800, - wins: 188, - }, - { - countryCode: 'IN', - countryName: 'India', - handle: 'soso0574', - name: 'Jianchang S', - rating: 1600, - wins: 176, - }, - { - countryCode: 'IN', - countryName: 'India', - handle: 'vasilica.olaru', - name: 'vasilica.olaru', - rating: 2400, - wins: 132, - }, - { - countryCode: 'IN', - countryName: 'India', - handle: 'ngoctay', - name: 'Minh Ngoc P', - rating: 780, - wins: 90, - }, -] - -function hashString(value: string): number { - let hash = 0 - for (let index = 0; index < value.length; index += 1) { - hash = ((hash * 31) + value.charCodeAt(index)) % 2147483647 - } - - return Math.abs(hash) -} - -function buildGeneratedMembers(categoryId: string, startWins: number): SkillMemberMock[] { - const members: SkillMemberMock[] = [] - - for (let index = 0; index < 90; index += 1) { - const seed = hashString(`${categoryId}-${index}`) - const country = COUNTRIES[seed % COUNTRIES.length] - const firstName = FIRST_NAMES[seed % FIRST_NAMES.length] - const lastName = LAST_NAMES[hashString(`${categoryId}-last-${index}`) % LAST_NAMES.length] - const handleBase = HANDLES[hashString(`${categoryId}-handle-${index}`) % HANDLES.length] - - members.push({ - countryCode: country.code, - countryName: country.name, - handle: `${handleBase}${index + 1}`, - name: `${firstName} ${lastName.charAt(0)}`, - rating: RATINGS[seed % RATINGS.length], - wins: Math.max(1, startWins - index), - }) - } - - return members -} - -function buildMembersForCategory(categoryId: string): SkillMemberMock[] { - if (categoryId === PROGRAMMING_CATEGORY_ID) { - return [ - ...PROGRAMMING_TOP_MEMBERS, - ...buildGeneratedMembers(categoryId, 89), - ] - } - - const seed = hashString(categoryId) - const startWins = 120 + (seed % 80) - - return buildGeneratedMembers(categoryId, startWins) - .slice(0, 100) - .sort((left, right) => right.wins - left.wins) -} - -export const SKILL_MEMBERS_BY_CATEGORY: Record = Object.fromEntries( - SKILL_CATEGORIES.map(category => [ - category.id, - buildMembersForCategory(category.id), - ]), -) - -export function getMembersForCategory(categoryId: string): SkillMemberMock[] { - return SKILL_MEMBERS_BY_CATEGORY[categoryId] || [] -} - -export function getTopMemberForCategory(categoryId: string): SkillMemberMock | undefined { - if (categoryId === PROGRAMMING_CATEGORY_ID) { - return { - countryCode: 'IN', - countryName: 'India', - handle: 'banerjeesourish', - name: 'Sourish Banerjee', - rating: 1400, - wins: 1768, - } - } - - return getMembersForCategory(categoryId)[0] -} From 18129fccf8f98db11fee6b2f0e339de142feb3cd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 24 Aug 2026 15:00:16 +1000 Subject: [PATCH 36/44] Revert "Mirror code to AWS code commit for backup purposes" This reverts commit 7d71cb8e4da77b4f8507f2902e1aec73bafef68e. --- .github/workflows/codecommit-mirror.yml | 142 ------------------ README.md | 5 - docs/github-codecommit-mirror.md | 184 ------------------------ 3 files changed, 331 deletions(-) delete mode 100644 .github/workflows/codecommit-mirror.yml delete mode 100644 docs/github-codecommit-mirror.md diff --git a/.github/workflows/codecommit-mirror.yml b/.github/workflows/codecommit-mirror.yml deleted file mode 100644 index 6b485cc9c..000000000 --- a/.github/workflows/codecommit-mirror.yml +++ /dev/null @@ -1,142 +0,0 @@ -name: Mirror GitHub to AWS CodeCommit - -on: - push: - branches: - - '**' - tags: - - '**' - schedule: - - cron: '17 */6 * * *' - workflow_dispatch: - -permissions: - contents: read - id-token: write - -concurrency: - group: codecommit-mirror-${{ github.repository }} - cancel-in-progress: false - -jobs: - mirror: - name: Synchronize Git refs - if: ${{ vars.CODECOMMIT_MIRROR_ENABLED == 'true' }} - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - MIRROR_AWS_REGION: ${{ vars.CODECOMMIT_MIRROR_AWS_REGION }} - MIRROR_REPOSITORY: ${{ vars.CODECOMMIT_MIRROR_REPOSITORY }} - MIRROR_ROLE_ARN: ${{ vars.CODECOMMIT_MIRROR_ROLE_ARN }} - SOURCE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - - steps: - - name: Validate mirror configuration - shell: bash - run: | - set -euo pipefail - - configuration_is_valid=true - - if [[ -z "${MIRROR_AWS_REGION}" ]]; then - echo "::error::Repository variable CODECOMMIT_MIRROR_AWS_REGION is not configured." - configuration_is_valid=false - fi - - if [[ -z "${MIRROR_REPOSITORY}" ]]; then - echo "::error::Repository variable CODECOMMIT_MIRROR_REPOSITORY is not configured." - configuration_is_valid=false - fi - - if [[ -z "${MIRROR_ROLE_ARN}" ]]; then - echo "::error::Repository variable CODECOMMIT_MIRROR_ROLE_ARN is not configured." - configuration_is_valid=false - fi - - if [[ -z "${SOURCE_DEFAULT_BRANCH}" ]]; then - echo "::error::The GitHub event did not identify the repository's default branch." - configuration_is_valid=false - fi - - if [[ "${configuration_is_valid}" != "true" ]]; then - exit 1 - fi - - if [[ ! "${MIRROR_AWS_REGION}" =~ ^[a-z]{2}(-[a-z0-9]+)+-[0-9]+$ ]]; then - echo "::error::CODECOMMIT_MIRROR_AWS_REGION is not a valid AWS Region name." - exit 1 - fi - - if [[ ! "${MIRROR_REPOSITORY}" =~ ^[A-Za-z0-9._-]{1,100}$ ]]; then - echo "::error::CODECOMMIT_MIRROR_REPOSITORY is not a valid CodeCommit repository name." - exit 1 - fi - - if [[ ! "${MIRROR_ROLE_ARN}" =~ ^arn:(aws|aws-us-gov|aws-cn):iam::[0-9]{12}:role/.+$ ]]; then - echo "::error::CODECOMMIT_MIRROR_ROLE_ARN is not a valid IAM role ARN." - exit 1 - fi - - - name: Check out every branch and tag - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ env.SOURCE_DEFAULT_BRANCH }} - fetch-depth: 0 - persist-credentials: false - - - name: Configure short-lived AWS credentials - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 - with: - role-to-assume: ${{ env.MIRROR_ROLE_ARN }} - aws-region: ${{ env.MIRROR_AWS_REGION }} - role-session-name: GitHubActions-platform-ui-mirror - - - name: Synchronize branches, tags, and the default branch - shell: bash - run: | - set -euo pipefail - - codecommit_url="https://git-codecommit.${MIRROR_AWS_REGION}.amazonaws.com/v1/repos/${MIRROR_REPOSITORY}" - git remote add codecommit "${codecommit_url}" - git config --local credential.helper '!aws codecommit credential-helper $@' - git config --local credential.UseHttpPath true - - while read -r object_name remote_ref; do - if [[ "${remote_ref}" == "refs/remotes/origin/HEAD" ]]; then - continue - fi - - branch_name="${remote_ref#refs/remotes/origin/}" - git update-ref "refs/heads/${branch_name}" "${object_name}" - done < <(git for-each-ref --format='%(objectname) %(refname)' refs/remotes/origin) - - # Create/update source refs first so the GitHub default branch exists in CodeCommit. - git push --force codecommit \ - 'refs/heads/*:refs/heads/*' \ - 'refs/tags/*:refs/tags/*' - - aws codecommit update-default-branch \ - --region "${MIRROR_AWS_REGION}" \ - --repository-name "${MIRROR_REPOSITORY}" \ - --default-branch-name "${SOURCE_DEFAULT_BRANCH}" - - # Prune only branches and tags; provider-specific internal refs are left untouched. - git push --force --prune codecommit \ - 'refs/heads/*:refs/heads/*' \ - 'refs/tags/*:refs/tags/*' - - - name: Verify mirrored ref object IDs - shell: bash - run: | - set -euo pipefail - - source_refs="${RUNNER_TEMP}/platform-ui-source-refs" - destination_refs="${RUNNER_TEMP}/platform-ui-codecommit-refs" - - git for-each-ref --format='%(objectname) %(refname)' refs/heads refs/tags \ - | sort > "${source_refs}" - git ls-remote --refs codecommit 'refs/heads/*' 'refs/tags/*' \ - | awk '{ print $1, $2 }' \ - | sort > "${destination_refs}" - - diff -u "${source_refs}" "${destination_refs}" diff --git a/README.md b/README.md index bec2b803e..b52243e1c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ All future user interfaces at Topcoder will be implemented here. Pre-existing us # Source Control & CI/CD - [Deployments](#deployments) -- [Repository Backup](#repository-backup) - [Pull Requests](#pull-requests) - [Branching](#branching) - [Commits](#commits) @@ -30,10 +29,6 @@ The `dev` branch is auto-deployed to the dev environment: https://platform-mvp.t The `master` branch is auto-deployed to the production environment: https://platform-ui.topcoder.com. -## Repository Backup - -Git branches, tags, and their reachable history can be mirrored from GitHub to AWS CodeCommit by the `Mirror GitHub to AWS CodeCommit` workflow. See the [CodeCommit mirror setup and operations guide](docs/github-codecommit-mirror.md) for its behavior, limitations, and the one-time AWS and GitHub administrator setup. - ## Pull Requests If a Jira ticket requires any code changes, it should have its own pull request. diff --git a/docs/github-codecommit-mirror.md b/docs/github-codecommit-mirror.md deleted file mode 100644 index eb2bb47b0..000000000 --- a/docs/github-codecommit-mirror.md +++ /dev/null @@ -1,184 +0,0 @@ -# GitHub backup mirror to AWS CodeCommit - -The [`codecommit-mirror.yml`](../.github/workflows/codecommit-mirror.yml) workflow maintains a secondary Git copy of `topcoder-platform/platform-ui` in a dedicated AWS CodeCommit repository. It is disabled until the repository variable `CODECOMMIT_MIRROR_ENABLED` is set to `true`. - -AWS returned CodeCommit to full general availability, including new customers, on 24 November 2025. See [The Future of AWS CodeCommit](https://aws.amazon.com/blogs/devops/aws-codecommit-returns-to-general-availability/). - -## Provisioned status - -The tenant setup was completed on 24 August 2026: - -- The dedicated `platform-ui` CodeCommit repository exists in `us-east-1` with `dev` as its default branch. -- The AWS account has the GitHub Actions OIDC provider and the `GitHubActionsPlatformUiCodeCommitMirror` role with the repository-scoped `PlatformUiCodeCommitMirrorAccess` policy. -- All four GitHub repository variables are configured, including `CODECOMMIT_MIRROR_ENABLED=true`. -- The initial mirror was seeded directly from GitHub and all branch and tag object IDs were verified. - -The remaining activation step is to commit this workflow and merge it into GitHub's `dev` default branch. The enabled variable is inert until GitHub contains the workflow file. - -## What the workflow does - -For every GitHub push to a branch or tag, the workflow: - -1. Fetches the complete GitHub history for all branches and tags. -2. Uses GitHub OpenID Connect (OIDC) to assume a narrowly scoped AWS IAM role. No long-lived AWS access key is stored in GitHub. -3. Force-updates all CodeCommit branch and tag refs to the exact Git object IDs held by GitHub. -4. Sets the CodeCommit default branch to the GitHub default branch. -5. Removes CodeCommit branches and tags that no longer exist in GitHub. -6. Fails the run if the final branch or tag object IDs differ between the two repositories. - -Runs are serialized so that an older run cannot finish after a newer run and move the mirror backwards. A full reconciliation also runs every six hours and can be started manually. This catches current refs after skipped Actions, pushes made with GitHub's workflow token, or pushes to an older branch that does not contain the workflow file. - -The first successful run seeds the entire repository. Later runs still fetch the complete GitHub repository on a fresh runner, but Git sends CodeCommit only objects that the destination does not already have. - -## One-time setup reference - -These steps are complete for the current deployment. Use them only when auditing or rebuilding the integration. The target is destructive by design: it must be a dedicated mirror, because branches and tags that exist only in CodeCommit will be deleted. - -### 1. Create the CodeCommit repository - -Choose the AWS account, supported AWS Region, and repository name. `platform-ui` is the recommended repository name. Create it without an initial README or other content where possible. - -For example, with an authenticated AWS CLI: - -```bash -aws codecommit create-repository \ - --region \ - --repository-name platform-ui \ - --repository-description "Read-only mirror of topcoder-platform/platform-ui" -``` - -Do not use this CodeCommit repository for development or CodeCommit pull requests. GitHub is the source of truth and the next synchronization can overwrite or delete CodeCommit refs. - -### 2. Add GitHub as an AWS OIDC identity provider - -If the AWS account already has the GitHub Actions OIDC provider, reuse it. Otherwise, in IAM add an OpenID Connect provider with: - -- Provider URL: `https://token.actions.githubusercontent.com` -- Audience: `sts.amazonaws.com` - -GitHub documents the AWS setup in [Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws). - -### 3. Create the IAM mirror role - -Create a role such as `GitHubActionsPlatformUiCodeCommitMirror`. Replace `` in this trust policy. The policy accepts both GitHub's legacy repository-name subject and its immutable repository-ID subject. The IDs shown are the stable IDs for the existing `topcoder-platform/platform-ui` repository and its owner. - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "TrustPlatformUiGitHubActions", - "Effect": "Allow", - "Principal": { - "Federated": "arn:aws:iam:::oidc-provider/token.actions.githubusercontent.com" - }, - "Action": "sts:AssumeRoleWithWebIdentity", - "Condition": { - "StringEquals": { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" - }, - "StringLike": { - "token.actions.githubusercontent.com:sub": [ - "repo:topcoder-platform/platform-ui:ref:refs/*", - "repo:topcoder-platform@25333036/platform-ui@462526084:ref:refs/*" - ] - } - } - } - ] -} -``` - -Attach the following permissions policy after replacing ``, ``, and ``. `GitPull` is required for the final object-ID verification, `GitPush` synchronizes Git data, and `UpdateDefaultBranch` keeps the repository metadata aligned. - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "SynchronizePlatformUiMirror", - "Effect": "Allow", - "Action": [ - "codecommit:GitPull", - "codecommit:GitPush", - "codecommit:UpdateDefaultBranch" - ], - "Resource": "arn:aws:codecommit:::" - } - ] -} -``` - -Do not attach a broad managed CodeCommit policy. This role needs access to only the single mirror repository. Anyone able to modify and push a workflow on an allowed `platform-ui` ref can request this role, so its permissions must remain mirror-only. - -### 4. Configure GitHub repository variables - -In `topcoder-platform/platform-ui`, open **Settings > Secrets and variables > Actions > Variables** and create these repository variables: - -| Variable | Example | Purpose | -| ------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------- | -| `CODECOMMIT_MIRROR_AWS_REGION` | `us-east-1` | Region containing the CodeCommit repository | -| `CODECOMMIT_MIRROR_REPOSITORY` | `platform-ui` | CodeCommit repository name | -| `CODECOMMIT_MIRROR_ROLE_ARN` | `arn:aws:iam::123456789012:role/GitHubActionsPlatformUiCodeCommitMirror` | OIDC role assumed by the workflow | - -These values identify AWS resources but are not credentials, so repository variables are appropriate. No GitHub Actions secrets are required. - -For a new deployment, leave `CODECOMMIT_MIRROR_ENABLED` unset while creating and reviewing the infrastructure. After this workflow is present on the GitHub default branch and the three variables above are correct, add: - -| Variable | Value | -| --------------------------- | ------ | -| `CODECOMMIT_MIRROR_ENABLED` | `true` | - -Setting this last prevents expected failures while setup is incomplete. The value is case-sensitive. - -The same setup can be performed with GitHub CLI by an administrator: - -```bash -gh variable set CODECOMMIT_MIRROR_AWS_REGION \ - --repo topcoder-platform/platform-ui \ - --body -gh variable set CODECOMMIT_MIRROR_REPOSITORY \ - --repo topcoder-platform/platform-ui \ - --body platform-ui -gh variable set CODECOMMIT_MIRROR_ROLE_ARN \ - --repo topcoder-platform/platform-ui \ - --body arn:aws:iam:::role/GitHubActionsPlatformUiCodeCommitMirror -gh variable set CODECOMMIT_MIRROR_ENABLED \ - --repo topcoder-platform/platform-ui \ - --body true -``` - -### 5. Seed and verify the mirror - -After enabling, open **Actions > Mirror GitHub to AWS CodeCommit > Run workflow** and run it from the default branch. The first run may take longer because it transfers the existing history and all current refs. - -A successful run verifies every `refs/heads/*` and `refs/tags/*` object ID itself. Also confirm in CodeCommit that: - -- the default branch matches GitHub; -- expected active branches and tags are present; and -- the latest default-branch commit ID matches GitHub. - -The push that adds this workflow can run it automatically if the variables were enabled first. A manual run is still recommended as the explicit acceptance check. - -## Ongoing operation - -- Treat a failed **Mirror GitHub to AWS CodeCommit** run as a backup warning. It does not roll back or block the original GitHub push. -- The next push or six-hour reconciliation attempts a complete repair, so retries are safe. -- Use **Run workflow** for an immediate repair or after changing AWS configuration. -- Keep the two third-party Actions pinned to reviewed commit SHAs. Review and update those pins deliberately when upgrading. -- If the repository is renamed, transferred, or opts into GitHub immutable OIDC subjects, verify the IAM role's `sub` conditions before the next run. The current trust policy already includes the present immutable owner and repository IDs. -- If the GitHub default branch changes, no manual CodeCommit change is needed; the next successful run updates it. - -## Scope and recovery limitations - -This is an exact secondary Git repository, not a point-in-time archive: - -- Branch deletion, tag deletion, tag movement, and force-pushes are reproduced in CodeCommit. Commits left unreachable by all mirrored refs are not guaranteed to remain recoverable indefinitely. -- A commit pushed and then made unreachable before any workflow or reconciliation fetches it can be missed. Protect important branches against deletion and force-pushes if historical retention is required. -- Only Git branches, Git tags, commits, trees, and ordinary Git blobs are mirrored. GitHub pull requests, issues, discussions, releases, Actions logs/artifacts, branch protections, repository settings, and secrets are outside the scope. -- Git LFS pointer files are Git blobs, but their external LFS objects are not copied by a normal Git push. Add a separate LFS/object-storage backup before adopting LFS. -- The workflow does not copy provider-specific internal refs such as GitHub pull-request refs. - -If the requirement changes from a warm Git replica to immutable, point-in-time retention, add a separate scheduled `git bundle` archive in versioned/object-locked storage rather than changing this mirror's destructive synchronization semantics. - -For disaster recovery, an administrator with CodeCommit read access can clone the CodeCommit repository and push its branches and tags to a new GitHub repository. Keep the CodeCommit repository and the mirror IAM role under the normal AWS backup-access and break-glass procedures. From ea203ffef76ad8b9fda978c752be7c8b27f10016 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 24 Aug 2026 15:11:24 +0530 Subject: [PATCH 37/44] Fix css --- .../SkillBubblesChart.module.scss | 29 ++++++++++++++----- .../SkillStatisticsPage/SkillBubblesChart.tsx | 13 +++++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss index da0416245..221cac070 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -17,25 +17,19 @@ align-items: center; border: 0; border-radius: 50%; + box-sizing: border-box; color: #fff; cursor: pointer; display: flex; - flex-direction: column; justify-content: center; overflow: hidden; - padding: 8px; + padding: 0; position: absolute; text-align: center; transform: translate(-50%, -50%); transition: box-shadow 160ms ease, transform 160ms ease; z-index: 1; - svg { - color: #fff; - flex: 0 0 auto; - margin-bottom: 4px; - } - &:hover, &:focus-visible, &.hovered { @@ -54,12 +48,31 @@ z-index: 4; } +.bubbleInner { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + width: 80%; + + svg { + color: #fff; + flex: 0 0 auto; + margin-bottom: 4px; + } +} + .label { display: -webkit-box; font-family: 'Nunito Sans', sans-serif; font-weight: 700; line-height: 1.15; + max-width: 100%; overflow: hidden; + overflow-wrap: anywhere; + width: 100%; + word-break: break-word; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx index 09f395788..040f36424 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -37,8 +37,8 @@ const POPOVER_GAP = 12 const POPOVER_ESTIMATED_HEIGHT = 340 const POPOVER_WIDTH = 320 const VIEW_PAD = 8 -const MIN_BUBBLE_FONT_SIZE = 12 -const MAX_BUBBLE_FONT_SIZE = 20 +const MIN_BUBBLE_FONT_SIZE = 10 +const MAX_BUBBLE_FONT_SIZE = 16 type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' @@ -258,7 +258,8 @@ const SkillBubblesChart: FC = props => { minPackedRadius, maxPackedRadius, ) - const iconSize = Math.max(14, Math.min(28, circle.r / 4.6)) + const innerSize = circle.r * 1.16 + const iconSize = Math.max(12, Math.min(22, innerSize / 5.5)) return ( ) })} From 1d567d83cef4df0e4dfb14f993eb8fd283328bf9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 24 Aug 2026 13:35:49 +0300 Subject: [PATCH 38/44] PM-5939 - campus leaderboard UI --- src/apps/campus/src/CampusApp.tsx | 1 + .../campus/src/lib/assets/icons/icon-help.svg | 5 + .../campus/src/lib/assets/icons/icon-info.svg | 3 + .../src/lib/assets/icons/icon-medal-1st.svg | 9 + .../src/lib/assets/icons/icon-medal-2nd.svg | 9 + .../src/lib/assets/icons/icon-medal-3rd.svg | 9 + .../lib/assets/icons/icon-result-failed.svg | 5 + .../lib/assets/icons/icon-result-passed.svg | 5 + .../lib/assets/icons/icon-stat-members.svg | 8 + .../src/lib/assets/icons/icon-stat-passed.svg | 8 + .../lib/assets/icons/icon-stat-registered.svg | 8 + .../lib/assets/icons/icon-stat-submitted.svg | 8 + .../src/lib/assets/icons/icon-stat-wins.svg | 8 + src/apps/campus/src/lib/assets/icons/index.ts | 45 ++++ src/apps/campus/src/lib/components/index.ts | 1 + .../components/stat-card/StatCard.module.scss | 44 ++++ .../src/lib/components/stat-card/StatCard.tsx | 30 +++ .../src/lib/components/stat-card/index.ts | 1 + src/apps/campus/src/lib/styles/index.scss | 67 +++++ .../CampusLeaderboardPage.module.scss | 243 +++++++++++------- .../CampusLeaderboardPage.spec.tsx | 7 +- .../leaderboard/CampusLeaderboardPage.tsx | 165 ++++++------ .../ParticipationHistoryModal.module.scss | 114 ++++++-- .../leaderboard/ParticipationHistoryModal.tsx | 179 +++++++------ .../leaderboard/RankingRulesModal.module.scss | 76 +++++- .../pages/leaderboard/RankingRulesModal.tsx | 31 ++- 26 files changed, 796 insertions(+), 293 deletions(-) create mode 100644 src/apps/campus/src/lib/assets/icons/icon-help.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-info.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-result-failed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-result-passed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-members.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg create mode 100644 src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg create mode 100644 src/apps/campus/src/lib/assets/icons/index.ts create mode 100644 src/apps/campus/src/lib/components/index.ts create mode 100644 src/apps/campus/src/lib/components/stat-card/StatCard.module.scss create mode 100644 src/apps/campus/src/lib/components/stat-card/StatCard.tsx create mode 100644 src/apps/campus/src/lib/components/stat-card/index.ts create mode 100644 src/apps/campus/src/lib/styles/index.scss diff --git a/src/apps/campus/src/CampusApp.tsx b/src/apps/campus/src/CampusApp.tsx index 931de6a3f..a5c01921d 100644 --- a/src/apps/campus/src/CampusApp.tsx +++ b/src/apps/campus/src/CampusApp.tsx @@ -4,6 +4,7 @@ import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { toolTitle } from './campus.routes' +import './lib/styles/index.scss' const CampusApp: FC = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) diff --git a/src/apps/campus/src/lib/assets/icons/icon-help.svg b/src/apps/campus/src/lib/assets/icons/icon-help.svg new file mode 100644 index 000000000..e2e8d906c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-help.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-info.svg b/src/apps/campus/src/lib/assets/icons/icon-info.svg new file mode 100644 index 000000000..28595a183 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg new file mode 100644 index 000000000..997f82201 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-1st.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg new file mode 100644 index 000000000..b92980117 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-2nd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg new file mode 100644 index 000000000..118a81b70 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-medal-3rd.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg new file mode 100644 index 000000000..a46b3769f --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-failed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg new file mode 100644 index 000000000..9112d0cb3 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-result-passed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg new file mode 100644 index 000000000..268aaf41c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-members.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg new file mode 100644 index 000000000..406071c9c --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-passed.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg new file mode 100644 index 000000000..c140d46dd --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-registered.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg new file mode 100644 index 000000000..3e461a0e1 --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-submitted.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg new file mode 100644 index 000000000..a427a31fa --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/icon-stat-wins.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apps/campus/src/lib/assets/icons/index.ts b/src/apps/campus/src/lib/assets/icons/index.ts new file mode 100644 index 000000000..9b710220b --- /dev/null +++ b/src/apps/campus/src/lib/assets/icons/index.ts @@ -0,0 +1,45 @@ +import { ReactComponent as IconHelp } from './icon-help.svg' +import { ReactComponent as IconInfo } from './icon-info.svg' +import { ReactComponent as IconMedal1st } from './icon-medal-1st.svg' +import { ReactComponent as IconMedal2nd } from './icon-medal-2nd.svg' +import { ReactComponent as IconMedal3rd } from './icon-medal-3rd.svg' +import { ReactComponent as IconResultFailed } from './icon-result-failed.svg' +import { ReactComponent as IconResultPassed } from './icon-result-passed.svg' +import { ReactComponent as IconStatMembers } from './icon-stat-members.svg' +import { ReactComponent as IconStatPassed } from './icon-stat-passed.svg' +import { ReactComponent as IconStatRegistered } from './icon-stat-registered.svg' +import { ReactComponent as IconStatSubmitted } from './icon-stat-submitted.svg' +import { ReactComponent as IconStatWins } from './icon-stat-wins.svg' + +export { + IconHelp, + IconInfo, + IconMedal1st, + IconMedal2nd, + IconMedal3rd, + IconResultFailed, + IconResultPassed, + IconStatMembers, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, +} + +/** + * Medal badges shown for the top three placements. + */ +export const placementIcons: { [placement: number]: typeof IconMedal1st } = { + 1: IconMedal1st, + 2: IconMedal2nd, + 3: IconMedal3rd, +} + +/** + * Ordinal labels for the top three placements. + */ +export const placementLabels: { [placement: number]: string } = { + 1: '1st place', + 2: '2nd place', + 3: '3rd place', +} diff --git a/src/apps/campus/src/lib/components/index.ts b/src/apps/campus/src/lib/components/index.ts new file mode 100644 index 000000000..3414cdcdf --- /dev/null +++ b/src/apps/campus/src/lib/components/index.ts @@ -0,0 +1 @@ +export * from './stat-card' diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss new file mode 100644 index 000000000..f48eab043 --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss @@ -0,0 +1,44 @@ +@import '@libs/ui/styles/includes'; + +.statCard { + align-items: center; + border: 1px solid var(--TableBorderColor); + border-radius: 8px; + display: flex; + flex: 1 0 0; + gap: $sp-2; + min-width: 0; + padding: $sp-4 $sp-6; +} + +.icon { + flex: 0 0 auto; + height: 50px; + width: 50px; +} + +.stat { + color: var(--FontColor); + display: flex; + flex-direction: column; + min-width: 0; +} + +.value { + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; +} + +.label { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 20px; + + // single line at the design width; wraps on narrower viewports + @media (min-width: 1280px) { + white-space: nowrap; + } +} diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.tsx b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx new file mode 100644 index 000000000..0ea689c3e --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.tsx @@ -0,0 +1,30 @@ +/** + * Bordered card showing one participation statistic next to its icon. + */ +import { FC, FunctionComponent, SVGProps } from 'react' + +import styles from './StatCard.module.scss' + +interface StatCardProps { + readonly icon: FunctionComponent> + readonly label: string + readonly value?: number +} + +export const StatCard: FC = (props: StatCardProps) => { + const Icon: FunctionComponent> = props.icon + + return ( +
+ +
+
+ {props.value?.toLocaleString() ?? '-'} +
+
{props.label}
+
+
+ ) +} + +export default StatCard diff --git a/src/apps/campus/src/lib/components/stat-card/index.ts b/src/apps/campus/src/lib/components/stat-card/index.ts new file mode 100644 index 000000000..4626bce2f --- /dev/null +++ b/src/apps/campus/src/lib/components/stat-card/index.ts @@ -0,0 +1 @@ +export { StatCard } from './StatCard' diff --git a/src/apps/campus/src/lib/styles/index.scss b/src/apps/campus/src/lib/styles/index.scss new file mode 100644 index 000000000..3d99e7f04 --- /dev/null +++ b/src/apps/campus/src/lib/styles/index.scss @@ -0,0 +1,67 @@ +@import '@libs/ui/styles/includes'; +@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&display=swap'); + +:root { + --Link: #0d61bf; + --FontColor: #0a0a0a; + --SubtitleColor: #202020; + --GrayFontColor: #767676; + --TableBorderColor: #a8a8a8; + --TableRowBorderColor: #e0e0e0; + --TableTextColor: #161616; + --TooltipColor: #0f172a; +} + +// Reskin of the shared ~/libs/ui Table to the campus design: +// Nunito Sans header and cells, 52px rows, square cells, gray rules. +.campus-table { + table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + + thead th { + border-bottom: 1px solid var(--TableBorderColor); + height: 52px; + padding: 0 $sp-4 !important; + vertical-align: middle; + + > div { + align-items: center; + color: var(--TableTextColor) !important; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + gap: $sp-1; + letter-spacing: normal; + line-height: 20px; + text-transform: none; + } + } + + td { + border-bottom: 1px solid var(--TableRowBorderColor); + color: var(--TableTextColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + height: 52px; + letter-spacing: normal; + line-height: 20px; + max-width: none; + padding: 0 $sp-4; + vertical-align: middle; + + &:first-child, + &:last-child { + border-radius: 0; + } + + // the shared table centers the second to last column + &:nth-last-child(2) { + text-align: left; + } + } + } +} diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index 3505c8520..5386c7035 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -1,98 +1,79 @@ @import '@libs/ui/styles/includes'; +$section-gap: 40px; +$card-gap: 35px; + .header { - margin-top: $sp-8; - margin-bottom: $sp-6; + margin-top: $section-gap; + margin-bottom: $section-gap; +} - h1 { - margin-bottom: $sp-2; - } +.title { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 33px; + font-weight: 700; + letter-spacing: normal; + line-height: 38px; + margin-bottom: $sp-2; + text-transform: none; } .subtitle { - color: $black-60; + color: var(--SubtitleColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 18px; + font-weight: 400; + line-height: 25px; } .stats { - display: grid; - gap: $sp-4; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - margin-bottom: $sp-6; -} - -.statCard { - align-items: center; - background: $tc-white; - border: 1px solid $black-10; - border-radius: 8px; display: flex; - gap: $sp-4; - padding: $sp-4; -} - -.statIcon { align-items: center; - border-radius: 50%; - display: flex; - flex: 0 0 auto; - height: 48px; - justify-content: center; - width: 48px; + gap: $card-gap; + margin-bottom: $section-gap; - svg { - height: 24px; - width: 24px; + @include ltemd { + flex-direction: column; + align-items: stretch; + gap: $sp-4; } } -.statIconMembers { - background: #e6f7f0; - color: #0ab88a; -} - -.statIconRegistered { - background: #e9f2fe; - color: #2a8ded; -} - -.statIconSubmitted { - background: #f0eafc; - color: #7b61ff; -} - -.statLabel { - color: $black-80; - margin-bottom: $sp-1; -} - -.statValue { - @include font-barlow-condensed; - - font-size: 28px; - font-weight: 500; -} - .toolbar { align-items: center; + border-bottom: 1px solid var(--TableBorderColor); display: flex; - justify-content: space-between; gap: $sp-4; - margin-bottom: $sp-4; + justify-content: space-between; + padding-bottom: $sp-4; } .filter { - max-width: 320px; - min-width: 220px; - width: 100%; -} + max-width: 100%; + width: 234px; + + :global(.input-el) { + border-color: var(--TableBorderColor); + border-radius: 4px; + height: 40px; + justify-content: center; + margin-bottom: 0; + padding: $sp-2 $sp-4; + } -.tableWrapper { - position: relative; -} + :global(.input-el) span { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 400; + line-height: 22px; + } -.lbTable { - tbody td { - vertical-align: middle; + :global(.input-el) svg { + color: var(--GrayFontColor); + height: 22px; + width: 22px; } } @@ -100,62 +81,124 @@ align-items: center; background: none; border: none; - color: $turq-160; + color: var(--Link); cursor: pointer; display: flex; - gap: $sp-2; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + gap: $sp-1; + line-height: 22px; padding: 0; + white-space: nowrap; svg { + flex: 0 0 auto; height: 20px; width: 20px; } } -.rank { +.tableWrapper { + position: relative; +} + +.lbTable { + table { + // doubled to win over the reskin's second-to-last column alignment + td.numberCell.numberCell { + text-align: right; + + :global(.TableCell_blockCell) { + justify-content: flex-end; + } + } + + th:global(.column-id-rank-) { + width: 52px; + } + + th:global(.column-id-member-) { + width: 210px; + } + + th:global(.column-id-wins-), + th:global(.column-id-passingSubmissions-), + th:global(.column-id-submissions-), + th:global(.column-id-registrations-) { + width: 220px; + + > div { + justify-content: flex-end; + } + } + } +} + +.infoIcon { align-items: center; - border-radius: 50%; + color: var(--GrayFontColor); display: inline-flex; - font-weight: 700; - height: 28px; + height: 24px; justify-content: center; - width: 28px; + width: 24px; + + svg { + height: 14px; + width: 14px; + } } -.gold { - background: #f5c344; - color: $tc-white; +.tooltip { + // doubled to win over the shared tooltip + react-tooltip variant styles + &.tooltip { + background-color: var(--TooltipColor); + border-radius: 8px; + color: $tc-white; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 20px; + max-width: 244px; + padding: $sp-3; + text-align: left; + } } -.silver { - background: $black-20; - color: $black-100; +.medal { + display: block; + height: 20px; + width: 20px; } -.bronze { - background: #d9a48f; - color: $tc-white; +.rank { + display: inline-block; + text-align: center; + width: 20px; } .handleCell { align-items: center; display: flex; - gap: $sp-3; + gap: $sp-2; } .avatar { flex: 0 0 auto; - height: 40px; - width: 40px; -} + height: 32px; + width: 32px; -.handle { - font-weight: 500; + // doubled to win over the shared avatar's responsive min-width + &.avatar img { + min-width: 0; + } } -.wins { - color: #0ab88a; - font-weight: 500; +.handle { + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 20px; } .chevronButton { @@ -167,17 +210,19 @@ display: inline-flex; justify-content: center; padding: 0; + margin-left: auto; } .chevron { - color: $black-60; - height: 20px; - width: 20px; + color: var(--FontColor); + height: 24px; + width: 24px; } .empty, .error { - color: $black-60; + color: var(--GrayFontColor); + font-family: 'Nunito Sans', sans-serif; padding: $sp-6 0; text-align: center; } diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx index 427ec258b..9d9cc060b 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -91,6 +91,9 @@ jest.mock('~/libs/ui', () => { ), + Tooltip: (props: PropsWithChildren<{ content?: ReactNode }>): JSX.Element => ( + <>{props.children} + ), } }, { virtual: true }) @@ -211,11 +214,11 @@ describe('CampusLeaderboardPage', () => { fireEvent.click(screen.getByRole('button', { name: /View participation history for testaws1/i, })) - expect(screen.getByText(/testaws1 — Participation History/)) + expect(screen.getByText('testaws1 Participation History')) .toBeInTheDocument() expect(screen.getByText('Campus Sprint')) .toBeInTheDocument() - expect(screen.getByText('Won (place 1)')) + expect(screen.getByLabelText('1st place')) .toBeInTheDocument() }) diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index 444ddc35c..c7ed8c989 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -14,6 +14,7 @@ import { PageTitle, Table, TableColumn, + Tooltip, } from '~/libs/ui' import { ProfilePicture } from '~/libs/shared' import { EnvironmentConfig } from '~/config' @@ -23,6 +24,15 @@ import { CampusLeaderboardMember, } from '../../lib/models' import { CampusLeaderboardResource, useCampusLeaderboard } from '../../lib/hooks' +import { + IconHelp, + IconInfo, + IconStatMembers, + IconStatRegistered, + IconStatSubmitted, + placementIcons, +} from '../../lib/assets/icons' +import { StatCard } from '../../lib/components' import { ParticipationHistoryModal } from './ParticipationHistoryModal' import { RankingRulesModal } from './RankingRulesModal' @@ -36,30 +46,45 @@ const CHALLENGE_FILTER_OPTIONS: ReadonlyArray = [ { label: 'Campus Challenges', value: 'campus' }, ] -const RANK_MEDAL_CLASSES: { [rank: number]: string } = { - 1: styles.gold, - 2: styles.silver, - 3: styles.bronze, -} - /** - * Marks rows that open the participation history modal. + * Renders a column header with an info tooltip, as designed. * - * @param member leaderboard row. - * @returns row class name, when the row is clickable. + * @param label column header text. + * @param tooltip tooltip copy. + * @returns header renderer. */ +function headerWithTooltip(label: string, tooltip: string): () => JSX.Element { + return function renderHeader(): JSX.Element { + return ( + <> + {label} + + + + + + + ) + } +} + /** - * Renders a rank badge, medal-styled for the top three ranks. + * Renders the placement: a medal for the top three ranks, the number otherwise. * * @param member leaderboard row. * @returns rank cell. */ function renderRank(member: CampusLeaderboardMember): JSX.Element { - return ( - - {member.rank} - - ) + const Medal = placementIcons[member.rank] + + return Medal + ? + : {member.rank} } /** @@ -150,40 +175,48 @@ export const CampusLeaderboardPage: FC = () => { type: 'element', }, { - columnId: 'handle', - label: 'Handle', + columnId: 'member', + label: 'Member', renderer: renderHandle, type: 'element', }, { - columnId: 'registrations', - label: 'Number of Registrations', - propertyName: 'registrations', - tooltip: 'Challenges the member registered for.', + className: styles.numberCell, + columnId: 'wins', + label: '# of Wins', + propertyName: 'wins', type: 'number', }, { - columnId: 'submissions', - label: 'Number of Submissions', - propertyName: 'submissions', - tooltip: 'Challenges the member submitted to. At most one submission is counted per challenge.', + className: styles.numberCell, + columnId: 'passingSubmissions', + label: headerWithTooltip( + '# of Passing Submissions', + 'Challenges where a submission passed review. ' + + 'At most one passing submission is counted per challenge.', + ), + propertyName: 'passingSubmissions', type: 'number', }, { - columnId: 'passingSubmissions', - label: 'Number of Passing Submissions', - propertyName: 'passingSubmissions', - tooltip: 'Challenges where a submission passed review. ' - + 'At most one passing submission is counted per challenge.', + className: styles.numberCell, + columnId: 'submissions', + label: headerWithTooltip( + '# of Submissions', + 'Challenges the member submitted to. At most one submission is counted per challenge.', + ), + propertyName: 'submissions', type: 'number', }, { - columnId: 'wins', - label: 'Number of Wins', - renderer: (member: CampusLeaderboardMember) => ( - {member.wins} + className: styles.numberCell, + columnId: 'registrations', + label: headerWithTooltip( + '# of Registrations', + 'Challenges the member registered for.', ), - type: 'numberElement', + propertyName: 'registrations', + type: 'number', }, { columnId: 'open', @@ -217,7 +250,7 @@ export const CampusLeaderboardPage: FC = () => { Campus Program Leaderboard
-

Campus Program Leaderboard

+

Campus Program Leaderboard

{`Track participation and performance of members in the ${displayGroupName} `} group across challenges. @@ -235,45 +268,21 @@ export const CampusLeaderboardPage: FC = () => { {(!!data || isLoading) && ( <>

-
- - - -
-
Total Members in Group
-
- {data?.summary.totalMembers.toLocaleString() ?? '-'} -
-
-
-
- - - -
-
- Members - {' Registered to Any Challenge'} -
-
- {data?.summary.membersRegistered.toLocaleString() ?? '-'} -
-
-
-
- - - -
-
- Members - {' Submitted to Any Challenge'} -
-
- {data?.summary.membersSubmitted.toLocaleString() ?? '-'} -
-
-
+ + +
@@ -290,15 +299,15 @@ export const CampusLeaderboardPage: FC = () => { onClick={function onRulesClick() { setRulesVisible(true) }} type='button' > - - How rankings are calculated + + How ratings are calculated
h3) { + padding-top: 0; + } + + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } } -.challengeCell { +.body { display: flex; flex-direction: column; + gap: $box-padding; + margin-top: $box-padding; + + // nested to win over the shared modal body link styling + .workLink { + color: var(--Link); + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } } -.challengeName { +.stats { align-items: center; - display: inline-flex; - font-weight: 500; - gap: $sp-1; + display: flex; + gap: $sp-6; + + @include ltemd { + align-items: stretch; + flex-direction: column; + gap: $sp-4; + } } -.externalIcon { - width: 1rem; - height: 1rem; - flex: none; +.historyTable { + table { + th:global(.column-id-track-) { + width: 140px; + } + + th:global(.column-id-registrationDate-), + th:global(.column-id-submissionDate-) { + width: 150px; + } + + th:global(.column-id-result-) { + width: 160px; + } + } +} + +.result { + align-items: center; + display: inline-flex; + gap: $sp-2; } -.challengeMeta { - color: $black-60; - font-size: 12px; +.resultIcon, +.medal { + flex: 0 0 auto; + height: 20px; + width: 20px; } diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index ea06998f4..01cdf5f4e 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -2,12 +2,24 @@ * Participation history for one leaderboard member. */ import { FC, useMemo } from 'react' +import classNames from 'classnames' -import { BaseModal, IconOutline, Table, TableColumn } from '~/libs/ui' +import { BaseModal, Table, TableColumn } from '~/libs/ui' import { textFormatDateLocaleShortString } from '~/libs/shared' import { EnvironmentConfig } from '~/config' import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' +import { + IconResultFailed, + IconResultPassed, + IconStatPassed, + IconStatRegistered, + IconStatSubmitted, + IconStatWins, + placementIcons, + placementLabels, +} from '../../lib/assets/icons' +import { StatCard } from '../../lib/components' import styles from './ParticipationHistoryModal.module.scss' @@ -27,48 +39,51 @@ function formatDate(value: string | null): string { } /** - * Describes the outcome of a member's participation in a challenge. + * Renders the outcome of a member's participation: a medal for a top three + * placement, a pass or fail tag once the submission was reviewed. * * @param entry participation entry. - * @returns human readable result. + * @returns result cell. */ -function formatPlacement(placement: number | null): string | undefined { - if (placement === 2) { - return '2nd place' - } - - if (placement === 3) { - return '3rd place' - } - - return placement && placement > 1 ? `Place ${placement}` : undefined -} - -function formatResult(entry: CampusParticipation): string { - if (entry.won) { - return entry.placement ? `Won (place ${entry.placement})` : 'Won' - } - - const placement = formatPlacement(entry.placement) - if (placement) { - return placement - } - - if (entry.challengeStatus !== 'COMPLETED') { - if (entry.challengeStatus === 'ACTIVE') { - return 'Challenge is in progress' - } +function renderResult(entry: CampusParticipation): JSX.Element { + const placement: number | null = entry.placement + const Medal = placement ? placementIcons[placement] : undefined + + if (Medal) { + return ( + + + + ) } if (entry.passedReview) { - return 'Passed review' + return ( + + + Passed Review + + ) } if (entry.submitted) { - return 'Did not pass review' + return ( + + + Failed Review + + ) } - return 'No submission' + return ( + + {entry.challengeStatus === 'ACTIVE' ? 'Challenge is in progress' : 'No submission'} + + ) } export const ParticipationHistoryModal: FC = props => { @@ -76,49 +91,48 @@ export const ParticipationHistoryModal: FC = pro const columns = useMemo>>(() => [ { - columnId: 'challenge', - label: 'Challenge', + columnId: 'work', + label: 'Work', renderer: (entry: CampusParticipation) => { const challengePath = `${EnvironmentConfig.REVIEW.CHALLENGE_PAGE_URL}/${encodeURIComponent(entry.challengeId)}` return ( -
- - {entry.challengeName ?? entry.challengeId} - - - - {[entry.challengeTrack, entry.challengeType].filter(Boolean) - .join(' • ')} - -
+ + {entry.challengeName ?? entry.challengeId} + ) }, type: 'element', }, { - columnId: 'registeredAt', - label: 'Registered', + columnId: 'track', + label: 'Track', + propertyName: 'challengeTrack', + type: 'text', + }, + { + columnId: 'registrationDate', + label: 'Registration Date', renderer: (entry: CampusParticipation) => {formatDate(entry.registeredAt)}, type: 'element', }, { - columnId: 'submittedDate', - label: 'Submitted', + columnId: 'submissionDate', + label: 'Submission Date', renderer: (entry: CampusParticipation) => {formatDate(entry.submittedDate)}, type: 'element', }, { columnId: 'result', label: 'Result', - renderer: (entry: CampusParticipation) => {formatResult(entry)}, + renderer: renderResult, type: 'element', }, ], []) @@ -129,32 +143,45 @@ export const ParticipationHistoryModal: FC = pro return ( -
- - {`${member.registrations} registrations`} - - - {`${member.submissions} submissions`} - - - {`${member.passingSubmissions} passing`} - - - {`${member.wins} wins`} - +
+
+ + + + +
+ +
- -
) } diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss index 3163ddb50..691e6b8f7 100644 --- a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.module.scss @@ -1,14 +1,76 @@ @import '@libs/ui/styles/includes'; -.rules { - list-style: decimal outside; - margin: $sp-3 0 $sp-4 $sp-5; +$box-padding: 40px; +$box-width: 476px; +$text-gap: 20px; + +.modal { + border-radius: 8px !important; + max-width: calc(100vw - #{$sp-8}) !important; + min-width: 0 !important; + padding: $box-padding !important; + width: $box-width !important; + + @include ltemd { + padding: $sp-4 !important; + width: calc(100vw - #{$sp-4}) !important; + } + + :global(.react-responsive-modal-closeButton) { + right: $sp-2; + top: $sp-2; + + svg { + height: 22px; + width: 22px; + } + } + + // the shared modal header pads its top by 5px, the design does not + div:has(> h3) { + padding-top: 0; + } - li { - margin-bottom: $sp-1; + h3 { + color: var(--FontColor); + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + letter-spacing: normal; + line-height: 30px; + text-transform: none; + } + + :global(.modal-body) { + margin: 0; + padding: 0; + } + + .body { + color: var(--FontColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + letter-spacing: normal; + line-height: 20px; + margin-top: $sp-6; + + p { + margin: 0 0 $text-gap; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: 700; + } } } -.note { - color: $black-60; +.rules { + list-style: decimal outside; + margin: 0 0 $text-gap; + padding-left: $sp-6; } diff --git a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx index d7bebd976..903f0a346 100644 --- a/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/RankingRulesModal.tsx @@ -19,23 +19,28 @@ export const RankingRulesModal: FC = props => { return ( -

Members are ranked by the following criteria, in order:

-
    -
  1. Number of wins, highest first
  2. -
  3. Number of passing submissions, highest first
  4. -
  5. Number of registrations, highest first
  6. -
  7. Signup time, earliest first
  8. -
-

- At most one submission and one passing submission are counted per member per - challenge. Every member of the group is listed, including members with no - challenge activity. -

+
+

Members are ranked by the following criteria, in order:

+
    +
  1. Number of wins, highest first
  2. +
  3. Number of passing submissions, highest first
  4. +
  5. Number of registrations, highest first
  6. +
  7. Signup time, earliest first
  8. +
+

+ Note: + At most one submission and one passing submission are counted per member per + challenge. Every member of the group is listed, including members with no + challenge activity. +

+
) } From 18b5b00e7d0b1952c4ac842d7f1ff68df3bfa82d Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 25 Aug 2026 09:13:18 +0530 Subject: [PATCH 39/44] PM-5964 Alignment issue in profile section --- .../TcSpecialRolesBanner.module.scss | 25 ++++++----- .../TcSpecialRolesBanner.spec.tsx | 17 +++++--- .../TcSpecialRolesBanner.tsx | 7 +-- .../DefaultAchievementsView.module.scss | 43 +++++++++++++++---- .../DefaultAchievementsView.spec.tsx | 21 +++++++++ .../DefaultAchievementsView.tsx | 33 +++++++++----- 6 files changed, 106 insertions(+), 40 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.spec.tsx diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss index f8d3e46cd..d096980d2 100644 --- a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.module.scss @@ -1,11 +1,7 @@ @import '@libs/ui/styles/includes'; .rolesSection { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); - gap: $sp-4; - margin-top: $sp-4; - container-type: inline-size; + display: contents; } .roleCard { @@ -14,12 +10,23 @@ align-items: center; justify-content: space-between; gap: $sp-4; + width: 100%; min-height: 66px; padding: $sp-3 $sp-4; border-radius: 10px; color: $tc-white; overflow: visible; + @include gtexl { + &.reviewer { + grid-column: 1; + } + + &.copilot { + grid-column: 2; + } + } + &.reviewer { background: linear-gradient(155deg, #065D6E 0%, #3E3B91 100%); } @@ -127,12 +134,8 @@ } } -@include gtexl { - @container (min-width: 656px) { - .roleCard + .roleCard { - margin-left: $sp-10; - } - } +.singleRole .roleCard { + grid-column: 1 / -1; } @container (max-width: 420px) { diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx index 8a731a5e4..8bb52f235 100644 --- a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.spec.tsx @@ -45,14 +45,17 @@ describe('TcSpecialRolesBanner styles', () => { .toMatch(/font-size: 22px;/) }) - it('aligns the second role card with member stats on desktop', () => { + it('lets role cards share the parent achievements grid columns', () => { expect(tcSpecialRolesBannerStyles) - .toMatch(new RegExp( - '@include gtexl \\{[\\s\\S]*?' - + '@container \\(min-width: 656px\\) \\{[\\s\\S]*?' - + '\\.roleCard \\+ \\.roleCard \\{[\\s\\S]*?' - + 'margin-left: \\$sp-10;', - )) + .toMatch(/\.rolesSection \{[\s\S]*?display: contents;/) + expect(tcSpecialRolesBannerStyles) + .toMatch(/&.reviewer \{[\s\S]*?grid-column: 1;/) + expect(tcSpecialRolesBannerStyles) + .toMatch(/&.copilot \{[\s\S]*?grid-column: 2;/) + expect(tcSpecialRolesBannerStyles) + .toMatch(/\.singleRole \.roleCard \{[\s\S]*?grid-column: 1 \/ -1;/) + expect(tcSpecialRolesBannerStyles).not + .toMatch(/margin-left: \$sp-10;/) }) }) diff --git a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx index d7f19fb79..656f23db3 100644 --- a/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx +++ b/src/apps/profiles/src/components/tc-achievements/TcSpecialRolesBanner/TcSpecialRolesBanner.tsx @@ -93,8 +93,9 @@ const SpecialRoleCard: FC = pr /** * Shows reviewer and copilot summary cards above Member Stats. * - * A single role spans the available width, while two roles share the row and - * collapse to a vertical stack on narrow profile layouts. + * Cards participate in the parent achievements grid so a reviewer card shares + * a column with the TCO banner and a copilot card shares a column with Member + * Stats. A single role spans the available width. * * This component does not throw. * @@ -121,7 +122,7 @@ const TcSpecialRolesBanner: FC = props => { } return roles.length === 0 ? <> : ( -
+
{roles.map(role => ( * { + flex: 1 1 auto; + width: 100%; } +} + +.col1 { + grid-column: 1; +} - >div:not(:last-child) { - margin-right: $sp-8; +.col2 { + grid-column: 1; - @include ltelg { - margin-right: 0; - margin-bottom: $sp-8; + @include gtexl { + .twoColumns & { + grid-column: 2; } } } + +.spanAll { + grid-column: 1 / -1; +} diff --git a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.spec.tsx b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.spec.tsx new file mode 100644 index 000000000..f068a70d8 --- /dev/null +++ b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.spec.tsx @@ -0,0 +1,21 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { readFileSync } from 'fs' + +const defaultAchievementsViewStyles = readFileSync(`${__dirname}/DefaultAchievementsView.module.scss`, 'utf8') + +describe('DefaultAchievementsView styles', () => { + it('shares columns between special roles and the TCO / member stats cards', () => { + expect(defaultAchievementsViewStyles) + .toMatch(/\.achievementsGrid \{[\s\S]*?display: grid;/) + expect(defaultAchievementsViewStyles) + .toMatch(/&.twoColumns \{[\s\S]*?grid-template-columns: 1fr 1fr;/) + expect(defaultAchievementsViewStyles) + .toMatch(/&.twoColumns \{[\s\S]*?column-gap: \$sp-8;/) + expect(defaultAchievementsViewStyles) + .toMatch(/\.col1 \{[\s\S]*?grid-column: 1;/) + expect(defaultAchievementsViewStyles) + .toMatch(/\.twoColumns & \{[\s\S]*?grid-column: 2;/) + expect(defaultAchievementsViewStyles) + .toMatch(/\.spanAll \{[\s\S]*?grid-column: 1 \/ -1;/) + }) +}) diff --git a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx index 3db3a7133..bcdcb46df 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx @@ -1,4 +1,5 @@ import { FC, useMemo } from 'react' +import classNames from 'classnames' import { MemberRoleStats, UserProfile, UserStats } from '~/libs/core' @@ -22,6 +23,11 @@ const DefaultAchievementsView: FC = props => { const hasTcoBanner = props.tcoWins > 0 || props.tcoQualifications > 0 || props.tcoTrips > 0 const activeTracks: MemberStatsTrack[] = useMemo(() => getActiveTracks(props.memberStats), [props.memberStats]) const hasMemberStats = activeTracks.length > 0 + const hasReviewer = !!props.roleStats?.reviewer?.challengeCount + const hasCopilot = !!props.roleStats?.copilot?.challengeCount + const hasRoles = hasReviewer || hasCopilot + const hasTwoColumns = (hasReviewer && hasCopilot) || (hasTcoBanner && hasMemberStats) + const hasAchievementsGrid = hasRoles || hasTcoBanner || hasMemberStats return ( <> @@ -29,18 +35,25 @@ const DefaultAchievementsView: FC = props => { - - - {(hasTcoBanner || hasMemberStats) && ( -
+ {hasAchievementsGrid && ( +
+ {hasTcoBanner && ( - +
+ +
+ )} + {hasMemberStats && ( +
+ +
)} - {hasMemberStats && }
)} From 60978569d23c88d06a6698ca6bc9b905347a68b4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 25 Aug 2026 14:56:02 +1000 Subject: [PATCH 40/44] PM-5758: require a count when design submissions are limited What was broken In Work Manager a copilot could select "Limited" for the design submission limit, leave the "Limit count" textbox empty, and still save or autosave the challenge. No error was shown, and the challenge was persisted with limit metadata that declares a limit but carries no number, which no downstream application can enforce. Root cause The submission-limit radio selection and the count are display-only form fields that are serialized into the legacy submissionLimit challenge metadata entry. Nothing in the challenge editor schema validated that entry, so an empty count produced no validation error, formState.isValid stayed true, and both manual save and autosave accepted the incomplete value. What was changed The parsing and serialization of the submissionLimit metadata contract moved into a shared submission-limit utility so the editor field and the validation schema read the same value. The challenge editor schema now rejects limit metadata that declares a limit without a count of at least 1, and reports the message on the visible submissionLimitCount field so the error renders under "Limit count" and in the save footer. Saving, autosaving, and launching are blocked until a count is entered. The rule only applies while the control is editable. The challenge editor publishes an isSubmissionLimitConfigurable flag on the existing yup validation context, which is true only for Design submission settings that have no uploaded contest or checkpoint submissions. That keeps non-Design challenges, and challenges whose limit is already locked by member submissions, saveable. The field revalidates itself after each mode or count change so the error tracks the value that would be saved. Any added/updated tests Added submission-limit utility tests for detecting a limited setting with a missing or zero count and for the contest/checkpoint submission check. Added challenge editor schema tests that a limited setting without a count is rejected on the submissionLimitCount path, that a zero count is rejected, that a valid count and an unlimited setting pass, and that the rule is skipped when the limit is not configurable. Added MaximumSubmissionsField tests that saving is blocked while the count is empty and succeeds once a count is entered. Five of the new tests fail against the unchanged source. --- .../schemas/challenge-editor.schema.spec.ts | 113 ++++++++++++ .../lib/schemas/challenge-editor.schema.ts | 36 +++- src/apps/work/src/lib/utils/index.ts | 1 + .../lib/utils/submission-limit.utils.spec.ts | 74 ++++++++ .../src/lib/utils/submission-limit.utils.ts | 166 ++++++++++++++++++ .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/ChallengeEditorForm.tsx | 20 +++ .../MaximumSubmissionsField.spec.tsx | 107 +++++++++++ .../MaximumSubmissionsField.tsx | 148 ++++------------ 9 files changed, 553 insertions(+), 114 deletions(-) create mode 100644 src/apps/work/src/lib/utils/submission-limit.utils.spec.ts create mode 100644 src/apps/work/src/lib/utils/submission-limit.utils.ts diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts index 970d00aa9..6b51cb33b 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts @@ -5,6 +5,9 @@ import { REVIEW_TYPES, ROUND_TYPES, } from '../constants/challenge-editor.constants' +import { + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../utils/submission-limit.utils' import { challengeAdvancedOptionsSchema, @@ -488,3 +491,113 @@ describe('challenge-editor schema reviewer slot assignment validation', () => { .toThrow(`Number of reviewers cannot exceed ${MAX_MANUAL_REVIEWER_COUNT}`) }) }) + +describe('challenge-editor schema submission limit validation', () => { + const baseFormData = { + roundType: ROUND_TYPES.SINGLE_ROUND, + } + const configurableContext = { + context: { + isSubmissionLimitConfigurable: true, + }, + } + + function buildSubmissionLimitMetadata(count: string, limit: string): Array<{ + name: string + value: string + }> { + return [{ + name: 'submissionLimit', + value: JSON.stringify({ + count, + limit, + unlimited: limit === 'true' + ? 'false' + : 'true', + }), + }] + } + + it('rejects a limited submission setting without a count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toThrow(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE) + }) + + it('reports the missing count on the visible limit field', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toMatchObject({ + path: 'submissionLimitCount', + }) + }) + + it('rejects a limited submission setting with a zero count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('0', 'true'), + }, + configurableContext, + ), + ) + .rejects + .toThrow(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE) + }) + + it('accepts a limited submission setting with a count', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('2', 'true'), + }, + configurableContext, + ), + ) + .resolves + .toBeTruthy() + }) + + it('accepts an unlimited submission setting', async () => { + await expect( + challengeAdvancedOptionsSchema.validate( + { + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'false'), + }, + configurableContext, + ), + ) + .resolves + .toBeTruthy() + }) + + it('skips the count rule when the submission limit is not configurable', async () => { + await expect( + challengeAdvancedOptionsSchema.validate({ + ...baseFormData, + metadata: buildSubmissionLimitMetadata('', 'true'), + }), + ) + .resolves + .toBeTruthy() + }) +}) diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts index ba83b3883..4141fe648 100644 --- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts @@ -13,12 +13,17 @@ import { } from '../constants/challenge-editor.constants' import { ChallengeEditorFormData, + ChallengeMetadata, ChallengeReviewer, } from '../models' import { isSkillsRequired, } from '../utils/challenge-editor.utils' import { isReviewerAssignmentOptional } from '../utils/reviewer.utils' +import { + isSubmissionLimitCountMissing, + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../utils/submission-limit.utils' /** * Validation context supplied to the challenge editor schema by the challenge editor form. @@ -30,6 +35,10 @@ export interface ChallengeEditorValidationContext { /** Whether the edited challenge is a Design `Challenge`, whose private reviewers are * automatically assigned to the selected copilot during save. */ isDesignChallenge?: boolean + /** Whether the submission-limit control is currently editable. The limit is only rendered for + * Design submission settings and is locked once members have uploaded submissions, so the + * required-count rule is skipped when the copilot cannot correct the value. */ + isSubmissionLimitConfigurable?: boolean } function isSchedulingApiEnabled(value: unknown): boolean { @@ -421,7 +430,32 @@ export const challengeAdvancedOptionsSchema = yup.object({ .optional(), metadata: yup.array() .of(metadataSchema) - .optional(), + .optional() + .test( + 'submission-limit-count-required', + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, + function validateSubmissionLimitCount(value: unknown): boolean | yup.ValidationError { + const isSubmissionLimitConfigurable = ( + this.options.context as ChallengeEditorValidationContext | undefined + )?.isSubmissionLimitConfigurable === true + + if ( + !isSubmissionLimitConfigurable + || !isSubmissionLimitCountMissing(value as ChallengeMetadata[] | undefined) + ) { + return true + } + + /* + * The limit is edited through display-only form fields, so the error is reported on + * the visible count input instead of the metadata array that stores the value. + */ + return this.createError({ + message: SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, + path: 'submissionLimitCount', + }) + }, + ), reviewer: yup.string() .transform(emptyStringToUndefined) .optional(), diff --git a/src/apps/work/src/lib/utils/index.ts b/src/apps/work/src/lib/utils/index.ts index dae957d45..435888bb1 100644 --- a/src/apps/work/src/lib/utils/index.ts +++ b/src/apps/work/src/lib/utils/index.ts @@ -34,6 +34,7 @@ export * from './rating.utils' export * from './resource-deletion.utils' export * from './sorting.utils' export * from './storage.utils' +export * from './submission-limit.utils' export * from './timezone.utils' export * from './toast.utils' export * from './user.utils' diff --git a/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts b/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts new file mode 100644 index 000000000..64594a9bb --- /dev/null +++ b/src/apps/work/src/lib/utils/submission-limit.utils.spec.ts @@ -0,0 +1,74 @@ +import { + hasChallengeSubmissions, + isSubmissionLimitCountMissing, +} from './submission-limit.utils' + +function buildSubmissionLimitMetadata(count: string, limit: string): Array<{ + name: string + value: string +}> { + return [{ + name: 'submissionLimit', + value: JSON.stringify({ + count, + limit, + unlimited: limit === 'true' + ? 'false' + : 'true', + }), + }] +} + +describe('isSubmissionLimitCountMissing', () => { + it('detects a limited setting without a count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('', 'true'))) + .toBe(true) + }) + + it('detects a limited setting with a zero count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('0', 'true'))) + .toBe(true) + }) + + it('accepts a limited setting with a positive count', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('3', 'true'))) + .toBe(false) + }) + + it('accepts an unlimited setting', () => { + expect(isSubmissionLimitCountMissing(buildSubmissionLimitMetadata('', 'false'))) + .toBe(false) + }) + + it('accepts missing and malformed metadata', () => { + expect(isSubmissionLimitCountMissing(undefined)) + .toBe(false) + expect(isSubmissionLimitCountMissing([{ + name: 'submissionLimit', + value: '{invalid', + }])) + .toBe(false) + }) +}) + +describe('hasChallengeSubmissions', () => { + it('reports contest submissions', () => { + expect(hasChallengeSubmissions({ numOfSubmissions: 1 })) + .toBe(true) + }) + + it('reports checkpoint submissions', () => { + expect(hasChallengeSubmissions({ numOfCheckpointSubmissions: '2' })) + .toBe(true) + }) + + it('reports no submissions', () => { + expect(hasChallengeSubmissions({ + numOfCheckpointSubmissions: 0, + numOfSubmissions: 0, + })) + .toBe(false) + expect(hasChallengeSubmissions(undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/work/src/lib/utils/submission-limit.utils.ts b/src/apps/work/src/lib/utils/submission-limit.utils.ts new file mode 100644 index 000000000..ec76e1789 --- /dev/null +++ b/src/apps/work/src/lib/utils/submission-limit.utils.ts @@ -0,0 +1,166 @@ +import { ChallengeMetadata } from '../models' + +import { getMetadataValue } from './metadata.utils' + +export const SUBMISSION_LIMIT_METADATA_NAME = 'submissionLimit' +export const SUBMISSION_LIMIT_LIMITED_MODE = 'limited' +export const SUBMISSION_LIMIT_UNLIMITED_MODE = 'unlimited' +export const SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE + = 'Enter a submission limit of at least 1 when submissions are limited' + +export type SubmissionLimitMode = + typeof SUBMISSION_LIMIT_LIMITED_MODE + | typeof SUBMISSION_LIMIT_UNLIMITED_MODE + +export interface SubmissionLimitMetadata { + count: string + mode: SubmissionLimitMode +} + +const defaultSubmissionLimitMetadata: SubmissionLimitMetadata = { + count: '', + mode: SUBMISSION_LIMIT_UNLIMITED_MODE, +} + +/** + * Converts legacy string and boolean flags to a strict boolean. + * + * @param value legacy metadata flag. + * @returns Whether the flag is enabled. + * @throws Does not throw. + */ +function toBoolean(value: unknown): boolean { + return value === true || value === 'true' +} + +/** + * Removes non-numeric characters from a submission-limit count. + * + * @param value raw form or metadata value. + * @returns The digits-only submission count. + * @throws Does not throw. + */ +export function sanitizeSubmissionLimitCount(value: string): string { + return value.replace(/[^\d]/g, '') +} + +/** + * Parses the legacy JSON string stored in `submissionLimit` challenge metadata. + * + * Missing, malformed, and explicitly non-limited values use the product default of unlimited. + * A positive count without either flag is retained for compatibility with older payloads. + * + * @param value serialized challenge metadata value. + * @returns The submission-limit mode and sanitized count used by the form. + * @throws Does not throw; malformed metadata falls back to unlimited. + */ +export function parseSubmissionLimitMetadata(value: string | undefined): SubmissionLimitMetadata { + if (!value) { + return defaultSubmissionLimitMetadata + } + + try { + const parsedValue = JSON.parse(value) as unknown + + if (!parsedValue || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) { + return defaultSubmissionLimitMetadata + } + + const parsedMetadata = parsedValue as Record + const rawCount = typeof parsedMetadata.count === 'string' + || typeof parsedMetadata.count === 'number' + ? String(parsedMetadata.count) + : '' + const count = sanitizeSubmissionLimitCount(rawCount) + const isUnlimited = toBoolean(parsedMetadata.unlimited) + const isLimited = toBoolean(parsedMetadata.limit) + || (!isUnlimited && Number(count) > 0) + + return { + count: isLimited + ? count + : '', + mode: isLimited + ? SUBMISSION_LIMIT_LIMITED_MODE + : SUBMISSION_LIMIT_UNLIMITED_MODE, + } + } catch { + return defaultSubmissionLimitMetadata + } +} + +/** + * Serializes the editor state to the legacy submission-limit metadata contract. + * + * @param mode selected unlimited or limited mode. + * @param count digits-only maximum submission count. + * @returns The JSON string persisted in challenge metadata. + * @throws Does not throw. + */ +export function serializeSubmissionLimitMetadata( + mode: SubmissionLimitMode, + count: string | undefined, +): string { + const isLimited = mode === SUBMISSION_LIMIT_LIMITED_MODE + + return JSON.stringify({ + count: isLimited + ? (count || '') + : '', + limit: isLimited + ? 'true' + : 'false', + unlimited: isLimited + ? 'false' + : 'true', + }) +} + +/** + * Detects a limited submission setting that is missing a usable count. + * + * @param metadata current challenge metadata entries. + * @returns `true` when submissions are limited but no positive count is configured. + * @throws Does not throw. + */ +export function isSubmissionLimitCountMissing(metadata: ChallengeMetadata[] | undefined): boolean { + const submissionLimit = parseSubmissionLimitMetadata( + getMetadataValue(metadata, SUBMISSION_LIMIT_METADATA_NAME), + ) + + return submissionLimit.mode === SUBMISSION_LIMIT_LIMITED_MODE + && Number(submissionLimit.count || 0) < 1 +} + +/** + * Normalizes a challenge submission counter that form values expose as an unknown value. + * + * @param value raw counter from a challenge payload or watched form value. + * @returns The counter as a finite number, or `0` when it is missing or not numeric. + * @throws Does not throw. + */ +function toSubmissionCount(value: unknown): number { + const count = Number(value ?? 0) + + return Number.isFinite(count) + ? count + : 0 +} + +/** + * Reports whether members have already uploaded contest or checkpoint submissions. + * + * @param counts challenge or form submission counters. + * @returns `true` when at least one submission of either type exists. + * @throws Does not throw. + */ +export function hasChallengeSubmissions( + counts: { + numOfCheckpointSubmissions?: unknown + numOfSubmissions?: unknown + } | undefined, +): boolean { + return toSubmissionCount(counts?.numOfSubmissions) + + toSubmissionCount(counts?.numOfCheckpointSubmissions) + > 0 +} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index f9fe9c912..d61c395e1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -89,7 +89,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-limit compatibility fields. - `RegisteredMemberDownloadField`: shown in Advanced Options for every created challenge type. The radio group persists `allowAllRegistrantsToDownloadWinningSubmissions` as the exact string `true` for all challenge registrants or `false` for passing submitters only. New Development challenges default to passing submitters; other new challenges, including Design, default to all registrants. Existing challenges without the metadata retain passing-submitter-only access. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. -- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. +- `MaximumSubmissionsField`: submission-limit editor with `Unlimited` (the default) and `Limited` modes. Limited mode reveals a numeric count field, and both modes persist the legacy `submissionLimit` JSON metadata contract consumed by challenge and review applications. The selection and count are display-only fields, so they are re-seeded from the persisted metadata on every render; that keeps the saved limit visible after the challenge loads and after a draft save resets the form. Existing limited values are restored without being overwritten, including when a draft-save response omits submission-limit metadata, while missing or malformed metadata is normalized to unlimited after initial resource hydration so copilot restoration completes before autosave/manual-save treats the default as a user change. Once the challenge has at least one contest or checkpoint submission the mode and count become read-only, because review scorecards are created from the limit that applied when members submitted. Limited mode requires a count of at least 1: the challenge editor schema validates the persisted `submissionLimit` metadata and reports a missing count on the visible `Limit count` field, so saving, autosaving, and launching are blocked until the count is entered. The rule is skipped when the limit is not configurable, which keeps non-Design challenges and challenges that already have submissions saveable. - `ChallengeDescriptionField`: public markdown spec editor with a `Copy spec` action that copies the current Markdown in both edit and read-only view modes. - `ChallengePrivateDescriptionField`: optional private markdown spec editor. 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 3b3399021..7616fe245 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -97,6 +97,9 @@ import { getProjectBillingAccountChallengeErrorMessage, getProjectBillingAccountChallengeIssue, } from '../../../../lib/utils/project-billing-account.utils' +import { + hasChallengeSubmissions, +} from '../../../../lib/utils/submission-limit.utils' import { resolveMatchingChallengeViewPath, } from '../ChallengeEditorPage.utils' @@ -2088,6 +2091,7 @@ export const ChallengeEditorForm: FC = ( const validationContextRef = useRef({ isDesignChallenge: false, + isSubmissionLimitConfigurable: false, }) const formMethods = useForm({ context: validationContextRef.current, @@ -2427,6 +2431,22 @@ export const ChallengeEditorForm: FC = ( shouldUseSimplifiedDesignReview, trigger, ]) + + /* + * The submission limit is only editable inside Design submission settings, and it is locked + * once members have uploaded submissions. Publishing that state to the validation context keeps + * the required-count rule from blocking saves on challenges where the copilot cannot change it. + */ + const isSubmissionLimitConfigurable = showSubmissionSettingsSection + && !hasChallengeSubmissions({ + numOfCheckpointSubmissions: values.numOfCheckpointSubmissions, + numOfSubmissions: values.numOfSubmissions, + }) + + useEffect(() => { + validationContextRef.current.isSubmissionLimitConfigurable = isSubmissionLimitConfigurable + }, [isSubmissionLimitConfigurable]) + /** * Validates the copilot required for hidden private Design reviewer assignments. * diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx index f84e7ea2d..635facada 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.spec.tsx @@ -2,6 +2,7 @@ import { FC, useCallback, + useState, } from 'react' import { render, @@ -13,14 +14,25 @@ import { FormProvider, useForm, } from 'react-hook-form' +import { yupResolver } from '@hookform/resolvers/yup' import { ChallengeEditorFormData, ChallengeMetadata, } from '../../../../../lib/models' +import { challengeAdvancedOptionsSchema } from '../../../../../lib/schemas/challenge-editor.schema' +import { + SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE, +} from '../../../../../lib/utils/submission-limit.utils' import { MaximumSubmissionsField } from './MaximumSubmissionsField' +jest.mock('~/config', () => ({ + EnvironmentConfig: new Proxy({}, { + get: (): unknown => 'https://www.topcoder-dev.com', + }), +}), { virtual: true }) + let mockStaleMetadata: ChallengeMetadata[] | undefined jest.mock('react-hook-form', () => { @@ -50,10 +62,13 @@ interface TestHarnessProps { numOfSubmissions?: number onMetadataWrite?: () => void staleSubmissionLimitMode?: string + validateSubmissionLimit?: boolean } const TestHarness: FC = (props: TestHarnessProps) => { + const [savedCount, setSavedCount] = useState(0) const formMethods = useForm({ + context: { isSubmissionLimitConfigurable: true }, defaultValues: { description: 'Public challenge specification', metadata: props.defaultMetadata, @@ -68,6 +83,10 @@ const TestHarness: FC = (props: TestHarnessProps) => { ? { submissionLimitCount: '', submissionLimitMode: props.staleSubmissionLimitMode } : {}), } as ChallengeEditorFormData, + mode: 'onChange', + resolver: props.validateSubmissionLimit + ? (yupResolver(challengeAdvancedOptionsSchema) as never) + : undefined, }) const resetToPersistedValues = useCallback(() => { // Mirrors the editor resetting the form from saved challenge data, which drops the @@ -97,6 +116,12 @@ const TestHarness: FC = (props: TestHarnessProps) => { props.onMetadataWrite, ]) const values = formMethods.watch() + const saveChallenge = useCallback(() => { + formMethods.handleSubmit(() => { + setSavedCount(currentSavedCount => currentSavedCount + 1) + })() + .catch(() => undefined) + }, [formMethods]) return ( = (props: TestHarnessProps) => { > + + {String(savedCount)} {String(formMethods.formState.isDirty)} {JSON.stringify(values.metadata || [])} @@ -528,4 +555,84 @@ describe('MaximumSubmissionsField', () => { expect(await screen.findByRole('spinbutton', { name: 'Limit count' })) .toBeTruthy() }) + it('blocks saving a limited submission setting without a count', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Unlimited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('radio', { name: 'Limited' })) + + expect(await screen.findByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + }) + expect(screen.getByTestId('saved-count').textContent) + .toBe('0') + }) + + it('saves once a limited submission count is entered', async () => { + const user = userEvent.setup() + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('radio', { name: 'Limited' }) as HTMLInputElement).checked) + .toBe(true) + }) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeTruthy() + }) + expect(screen.getByTestId('saved-count').textContent) + .toBe('0') + + await user.type(screen.getByRole('spinbutton', { name: 'Limit count' }), '2') + + await waitFor(() => { + expect(screen.queryByText(SUBMISSION_LIMIT_COUNT_REQUIRED_MESSAGE)) + .toBeNull() + }) + + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByTestId('saved-count').textContent) + .toBe('1') + }) + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx index 18c05a852..ab74380ec 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MaximumSubmissionsField/MaximumSubmissionsField.tsx @@ -22,24 +22,27 @@ import { getMetadataValue, setMetadataValue, } from '../../../../../lib/utils/metadata.utils' +import { + hasChallengeSubmissions, + parseSubmissionLimitMetadata, + sanitizeSubmissionLimitCount, + serializeSubmissionLimitMetadata, + SubmissionLimitMode, + SUBMISSION_LIMIT_LIMITED_MODE, + SUBMISSION_LIMIT_METADATA_NAME, + SUBMISSION_LIMIT_UNLIMITED_MODE, +} from '../../../../../lib/utils/submission-limit.utils' import styles from './MaximumSubmissionsField.module.scss' -const SUBMISSION_LIMIT_FIELD = 'submissionLimit' +const SUBMISSION_LIMIT_FIELD = SUBMISSION_LIMIT_METADATA_NAME const SUBMISSION_LIMIT_COUNT_FIELD = 'submissionLimitCount' const SUBMISSION_LIMIT_MODE_FIELD = 'submissionLimitMode' -const LIMITED_MODE = 'limited' -const UNLIMITED_MODE = 'unlimited' +const LIMITED_MODE = SUBMISSION_LIMIT_LIMITED_MODE +const UNLIMITED_MODE = SUBMISSION_LIMIT_UNLIMITED_MODE const SUBMITTED_LIMIT_LOCK_HINT = 'The submission limit cannot be changed after the first submission is uploaded.' -type SubmissionLimitMode = typeof LIMITED_MODE | typeof UNLIMITED_MODE - -interface SubmissionLimitMetadata { - count: string - mode: SubmissionLimitMode -} - interface SubmissionLimitFormData extends ChallengeEditorFormData { submissionLimitCount?: string submissionLimitMode?: SubmissionLimitMode @@ -56,11 +59,6 @@ const submissionLimitOptions: FormRadioOption[] = [ }, ] -const defaultSubmissionLimitMetadata: SubmissionLimitMetadata = { - count: '', - mode: UNLIMITED_MODE, -} - interface MaximumSubmissionsFieldProps { /** * Defers automatic metadata normalization while the editor restores persisted assignments. @@ -70,100 +68,6 @@ interface MaximumSubmissionsFieldProps { deferDirty?: boolean } -/** - * Converts legacy string and boolean flags to a strict boolean. - * - * @param value legacy metadata flag. - * @returns Whether the flag is enabled. - * @throws Does not throw. - */ -function toBoolean(value: unknown): boolean { - return value === true || value === 'true' -} - -/** - * Removes non-numeric characters from a submission-limit count. - * - * @param value raw form or metadata value. - * @returns The digits-only submission count. - * @throws Does not throw. - */ -function sanitizeSubmissionLimitCount(value: string): string { - return value.replace(/[^\d]/g, '') -} - -/** - * Parses the legacy JSON string stored in `submissionLimit` challenge metadata. - * - * Missing, malformed, and explicitly non-limited values use the product default of unlimited. - * A positive count without either flag is retained for compatibility with older payloads. - * - * @param value serialized challenge metadata value. - * @returns The submission-limit mode and sanitized count used by the form. - * @throws Does not throw; malformed metadata falls back to unlimited. - */ -function parseSubmissionLimitMetadata(value: string | undefined): SubmissionLimitMetadata { - if (!value) { - return defaultSubmissionLimitMetadata - } - - try { - const parsedValue = JSON.parse(value) as unknown - - if (!parsedValue || typeof parsedValue !== 'object' || Array.isArray(parsedValue)) { - return defaultSubmissionLimitMetadata - } - - const parsedMetadata = parsedValue as Record - const rawCount = typeof parsedMetadata.count === 'string' - || typeof parsedMetadata.count === 'number' - ? String(parsedMetadata.count) - : '' - const count = sanitizeSubmissionLimitCount(rawCount) - const isUnlimited = toBoolean(parsedMetadata.unlimited) - const isLimited = toBoolean(parsedMetadata.limit) - || (!isUnlimited && Number(count) > 0) - - return { - count: isLimited - ? count - : '', - mode: isLimited - ? LIMITED_MODE - : UNLIMITED_MODE, - } - } catch { - return defaultSubmissionLimitMetadata - } -} - -/** - * Serializes the editor state to the legacy submission-limit metadata contract. - * - * @param mode selected unlimited or limited mode. - * @param count digits-only maximum submission count. - * @returns The JSON string persisted in challenge metadata. - * @throws Does not throw. - */ -function serializeSubmissionLimitMetadata( - mode: SubmissionLimitMode, - count: string | undefined, -): string { - const isLimited = mode === LIMITED_MODE - - return JSON.stringify({ - count: isLimited - ? (count || '') - : '', - limit: isLimited - ? 'true' - : 'false', - unlimited: isLimited - ? 'false' - : 'true', - }) -} - /** * Renders and persists the design-challenge submission-limit setting. * @@ -182,9 +86,10 @@ export const MaximumSubmissionsField: FC = ( control, getValues, setValue, + trigger, }: Pick< UseFormReturn, - 'control' | 'getValues' | 'setValue' + 'control' | 'getValues' | 'setValue' | 'trigger' > = useFormContext() const metadata = useWatch({ control, @@ -207,7 +112,10 @@ export const MaximumSubmissionsField: FC = ( name: 'numOfCheckpointSubmissions', }) as number | string | undefined const submissionLimitValue = getMetadataValue(metadata, SUBMISSION_LIMIT_FIELD) - const isLocked = Number(numOfSubmissions || 0) + Number(numOfCheckpointSubmissions || 0) > 0 + const isLocked = hasChallengeSubmissions({ + numOfCheckpointSubmissions, + numOfSubmissions, + }) const persistSubmissionLimitMetadata = useCallback(( mode: SubmissionLimitMode, @@ -304,6 +212,16 @@ export const MaximumSubmissionsField: FC = ( submissionLimitValue, ]) + /* + * The required-count rule is validated from the persisted metadata, which this component + * writes after React Hook Form has already scheduled its own change validation. Revalidating + * the count field here keeps the error in step with the value that would be saved. + */ + const revalidateSubmissionLimitCount = useCallback((): void => { + trigger(SUBMISSION_LIMIT_COUNT_FIELD) + .catch(() => undefined) + }, [trigger]) + const handleModeChange = useCallback((value: boolean | string): void => { if (value !== LIMITED_MODE && value !== UNLIMITED_MODE) { return @@ -328,15 +246,21 @@ export const MaximumSubmissionsField: FC = ( ? count : '', ) + revalidateSubmissionLimitCount() }, [ getValues, persistSubmissionLimitMetadata, + revalidateSubmissionLimitCount, setValue, ]) const handleCountChange = useCallback((count: string): void => { persistSubmissionLimitMetadata(LIMITED_MODE, count) - }, [persistSubmissionLimitMetadata]) + revalidateSubmissionLimitCount() + }, [ + persistSubmissionLimitMetadata, + revalidateSubmissionLimitCount, + ]) return (
From 9d918a6eb5b8a575ad6c11b7ef495ed4ff4c1f4c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 25 Aug 2026 07:58:14 +0300 Subject: [PATCH 41/44] PM-5727 - UI for duplicate submisisons in work app & review app --- .../TabContentSubmissions.spec.tsx | 2 + .../TabContentSubmissions.tsx | 2 + .../CollapsibleAiReviewsRow.tsx | 16 +- .../SubmissionDuplicates.module.scss | 93 ++++++++ .../SubmissionDuplicates.spec.tsx | 197 +++++++++++++++ .../SubmissionDuplicatesBadge.tsx | 72 ++++++ .../SubmissionDuplicatesPanel.tsx | 135 +++++++++++ .../components/SubmissionDuplicates/index.ts | 2 + .../TableCheckpointSubmissions.tsx | 2 + .../TableIterativeReview.tsx | 2 + .../TableSubmissionScreening.tsx | 2 + .../components/TableWinners/TableWinners.tsx | 2 + .../common/TableColumnRenderers.tsx | 2 + src/apps/review/src/lib/components/index.ts | 1 + .../lib/contexts/ChallengeDetailContext.ts | 2 + .../ChallengeDetailContextProvider.tsx | 28 +++ src/apps/review/src/lib/hooks/index.ts | 1 + ...FetchChallengeResults.integration.spec.tsx | 2 + .../lib/hooks/useFetchSubmissionDuplicates.ts | 129 ++++++++++ .../ChallengeDetailContextModel.model.ts | 4 + .../lib/models/SubmissionDuplicate.model.ts | 26 ++ src/apps/review/src/lib/models/index.ts | 1 + src/apps/review/src/lib/services/index.ts | 1 + .../submission-duplicates.service.spec.ts | 181 ++++++++++++++ .../services/submission-duplicates.service.ts | 159 +++++++++++++ src/apps/review/src/lib/utils/index.ts | 1 + .../lib/utils/submissionDuplicates.spec.ts | 39 +++ .../src/lib/utils/submissionDuplicates.ts | 54 +++++ .../SubmissionDuplicatesRow.module.scss | 97 ++++++++ .../SubmissionDuplicatesRow.tsx | 140 +++++++++++ .../SubmissionDuplicatesRow/index.ts | 1 + .../SubmissionsTable.spec.tsx | 142 ++++++++++- .../SubmissionsTable/SubmissionsTable.tsx | 224 ++++++++++-------- src/apps/work/src/lib/components/index.ts | 1 + src/apps/work/src/lib/hooks/index.ts | 1 + .../lib/hooks/useFetchSubmissionDuplicates.ts | 128 ++++++++++ .../lib/models/SubmissionDuplicate.model.ts | 26 ++ src/apps/work/src/lib/models/index.ts | 1 + src/apps/work/src/lib/services/index.ts | 1 + .../services/submission-duplicates.service.ts | 123 ++++++++++ .../work/src/lib/utils/permissions.utils.ts | 13 + .../SubmissionsSection.spec.tsx | 46 ++++ .../SubmissionsSection/SubmissionsSection.tsx | 25 ++ 43 files changed, 2016 insertions(+), 111 deletions(-) create mode 100644 src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss create mode 100644 src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx create mode 100644 src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx create mode 100644 src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx create mode 100644 src/apps/review/src/lib/components/SubmissionDuplicates/index.ts create mode 100644 src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts create mode 100644 src/apps/review/src/lib/models/SubmissionDuplicate.model.ts create mode 100644 src/apps/review/src/lib/services/submission-duplicates.service.spec.ts create mode 100644 src/apps/review/src/lib/services/submission-duplicates.service.ts create mode 100644 src/apps/review/src/lib/utils/submissionDuplicates.spec.ts create mode 100644 src/apps/review/src/lib/utils/submissionDuplicates.ts create mode 100644 src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss create mode 100644 src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx create mode 100644 src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts create mode 100644 src/apps/work/src/lib/hooks/useFetchSubmissionDuplicates.ts create mode 100644 src/apps/work/src/lib/models/SubmissionDuplicate.model.ts create mode 100644 src/apps/work/src/lib/services/submission-duplicates.service.ts diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx index 481e63317..f81f519bd 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.spec.tsx @@ -214,12 +214,14 @@ const challengeDetailContextValue = { challengeId: 'challenge-1', challengeInfo, challengeSubmissions: [submission], + duplicatesBySubmissionId: {}, hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, isLoadingChallengeResources: false, isLoadingChallengeSubmissions: false, + isLoadingSubmissionDuplicates: false, myResources: [], myRoles: [], registrants: [], diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx index 657488568..2fef11c79 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx @@ -45,6 +45,7 @@ import { import type { SubmissionHistoryPartition } from '../../utils' import { TABLE_DATE_FORMAT } from '../../../config/index.config' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import { useRolePermissions, UseRolePermissionsResult } from '../../hooks' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' import { @@ -437,6 +438,7 @@ export const TabContentSubmissions: FC = props => { > + {canShowTopgearReprocess && (
{isOpen && portalContainer && createPortal( -
- -
, + <> + +
+ +
+ , portalContainer, )}
diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss new file mode 100644 index 000000000..faaf51935 --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.module.scss @@ -0,0 +1,93 @@ +@import '@libs/ui/styles/includes'; + +.badge { + display: inline-flex; + align-items: center; + color: $red-100; + cursor: pointer; + background: none; + border: none; + padding: 0; + line-height: 1; + + svg { + width: 16px; + height: 16px; + } +} + +.panel { + display: flex; + flex-direction: column; + gap: $sp-2; + padding: $sp-3 0; +} + +.panelTitle { + display: flex; + align-items: center; + gap: $sp-1; + color: $red-100; + font-weight: 700; + + svg { + width: 16px; + height: 16px; + } +} + +.panelBox { + border: 1px solid $black-20; + border-radius: 4px; + padding: $sp-3; + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.duplicate { + display: flex; + flex-direction: column; + gap: 2px; +} + +.duplicateLine { + display: flex; + align-items: center; + gap: $sp-1; + flex-wrap: wrap; +} + +.bullet { + color: $black-60; +} + +.duplicateId { + color: $black-60; +} + +.crossChallenge { + display: inline-flex; + align-items: center; + gap: $sp-1; + color: $red-100; + padding-left: $sp-4; + + svg { + width: 14px; + height: 14px; + } +} + +.crossChallengeLink { + display: inline-flex; + align-items: center; + gap: 2px; + color: $red-100; + text-decoration: underline; + + svg { + width: 12px; + height: 12px; + } +} diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx new file mode 100644 index 000000000..a82d96e9c --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicates.spec.tsx @@ -0,0 +1,197 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import type { ChallengeDetailContextModel, SubmissionDuplicatesMap } from '../../models' + +import { SubmissionDuplicatesBadge } from './SubmissionDuplicatesBadge' +import { SubmissionDuplicatesPanel } from './SubmissionDuplicatesPanel' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://example.com/challenges', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => { + const React = jest.requireActual('react') + + return { + IconOutline: { + ExclamationIcon: () => React.createElement('svg'), + ExternalLinkIcon: () => React.createElement('svg'), + LightningBoltIcon: () => React.createElement('svg'), + }, + Tooltip: (props: { children: React.ReactNode }) => ( + React.createElement(React.Fragment, undefined, props.children) + ), + } +}, { virtual: true }) + +const sameChallengeDuplicate = { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + isCrossChallenge: false, + submissionId: '12I.RbObnTFCVt', + submittedAt: '2026-07-13T09:35:00.000Z', + user: '2001', + userHandle: 'testmfa1', +} + +const crossChallengeDuplicate = { + challenge: 'challenge-2', + challengeTitle: 'Basketball Stats App', + isCrossChallenge: true, + submissionId: 'plkGwR_M_145', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + userHandle: 'sathya22in', +} + +/** + * Renders a component inside a challenge detail context carrying duplicates. + * + * @param element component under test + * @param duplicatesBySubmissionId duplicate matches exposed through context + * @returns The testing-library render result. + */ +function renderWithDuplicates( + element: JSX.Element, + duplicatesBySubmissionId: SubmissionDuplicatesMap, +): ReturnType { + const contextValue = { + duplicatesBySubmissionId, + } as ChallengeDetailContextModel + + return render( + + {element} + , + ) +} + +describe('SubmissionDuplicatesBadge', () => { + it('renders nothing when the submission has no duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [] }, + ) + + expect(screen.queryByRole('img')) + .toBeNull() + }) + + it('renders nothing when no submission id is supplied', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate] }, + ) + + expect(screen.queryByRole('img')) + .toBeNull() + }) + + it('summarizes same-challenge duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate] }, + ) + + expect(screen.getByRole('img', { name: '1 identical submission on this challenge' })) + .toBeTruthy() + }) + + it('calls out cross-challenge duplicates', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + expect(screen.getByRole('img', { + name: '2 identical submissions, 1 on other challenges', + })) + .toBeTruthy() + }) +}) + +describe('SubmissionDuplicatesPanel', () => { + it('renders nothing when the submission has no duplicates', () => { + renderWithDuplicates( + , + {}, + ) + + expect(screen.queryByText(/Duplicates/)) + .toBeNull() + }) + + it('lists every duplicate with handle, id and date', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + expect(screen.getAllByText( + (_content, element) => element?.textContent === 'Duplicates (2)', + ).length) + .toBeGreaterThan(0) + expect(screen.getByText('testmfa1')) + .toBeTruthy() + expect(screen.getByText('(12I.RbObnTFCVt)')) + .toBeTruthy() + expect(screen.getByText('sathya22in')) + .toBeTruthy() + }) + + it('links only cross-challenge duplicates to their originating challenge', () => { + renderWithDuplicates( + , + { 'submission-1': [sameChallengeDuplicate, crossChallengeDuplicate] }, + ) + + const links = screen.getAllByRole('link') + + expect(links) + .toHaveLength(1) + expect(links[0].getAttribute('href')) + .toBe('https://example.com/challenges/challenge-2') + expect(links[0].textContent) + .toContain('Basketball Stats App') + }) + + it('falls back to the member id when no handle resolved', () => { + renderWithDuplicates( + , + { + 'submission-1': [ + { + ...sameChallengeDuplicate, + userHandle: undefined, + }, + ], + }, + ) + + expect(screen.getByText('2001')) + .toBeTruthy() + }) + + it('renders a placeholder date when the timestamp is unusable', () => { + renderWithDuplicates( + , + { + 'submission-1': [ + { + ...sameChallengeDuplicate, + submittedAt: undefined, + }, + ], + }, + ) + + expect(screen.getByText('- --')) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx new file mode 100644 index 000000000..4c94686ba --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesBadge.tsx @@ -0,0 +1,72 @@ +/** + * Warning badge shown next to a submission ID when identical submissions exist. + */ +import { FC, useContext, useMemo } from 'react' + +import { IconOutline, Tooltip } from '~/libs/ui' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import { ChallengeDetailContextModel, SubmissionDuplicate } from '../../models' + +import styles from './SubmissionDuplicates.module.scss' + +interface SubmissionDuplicatesBadgeProps { + submissionId?: string +} + +/** + * Builds the tooltip summary for a set of duplicate matches. + * @param duplicates Duplicate matches for the submission. + * @returns Count summary, calling out cross-challenge matches when present. + */ +function getTooltipContent(duplicates: SubmissionDuplicate[]): string { + const countLabel = `${duplicates.length} identical submission${duplicates.length === 1 ? '' : 's'}` + const crossChallengeCount = duplicates.filter(duplicate => duplicate.isCrossChallenge).length + + if (!crossChallengeCount) { + return `${countLabel} on this challenge` + } + + if (crossChallengeCount === duplicates.length) { + return `${countLabel} on other challenges` + } + + return `${countLabel}, ${crossChallengeCount} on other challenges` +} + +/** + * Renders the duplicate-submission warning icon, or nothing when the submission + * has no known duplicates. + * + * Duplicate data comes from `ChallengeDetailContext`, so the badge can be + * dropped into any table cell without threading props through the renderer. + */ +export const SubmissionDuplicatesBadge: FC = props => { + const { duplicatesBySubmissionId }: ChallengeDetailContextModel + = useContext(ChallengeDetailContext) + + const duplicates = useMemo( + () => (props.submissionId + ? duplicatesBySubmissionId[props.submissionId] ?? [] + : []), + [duplicatesBySubmissionId, props.submissionId], + ) + + if (!duplicates.length) { + return <> + } + + return ( + + + + + ) +} + +export default SubmissionDuplicatesBadge diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx new file mode 100644 index 000000000..12924db2b --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/SubmissionDuplicatesPanel.tsx @@ -0,0 +1,135 @@ +/** + * Duplicate submission list rendered above the AI reviewers table. + */ +import { FC, useContext, useMemo } from 'react' +import moment from 'moment' + +import { EnvironmentConfig } from '~/config' +import { IconOutline } from '~/libs/ui' + +import { ChallengeDetailContext } from '../../contexts/ChallengeDetailContext' +import { ChallengeDetailContextModel, SubmissionDuplicate } from '../../models' +import { TABLE_DATE_FORMAT } from '../../constants' + +import styles from './SubmissionDuplicates.module.scss' + +interface SubmissionDuplicatesPanelProps { + submissionId?: string +} + +interface DuplicateEntryProps { + duplicate: SubmissionDuplicate +} + +/** + * Formats a duplicate's submission timestamp for display. + * @param submittedAt ISO timestamp reported by the duplicates endpoint. + * @returns Formatted date, or an em dash when the timestamp is missing or invalid. + */ +function formatSubmittedAt(submittedAt?: string): string { + if (!submittedAt) { + return '--' + } + + const parsed = moment(submittedAt) + + return parsed.isValid() + ? parsed.format(TABLE_DATE_FORMAT) + : '--' +} + +/** + * Renders a single duplicate entry, adding the originating challenge link when + * the match comes from a different challenge. + * @param duplicate Duplicate match to render. + * @returns The duplicate list item. + */ +const DuplicateEntry: FC = (props: DuplicateEntryProps) => { + const duplicate: SubmissionDuplicate = props.duplicate + const challengeUrl = duplicate.challenge + ? `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${duplicate.challenge}` + : undefined + + return ( +
+
+ + {duplicate.userHandle || duplicate.user || 'Unknown member'} + + ( + {duplicate.submissionId} + ) + + + - + {' '} + {formatSubmittedAt(duplicate.submittedAt)} + +
+ + {duplicate.isCrossChallenge && ( +
+
+ )} +
+ ) +} + +/** + * Renders the duplicates block for a submission, or nothing when the submission + * has no known duplicates. + * + * Duplicate data comes from `ChallengeDetailContext` so the panel can be dropped + * into any expandable submission row. + */ +export const SubmissionDuplicatesPanel: FC = props => { + const { duplicatesBySubmissionId }: ChallengeDetailContextModel + = useContext(ChallengeDetailContext) + + const duplicates = useMemo( + () => (props.submissionId + ? duplicatesBySubmissionId[props.submissionId] ?? [] + : []), + [duplicatesBySubmissionId, props.submissionId], + ) + + if (!duplicates.length) { + return <> + } + + return ( +
+
+
+ +
+ {duplicates.map(duplicate => ( + + ))} +
+
+ ) +} + +export default SubmissionDuplicatesPanel diff --git a/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts b/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts new file mode 100644 index 000000000..27efbfa34 --- /dev/null +++ b/src/apps/review/src/lib/components/SubmissionDuplicates/index.ts @@ -0,0 +1,2 @@ +export { default as SubmissionDuplicatesBadge } from './SubmissionDuplicatesBadge' +export { default as SubmissionDuplicatesPanel } from './SubmissionDuplicatesPanel' diff --git a/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx index db8de2d8d..830f7bf46 100644 --- a/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx +++ b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx @@ -46,6 +46,7 @@ import { ConfirmModal } from '../ConfirmModal' import { useRolePermissions, UseRolePermissionsResult, useSubmissionDownloadAccess } from '../../hooks' import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' import styles from './TableCheckpointSubmissions.module.scss' @@ -329,6 +330,7 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { > + ) }, diff --git a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx index e0cadd634..24f31d5b7 100644 --- a/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx +++ b/src/apps/review/src/lib/components/TableIterativeReview/TableIterativeReview.tsx @@ -54,6 +54,7 @@ import { resolveSubmissionReviewResult } from '../common/reviewResult' import { ProgressBar } from '../ProgressBar' import { TableWrapper } from '../TableWrapper' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import { EscalationModals } from '../TableReview/EscalationModals' import { SUBMISSION_DOWNLOAD_RESTRICTION_MESSAGE } from '../../constants' @@ -895,6 +896,7 @@ export const TableIterativeReview: FC = (props: Props) => { > + ) }, diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx index 512ce4dee..5cacef591 100644 --- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx +++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx @@ -57,6 +57,7 @@ import { useRole, useRolePermissions, UseRolePermissionsResult, useSubmissionDow import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import type { useRoleProps } from '../../hooks/useRole' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import styles from './TableSubmissionScreening.module.scss' @@ -208,6 +209,7 @@ const createSubmissionColumn = (config: SubmissionColumnConfig): TableColumn + ) }, diff --git a/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx b/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx index 445c75005..5b34e7491 100644 --- a/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx +++ b/src/apps/review/src/lib/components/TableWinners/TableWinners.tsx @@ -24,6 +24,7 @@ import type { PhaseOrderingOptions } from '../../utils' import { useSubmissionDownloadAccess } from '../../hooks' import type { UseSubmissionDownloadAccessResult } from '../../hooks/useSubmissionDownloadAccess' import { CollapsibleAiReviewsRow } from '../CollapsibleAiReviewsRow' +import { SubmissionDuplicatesBadge } from '../SubmissionDuplicates/SubmissionDuplicatesBadge' import styles from './TableWinners.module.scss' @@ -170,6 +171,7 @@ export const TableWinners: FC = (props: Props) => { ) : undefined} {renderedDownloadButton} + - + ) } diff --git a/src/apps/review/src/lib/components/index.ts b/src/apps/review/src/lib/components/index.ts index 8d4cfa287..d13c04229 100644 --- a/src/apps/review/src/lib/components/index.ts +++ b/src/apps/review/src/lib/components/index.ts @@ -22,4 +22,5 @@ export * from './ChallengeTimeline' export * from './ConfirmModal' export * from './ScorecardsFilter' export * from './TableScorecards' +export * from './SubmissionDuplicates' export * from './SubmissionHistoryModal' diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts index 7560bb931..f58c2d410 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts @@ -15,12 +15,14 @@ export const ChallengeDetailContext: Context challengeScopedFetchError: undefined, challengeSubmissions: [], challengeSubmissionsError: undefined, + duplicatesBySubmissionId: {}, hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, isLoadingChallengeResources: false, isLoadingChallengeSubmissions: false, + isLoadingSubmissionDuplicates: false, myResources: [], myRoles: [], registrants: [], diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx index cf662918f..42b553c73 100644 --- a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx +++ b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx @@ -21,8 +21,11 @@ import { useFetchChallengeResourcesProps, useFetchChallengeSubmissions, useFetchChallengeSubmissionsProps, + useFetchSubmissionDuplicates, + UseFetchSubmissionDuplicatesResult, } from '../hooks' import type { ChallengeVisibilityFlags } from '../hooks/useFetchChallengeSubmissions' +import { canViewSubmissionDuplicates } from '../utils' import { ChallengeDetailContext } from './ChallengeDetailContext' import { ReviewAppContext } from './ReviewAppContext' @@ -123,6 +126,27 @@ export const ChallengeDetailContextProvider: FC = props => { [aiReviewDecisions], ) + // Duplicate detection is queried for every visible submission at once so any + // tab can decorate its rows straight from context. + const duplicateCheckSubmissionIds = useMemo( + () => challengeSubmissions + .map(submission => `${submission.id ?? ''}`.trim()) + .filter(Boolean), + [challengeSubmissions], + ) + const canQueryDuplicates = useMemo( + () => canViewSubmissionDuplicates(myRoles, loginUserInfo?.roles), + [loginUserInfo?.roles, myRoles], + ) + const { + duplicatesBySubmissionId, + isLoading: isLoadingSubmissionDuplicates, + }: UseFetchSubmissionDuplicatesResult = useFetchSubmissionDuplicates( + challengeId, + duplicateCheckSubmissionIds, + canQueryDuplicates, + ) + const enrichedChallengeInfo = useMemo( () => (challengeInfo ? { @@ -165,12 +189,14 @@ export const ChallengeDetailContextProvider: FC = props => { challengeScopedFetchError, challengeSubmissions, challengeSubmissionsError, + duplicatesBySubmissionId, hasChallengeScopedFetchError: !!challengeScopedFetchError, isLoadingAiReviewConfig, isLoadingAiReviewDecisions, isLoadingChallengeInfo: isLoadingChallengeInfoCombined, isLoadingChallengeResources, isLoadingChallengeSubmissions, + isLoadingSubmissionDuplicates, myResources, myRoles, registrants, @@ -187,9 +213,11 @@ export const ChallengeDetailContextProvider: FC = props => { challengeScopedFetchError, challengeSubmissions, challengeSubmissionsError, + duplicatesBySubmissionId, isLoadingChallengeInfoCombined, isLoadingChallengeResources, isLoadingChallengeSubmissions, + isLoadingSubmissionDuplicates, aiReviewConfig, aiReviewDecisionsBySubmissionId, isLoadingAiReviewConfig, diff --git a/src/apps/review/src/lib/hooks/index.ts b/src/apps/review/src/lib/hooks/index.ts index 89595ac76..ba08224ed 100644 --- a/src/apps/review/src/lib/hooks/index.ts +++ b/src/apps/review/src/lib/hooks/index.ts @@ -23,3 +23,4 @@ export * from './useFetchAiReviewData' export * from './useFetchSubmissionInfo' export * from './useReviewEditAccess' export * from './useFetchAiReviewEscalations' +export * from './useFetchSubmissionDuplicates' diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx index 1c1ed4e31..ff9f8e2d5 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.integration.spec.tsx @@ -118,12 +118,14 @@ const buildContextValue = ( challengeScopedFetchError: undefined, challengeSubmissions: [], challengeSubmissionsError: undefined, + duplicatesBySubmissionId: {}, hasChallengeScopedFetchError: false, isLoadingAiReviewConfig: false, isLoadingAiReviewDecisions: false, isLoadingChallengeInfo: false, isLoadingChallengeResources: false, isLoadingChallengeSubmissions: false, + isLoadingSubmissionDuplicates: false, myResources: [], myRoles: [], registrants: [], diff --git a/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts b/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts new file mode 100644 index 000000000..7dc7682cc --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchSubmissionDuplicates.ts @@ -0,0 +1,129 @@ +import { useMemo } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' +import { + fetchMemberHandles, + fetchSubmissionDuplicates, + getSubmissionDuplicatesCacheKey, +} from '../services' + +export interface UseFetchSubmissionDuplicatesResult { + duplicatesBySubmissionId: SubmissionDuplicatesMap + isLoading: boolean +} + +const EMPTY_DUPLICATES: SubmissionDuplicatesMap = {} + +/** + * Resolves member handles for the members behind the duplicate submissions. + * @param duplicatesBySubmissionId Duplicate matches keyed by checked submission id. + * @returns The same map with `userHandle` filled in wherever a handle resolved. + */ +async function withMemberHandles( + duplicatesBySubmissionId: SubmissionDuplicatesMap, +): Promise { + const memberIds = Array.from(new Set( + Object.values(duplicatesBySubmissionId) + .flat() + .map(duplicate => duplicate.user) + .filter((memberId): memberId is string => !!memberId && /^\d+$/.test(memberId)), + )) + + if (!memberIds.length) { + return duplicatesBySubmissionId + } + + let handlesByMemberId: Map + try { + handlesByMemberId = await fetchMemberHandles(memberIds) + } catch { + return duplicatesBySubmissionId + } + + return Object.entries(duplicatesBySubmissionId) + .reduce((result, [submissionId, duplicates]) => { + result[submissionId] = duplicates.map((duplicate: SubmissionDuplicate) => { + const handle = duplicate.user + ? handlesByMemberId.get(Number(duplicate.user)) + : undefined + + return handle + ? { + ...duplicate, + userHandle: handle, + } + : duplicate + }) + + return result + }, {}) +} + +/** + * Fetches SHA-256 duplicate matches for a challenge's submissions. + * + * Duplicate detection is an operator-only endpoint, so the caller must gate the + * request with `enabled`. Failures resolve to an empty map instead of surfacing + * a toast, because duplicate badges are supplementary to every table they + * decorate. + * + * @param challengeId Challenge that owns the submissions being checked. + * @param submissionIds Submission ids to check for duplicates. + * @param enabled Whether the caller is allowed to query duplicates. + * @returns Duplicate matches keyed by submission id plus the loading flag. + */ +export function useFetchSubmissionDuplicates( + challengeId?: string, + submissionIds: string[] = [], + enabled: boolean = true, +): UseFetchSubmissionDuplicatesResult { + const normalizedSubmissionIds = useMemo( + () => Array.from(new Set( + submissionIds + .map(submissionId => `${submissionId ?? ''}`.trim()) + .filter(Boolean), + )), + [submissionIds], + ) + + const cacheKey = enabled + ? getSubmissionDuplicatesCacheKey(challengeId, normalizedSubmissionIds, true) + : undefined + + const { + data: duplicatesBySubmissionId = EMPTY_DUPLICATES, + isValidating: isLoading, + }: SWRResponse = useSWR( + cacheKey, + { + fetcher: async (): Promise => { + if (!challengeId || !normalizedSubmissionIds.length) { + return EMPTY_DUPLICATES + } + + try { + const duplicates = await fetchSubmissionDuplicates( + challengeId, + normalizedSubmissionIds, + true, + ) + + return withMemberHandles(duplicates) + } catch { + // Duplicate detection is optional context; a denied or failed + // lookup must not break the tables it decorates. + return EMPTY_DUPLICATES + } + }, + isPaused: () => !cacheKey, + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ) + + return { + duplicatesBySubmissionId, + isLoading, + } +} diff --git a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts index 0208d4549..973f0b1eb 100644 --- a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts +++ b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts @@ -2,6 +2,7 @@ import { BackendResource } from './BackendResource.model' import { BackendSubmission } from './BackendSubmission.model' import { ChallengeInfo } from './ChallengeInfo.model' import { AiReviewConfig, AiReviewDecision } from './AiReview.model' +import { SubmissionDuplicatesMap } from './SubmissionDuplicate.model' /** * Model for challenge detail context @@ -28,6 +29,9 @@ export interface ChallengeDetailContextModel { aiReviewDecisionsBySubmissionId: Record isLoadingAiReviewConfig: boolean isLoadingAiReviewDecisions: boolean + /** SHA-256 duplicate matches keyed by submission id; empty when not permitted. */ + duplicatesBySubmissionId: SubmissionDuplicatesMap + isLoadingSubmissionDuplicates: boolean resourceMemberIdMapping: { [memberId: string]: BackendResource } diff --git a/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts b/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts new file mode 100644 index 000000000..ed7dd05a1 --- /dev/null +++ b/src/apps/review/src/lib/models/SubmissionDuplicate.model.ts @@ -0,0 +1,26 @@ +/** + * Models for the SHA-256 duplicate submission detection endpoint. + */ + +/** + * A submission sharing the exact SHA-256 digest of the checked submission. + */ +export interface SubmissionDuplicate { + /** Challenge that owns the duplicate submission. */ + challenge?: string + /** Challenge name, when the API could resolve it. */ + challengeTitle?: string + /** True when the duplicate lives on a different challenge. */ + isCrossChallenge: boolean + /** ID of the duplicate submission. */ + submissionId: string + /** ISO timestamp the duplicate was submitted. */ + submittedAt?: string + /** Member ID that created the duplicate submission. */ + user?: string + /** Member handle resolved from `user`, when available. */ + userHandle?: string +} + +/** Duplicate matches keyed by the checked submission ID. */ +export type SubmissionDuplicatesMap = Record diff --git a/src/apps/review/src/lib/models/index.ts b/src/apps/review/src/lib/models/index.ts index ea54c5535..827f7edcf 100644 --- a/src/apps/review/src/lib/models/index.ts +++ b/src/apps/review/src/lib/models/index.ts @@ -44,6 +44,7 @@ export * from './ChallengeDetailContextModel.model' export * from './FormContactManager.model' export * from './BackendContactRequest.model' export * from './BackendSubmission.model' +export * from './SubmissionDuplicate.model' export * from './BackendReview.model' export * from './BackendMeta.model' export * from './BackendResponseWithMeta.model' diff --git a/src/apps/review/src/lib/services/index.ts b/src/apps/review/src/lib/services/index.ts index 2c3fba054..5c85478bd 100644 --- a/src/apps/review/src/lib/services/index.ts +++ b/src/apps/review/src/lib/services/index.ts @@ -8,3 +8,4 @@ export * from './challenge-phases.service' export * from './aiReviewEscalation.service' export * from './aiReview.service' export * from './submission-reprocess.service' +export * from './submission-duplicates.service' diff --git a/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts b/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts new file mode 100644 index 000000000..657767704 --- /dev/null +++ b/src/apps/review/src/lib/services/submission-duplicates.service.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { xhrGetAsync } from '~/libs/core' + +import { + chunkSubmissionIds, + fetchSubmissionDuplicates, + getSubmissionDuplicatesCacheKey, +} from './submission-duplicates.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { + V6: 'https://api.test/v6', + }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { virtual: true }) + +const xhrGetAsyncMock = xhrGetAsync as jest.MockedFunction + +describe('submission-duplicates.service', () => { + beforeEach(() => { + xhrGetAsyncMock.mockReset() + }) + + describe('chunkSubmissionIds', () => { + it('splits ids into chunks of at most 100', () => { + const ids = Array.from({ length: 205 }, (_, index) => `submission-${index}`) + + expect(chunkSubmissionIds(ids) + .map(chunk => chunk.length)) + .toEqual([100, 100, 5]) + }) + }) + + describe('getSubmissionDuplicatesCacheKey', () => { + it('is stable regardless of submission id order', () => { + expect(getSubmissionDuplicatesCacheKey('challenge-1', ['b', 'a'], true)) + .toBe(getSubmissionDuplicatesCacheKey('challenge-1', ['a', 'b'], true)) + }) + + it('varies with the cross-challenge flag', () => { + expect(getSubmissionDuplicatesCacheKey('challenge-1', ['a'], true)) + .not + .toBe(getSubmissionDuplicatesCacheKey('challenge-1', ['a'], false)) + }) + + it('is undefined without a challenge or submission ids', () => { + expect(getSubmissionDuplicatesCacheKey(undefined, ['a'])) + .toBeUndefined() + expect(getSubmissionDuplicatesCacheKey('challenge-1', [])) + .toBeUndefined() + }) + }) + + describe('fetchSubmissionDuplicates', () => { + it('requests every submission id and flags cross-challenge matches', async () => { + xhrGetAsyncMock.mockResolvedValue({ + 'submission-1': { + duplicates: [ + { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + submissionId: 'submission-2', + submittedAt: '2026-07-13T09:35:00.000Z', + user: 2001, + }, + { + challenge: 'challenge-9', + challengeTitle: 'Other Challenge', + submissionId: 'submission-9', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + }, + ], + }, + } as never) + + const result = await fetchSubmissionDuplicates( + 'challenge-1', + ['submission-1'], + true, + ) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates' + + '?submissionId=submission-1&crossChallenge=true', + ) + expect(result['submission-1']) + .toEqual([ + { + challenge: 'challenge-1', + challengeTitle: 'This Challenge', + isCrossChallenge: false, + submissionId: 'submission-2', + submittedAt: '2026-07-13T09:35:00.000Z', + user: '2001', + }, + { + challenge: 'challenge-9', + challengeTitle: 'Other Challenge', + isCrossChallenge: true, + submissionId: 'submission-9', + submittedAt: '2026-07-09T11:21:00.000Z', + user: '2002', + }, + ]) + }) + + it('omits the cross-challenge flag for same-challenge lookups', async () => { + xhrGetAsyncMock.mockResolvedValue({} as never) + + await fetchSubmissionDuplicates('challenge-1', ['submission-1']) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates?submissionId=submission-1', + ) + }) + + it('chunks large id lists into separate requests and merges the results', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `submission-${index}`) + xhrGetAsyncMock.mockImplementation(async url => ( + `${url}`.includes('submission-100') + ? { 'submission-100': { duplicates: [{ submissionId: 'dup-b' }] } } as never + : { 'submission-0': { duplicates: [{ submissionId: 'dup-a' }] } } as never + )) + + const result = await fetchSubmissionDuplicates('challenge-1', ids, true) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledTimes(2) + expect(result['submission-0']?.[0].submissionId) + .toBe('dup-a') + expect(result['submission-100']?.[0].submissionId) + .toBe('dup-b') + }) + + it('deduplicates and trims requested ids', async () => { + xhrGetAsyncMock.mockResolvedValue({} as never) + + await fetchSubmissionDuplicates( + 'challenge-1', + [' submission-1 ', 'submission-1', ''], + ) + + expect(xhrGetAsyncMock) + .toHaveBeenCalledWith( + 'https://api.test/v6/submissions/challenge-1/duplicates?submissionId=submission-1', + ) + }) + + it('skips the request when there is nothing to check', async () => { + expect(await fetchSubmissionDuplicates('challenge-1', [])) + .toEqual({}) + expect(await fetchSubmissionDuplicates('', ['submission-1'])) + .toEqual({}) + expect(xhrGetAsyncMock) + .not + .toHaveBeenCalled() + }) + + it('tolerates malformed duplicate payloads', async () => { + xhrGetAsyncMock.mockResolvedValue({ + 'submission-1': { duplicates: 'nope' }, + 'submission-2': { duplicates: [undefined, {}, { submissionId: 'dup-a' }] }, + } as never) + + const result = await fetchSubmissionDuplicates('challenge-1', ['submission-1']) + + expect(result['submission-1']) + .toEqual([]) + expect(result['submission-2']) + .toHaveLength(1) + }) + }) +}) diff --git a/src/apps/review/src/lib/services/submission-duplicates.service.ts b/src/apps/review/src/lib/services/submission-duplicates.service.ts new file mode 100644 index 000000000..95f1248e3 --- /dev/null +++ b/src/apps/review/src/lib/services/submission-duplicates.service.ts @@ -0,0 +1,159 @@ +/** + * Service for the SHA-256 duplicate submission detection endpoint. + */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' + +const v6BaseUrl = `${EnvironmentConfig.API.V6}` + +/** The API rejects requests carrying more submission ids than this. */ +export const DUPLICATES_REQUEST_CHUNK_SIZE = 100 + +interface DuplicateGroupResponse { + duplicates?: unknown +} + +type DuplicatesResponse = Record + +function toOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Splits submission ids into request-sized chunks. + * @param submissionIds Unique submission ids to check. + * @returns Chunks no larger than the API's per-request id limit. + */ +export function chunkSubmissionIds(submissionIds: string[]): string[][] { + const chunks: string[][] = [] + + for (let index = 0; index < submissionIds.length; index += DUPLICATES_REQUEST_CHUNK_SIZE) { + chunks.push(submissionIds.slice(index, index + DUPLICATES_REQUEST_CHUNK_SIZE)) + } + + return chunks +} + +/** + * Normalizes one duplicate entry returned by the API. + * @param value Raw duplicate entry. + * @param challengeId Challenge the checked submission belongs to. + * @returns Normalized duplicate, or `undefined` when the entry has no submission id. + */ +function toSubmissionDuplicate( + value: unknown, + challengeId: string, +): SubmissionDuplicate | undefined { + if (typeof value !== 'object' || !value) { + return undefined + } + + const entry = value as Record + const submissionId = toOptionalString(entry.submissionId) + + if (!submissionId) { + return undefined + } + + const challenge = toOptionalString(entry.challenge) + + return { + challenge, + challengeTitle: toOptionalString(entry.challengeTitle), + isCrossChallenge: !!challenge && challenge !== challengeId, + submissionId, + submittedAt: toOptionalString(entry.submittedAt), + user: toOptionalString(entry.user), + } +} + +/** + * Builds the cache key for a duplicate-detection request. + * @param challengeId Challenge that owns the checked submissions. + * @param submissionIds Submission ids being checked. + * @param crossChallenge Whether other challenges are searched too. + * @returns Stable SWR cache key, or `undefined` when there is nothing to fetch. + */ +export function getSubmissionDuplicatesCacheKey( + challengeId?: string, + submissionIds: string[] = [], + crossChallenge: boolean = false, +): string | undefined { + if (!challengeId || !submissionIds.length) { + return undefined + } + + return [ + `${v6BaseUrl}/submissions/${challengeId}/duplicates`, + `crossChallenge=${crossChallenge}`, + [...submissionIds].sort() + .join(','), + ].join('|') +} + +/** + * Fetches submissions sharing a SHA-256 digest with the supplied submissions. + * @param challengeId Challenge that owns every checked submission. + * @param submissionIds Submission ids to check; chunked to respect the API limit. + * @param crossChallenge When true, duplicates from other challenges are included. + * @returns Duplicate matches keyed by checked submission id. + */ +export async function fetchSubmissionDuplicates( + challengeId: string, + submissionIds: string[], + crossChallenge: boolean = false, +): Promise { + const normalizedChallengeId = challengeId.trim() + const uniqueSubmissionIds = Array.from(new Set( + submissionIds + .map(submissionId => toOptionalString(submissionId)) + .filter((submissionId): submissionId is string => !!submissionId), + )) + + if (!normalizedChallengeId || !uniqueSubmissionIds.length) { + return {} + } + + const responses = await Promise.all( + chunkSubmissionIds(uniqueSubmissionIds) + .map(async chunk => { + const query = new URLSearchParams() + chunk.forEach(submissionId => { + query.append('submissionId', submissionId) + }) + + if (crossChallenge) { + query.set('crossChallenge', 'true') + } + + return xhrGetAsync( + `${v6BaseUrl}/submissions/${normalizedChallengeId}/duplicates?${query.toString()}`, + ) + }), + ) + + return responses.reduce((result, response) => { + Object.entries(response ?? {}) + .forEach(([submissionId, group]) => { + const rawDuplicates: unknown = group?.duplicates + const duplicates: unknown[] = Array.isArray(rawDuplicates) + ? rawDuplicates + : [] + + result[submissionId] = duplicates + .map(duplicate => toSubmissionDuplicate(duplicate, normalizedChallengeId)) + .filter((duplicate): duplicate is SubmissionDuplicate => !!duplicate) + }) + + return result + }, {}) +} diff --git a/src/apps/review/src/lib/utils/index.ts b/src/apps/review/src/lib/utils/index.ts index 2f86d2098..37256688b 100644 --- a/src/apps/review/src/lib/utils/index.ts +++ b/src/apps/review/src/lib/utils/index.ts @@ -23,3 +23,4 @@ export * from './metadataMatching' export * from './reviewMatching' export * from './reviewBuilding' export * from './submissionOwnership' +export * from './submissionDuplicates' diff --git a/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts b/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts new file mode 100644 index 000000000..33b3d156e --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionDuplicates.spec.ts @@ -0,0 +1,39 @@ +import { canViewSubmissionDuplicates } from './submissionDuplicates' + +describe('canViewSubmissionDuplicates', () => { + it('allows administrators from the token roles', () => { + expect(canViewSubmissionDuplicates([], ['Topcoder User', 'administrator'])) + .toBe(true) + }) + + it('allows project managers from the token roles', () => { + expect(canViewSubmissionDuplicates([], ['Project Manager'])) + .toBe(true) + }) + + it.each([ + 'Copilot', + 'Manager', + 'Reviewer', + 'Iterative Reviewer', + 'Checkpoint Screener', + ])('allows the %s challenge resource role', challengeRole => { + expect(canViewSubmissionDuplicates([challengeRole], ['Topcoder User'])) + .toBe(true) + }) + + it('denies submitters', () => { + expect(canViewSubmissionDuplicates(['Submitter'], ['Topcoder User'])) + .toBe(false) + }) + + it('denies observers and approvers, which the API does not accept', () => { + expect(canViewSubmissionDuplicates(['Observer', 'Approver'], ['Topcoder User'])) + .toBe(false) + }) + + it('denies anonymous callers', () => { + expect(canViewSubmissionDuplicates(undefined, undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/review/src/lib/utils/submissionDuplicates.ts b/src/apps/review/src/lib/utils/submissionDuplicates.ts new file mode 100644 index 000000000..54ded4d81 --- /dev/null +++ b/src/apps/review/src/lib/utils/submissionDuplicates.ts @@ -0,0 +1,54 @@ +/** + * Access rules for the operator-only submission duplicate detection endpoint. + */ + +/** + * Challenge resource role fragments the duplicates endpoint accepts. + * Mirrors the review API's `DUPLICATE_DETECTION_RESOURCE_ROLE_FRAGMENTS`. + */ +const DUPLICATE_CHALLENGE_ROLE_FRAGMENTS = [ + 'copilot', + 'manager', + 'reviewer', + 'screener', +] + +/** Token roles the duplicates endpoint accepts without a challenge resource. */ +const DUPLICATE_TOKEN_ROLES = [ + 'administrator', + 'project manager', +] + +function normalizeRoles(roles: Array | undefined): string[] { + return (roles ?? []) + .map(role => `${role ?? ''}`.trim() + .toLowerCase()) + .filter(Boolean) +} + +/** + * Determines whether the current user may query submission duplicates. + * + * The endpoint answers only for admins, PMs, and challenge + * Reviewer/Screener/Copilot/Manager resources, so the UI must not call it for + * anyone else — a submitter would only collect a 403. + * + * @param challengeRoles Resource role names the user holds on the challenge. + * @param tokenRoles Roles carried by the auth token. + * @returns True when the duplicates endpoint will answer for this user. + */ +export function canViewSubmissionDuplicates( + challengeRoles: string[] | undefined, + tokenRoles: Array | undefined, +): boolean { + const normalizedTokenRoles = normalizeRoles(tokenRoles) + if (normalizedTokenRoles.some(role => DUPLICATE_TOKEN_ROLES.includes(role))) { + return true + } + + const normalizedChallengeRoles = normalizeRoles(challengeRoles) + + return normalizedChallengeRoles.some( + role => DUPLICATE_CHALLENGE_ROLE_FRAGMENTS.some(fragment => role.includes(fragment)), + ) +} diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss new file mode 100644 index 000000000..345409045 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.module.scss @@ -0,0 +1,97 @@ +@import '@libs/ui/styles/includes'; + +.cell { + padding-top: 0; +} + +.toggle { + align-items: center; + background: transparent; + border: 0; + color: $red-100; + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 500; + gap: $sp-1; + padding: 0; + width: 100%; + + svg { + height: 16px; + width: 16px; + } +} + +.toggleLabel { + flex: 1; + text-align: left; +} + +.chevron { + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + border: 1px solid $black-20; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: $sp-2; + margin-top: $sp-2; + padding: $sp-3; +} + +.duplicate { + display: flex; + flex-direction: column; + gap: 2px; +} + +.duplicateLine { + align-items: center; + color: $black-80; + display: flex; + flex-wrap: wrap; + font-size: 13px; + gap: $sp-1; +} + +.bullet { + color: $black-60; +} + +.duplicateMeta { + color: $black-60; +} + +.crossChallenge { + align-items: center; + color: $red-100; + display: inline-flex; + font-size: 13px; + gap: $sp-1; + padding-left: $sp-4; + + svg { + height: 14px; + width: 14px; + } +} + +.crossChallengeLink { + align-items: center; + color: $red-100; + display: inline-flex; + gap: 2px; + text-decoration: underline; + + svg { + height: 12px; + width: 12px; + } +} diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx new file mode 100644 index 000000000..8238aa269 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/SubmissionDuplicatesRow.tsx @@ -0,0 +1,140 @@ +/** + * Expandable duplicates row rendered under a submissions table row. + */ +import { FC, useCallback, useState } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { IconOutline } from '~/libs/ui' + +import { SubmissionDuplicate } from '../../models' + +import styles from './SubmissionDuplicatesRow.module.scss' + +interface SubmissionDuplicatesRowProps { + colSpan: number + duplicates: SubmissionDuplicate[] +} + +interface DuplicateEntryProps { + duplicate: SubmissionDuplicate +} + +/** + * Formats a duplicate's submission timestamp as `Jul 13, 7:39 AM`. + * @param submittedAt ISO timestamp reported by the duplicates endpoint. + * @returns Formatted timestamp, or a dash when it is missing or unparseable. + */ +function formatDuplicateDate(submittedAt?: string): string { + if (!submittedAt) { + return '-' + } + + const parsed = new Date(submittedAt) + if (Number.isNaN(parsed.getTime())) { + return '-' + } + + return parsed.toLocaleString('en-US', { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short', + }) +} + +/** + * Renders a single duplicate entry, adding the originating challenge link when + * the match comes from a different challenge. + */ +const DuplicateEntry: FC = (props: DuplicateEntryProps) => { + const duplicate: SubmissionDuplicate = props.duplicate + const challengeUrl = duplicate.challenge + ? `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${duplicate.challenge}` + : undefined + + return ( + + ) +} + +/** + * Renders the collapsed-by-default duplicates row for one submission. + * + * The caller must render this only when duplicates exist; the wireframe hides + * the row entirely for submissions with no identical siblings. + */ +export const SubmissionDuplicatesRow: FC = props => { + const [isOpen, setIsOpen] = useState(false) + + const toggleOpen = useCallback((): void => { + setIsOpen(wasOpen => !wasOpen) + }, []) + + const countLabel = `${props.duplicates.length} duplicate${props.duplicates.length === 1 ? '' : 's'}` + + return ( +
+ + + ) +} + +export default SubmissionDuplicatesRow diff --git a/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts new file mode 100644 index 000000000..01f6e01d7 --- /dev/null +++ b/src/apps/work/src/lib/components/SubmissionDuplicatesRow/index.ts @@ -0,0 +1 @@ +export * from './SubmissionDuplicatesRow' diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index ce0827d80..855d5af35 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -1,11 +1,24 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { SubmissionsTable } from './SubmissionsTable' +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://example.com/challenges', + }, + }, +}), { + virtual: true, +}) jest.mock('~/libs/ui', () => ({ IconOutline: { + ChevronDownIcon: (): JSX.Element => , ClockIcon: (): JSX.Element => , + ExclamationIcon: (): JSX.Element => , + ExternalLinkIcon: (): JSX.Element => , + LightningBoltIcon: (): JSX.Element => , XCircleIcon: (): JSX.Element => , }, IconSolid: { @@ -565,4 +578,131 @@ describe('SubmissionsTable', () => { expect(screen.getByRole('img', { name: 'Test status: FAILED' })) .toBeTruthy() }) + describe('duplicate submissions', () => { + const submissions = [ + { + challengeId: 'challenge-123', + createdBy: 'member-1', + id: 'submission-1', + review: [ + { + finalScore: 95, + initialScore: 90, + }, + ], + type: 'SUBMISSION', + }, + ] + + function renderWithDuplicates( + duplicatesBySubmissionId?: Record>>, + ): void { + render( + , + ) + } + + it('hides the duplicates row when the submission has no duplicates', () => { + renderWithDuplicates({ 'submission-1': [] }) + + expect(screen.queryByRole('button', { name: /duplicate/ })) + .toBeNull() + }) + + it('hides the duplicates row when duplicates were never fetched', () => { + renderWithDuplicates(undefined) + + expect(screen.queryByRole('button', { name: /duplicate/ })) + .toBeNull() + }) + + it('renders a collapsed duplicates row and expands it on click', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'PNM4cbZgII428Iv', + submittedAt: '2026-07-13T07:39:00.000Z', + user: '2001', + userHandle: 'taasintake500', + }, + { + challenge: 'challenge-999', + challengeTitle: 'Basketball Stats App', + isCrossChallenge: true, + submissionId: '12I.RbObnTFCVt', + submittedAt: '2026-07-10T14:15:00.000Z', + user: '2002', + userHandle: 'testmfa1', + }, + ], + }) + + const toggle = screen.getByRole('button', { name: /2 duplicates/ }) + expect(toggle.getAttribute('aria-expanded')) + .toBe('false') + expect(screen.queryByText('taasintake500')) + .toBeNull() + + fireEvent.click(toggle) + + expect(toggle.getAttribute('aria-expanded')) + .toBe('true') + expect(screen.getByText('taasintake500')) + .toBeTruthy() + expect(screen.getByText('(PNM4cbZgII428Iv)')) + .toBeTruthy() + expect( + screen.getByRole('link', { name: 'Basketball Stats App' }) + .getAttribute('href'), + ) + .toBe('https://example.com/challenges/challenge-999') + }) + + it('singularizes the duplicate count label', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'other-submission', + }, + ], + }) + + expect(screen.getByRole('button', { name: /1 duplicate$/ })) + .toBeTruthy() + }) + + it('falls back to the member id and a dash when handle or date are missing', () => { + renderWithDuplicates({ + 'submission-1': [ + { + challenge: 'challenge-123', + isCrossChallenge: false, + submissionId: 'other-submission', + user: '2003', + }, + ], + }) + + fireEvent.click(screen.getByRole('button', { name: /1 duplicate/ })) + + expect(screen.getByText('2003')) + .toBeTruthy() + expect(screen.getByText('- -')) + .toBeTruthy() + }) + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index 0f47b1bb2..ab2a7b588 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -1,5 +1,6 @@ import { FC, + Fragment, MouseEvent, ReactElement, } from 'react' @@ -15,7 +16,8 @@ import { COMMUNITY_APP_URL, REVIEW_APP_URL } from '../../constants' import { ReactComponent as IconDownloadArtifacts } from '../../assets/icons/IconDownloadArtifacts.svg' import { ReactComponent as IconRunnerLogs } from '../../assets/icons/IconRunnerLogs.svg' import { ReactComponent as IconSquareDownload } from '../../assets/icons/IconSquareDownload.svg' -import { Submission } from '../../models' +import { Submission, SubmissionDuplicatesMap } from '../../models' +import { SubmissionDuplicatesRow } from '../SubmissionDuplicatesRow' import { formatDateTime, getRatingLevel, @@ -49,6 +51,8 @@ interface SubmissionsTableProps { canDownloadSubmissions: boolean canViewRunnerLogs?: boolean challengeId: string + /** SHA-256 duplicate matches keyed by submission id; empty when not permitted. */ + duplicatesBySubmissionId?: SubmissionDuplicatesMap isLoading?: boolean isLoadingMembers?: boolean onDownloadSubmission: (submissionId: string) => void @@ -405,116 +409,128 @@ export const SubmissionsTable: FC = ( : '' const reviewLink = `${REVIEW_APP_URL}/active-challenges/${props.challengeId}` + `/challenge-details?tab=${reviewTab}` + const duplicates = props.duplicatesBySubmissionId?.[submission.id] ?? [] return ( - - + + + + + + + + + {props.showMarathonMatchTestProgress ? ( - - {handleDisplay} - + <> + + + + + + ) - : ( - - {handleDisplay} - - )} - - - - - - - - - {props.showMarathonMatchTestProgress + : undefined} + + + + + + + {duplicates.length > 0 ? ( - <> - - - - - - + ) : undefined} - - - - - + ) })} diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index 579df8e7d..97c1ec3dd 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -34,6 +34,7 @@ export * from './ResourceAddModal' export * from './ResourcesTable' export * from './ShowcasePostPreview' export * from './TerminateAssignmentModal' +export * from './SubmissionDuplicatesRow' export * from './SubmissionHistoryModal' export * from './SubmissionRunnerLogsModal' export * from './SubmissionsTable' diff --git a/src/apps/work/src/lib/hooks/index.ts b/src/apps/work/src/lib/hooks/index.ts index 7e0c60778..59b557475 100644 --- a/src/apps/work/src/lib/hooks/index.ts +++ b/src/apps/work/src/lib/hooks/index.ts @@ -30,6 +30,7 @@ export * from './useFetchReviews' export * from './useFetchSubmissionArtifacts' export * from './useFetchSubmissionRunnerLogs' export * from './useFetchSubmissions' +export * from './useFetchSubmissionDuplicates' export * from './useFetchSubmissionVersions' export * from './useFetchTaasProject' export * from './useFetchTaasProjects' diff --git a/src/apps/work/src/lib/hooks/useFetchSubmissionDuplicates.ts b/src/apps/work/src/lib/hooks/useFetchSubmissionDuplicates.ts new file mode 100644 index 000000000..8d1b95042 --- /dev/null +++ b/src/apps/work/src/lib/hooks/useFetchSubmissionDuplicates.ts @@ -0,0 +1,128 @@ +import { useMemo } from 'react' +import useSWR, { SWRResponse } from 'swr' + +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' +import { fetchMembersByUserIds, fetchSubmissionDuplicates } from '../services' + +export interface UseFetchSubmissionDuplicatesResult { + duplicatesBySubmissionId: SubmissionDuplicatesMap + isLoading: boolean +} + +const EMPTY_DUPLICATES: SubmissionDuplicatesMap = {} + +/** + * Resolves member handles for the members behind the duplicate submissions. + * @param duplicatesBySubmissionId Duplicate matches keyed by checked submission id. + * @returns The same map with `userHandle` filled in wherever a handle resolved. + */ +async function withMemberHandles( + duplicatesBySubmissionId: SubmissionDuplicatesMap, +): Promise { + const memberIds = Array.from(new Set( + Object.values(duplicatesBySubmissionId) + .flat() + .map(duplicate => duplicate.user) + .filter((memberId): memberId is string => !!memberId && /^\d+$/.test(memberId)), + )) + + if (!memberIds.length) { + return duplicatesBySubmissionId + } + + const members = await fetchMembersByUserIds(memberIds, 'userId,handle') + const handlesByMemberId = new Map( + members + .filter(member => !!member.handle) + .map(member => [member.userId, member.handle as string]), + ) + + if (!handlesByMemberId.size) { + return duplicatesBySubmissionId + } + + return Object.entries(duplicatesBySubmissionId) + .reduce((result, [submissionId, duplicates]) => { + result[submissionId] = duplicates.map((duplicate: SubmissionDuplicate) => { + const handle = duplicate.user + ? handlesByMemberId.get(duplicate.user) + : undefined + + return handle + ? { + ...duplicate, + userHandle: handle, + } + : duplicate + }) + + return result + }, {}) +} + +/** + * Fetches SHA-256 duplicate matches for a challenge's submissions. + * + * Duplicate detection is an operator-only endpoint, so the caller must gate the + * request with `enabled`. Failures resolve to an empty map rather than surfacing + * an error, because the duplicates row only supplements the submissions table. + * + * @param challengeId Challenge that owns the submissions being checked. + * @param submissionIds Submission ids to check for duplicates. + * @param enabled Whether the caller is allowed to query duplicates. + * @returns Duplicate matches keyed by submission id plus the loading flag. + */ +export function useFetchSubmissionDuplicates( + challengeId?: string, + submissionIds: string[] = [], + enabled: boolean = true, +): UseFetchSubmissionDuplicatesResult { + const normalizedSubmissionIds = useMemo( + () => Array.from(new Set( + submissionIds + .map(submissionId => `${submissionId ?? ''}`.trim()) + .filter(Boolean), + )) + .sort(), + [submissionIds], + ) + + const swrKey = enabled && challengeId && normalizedSubmissionIds.length + ? [ + 'submission-duplicates', + challengeId, + normalizedSubmissionIds.join(','), + ] + : undefined + + const { + data: duplicatesBySubmissionId = EMPTY_DUPLICATES, + isValidating: isLoading, + }: SWRResponse = useSWR( + swrKey, + async () => { + try { + const duplicates = await fetchSubmissionDuplicates( + challengeId as string, + normalizedSubmissionIds, + true, + ) + + return withMemberHandles(duplicates) + } catch { + // Duplicate detection is optional context; a denied or failed + // lookup must not break the submissions table. + return EMPTY_DUPLICATES + } + }, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + }, + ) + + return { + duplicatesBySubmissionId, + isLoading, + } +} diff --git a/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts b/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts new file mode 100644 index 000000000..ed7dd05a1 --- /dev/null +++ b/src/apps/work/src/lib/models/SubmissionDuplicate.model.ts @@ -0,0 +1,26 @@ +/** + * Models for the SHA-256 duplicate submission detection endpoint. + */ + +/** + * A submission sharing the exact SHA-256 digest of the checked submission. + */ +export interface SubmissionDuplicate { + /** Challenge that owns the duplicate submission. */ + challenge?: string + /** Challenge name, when the API could resolve it. */ + challengeTitle?: string + /** True when the duplicate lives on a different challenge. */ + isCrossChallenge: boolean + /** ID of the duplicate submission. */ + submissionId: string + /** ISO timestamp the duplicate was submitted. */ + submittedAt?: string + /** Member ID that created the duplicate submission. */ + user?: string + /** Member handle resolved from `user`, when available. */ + userHandle?: string +} + +/** Duplicate matches keyed by the checked submission ID. */ +export type SubmissionDuplicatesMap = Record diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index 69213f847..bbd562a43 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -31,6 +31,7 @@ export * from './Reviewer.model' export * from './ReviewType.model' export * from './Skill.model' export * from './Submission.model' +export * from './SubmissionDuplicate.model' export * from './TaasJob.model' export * from './Term.model' export * from './Timeline.model' diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index f58b5bd44..71257d3aa 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -37,6 +37,7 @@ export * from './resources.service' export * from './reviews.service' export * from './skills.service' export * from './submissions.service' +export * from './submission-duplicates.service' export * from './taas-projects.service' export * from './terms.service' export * from './timeline-templates.service' diff --git a/src/apps/work/src/lib/services/submission-duplicates.service.ts b/src/apps/work/src/lib/services/submission-duplicates.service.ts new file mode 100644 index 000000000..f586dc491 --- /dev/null +++ b/src/apps/work/src/lib/services/submission-duplicates.service.ts @@ -0,0 +1,123 @@ +/** + * Service for the SHA-256 duplicate submission detection endpoint. + */ +import { xhrGetAsync } from '~/libs/core' + +import { SUBMISSIONS_API_URL } from '../constants' +import { SubmissionDuplicate, SubmissionDuplicatesMap } from '../models' + +/** The API rejects requests carrying more submission ids than this. */ +const DUPLICATES_REQUEST_CHUNK_SIZE = 100 + +interface DuplicateGroupResponse { + duplicates?: unknown +} + +type DuplicatesResponse = Record + +function toOptionalString(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined + } + + const normalizedValue = String(value) + .trim() + + return normalizedValue || undefined +} + +/** + * Normalizes one duplicate entry returned by the API. + * @param value Raw duplicate entry. + * @param challengeId Challenge the checked submission belongs to. + * @returns Normalized duplicate, or `undefined` when the entry has no submission id. + */ +function toSubmissionDuplicate( + value: unknown, + challengeId: string, +): SubmissionDuplicate | undefined { + if (typeof value !== 'object' || !value) { + return undefined + } + + const entry = value as Record + const submissionId = toOptionalString(entry.submissionId) + + if (!submissionId) { + return undefined + } + + const challenge = toOptionalString(entry.challenge) + + return { + challenge, + challengeTitle: toOptionalString(entry.challengeTitle), + isCrossChallenge: !!challenge && challenge !== challengeId, + submissionId, + submittedAt: toOptionalString(entry.submittedAt), + user: toOptionalString(entry.user), + } +} + +/** + * Fetches submissions sharing a SHA-256 digest with the supplied submissions. + * + * Requests are chunked to respect the API's per-request submission id limit. + * + * @param challengeId Challenge that owns every checked submission. + * @param submissionIds Submission ids to check for duplicates. + * @param crossChallenge When true, duplicates from other challenges are included. + * @returns Duplicate matches keyed by checked submission id. + */ +export async function fetchSubmissionDuplicates( + challengeId: string, + submissionIds: string[], + crossChallenge: boolean = false, +): Promise { + const normalizedChallengeId = challengeId.trim() + const uniqueSubmissionIds = Array.from(new Set( + submissionIds + .map(submissionId => toOptionalString(submissionId)) + .filter((submissionId): submissionId is string => !!submissionId), + )) + + if (!normalizedChallengeId || !uniqueSubmissionIds.length) { + return {} + } + + const chunks: string[][] = [] + for (let index = 0; index < uniqueSubmissionIds.length; index += DUPLICATES_REQUEST_CHUNK_SIZE) { + chunks.push(uniqueSubmissionIds.slice(index, index + DUPLICATES_REQUEST_CHUNK_SIZE)) + } + + const responses = await Promise.all(chunks.map(async chunk => { + const query = new URLSearchParams() + chunk.forEach(submissionId => { + query.append('submissionId', submissionId) + }) + + if (crossChallenge) { + query.set('crossChallenge', 'true') + } + + return xhrGetAsync( + `${SUBMISSIONS_API_URL}/${normalizedChallengeId}/duplicates?${query.toString()}`, + ) + })) + + return responses.reduce((result, response) => { + Object.entries(response ?? {}) + .forEach(([submissionId, group]) => { + const rawDuplicates: unknown = group?.duplicates + const duplicates: unknown[] = Array.isArray(rawDuplicates) + ? rawDuplicates + : [] + + result[submissionId] = duplicates + .map(duplicate => toSubmissionDuplicate(duplicate, normalizedChallengeId)) + .filter((duplicate): duplicate is SubmissionDuplicate => !!duplicate) + }) + + return result + }, {}) +} diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts index 8a3789923..827b9e64c 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -221,6 +221,19 @@ export function canViewMarathonMatchRunnerLogs(userRoles: string[]): boolean { || hasCopilotRole(userRoles) } +/** + * Returns whether the supplied roles can query submission duplicate detection. + * @param userRoles caller roles from the decoded auth token or app context. + * @returns `true` for admins, project managers, and copilots; otherwise `false`. + * Used by `SubmissionsSection` so only callers the review API answers for issue + * the duplicates request; everyone else would collect a 403. + */ +export function canViewSubmissionDuplicates(userRoles: string[]): boolean { + return hasAdminRole(userRoles) + || hasManagerRole(userRoles) + || hasCopilotRole(userRoles) +} + export function canCreateTaasProject(userRoles: string[]): boolean { return hasAdminRole(userRoles) || hasCopilotRole(userRoles) } diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx index df35ddd99..9a4c89cef 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.spec.tsx @@ -18,6 +18,8 @@ const mockUseDownloadSubmission = jest.fn() const mockUseFetchSubmissions = jest.fn() const mockFetchMembersByUserIds = jest.fn() const mockIsMarathonMatchChallenge = jest.fn() +const mockCanViewSubmissionDuplicates = jest.fn() +const mockUseFetchSubmissionDuplicates = jest.fn() jest.mock('~/libs/ui', () => ({ Button: (props: { @@ -82,6 +84,9 @@ jest.mock('../../../../../lib/contexts', () => { jest.mock('../../../../../lib/hooks', () => ({ useDownloadAllSubmissions: (): unknown => mockUseDownloadAllSubmissions(), useDownloadSubmission: (): unknown => mockUseDownloadSubmission(), + useFetchSubmissionDuplicates: (...args: unknown[]): unknown => ( + mockUseFetchSubmissionDuplicates(...args) + ), useFetchSubmissions: (...args: unknown[]): unknown => mockUseFetchSubmissions(...args), })) @@ -92,6 +97,7 @@ jest.mock('../../../../../lib/services', () => ({ jest.mock('../../../../../lib/utils', () => ({ canDownloadSubmissions: (): boolean => false, canViewMarathonMatchRunnerLogs: (): boolean => false, + canViewSubmissionDuplicates: (...args: unknown[]): unknown => mockCanViewSubmissionDuplicates(...args), getSubmissionFinalScore: (): number => 0, getSubmissionInitialScore: (): number => 0, getSubmissionProvisionalScore: (): number => 0, @@ -180,6 +186,11 @@ describe('SubmissionsSection', () => { jest.clearAllMocks() mockIsMarathonMatchChallenge.mockReturnValue(true) + mockCanViewSubmissionDuplicates.mockReturnValue(true) + mockUseFetchSubmissionDuplicates.mockReturnValue({ + duplicatesBySubmissionId: {}, + isLoading: false, + }) mockUseDownloadAllSubmissions.mockReturnValue({ downloadAll: jest.fn(), isDownloading: false, @@ -273,4 +284,39 @@ describe('SubmissionsSection', () => { expect(screen.queryByText('bravo-provisional-submission')) .toBeNull() }) + describe('duplicate detection', () => { + it('requests duplicates for the visible submissions when the role allows it', () => { + render( + , + ) + + expect(mockUseFetchSubmissionDuplicates) + .toHaveBeenCalledWith( + 'challenge-1', + expect.arrayContaining([ + 'alpha-system-submission', + 'bravo-provisional-submission', + 'charlie-example-submission', + ]), + true, + ) + }) + + it('disables the duplicates request for roles the API rejects', () => { + mockCanViewSubmissionDuplicates.mockReturnValue(false) + + render( + , + ) + + expect(mockUseFetchSubmissionDuplicates) + .toHaveBeenCalledWith('challenge-1', expect.any(Array), false) + }) + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx index ace5be142..16b2b274b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/SubmissionsSection/SubmissionsSection.tsx @@ -24,6 +24,8 @@ import { WorkAppContext } from '../../../../../lib/contexts' import { useDownloadAllSubmissions, useDownloadSubmission, + useFetchSubmissionDuplicates, + type UseFetchSubmissionDuplicatesResult, useFetchSubmissions, } from '../../../../../lib/hooks' import { Challenge, Submission } from '../../../../../lib/models' @@ -32,6 +34,7 @@ import type { MemberProfile } from '../../../../../lib/services' import { canDownloadSubmissions, canViewMarathonMatchRunnerLogs, + canViewSubmissionDuplicates, getSubmissionFinalScore, getSubmissionInitialScore, getSubmissionProvisionalScore, @@ -601,6 +604,26 @@ export const SubmissionsSection: FC = ( const isMembersLoading = memberIdsToLoad.length > 0 const paginationTotal = sortedSubmissions.length + // Duplicates are only requested for the rows currently on screen, and only + // for roles the review API answers for. + const canCheckDuplicates = canViewSubmissionDuplicates(workAppContext.userRoles) + const duplicateCheckSubmissionIds = useMemo( + () => [ + ...paginatedSubmissions, + ...sortedCheckpointSubmissions, + ] + .map(submission => normalizeValue(submission.id)) + .filter(Boolean), + [paginatedSubmissions, sortedCheckpointSubmissions], + ) + const { + duplicatesBySubmissionId, + }: UseFetchSubmissionDuplicatesResult = useFetchSubmissionDuplicates( + props.challengeId, + duplicateCheckSubmissionIds, + canCheckDuplicates, + ) + const handleDownloadAll = useCallback(async (): Promise => { try { await downloadAllResult.downloadAll(toDownloadAllItems(submissionsResult.submissions)) @@ -793,6 +816,7 @@ export const SubmissionsSection: FC = ( canDownloadSubmissions={canDownload} canViewRunnerLogs={canViewRunnerLogs} challengeId={props.challengeId} + duplicatesBySubmissionId={duplicatesBySubmissionId} isLoading={submissionsResult.isLoading} isLoadingMembers={isMembersLoading} onDownloadSubmission={handleDownloadSubmission} @@ -815,6 +839,7 @@ export const SubmissionsSection: FC = ( canDownloadSubmissions={canDownload} canViewRunnerLogs={canViewRunnerLogs} challengeId={props.challengeId} + duplicatesBySubmissionId={duplicatesBySubmissionId} isLoading={false} isLoadingMembers={isMembersLoading} onDownloadSubmission={handleDownloadSubmission} From e005191a32ec7a9ec0be603310b71550e0e0da22 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 25 Aug 2026 09:18:51 +0300 Subject: [PATCH 42/44] Campus leaderboard mobile UI Fixes --- .../components/stat-card/StatCard.module.scss | 9 +++ .../CampusLeaderboardPage.module.scss | 4 + .../CampusLeaderboardPage.spec.tsx | 41 ++++++++++ .../leaderboard/CampusLeaderboardPage.tsx | 1 + .../ParticipationHistoryModal.module.scss | 78 +++++++++++++++++-- .../leaderboard/ParticipationHistoryModal.tsx | 48 ++++++++++-- 6 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss index f48eab043..179101251 100644 --- a/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss +++ b/src/apps/campus/src/lib/components/stat-card/StatCard.module.scss @@ -9,12 +9,21 @@ gap: $sp-2; min-width: 0; padding: $sp-4 $sp-6; + + @include ltemd { + padding: $sp-3; + } } .icon { flex: 0 0 auto; height: 50px; width: 50px; + + @include ltemd { + height: 40px; + width: 40px; + } } .stat { diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index 5386c7035..b8c9edac5 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -114,6 +114,10 @@ $card-gap: 35px; } } + th:global(.column-id-open-) { + width: 55px; + } + th:global(.column-id-rank-) { width: 52px; } diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx index 9d9cc060b..e3e2aa52c 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -40,9 +40,35 @@ jest.mock('~/config', () => ({ }, }), { virtual: true }) +let mockWindowWidth = 1280 + jest.mock('~/libs/shared', () => ({ ProfilePicture: (): JSX.Element => , textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), + useWindowSize: () => ({ height: 800, width: mockWindowWidth }), +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib/components/common/TableMobile', () => ({ + TableMobile: (props: { + columns: ReadonlyArray[]>, + data: ReadonlyArray, + }): JSX.Element => ( +
+ + + {isOpen && ( +
+ {props.duplicates.map(duplicate => ( + + ))} +
+ )} +
- {submission.memberHandle + +
+ {submission.memberHandle + ? ( + + {handleDisplay} + + ) + : ( + + {handleDisplay} + + )} + + {emailDisplay} + + {submissionDate} + + + {initialScore} + {' / '} + {finalScore} + + + {formatTestProcess(testProgress?.process)} + + {renderTestStatusIcon(testProgress?.status)} + + {testProgress?.progressPercent || ''} + - {emailDisplay} - - {submissionDate} - - - {initialScore} - {' / '} - {finalScore} - - + {submission.id} + +
+ + + + + {props.canViewRunnerLogs + ? ( + + ) + : undefined} + +
+
- {formatTestProcess(testProgress?.process)} - - {renderTestStatusIcon(testProgress?.status)} - - {testProgress?.progressPercent || ''} - - {submission.id} - -
- - - - - {props.canViewRunnerLogs - ? ( - - ) - : undefined} - -
-
+ + {props.data.map((row, rowIndex) => props.columns.map((group, groupIndex) => ( + + {group.map((column, cellIndex) => ( + + ))} + + )))} + +
+ {column.renderer + ? column.renderer(row) + : String((row as Record)[column.propertyName ?? ''])} +
+ ), }), { virtual: true }) jest.mock('~/libs/ui', () => { @@ -175,6 +201,7 @@ function renderPage(): void { describe('CampusLeaderboardPage', () => { beforeEach(() => { + mockWindowWidth = 1280 mockUseCampusLeaderboard.mockReturnValue({ data: leaderboard(), isLoading: false }) }) @@ -222,6 +249,20 @@ describe('CampusLeaderboardPage', () => { .toBeInTheDocument() }) + it('stacks the participation history into labelled rows on small screens', () => { + mockWindowWidth = 375 + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + expect(screen.getByText('Registration Date:')) + .toBeInTheDocument() + expect(screen.getByText('Campus Sprint')) + .toBeInTheDocument() + }) + it('re-requests the leaderboard when the challenge filter changes', () => { renderPage() diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index c7ed8c989..595427a59 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -219,6 +219,7 @@ export const CampusLeaderboardPage: FC = () => { type: 'number', }, { + className: styles.actionCell, columnId: 'open', label: '', renderer: (member: CampusLeaderboardMember) => (member.hasActivity ? ( diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss index e4c0374e4..3cd010765 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.module.scss @@ -9,9 +9,12 @@ $box-width: 1080px; padding: $box-padding !important; width: $box-width !important; + // full screen on mobile, like the rest of the platform's modals @include ltemd { - padding: $sp-4 !important; - width: calc(100vw - #{$sp-4}) !important; + border-radius: 0 !important; + max-width: 100vw !important; + padding: $sp-6 $sp-4 !important; + width: 100vw !important; } :global(.react-responsive-modal-closeButton) { @@ -37,6 +40,13 @@ $box-width: 1080px; letter-spacing: normal; line-height: 30px; text-transform: none; + word-break: break-word; + + @include ltemd { + font-size: 20px; + line-height: 26px; + padding-right: $sp-6; + } } :global(.modal-body) { @@ -51,6 +61,11 @@ $box-width: 1080px; gap: $box-padding; margin-top: $box-padding; + @include ltemd { + gap: $sp-6; + margin-top: $sp-6; + } + // nested to win over the shared modal body link styling .workLink { color: var(--Link); @@ -72,10 +87,12 @@ $box-width: 1080px; display: flex; gap: $sp-6; + // two by two rather than a four card column, which would push the + // participation history off screen @include ltemd { - align-items: stretch; - flex-direction: column; - gap: $sp-4; + display: grid; + gap: $sp-3; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -96,6 +113,57 @@ $box-width: 1080px; } } +// stacked "Label: value" rows, one block per challenge +.stackedTable { + width: 100%; + + tbody { + td { + border-bottom: 0; + color: var(--TableTextColor); + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + font-weight: 400; + letter-spacing: normal; + line-height: 20px; + padding: $sp-1 0 !important; + text-transform: none; + vertical-align: top; + + &:first-child { + color: var(--GrayFontColor); + padding-right: $sp-3 !important; + text-align: left; + white-space: nowrap; + } + + &:last-child { + text-align: right; + } + } + + // one rule per challenge, and a little more air between blocks. + // 5n == one row per column of `stackedColumns`, keep them in sync. + tr:nth-child(5n) td { + border-bottom: 1px solid var(--TableRowBorderColor); + padding-bottom: $sp-4 !important; + } + + tr:nth-child(5n + 1) td { + padding-top: $sp-4 !important; + } + + tr:last-child td { + border-bottom: 0; + } + } + + .workLink { + font-size: 14px; + line-height: 20px; + } +} + .result { align-items: center; display: inline-flex; diff --git a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx index 01cdf5f4e..e7d788ec2 100644 --- a/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx +++ b/src/apps/campus/src/pages/leaderboard/ParticipationHistoryModal.tsx @@ -5,7 +5,9 @@ import { FC, useMemo } from 'react' import classNames from 'classnames' import { BaseModal, Table, TableColumn } from '~/libs/ui' -import { textFormatDateLocaleShortString } from '~/libs/shared' +import { textFormatDateLocaleShortString, useWindowSize, WindowSize } from '~/libs/shared' +import { TableMobile } from '~/apps/admin/src/lib/components/common/TableMobile' +import { MobileTableColumn } from '~/apps/admin/src/lib/models/MobileTableColumn.model' import { EnvironmentConfig } from '~/config' import { CampusLeaderboardMember, CampusParticipation } from '../../lib/models' @@ -88,6 +90,10 @@ function renderResult(entry: CampusParticipation): JSX.Element { export const ParticipationHistoryModal: FC = props => { const member: CampusLeaderboardMember | undefined = props.member + const { width: screenWidth }: WindowSize = useWindowSize() + // five columns need more room than a tablet viewport offers, so anything + // narrower falls back to the stacked label/value layout + const isStacked: boolean = useMemo(() => screenWidth <= 984, [screenWidth]) const columns = useMemo>>(() => [ { @@ -137,6 +143,24 @@ export const ParticipationHistoryModal: FC = pro }, ], []) + // one "Label: value" row per column, stacked into a block per challenge + const stackedColumns = useMemo[][]>( + () => columns.map(column => [ + { + ...column, + className: '', + mobileType: 'label', + renderer: () =>
{`${column.label as string}:`}
, + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] as MobileTableColumn[]), + [columns], + ) + if (!member) { return <> } @@ -174,13 +198,21 @@ export const ParticipationHistoryModal: FC = pro />
- + {isStacked ? ( + + ) : ( +
+ )} ) From 0997f3a96c70895a1b724c26df2c6e6d4c18c8cf Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 25 Aug 2026 10:54:02 +0300 Subject: [PATCH 43/44] PM-5886 - campus leaderboard UI updates --- .../src/lib/assets/avatar-placeholder.png | Bin 0 -> 11210 bytes .../src/lib/assets/ic-user-placeholder.svg | 9 ++ src/apps/campus/src/lib/components/index.ts | 1 + .../member-avatar/MemberAvatar.module.scss | 7 + .../components/member-avatar/MemberAvatar.tsx | 33 +++++ .../src/lib/components/member-avatar/index.ts | 1 + .../lib/models/campus-leaderboard.model.ts | 1 + .../CampusLeaderboardPage.module.scss | 6 - .../CampusLeaderboardPage.spec.tsx | 129 +++++++++++++++++- .../leaderboard/CampusLeaderboardPage.tsx | 12 +- .../leaderboard/ParticipationHistoryModal.tsx | 47 ++++++- 11 files changed, 226 insertions(+), 20 deletions(-) create mode 100644 src/apps/campus/src/lib/assets/avatar-placeholder.png create mode 100755 src/apps/campus/src/lib/assets/ic-user-placeholder.svg create mode 100644 src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss create mode 100644 src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx create mode 100644 src/apps/campus/src/lib/components/member-avatar/index.ts diff --git a/src/apps/campus/src/lib/assets/avatar-placeholder.png b/src/apps/campus/src/lib/assets/avatar-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..d73649e2b546ce87c10ed76922ab81eb50a56264 GIT binary patch literal 11210 zcmV;*D>c-KP)Un1Tcb0GfjE231oU0H_TGMNu?Lmc#aRnKuYZ z3Ct+~0R~8O2ONzuuB0z`snHk<#8Gz9(7&XJnd0SPdpy!AHg~SM0w9A0FhBwXps2v| zm9Knd2%gm5P&|O3q7C8={Zm$TA+snKA(@44F0XLTTnJ+}PBs*ohXxG*5g_0v;!Iv) zy7SIE;QhenX&X3=a>JmlTIbI}yT%;1Qli>dScoj4wKZ0F)R+sZ)jGM;hLYAtpH^ z2HPg!T-Yc-r<7R-mm3})ZU7b$>Bg^r{p)4l{qA>%j~zSqiC15J^ew((V`s-7A*MAdFP$?Ingbm^{Th3q@@ZwU8^*XwW19wf5Xtw(0`nN z{`vQ)oyV`b>Z${@{j;C_?7`#5k8eJ5KH628l|hTl{8WZ z(YENu`Sa)B13>9pWBB3Gqenk+@4fe~R-cR=QZ%uQ@&Gs<^C6*n1W=SE-j~#m077Uc z2~zqXIwreJ$OxW{29}1UNlVC5KqCvVe7amGOVP!I_+AjG2D3Jg%^J3^2;x0`P-rTAAjM6 z7gnnz<5y4tiL^;|2+8Yv>PGE5&3?`MiA3sbP zUS<_?#avqeskY27L(26H^&5Z?6aeA~m=NF~EJAOuu{L5fe)OXs4e!{o<8hV>DdtA; zS}YApM!A3mA}*!1C`NUk*8<+hmCxK+);w>`sXB{N z9H-iTQNMwx_W+Wl&Tt_>XGusFA{GY{(|PzfQp%0W|F0!WmOLgU;P@(g>_Sl}Ol7>x zpiGq=%bbQgeKUrzP>fB0X&H&Yu@6m>3(pq+QJh{F{Xf7e1(ht3dV!o(yqafcF&`w42_T!bd-;{hoDn!` zgz__z7(2@v4}s>|G6yZsw_JMZrN0vYc4gpbd*e_7C=tRh!;qs`y}eLW&w7-=8kk~2 zbqZ({Btj_3WZP`($y=$X^f7i-Do{DcdMa)Hu{df&S+nC}F3c$`QZ}ollELkg#V%Rm zIKRTtw!A1O0P!#qg(^OYUQkZ~Bum1RQvf1OT*-!op{;phORf`Q{F%)>dCIJ;js$3@ zn5PwC_d+n!(Fb#5jsS^xj6t$ki6!QoOLF2ttClh3sB%3Aj{5Y~O8`}q)T99tX-sqC zC8kblmp%2=Q}^hJyGJ9dtd2~$t!UyoP{;{8H%~ ziDgS3q4P-8O}zjRlYy9vpBw;0LS#Wa^2j4Ab$ZOU{Bz>@LWuYVn07PL+`BV(fP^e! z_VSHBCyV^&?|=XMs{oOfePo-HHyElDyj17ES^-o}!s8ccn2?{C@~LTP$-)A?Lrd0U zDzsMQD|{UZ%IUd$lMHQvkx9sbC2hru6+7;~|Nhk_&$2D!N%>AwS3JiR)^gPepnTw1 z9#4S7!1Ad{+N$4p$t9Nno>k7&ji(Mjpq#uog9}+86J!(85J*<8S+nNiphjQZK zMCauP@k|2vM92tPmF%U+@ZpCa{)8Szg9J-oQqn(op<{p~R(G#xyeqA0V!})#A1G<> zwA2BUY5e9lze&IN#V>B*)KxrRs%JAjSh6gRv)+(V8;FwL_J8|d|N2+;K8*$d5n)D_ zf0ph_w5tFTT5#YLjDbj#V2}(v!p6~~M?bC!e+LE>;2|VEpmR$AS+#@cqKhudK|~|* z;1aKmx(OfyMd)F$tiw)|V4JRx<%@4)zOA^gPJ(d8;C4<4bdnt)l8eBX3AtX;18zmM zNjlnYou84GttmJMrrpefW7HFz=@4D-*i&w8+{g?pecR4C!hncWj{m%7v zkYx@cWW8$Dst14h%U|+K5r2p$8F^Ju$OVvsNKcw7MNhH?jkST}48j{9{_uxskKa@! z4hhv}4+oad>j{CaKY{Jhhhb=ZZxh46`h)2 zG${aKE=3H&mPbH}qjcJX-)MK5^CyE*PY0oV6ptmr+K>hqQymNK&>rmun5Nq1RO%yZ z>BmiIby%`;Z`AA$f5fW zCPr882v<-$aSN0!&z?iPrB%c>uvUqAe|D>y`c-ZiRS$z!mcF``aw( ziX?o(3DIN#ly~H~UXJ244+%JphaP%}kHc?`QD9P;P^5bqV#@Jp0FDIM%dY)fvj7s~ z=xOKbjE4kEq^K{Jvge+AZj~PJAwB5m&4dA87#2mBT=Wgzof*Km4H~ zb9uWmtMi_G65{vl!qVW|;IH(o(+(X09+ozi&JNkNvUqp_5}nA(EP2aY-tukfD4{EO zB6R0nwDMWnoECrF*F*v24nV7r%OO>vGas+5LU!ZOp+oCsE^m(BWsxG@*@R*2J!f0?4g5^bY_j!S)az zry*oFmMvRWy{bS^08M3jcF|`)Se-LXcsj<%r@-_ZW74Y*RtNwCarEL)x*~+INDiaU z*}{ncsCwTe@bG?xcIgQ4@P`O)7OpDCIY&MP>n8dGJh`yy?sGEjV->NGvILWx!dO1u2XHyE3egdE>o}7eKTJ zaH3O|u0mUP(v9E!?st{-X&)>V>m)2r-+^bG69z!|M&m3#rDDhl-Oz~4%}V@L#jQW> zq8+lbC{Ju(IMt4v?KWNjsqexF%^Gv3N@IPh*A@9dl~?OUUo#avAsC$fUK}wnq-lJjUd)3&01}hJ7kcWcr>Y&9Ruh&oc4c4!9e2$F(O7PM*tVO;K$b6G{xL!* zA0X*E3G+{Xb@tJ#w(;FKbga8Sz>~{NR>&&{M=s$bGBLYy>fJ_~{2J7eQ)vGaq&f_Tp;ZD?i>&4Sr^AtKZk3*eXh>nG( z$OJ$T&B*mwvT@_aY6X#QZGoh3bS!mZh>~xV??mSUP*FBUT^i+`7xA=f*Djpr%KG%m z_rdbiEo4t{8g4v%Q{* z^UuGY1Qe(yUKcU(>3YLm*PL40P5xq&<*c3G&2+9SxghI*{No=l)iPEY2la*C75ECB zJJEhSD}ZFfaGu?m94s30FD$ftrF{8fK$LB7yxVEO1BklaZojpMm92A3=n9=hAMbnL z`)*Mj{VHg=;_XAHJQqA8fYgaBSOWlIeQ>Z`LAQpK$ns^=r?fv^Agr4JP~Cm>L;F}D zsLruShgI_n7A)AfYSk)?Lja|rCCs`v2-2DTpfdr8dUl>@NY*FeV3j>KcJJPudcAxB zo_x~fSY~Zd_h%@%1=4P8t#k@_ATUt@s1z`kh`ciST0r5~Qm@5ifC?(Op^{D7QN6U} zuZLCN{HU<`b>09_o$dEq+sqfIx~_4dGl=$OEtcp&7=P9}9v&XXco-WPkg^QHY2-rf zTs7>5#3wZAG~En2jQw+R3zX|=J-0(cf(T@!rJzI&XEG@;b*pm9|;BS<0r;#@XuT`a`D=Km70#Rv6qBbdK>* zeOn$aWncgj>$R&BfTDu`o}qSNt|b45T8;O-=RF{mcu#(ni2&y+fDm%s))x@b543LG zI&|ypyj&Jo5c=mZ8C*c{D(VCvD!72a;|er}{{wUJ;>C1AbYOifg?evTp*nlu0gd(- zE?nr9r8E|PFIMISCL%1^DYXDdXlz7|OcuTm`N1=ovq_SKDs^TZnjsq z_08q0_hRhC{PQf$0lj-81s0T!3x32~0Tf&T$;+3vbFd5?MO;zNmOD{;V1W=q&6F#+ zj;_#kv@1WTG7jRQcK`%g*JWAw${xG0Fk`Xoe&?Nc`Zr%GUsKzSyv#*e(E& z6z6Klq4MFO-jyU-APyKS08yZB8UUzn3AeT{J-1kx$dw<&PK-Q*D){XHq5ydgWxjBz zr~vVYmxQf`J#`A@=p{X%87~3eZ(=a4f6b7|zz+Kndx2m%L@r|gt+05!}hZ!%jx z&MuRKV3tZCHv+N_)#{>^$1@gqj0*simvyFYIe?T*Nd>9U;FqUn=%yE_@Dv+db3g)E z8CIUqxkKTiRy@Vs&1~fY)04QQ6 zIx=~lI9quvkaSOy^B;!-563vfE6M>BpmC;_uRt2#Kv#KcB={~@_f3adb$2nR+s^?c zL4hK!t&0eUbjP@z+Uvz z?QwUjJ?7u7wmP+w1LE-E!(oANq79y?Q+~>UB?^|q96(`#Tv2C0O-g7h=QMH5+mlZ| z`3!L=@0@g9v|z!4x|;6mc6o4mX@RzH-(IaQnPAXAR>|&7yUr^BX?>#*g{!(OV=Q4D zDh2Z+ANj~(`9VG}pfM=&>I&y7fa;z%01f>~|9f;`=xuzQ`$?bR0bKBoRLPW7#%U*X zs{o{iTv!c>?LYmBFN7manmj_Nc=gp+D=T!~dFR2amFj*9YaRR)8n1ia04Rnh|K~qn zdg&$WSqF&@BQFc%U1`Ot090~f8FNyHiF{ZejYZfvapJ^I7tn10APJ$St8M`0x|6S4 z>6Gd44pgD7jaDLjAN23E_|??{AX%Sqr1^-5zXP4*3-L}QakO35DsZ;SI6rfaXl)@+ z3RGJMecEu{4s;Hn&?#S?qHFoSAr)f*Ct3_Z1SvXpwE&1^Of;60-PIUgIh=y6{1sPR zu~T64p%djHGV7$d2%tLWjLsl*hA!8wTelP4>bx&>&EJJ;%I7g)2vzRv7D$b?6p-n1 zW%0Qj*G`OhLu+PF{h&I9$%Oim>a;+)^^q=j%KzbIxpiF10?WgT)Cv;%WOkc0(QY@{_wCg+=Xvwy#oPd+QFBEg7N#`|9+?R zW_^ms4y-7xkCKU%NGkUC(8S@jP)7i%1Pv;9?bB8zg2xXG9pAip^T?@Fr)pou>JX&n z)A!fOlP9}A#?1QgN6ois`Fg(GSyyL$!^6YYZ(HY`cpaT+>cxQ~?>_R1Rh%;e3jX6C z|M(Um#4?syFIZinIt1C3fz{kdx95#+&`~+;LR2zOSYrmHJVYG7 z1(MB5>dau#0TK=IN`uCU!!JtI^1=O+vO-Uip*sBiXrkRDo|LY(H&YtxHh}V1Bhv9R z+7zKT^odB=X&3Tq`J~xLIH7xmlz}MMLLHYd0ii0a%7MoGcS1B`CyO5-zVN~e{|g3$ ziBx@}U8Cd2k9V!Dnbc4>0fbKE>%{bnLx&FCr$kdLNu_J;wApPpbws1A#33SKl~1Cb zIs&LHRGBQkkp?K@M`IDD0|yQiS6+D~uQu2UV!66%h46poDuB8f2X*2_UKO4VKr~<<2rE>gvd{sO`D%f%BKbmJ63o&yJVQGjTp?4nG(}0?%dJZ)XEgds#Q4EuKT27m)8bkrgXe zY|nk6Y`|nsn)s9dlde1qDl&g8(Nw0A>q5TI9-aGOeb8NyzqsJb3%&ouW#L<}Ccrya zzvluF9neOE#34L(9B9XNBfrRfAxvhr=BmiVFV24E>hcxR0z7~y(i05<0lFyb6@UBN z-)=4A1n4aZVD^2)zgplX@Q6atJ6wt1xY(!x8jK>p7^xN`*fNUPy8YACwyUjrt;*`;cOvfF&Pjcdn^!g zkWNOlMBOd_2YofA8^W|}*RDb!BhG6JLi}F*9;!u1yd#YVKyFmE9m<(@Sf;D@V>*;F z{{tWRz#dJu+SX%*G*}g%*tKupzKKQ$7Mz0x*igLu^2_I(IkHCP6x4%1vSY`N zjjCt;Xr=H0u<~%66(sm}o=eLUymKyJ6dW&r=!Ehuq%{?O5P1W;89@E5D$HsCjc2V0v#cF@Xt!)1rUuy zgAu~x{TQ7QZ z)~#E&2prWQfb2}(c* zgX!?$!~Ez0y|}hyJOX$m^WA_){!nFcRKW39#{LIN&V2xjsubwcq%!DE!T7ET1E_2^ z5R3QD2S^2jRh|dP=_dYAX@!#Vn(NlBd*<(d|NCcD##ewy=Mzp3ZWEV)z=3Ql@Ti4C zGQRcZn{VdJ2>c#{0bQUIZQJo#`7FnL%8OhdeTae&dt5YG0D)XOh!7x=hImhwcJJQJ zU;vc2_JHV$E3Visv)ck9@e+cd;(*340c5s%_3B)9g=BlHcJv}>{eqM>T-RKkN&7#E=NdloUs^UFJJB@<5v z6YT_|d>5g5gR6rCwA1DLozpB24`x{$d3(#QZ96Q|CY{@nhMHC$v6P%Q7M=KgDga{b z3}1+zJrFt7gjHG*z{EGy@EsQq2YcI#FTPkg);v{LUU_A@_~MI|J-9kaVEOXpiDgU6 zlCuSZ5y|jlJdolc-_gr1yUgGbAM1m>AcTrdgB}4c*X-q%JeVQGctk0GC?lUgJibn z>8GFGB%g=BQNUM*$kcxzL8sC2nFfFB+eAH`;ic>t2LIJ{$eS#613(_JiUbf{m{;I34@-{Vib1l@SBeg$BVv;k<000YTNklsw^J$Q|-Vzi!cv_Zvg!4&e|-W-Do=7w5RNjyVeT9W!w(DXiRs%oWvOa2tDh3&OQ>JbL$RlG!8l?lR zxeVTM38NXGeqdrXS$-MW#2Tpp6NidRF1h4eKl#Z|*5^wa-~mJ{R;;jPQu$eV2^5Jr z;wZCp#UxLiI<@nWM;^Hz*@#!LlLZWlBQphKE`lQHAc(EH^B;KN0f)-R-5}^1brV1g zior8TVW?mNMLBqz(&4e+TvH~9iA6lZrk1ol=bd-nS{>8ht%fRVq__LFMwoU$MTfJ2 z01k6{>s#NNm}AU|xnp(o@ZC_JF7v@2GP4C&xkAepa%*Nj4^A_(r<{r;^pUcEl+Th= zi*_48@jj&*m7bI;UarSX4Dv)cW-o<68UZ=fcD^ZLZh7dTht?lEcI{7haQ1i7NV( znJ}XYJmO)2C{MKO)O7%5BLp`%Z%n{!`9y?%Z>HtU&b1gZIY7iA7$H2Y5pkZrrcPS< zJ2uzu+qdu5+&@x%n|%q>I~G~3zVwr@E&yH@*VdCbJjM@cPI~~30CmHA-}_$w^hSLA z9m{~Ys44IO9C&_3As!Y7c?DJAkph%mkL96Zs7?R@9419SOo|`tN190^1oDa{OO`Zc zhRMSu$GRTY2q8L8g$tG2hJOT*^tAq7yz0_ZYbO+|grcS!OkpJ%iynZ4)zL1h8ZQx+ zDu5$Ey+dVo3RKO1{_~&6CgvP*hk8&d;HW-wj4vT5kDSFG15LRKkgknt1yDI^CRWJ$ zAOKiS6A?AT5^0Ap)Hr;r3qi;aNR{WlO?pejy

kc?Y7(a%{F6a zUW9%?G&klqswUWFk;f*(vVfZM3Ps? zdORuXvQ?n^sFb=2Yk-0QmCD?W(y z@C*>;U#qg41m@4khx(G1vTbjF``dTkefQlXD(61w3&4o%tEqo{8v{r0{&0)exY;w{aOQYxr=5HUgwa!{TUH#UFQq=Z7SpquSu&+zjAYE?T-= zmJAVb1=Dv;H-J2IK;%eyQ<@k;bEq4F;;5c&KW86JStFlhmw8sA;vBT09H;XawJ4B$ zNZ_J96pnSWWe}f_&q+Ivw7`>OMVuBu0FC41aZ;XxMLhbf0HbuF9UDB_RMNm_eDLt= zIQxPO92H<*#hNv1oZks3As3b9Vl0HyN__#KOacf6f(3vgUZr*JQ9v+c-pR*La44sn z57+#Qkn#yf8Ibc;hY-Ibcn$?9l+2%W%l)WPfyaETmxwbOm)9~x7{K#qq-xtdg|4Hp z0t@kyVwqwb1`ZE`@<=x|FV1v`$J`xPeq5v@-WOrN0Vsrmx%MAa`%?+l9c8=2%ht?Qjh>b8e$prxeR=jr#&x4S}O5n zNVDapEM(s-z$?ccoKw`gXPgAept9dK%#xhS4*k# zT~8ok2gTAPmd8t3Q|zjgq36Pz2F`W7nGlG?_C;Aox_;|YgEIj@)l?Czr0ujqgo~m` zJFX*u_}Pm2QlR-NP$`lB`%G!`hlhtL=f4pMxS-N%;h(73*j5|RDZxiuOz_!5phB#XYVy9 z0CYC>VId%uLD@k{`ie9m48j1I(`8-9xy^oRo#C7ZP^&lwsfkKp0HBFVY*4;M0{ + + + + + + + + diff --git a/src/apps/campus/src/lib/components/index.ts b/src/apps/campus/src/lib/components/index.ts index 3414cdcdf..683964206 100644 --- a/src/apps/campus/src/lib/components/index.ts +++ b/src/apps/campus/src/lib/components/index.ts @@ -1 +1,2 @@ +export * from './member-avatar' export * from './stat-card' diff --git a/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss new file mode 100644 index 000000000..8bf7d5190 --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.module.scss @@ -0,0 +1,7 @@ +@import '@libs/ui/styles/includes'; + +.avatar { + border-radius: 50%; + flex: 0 0 auto; + object-fit: cover; +} diff --git a/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx new file mode 100644 index 000000000..bf9b62fac --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/MemberAvatar.tsx @@ -0,0 +1,33 @@ +/** + * Member avatar, falling back to a placeholder when there is no usable photo. + */ +import { FC, useEffect, useState } from 'react' +import classNames from 'classnames' + +import avatarPlaceholder from '../../assets/ic-user-placeholder.svg' + +import styles from './MemberAvatar.module.scss' + +interface MemberAvatarProps { + readonly className?: string + readonly photoURL?: string | null +} + +export const MemberAvatar: FC = (props: MemberAvatarProps) => { + const [failed, setFailed] = useState(false) + const photoURL: string = props.photoURL?.trim() ?? '' + + // rows are reused as the leaderboard is filtered or paged + useEffect(() => { setFailed(false) }, [photoURL]) + + return ( + + ) +} + +export default MemberAvatar diff --git a/src/apps/campus/src/lib/components/member-avatar/index.ts b/src/apps/campus/src/lib/components/member-avatar/index.ts new file mode 100644 index 000000000..b10775e3d --- /dev/null +++ b/src/apps/campus/src/lib/components/member-avatar/index.ts @@ -0,0 +1 @@ +export { MemberAvatar } from './MemberAvatar' diff --git a/src/apps/campus/src/lib/models/campus-leaderboard.model.ts b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts index b450c0379..ff95e0b74 100644 --- a/src/apps/campus/src/lib/models/campus-leaderboard.model.ts +++ b/src/apps/campus/src/lib/models/campus-leaderboard.model.ts @@ -17,6 +17,7 @@ export interface CampusParticipation { placement: number | null registered: boolean registeredAt: string | null + reviewed: boolean score: number | null submitted: boolean submittedDate: string | null diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss index b8c9edac5..3e13de360 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.module.scss @@ -188,14 +188,8 @@ $card-gap: 35px; } .avatar { - flex: 0 0 auto; height: 32px; width: 32px; - - // doubled to win over the shared avatar's responsive min-width - &.avatar img { - min-width: 0; - } } .handle { diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx index e3e2aa52c..ecf5efd66 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.spec.tsx @@ -43,7 +43,6 @@ jest.mock('~/config', () => ({ let mockWindowWidth = 1280 jest.mock('~/libs/shared', () => ({ - ProfilePicture: (): JSX.Element => , textFormatDateLocaleShortString: (date?: Date): string | undefined => date?.toISOString(), useWindowSize: () => ({ height: 800, width: mockWindowWidth }), }), { virtual: true }) @@ -142,6 +141,7 @@ const participation = (overrides: Partial = {}): CampusPart placement: 1, registered: true, registeredAt: '2026-01-05T00:00:00.000Z', + reviewed: true, score: 95, submitted: true, submittedDate: '2026-01-20T00:00:00.000Z', @@ -249,6 +249,133 @@ describe('CampusLeaderboardPage', () => { .toBeInTheDocument() }) + it('falls back to the placeholder avatar when a member has no photo', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [ + member({ handle: 'with_photo', photoURL: 'https://images.example.test/a.png' }), + member({ handle: 'without_photo', photoURL: null, userId: '2' }), + ], + }, + isLoading: false, + }) + renderPage() + + const avatars = Array.from(document.querySelectorAll('img')) + + expect(avatars) + .toHaveLength(2) + expect(avatars[0].getAttribute('src')) + .toBe('https://images.example.test/a.png') + expect(avatars[1].getAttribute('src')) + .toContain('avatar-placeholder') + }) + + it('swaps in the placeholder avatar when a member photo fails to load', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ photoURL: 'https://images.example.test/broken.png' })], + }, + isLoading: false, + }) + renderPage() + + const avatar = document.querySelector('img') as HTMLImageElement + + expect(avatar.getAttribute('src')) + .toBe('https://images.example.test/broken.png') + + fireEvent.error(avatar) + + expect(avatar.getAttribute('src')) + .toContain('avatar-placeholder') + }) + + it('keeps a still running review out of the failed review state', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ + challenges: [participation({ + passedReview: false, + placement: null, + reviewed: false, + won: false, + })], + })], + }, + isLoading: false, + }) + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + expect(screen.getByText('In Review')) + .toBeInTheDocument() + expect(screen.queryByText('Failed Review')) + .not.toBeInTheDocument() + }) + + it('orders the participation history by submission date, then registration date', () => { + mockUseCampusLeaderboard.mockReturnValue({ + data: { + ...leaderboard(), + members: [member({ + challenges: [ + participation({ + challengeId: 'c1', + challengeName: 'Submitted first', + submittedDate: '2026-01-10T00:00:00.000Z', + }), + participation({ + challengeId: 'c2', + challengeName: 'Registered first, never submitted', + registeredAt: '2026-01-05T00:00:00.000Z', + submittedDate: null, + }), + participation({ + challengeId: 'c3', + challengeName: 'Submitted last', + submittedDate: '2026-02-20T00:00:00.000Z', + }), + participation({ + challengeId: 'c4', + challengeName: 'Registered last, never submitted', + registeredAt: '2026-01-20T00:00:00.000Z', + submittedDate: null, + }), + ], + })], + }, + isLoading: false, + }) + renderPage() + + fireEvent.click(screen.getByRole('button', { + name: /View participation history for testaws1/i, + })) + + const isChallengeLink = (link: HTMLElement): boolean => Boolean( + link.getAttribute('href') + ?.startsWith('https://review.example.test'), + ) + const challengeNames = screen.getAllByRole('link') + .filter(isChallengeLink) + .map(link => link.textContent) + + expect(challengeNames) + .toEqual([ + 'Submitted last', + 'Submitted first', + 'Registered last, never submitted', + 'Registered first, never submitted', + ]) + }) + it('stacks the participation history into labelled rows on small screens', () => { mockWindowWidth = 375 renderPage() diff --git a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx index 595427a59..606c73431 100644 --- a/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx +++ b/src/apps/campus/src/pages/leaderboard/CampusLeaderboardPage.tsx @@ -16,7 +16,6 @@ import { TableColumn, Tooltip, } from '~/libs/ui' -import { ProfilePicture } from '~/libs/shared' import { EnvironmentConfig } from '~/config' import { @@ -32,7 +31,7 @@ import { IconStatSubmitted, placementIcons, } from '../../lib/assets/icons' -import { StatCard } from '../../lib/components' +import { MemberAvatar, StatCard } from '../../lib/components' import { ParticipationHistoryModal } from './ParticipationHistoryModal' import { RankingRulesModal } from './RankingRulesModal' @@ -100,14 +99,7 @@ function renderHandle(member: CampusLeaderboardMember): JSX.Element { return (

From 54306858900118c43f12c478eb76667a638c3bf1 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 26 Aug 2026 23:06:02 +0530 Subject: [PATCH 44/44] PM-4699 Update mobile view --- .../src/config/routes.config.ts | 1 + .../components/NavTabs/NavTabs.module.scss | 92 ++++++++ .../lib/components/NavTabs/NavTabs.spec.tsx | 1 + .../src/lib/components/NavTabs/NavTabs.tsx | 104 +++++++- .../components/NavTabs/config/tabs-config.ts | 29 ++- .../SkillBubblesChart.module.scss | 12 +- .../SkillStatisticsPage/SkillBubblesChart.tsx | 192 +++++++++++++-- .../SkillMembersPanel.module.scss | 164 +++++++++++-- .../SkillStatisticsPage/SkillMembersPanel.tsx | 223 +++++++++++++----- .../SkillStatisticsPage.module.scss | 15 +- .../SkillStatisticsPage.spec.tsx | 131 +++++++++- .../SkillStatisticsPage.tsx | 1 + .../SkillStatisticsPage/packCircles.spec.ts | 43 ++++ .../SkillStatisticsPage/packCircles.ts | 50 +++- .../SkillStatisticsPage/useMobileView.ts | 24 ++ 15 files changed, 942 insertions(+), 140 deletions(-) create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.spec.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index 0e5a652a0..45ddf42b7 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -11,5 +11,6 @@ export const rootRoute: string export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' +export const statisticsNavRouteId = 'statistics-nav' export const statisticsRouteId = 'statistics' export const skillStatisticsRouteId = 'skill-statistics' diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss index 5c0f801eb..4c396af43 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.module.scss @@ -68,6 +68,7 @@ display: flex; align-items: center; gap: $sp-2; + position: relative; &.active { font-weight: 700; } @@ -104,6 +105,27 @@ color: var(--invertButtonColor); } } + + .hasChildren { + display: block; + + .submenu { + background: transparent; + border-radius: 0; + box-shadow: none; + display: none; + padding: 0; + position: static; + + li { + padding: $sp-2 $sp-6 $sp-2 $sp-10; + } + } + + &.menuOpen .submenu { + display: block; + } + } } } } @@ -115,6 +137,76 @@ align-items: center; } +.menuTrigger { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: inline-flex; + font: inherit; + gap: $sp-2; + line-height: inherit; + padding: 0; +} + +.chevron { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.hasChildren { + .menuTrigger { + width: 100%; + } + + .chevron { + transition: transform 160ms ease; + } + + &:hover, + &.menuOpen { + z-index: 5; + } + + &.menuOpen .chevron { + transform: rotate(-180deg); + } + + .submenu { + background: #fff; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(10, 10, 10, 0.12); + display: none; + left: 0; + min-width: 220px; + padding: 8px 0; + position: absolute; + top: calc(100% - 4px); + z-index: 120; + + li { + display: block; + font-weight: 400; + line-height: 22px; + margin-left: 0; + padding: 10px 16px; + white-space: nowrap; + + &:hover, + &.active { + font-weight: 700; + } + } + } + + &:hover .submenu, + &.menuOpen .submenu { + display: block; + } +} + .externalIcon { width: 16px; height: 16px; diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx index 1dd411b39..f8494f978 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx @@ -32,6 +32,7 @@ jest.mock('~/libs/shared/lib/hooks', () => ({ jest.mock('~/libs/ui', () => ({ IconOutline: { + ChevronDownIcon: () => chevron-down, ExternalLinkIcon: () => external-link, }, }), { diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx index ea751bff7..35ddefb4c 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx @@ -1,6 +1,7 @@ import { Dispatch, FC, + KeyboardEvent, MouseEvent, SetStateAction, useCallback, @@ -28,6 +29,7 @@ import styles from './NavTabs.module.scss' const NavTabs: FC = () => { const navigate: NavigateFunction = useNavigate() const [isOpen, setIsOpen] = useState(false) + const [openMenuId, setOpenMenuId] = useState() const triggerRef = useRef(null) const { pathname }: { pathname: string } = useLocation() @@ -59,8 +61,20 @@ const NavTabs: FC = () => { const triggerTab = useCallback(() => { setIsOpen(!isOpen) + setOpenMenuId(undefined) }, [isOpen]) + const closeMenus = useCallback(() => { + setIsOpen(false) + setOpenMenuId(undefined) + }, []) + + const navigateToTab = useCallback((tabId: string) => { + setActiveTab(tabId) + closeMenus() + navigate(`${rootRoute}/${tabId}`) + }, [closeMenus, navigate]) + const handleTabClick = useCallback( (event: MouseEvent) => { const { @@ -73,19 +87,36 @@ const NavTabs: FC = () => { } if (tabUrl) { - setIsOpen(false) + closeMenus() window.open(tabUrl, '_blank', 'noopener,noreferrer') return } - setActiveTab(tabId) - setIsOpen(false) - navigate(`${rootRoute}/${tabId}`) + navigateToTab(tabId) }, - [navigate], + [closeMenus, navigateToTab], ) - useClickOutside(triggerRef.current, () => setIsOpen(false)) + const toggleMenu = useCallback((event: MouseEvent, tabId: string) => { + event.stopPropagation() + setOpenMenuId(current => (current === tabId ? undefined : tabId)) + }, []) + + const handleMenuKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenMenuId(undefined) + } + }, []) + + const handleChildClick = useCallback(( + event: MouseEvent, + childId: string, + ) => { + event.stopPropagation() + navigateToTab(childId) + }, [navigateToTab]) + + useClickOutside(triggerRef.current, closeMenus) return (
{
    {tabs.map(tab => { - const isActive = tab.id === activeTab && !tab.url + const hasChildren = Boolean(tab.children?.length) + const isChildActive = tab.children?.some(child => ( + pathname === `/${child.id}` + || pathname.startsWith(`/${child.id}/`) + )) + const isActive = hasChildren + ? Boolean(isChildActive) || tab.id === activeTab + : tab.id === activeTab && !tab.url + const isMenuOpen = openMenuId === tab.id + + if (hasChildren) { + return ( +
  • + +
      + {tab.children?.map(child => { + const isChildItemActive = pathname === `/${child.id}` + || pathname.startsWith(`/${child.id}/`) + + return ( +
    • , + ) { + handleChildClick(event, child.id) + }} + > + {child.title} +
    • + ) + })} +
    +
  • + ) + } return (
  • pathname.includes(`/${item.id}`)) + ) + const matchItem = tabs.find(item => ( + item.children?.some(child => pathMatchesTab(pathname, child.id)) + || pathMatchesTab(pathname, item.id) + )) if (matchItem) { return matchItem.id diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss index 221cac070..ae6b811f6 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -8,8 +8,8 @@ width: 100%; @include ltemd { - height: 420px; - min-height: 420px; + height: auto; + min-height: 640px; } } @@ -99,7 +99,7 @@ border-top: 8px solid #0f172a; content: ''; height: 0; - left: 50%; + left: var(--arrow-x, 50%); position: absolute; top: 100%; transform: translateX(-50%); @@ -145,6 +145,12 @@ transform: translateY(-50%); } } + + &.anchored { + position: absolute; + width: min(320px, calc(100% - 16px)); + z-index: 5; + } } .popoverTitle { diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx index 040f36424..00476dfb2 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -28,7 +28,8 @@ import { import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg' import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg' -import { packCircles, PackedCircle } from './packCircles' +import { packCircles, packedBoundsHeight, PackedCircle } from './packCircles' +import { MOBILE_MAX_WIDTH, useMobileView } from './useMobileView' import styles from './SkillBubblesChart.module.scss' const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') @@ -39,6 +40,7 @@ const POPOVER_WIDTH = 320 const VIEW_PAD = 8 const MIN_BUBBLE_FONT_SIZE = 10 const MAX_BUBBLE_FONT_SIZE = 16 +const DOUBLE_TAP_MS = 450 type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' @@ -122,6 +124,37 @@ function getPopoverLayout( } } +function getAnchoredPopoverLayout( + circle: PackedCircle, + chartWidth: number, + chartHeight: number, + popoverWidth: number, + popoverHeight: number, +): PopoverLayout { + const bubbleTop = circle.y - circle.r + const bubbleBottom = circle.y + circle.r + const fitsTop = bubbleTop - POPOVER_GAP - popoverHeight >= VIEW_PAD + const fitsBottom = bubbleBottom + POPOVER_GAP + popoverHeight <= chartHeight - VIEW_PAD + const placement: PopoverPlacement = fitsTop || !fitsBottom ? 'top' : 'bottom' + const halfWidth = popoverWidth / 2 + const minLeft = VIEW_PAD + halfWidth + const maxLeft = chartWidth - VIEW_PAD - halfWidth + const left = maxLeft < minLeft + ? chartWidth / 2 + : Math.min(Math.max(circle.x, minLeft), maxLeft) + const layout: PopoverLayout = { + left, + placement, + top: placement === 'top' ? bubbleTop : bubbleBottom, + } + + if (Math.abs(left - circle.x) > 1) { + layout.arrowOffset = halfWidth + (circle.x - left) + } + + return layout +} + type SkillCategoryIcon = FC> interface SkillBubblesChartProps { @@ -157,8 +190,14 @@ function fontSizeForRadius( const SkillBubblesChart: FC = props => { const chartRef = useRef(null) + const lastTapRef = useRef<{ at: number; id: string }>() + const isMobileView = useMobileView() const [hoveredCategoryId, setHoveredCategoryId] = useState() - const [viewport, setViewport] = useState({ height: 560, width: 960 }) + const [previewedCategoryId, setPreviewedCategoryId] = useState() + const [viewport, setViewport] = useState(() => ({ + height: 560, + width: typeof window === 'undefined' ? 960 : Math.min(window.innerWidth, 960), + })) useEffect(() => { const node = chartRef.current @@ -191,6 +230,7 @@ const SkillBubblesChart: FC = props => { } }, []) + const isMobile = viewport.width > 0 && viewport.width <= MOBILE_MAX_WIDTH const packed = useMemo( () => packCircles( props.categories.map(category => ({ @@ -199,8 +239,13 @@ const SkillBubblesChart: FC = props => { })), viewport.width, viewport.height, + isMobile ? { fit: 'width' } : undefined, ), - [props.categories, viewport.height, viewport.width], + [isMobile, props.categories, viewport.height, viewport.width], + ) + const packedHeight = useMemo( + () => packedBoundsHeight(packed), + [packed], ) const packedById = useMemo( @@ -217,16 +262,76 @@ const SkillBubblesChart: FC = props => { const hoveredCategory = props.categories.find( category => category.id === hoveredCategoryId, ) - const hoveredCircle = hoveredCategory - ? packedById.get(hoveredCategory.id) + const previewedCategory = props.categories.find( + category => category.id === previewedCategoryId, + ) + const popoverCategory = hoveredCategory || previewedCategory + const popoverCircle = popoverCategory + ? packedById.get(popoverCategory.id) : undefined - const { data: hoveredMembers }: SWRResponse = useSWR( - hoveredCategory - ? expertSkillCategoryMembersCacheKey(hoveredCategory.name) + const { data: popoverMembers }: SWRResponse = useSWR( + popoverCategory + ? expertSkillCategoryMembersCacheKey(popoverCategory.name) : undefined, - () => fetchExpertSkillCategoryMembers(hoveredCategory?.name || ''), + () => fetchExpertSkillCategoryMembers(popoverCategory?.name || ''), ) - const topMember = hoveredMembers?.[0] + const topMember = popoverMembers?.[0] + + const hidePopover = useCallback(() => { + lastTapRef.current = undefined + setPreviewedCategoryId(undefined) + setHoveredCategoryId(undefined) + }, []) + + const openMembersTable = useCallback((categoryId: string) => { + lastTapRef.current = undefined + setPreviewedCategoryId(undefined) + if (isMobileView) { + setHoveredCategoryId(undefined) + } + + props.onSelect(categoryId) + }, [isMobileView, props]) + + const handleBubbleClick = useCallback((categoryId: string) => { + if (!isMobileView) { + openMembersTable(categoryId) + return + } + + const now = Date.now() + const lastTap = lastTapRef.current + if (lastTap && lastTap.id === categoryId && now - lastTap.at <= DOUBLE_TAP_MS) { + openMembersTable(categoryId) + return + } + + if (previewedCategoryId === categoryId) { + hidePopover() + return + } + + lastTapRef.current = { at: now, id: categoryId } + setPreviewedCategoryId(categoryId) + setHoveredCategoryId(categoryId) + }, [hidePopover, isMobileView, openMembersTable, previewedCategoryId]) + + useEffect(() => { + const onPointerDown = (event: Event): void => { + const target = event.target + if (target instanceof Element && target.closest('[aria-label="Skill category bubbles"] button')) { + return + } + + hidePopover() + } + + document.addEventListener('pointerdown', onPointerDown) + + return () => { + document.removeEventListener('pointerdown', onPointerDown) + } + }, [hidePopover]) const handleKeyDown = useCallback(( event: KeyboardEvent, @@ -234,9 +339,9 @@ const SkillBubblesChart: FC = props => { ) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - props.onSelect(categoryId) + openMembersTable(categoryId) } - }, [props]) + }, [openMembersTable]) return (
    = props => { className={styles.chart} ref={chartRef} role='group' + style={isMobile && packedHeight ? { height: packedHeight, minHeight: packedHeight } : undefined} > {props.categories.map(category => { const circle = packedById.get(category.id) @@ -253,6 +359,7 @@ const SkillBubblesChart: FC = props => { const Icon = getCategoryIcon(category.icon) const isSelected = category.id === props.selectedCategoryId + || category.id === previewedCategoryId const fontSize = fontSizeForRadius( circle.r, minPackedRadius, @@ -271,7 +378,12 @@ const SkillBubblesChart: FC = props => { )} key={category.id} onBlur={function onBlur() { setHoveredCategoryId(undefined) }} - onClick={function onClick() { props.onSelect(category.id) }} + onClick={function onClick() { handleBubbleClick(category.id) }} + onDoubleClick={function onDoubleClick() { + if (isMobileView) { + openMembersTable(category.id) + } + }} onFocus={function onFocus() { setHoveredCategoryId(category.id) }} onKeyDown={function onKeyDown(event: KeyboardEvent) { handleKeyDown(event, category.id) @@ -295,14 +407,15 @@ const SkillBubblesChart: FC = props => { ) })} - {hoveredCategory && hoveredCircle && ( + {popoverCategory && popoverCircle && ( )} @@ -311,6 +424,7 @@ const SkillBubblesChart: FC = props => { } interface SkillCategoryPopoverProps { + anchored?: boolean category: ExpertSkillCategory chartHeight: number chartRef: RefObject @@ -329,6 +443,20 @@ const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => useLayoutEffect(() => { const update = (): void => { + const height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT + const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH + + if (props.anchored) { + setLayout(getAnchoredPopoverLayout( + props.circle, + props.chartWidth, + props.chartHeight, + width, + height, + )) + return + } + const chartNode = props.chartRef.current const chartRect = chartNode?.getBoundingClientRect() const measured: ChartRect = chartRect && chartRect.width > 0 @@ -339,20 +467,27 @@ const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => top: 0, width: props.chartWidth, } - const height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT - const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH setLayout(getPopoverLayout(props.circle, measured, width, height)) } update() window.addEventListener('resize', update) - window.addEventListener('scroll', update, true) + if (!props.anchored) { + window.addEventListener('scroll', update, true) + } return () => { window.removeEventListener('resize', update) window.removeEventListener('scroll', update, true) } - }, [props.category.id, props.chartHeight, props.chartRef, props.chartWidth, props.circle]) + }, [ + props.anchored, + props.category.id, + props.chartHeight, + props.chartRef, + props.chartWidth, + props.circle, + ]) const topSkillsPercentage = props.category.skillsBreakdown.reduce( (total, skill) => total + skill.percentage, @@ -374,13 +509,18 @@ const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => } if (layout.arrowOffset !== undefined) { - Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + if (layout.placement === 'left' || layout.placement === 'right') { + Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + } else { + Object.assign(popoverStyle, { '--arrow-x': `${layout.arrowOffset}px` }) + } } const popover = (
    ) - return typeof document === 'undefined' - ? popover - : createPortal(popover, document.body) + if (props.anchored || typeof document === 'undefined') { + return popover + } + + return createPortal(popover, document.body) } export default SkillBubblesChart diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss index 35b950ec8..c143ce1ec 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss @@ -2,10 +2,8 @@ .section { margin-top: 40px; -} -.header { - h2 { + .header h2 { color: #151515; font-family: 'Figtree', sans-serif; font-size: 26px; @@ -35,9 +33,14 @@ } .filters { + background: #E9ECEF; + border-radius: 8px; + box-sizing: border-box; display: flex; flex-direction: column; gap: 16px; + padding: 16px; + height: fit-content; } .search { @@ -57,7 +60,6 @@ &:focus-visible { outline: 2px solid #078477; - outline-offset: 1px; } } @@ -80,9 +82,9 @@ span { color: #0a0a0a; font-family: 'Nunito Sans', sans-serif; - font-size: 12px; + font-size: 14px; font-weight: 700; - line-height: 16px; + line-height: 20px; } select { @@ -101,7 +103,6 @@ &:focus-visible { outline: 2px solid #078477; - outline-offset: 1px; } } } @@ -160,23 +161,152 @@ $visible-member-rows: 10; th:first-child, td:first-child { text-align: center; - width: 64px; + width: 8%; + } + + th:nth-child(2), + td:nth-child(2) { + width: 35%; } th:nth-child(3), td:nth-child(3) { - width: 88px; + text-align: left; + width: 20%; } th:nth-child(4), td:nth-child(4) { - width: 140px; + width: 23%; } - th:last-child, - td:last-child { + th:nth-child(5), + td:nth-child(5) { + padding-right: 32px; text-align: right; - width: 110px; + width: 18%; + } + + td.empty { + color: #545f71; + font-size: 14px; + height: $row-height * $visible-member-rows; + padding: 24px 16px; + text-align: center; + vertical-align: middle; + } + + .detailsRow { + display: none; + } + + @include ltemd { + thead { + display: none; + } + + th.desktopOnly, + td.desktopOnly { + display: none; + } + + th:first-child, + td:first-child { + padding-left: 8px; + padding-right: 8px; + width: 100px; + text-align: start; + } + + th:nth-child(2), + td:nth-child(2) { + padding-left: 8px; + padding-right: 8px; + width: auto; + } + + .memberRow { + cursor: pointer; + } + + .detailsRow { + display: table-row; + + td { + height: auto; + padding: 12px 8px; + vertical-align: top; + } + } + } +} + +.toggle { + align-items: center; + background: transparent; + border: 0; + color: #767676; + cursor: pointer; + display: none; + flex: 0 0 auto; + justify-content: center; + margin-left: auto; + padding: 8px; + + @include ltemd { + display: inline-flex; + } + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 2px; + } +} + +.chevron { + display: block; + flex: 0 0 20px; + height: 20px; + transition: transform 160ms ease; + width: 20px; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.details { + display: none; + grid-template-columns: 100px minmax(0, 1fr); + margin: 0; + row-gap: 10px; + column-gap: 50px; + + @include ltemd { + display: grid; + } +} + +.detail { + display: contents; + line-height: 20px; + + dt { + color: #0a0a0a; + font-size: 14px; + font-weight: 700; + justify-self: end; + overflow: visible; + padding-right: 8px; + white-space: nowrap; + } + + dd { + color: #1a1a1a; + font-size: 14px; + font-weight: 400; + margin: 0; + min-width: 0; } } @@ -199,6 +329,7 @@ $visible-member-rows: 10; .memberText { display: flex; + flex: 1 1 auto; flex-direction: column; min-width: 0; } @@ -259,10 +390,3 @@ $visible-member-rows: 10; min-width: 19px; text-align: center; } - -.empty { - color: #545f71; - font-size: 14px; - padding: 24px 16px; - text-align: center; -} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx index 8776462d1..48b73acc9 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -1,5 +1,12 @@ /* eslint-disable react/jsx-no-bind */ -import { ChangeEvent, FC, useMemo } from 'react' +import { + ChangeEvent, + FC, + Fragment, + MouseEvent, + useMemo, + useState, +} from 'react' import classNames from 'classnames' import { EnvironmentConfig } from '~/config' @@ -28,6 +35,8 @@ interface SkillMembersPanelProps { } const SkillMembersPanel: FC = props => { + const [expandedHandles, setExpandedHandles] = useState>(new Set()) + const countryOptions = useMemo(() => { const unique = new Map() props.members.forEach(member => { @@ -60,8 +69,24 @@ const SkillMembersPanel: FC = props => { .sort((left, right) => right.wins - left.wins) }, [props.countryFilter, props.members, props.search]) + function toggleExpanded(handle: string): void { + setExpandedHandles(current => { + const next = new Set(current) + if (next.has(handle)) { + next.delete(handle) + } else { + next.add(handle) + } + + return next + }) + } + return ( -
    +

    {`Members for ${props.category.name}`}

    @@ -106,9 +131,9 @@ const SkillMembersPanel: FC = props => {

- - - + + + @@ -136,59 +161,149 @@ const SkillMembersPanel: FC = props => { const profileUrl = `${EnvironmentConfig.USER_PROFILE_URL}/${ encodeURIComponent(member.handle) }` + const isExpanded = expandedHandles.has(member.handle) return ( - - - ) { + const target = event.target + if (target instanceof Element && target.closest('a')) { + return + } + + toggleExpanded(member.handle) + }} + > + + - - - - + + + + + + {isExpanded && ( + + + + )} + ) })} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss index b7a1e854d..3cb911837 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -6,16 +6,14 @@ flex-direction: column; font-family: 'Nunito Sans', sans-serif; overflow-x: hidden; - padding: 8px 0 0; -} + padding: 8px 0 48px; -.header { - h1 { + .header h1 { color: #151515; font-family: 'Figtree', sans-serif; - font-size: 32px; + font-size: 33px; font-weight: 700; - line-height: 38px; + line-height: 40px; margin-top: 20px; text-transform: none; } @@ -39,11 +37,16 @@ position: relative; transform: translateX(-50%); width: 100vw; + + @include ltemd { + padding: 0 16px; + } } .hint { color: #0a0a0a; font-size: 16px; + font-weight: 700; line-height: 22px; margin: 20px 0 8px; text-align: center; diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx index 3636316a1..10c7e7a97 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -49,6 +49,7 @@ jest.mock('~/apps/customer-portal/src/config/routes.config', () => ({ flexiTalentRouteId: 'flexi-talent', showcaseSearchRouteId: 'showcase', skillStatisticsRouteId: 'skill-statistics', + statisticsNavRouteId: 'statistics-nav', statisticsRouteId: 'statistics', talentSearchRouteId: 'talent-search', }), { @@ -157,26 +158,36 @@ function renderPage(): ReturnType { } describe('Customer Portal Skill Statistics tabs', () => { - it('adds Skill Statistics beside General Statistics', () => { + it('nests General Statistics and Skill Statistics under Statistics', () => { const tabs = getTabsConfig(['administrator'], false, false) expect(tabs.map(tab => tab.title)) .toEqual([ - 'General Statistics', - 'Skill Statistics', + 'Statistics', 'Talent Search', 'Showcase', 'Flexi-Talent', ]) + expect(tabs[0].children?.map(tab => tab.title)) + .toEqual([ + 'General Statistics', + 'Skill Statistics', + ]) expect(getTabIdFromPathName('/skill-statistics', ['administrator'], false, false)) - .toBe('skill-statistics') + .toBe('statistics-nav') expect(getTabIdFromPathName('/statistics', ['administrator'], false, false)) - .toBe('statistics') + .toBe('statistics-nav') }) }) describe('SkillStatisticsPage', () => { + const originalInnerWidth = window.innerWidth + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }) mockedFetchCategories.mockReset() mockedFetchMembers.mockReset() mockedFetchCategories.mockResolvedValue(CATEGORIES) @@ -220,7 +231,8 @@ describe('SkillStatisticsPage', () => { expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) .toBeInTheDocument() - expect(screen.getByText('Ghostar')) + expect(within(screen.getByRole('table')) + .getByText('Ghostar')) .toBeInTheDocument() }) @@ -228,7 +240,10 @@ describe('SkillStatisticsPage', () => { renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) - expect(await screen.findByText('billzedison')) + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(within(screen.getByRole('table')) + .getByText('billzedison')) .toBeInTheDocument() fireEvent.change(screen.getByLabelText('Search members'), { @@ -247,11 +262,29 @@ describe('SkillStatisticsPage', () => { .toBeInTheDocument() }) + it('centers the empty members message when filters match nobody', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Search members'), { + target: { value: 'no-such-member' }, + }) + + expect(screen.getByText('No members match the current filters.')) + .toBeInTheDocument() + }) + it('reranks members when filtering by country', async () => { renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) - expect(await screen.findByText('billzedison')) + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(within(screen.getByRole('table')) + .getByText('billzedison')) .toBeInTheDocument() fireEvent.change(screen.getByLabelText('Filter By'), { @@ -270,6 +303,88 @@ describe('SkillStatisticsPage', () => { .toBeInTheDocument() }) + it('opens the members table on double click in mobile view', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) + fireEvent.click(bubble) + + expect(screen.queryByRole('heading', { name: 'Members for Programming and Development' })) + .not.toBeInTheDocument() + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + fireEvent.doubleClick(bubble) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + }) + + it('hides the popover when the same bubble is clicked again', async () => { + const now = jest.spyOn(Date, 'now') + + try { + now.mockReturnValue(1_000) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + const bubble = await screen.findByRole('button', { name: 'Programming and Development' }) + fireEvent.click(bubble) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + now.mockReturnValue(1_500) + fireEvent.click(bubble) + + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('hides the popover when clicking outside the bubbles', async () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + + fireEvent.pointerDown(document.body) + + expect(screen.queryByText('Total Members')) + .not.toBeInTheDocument() + }) + + it('expands a member row to show rating, country, and wins', async () => { + renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Programming and Development' })) + + expect(await screen.findByRole('heading', { name: 'Members for Programming and Development' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByText('Justin G')) + + const details = screen.getByLabelText('Details for Ghostar') + expect(within(details) + .getByText('1900')) + .toBeInTheDocument() + expect(within(details) + .getByText('USA')) + .toBeInTheDocument() + expect(within(details) + .getByText('322')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Hide details for Ghostar' })) + .toHaveAttribute('aria-expanded', 'true') + }) + it('shows an error when skill categories fail to load', async () => { mockedFetchCategories.mockRejectedValueOnce(new Error('failed')) renderPage() diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx index 53fb628b2..7adcfd909 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -114,6 +114,7 @@ const SkillStatisticsPage: FC = () => { { + const items = [ + { id: 'a', r: 50 }, + { id: 'b', r: 40 }, + { id: 'c', r: 36 }, + { id: 'd', r: 60 }, + { id: 'e', r: 32 }, + { id: 'f', r: 48 }, + ] + + it('fits a landscape pack inside the given viewport', () => { + const packed = packCircles(items, 960, 560) + const maxX = Math.max(...packed.map(circle => circle.x + circle.r)) + const maxY = Math.max(...packed.map(circle => circle.y + circle.r)) + + expect(packed) + .toHaveLength(items.length) + expect(maxX) + .toBeLessThanOrEqual(960) + expect(maxY) + .toBeLessThanOrEqual(560) + }) + + it('packs a tall portrait cloud when fitting to width', () => { + const manyItems = Array.from({ length: 18 }, (_, index) => ({ + id: `item-${index}`, + r: 36 + ((index % 5) * 8), + })) + const contained = packCircles(manyItems, 360, 640) + const portrait = packCircles(manyItems, 360, 640, { fit: 'width' }) + const height = packedBoundsHeight(portrait) + const maxX = Math.max(...portrait.map(circle => circle.x + circle.r)) + + expect(maxX) + .toBeLessThanOrEqual(360) + expect(height) + .toBeGreaterThan(packedBoundsHeight(contained)) + expect(height) + .toBeGreaterThan(640) + }) +}) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts index c9cceac3a..14e9846e5 100644 --- a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts @@ -5,6 +5,11 @@ export type PackedCircle = { y: number } +export type PackCirclesOptions = { + fit?: 'contain' | 'width' + padding?: number +} + function overlaps( a: PackedCircle, b: PackedCircle, @@ -49,21 +54,36 @@ function shuffleItems(items: T[], rng: () => number): T[] { return shuffled } +function resolveAspect( + width: number, + height: number, + fit: PackCirclesOptions['fit'], +): number { + if (fit === 'width') { + // Portrait cloud: about two bubbles across, stacked long-ways. + return 0.48 + } + + return Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) +} + /** * Place circles in a tight non-overlapping cluster, then scale uniformly - * so the pack fits inside the given viewport while staying close together. - * Placement order is shuffled so the largest bubble is not always centered. + * so the pack fits the viewport. `fit: 'width'` keeps bubble size and lets + * the pack grow tall so mobile can scroll the long way. */ export function packCircles( items: Array<{ id: string; r: number }>, width: number, height: number, - padding: number = 6, + options: PackCirclesOptions = {}, ): PackedCircle[] { + const padding = options.padding ?? 6 + const fit = options.fit ?? 'contain' const rng = createRng(items.reduce((seed, item) => seed + hashString(item.id), 1)) const ordered = shuffleItems(items, rng) const placed: PackedCircle[] = [] - const aspect = Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) + const aspect = resolveAspect(width, height, fit) const angleOffset = rng() * Math.PI * 2 ordered.forEach(item => { @@ -111,7 +131,7 @@ export function packCircles( }) }) - if (!placed.length || width <= 0 || height <= 0) { + if (!placed.length || width <= 0 || (fit === 'contain' && height <= 0)) { return placed } @@ -124,9 +144,15 @@ export function packCircles( const inset = 16 const availableWidth = Math.max(width - (inset * 2), 1) const availableHeight = Math.max(height - (inset * 2), 1) - const scale = Math.min(availableWidth / packWidth, availableHeight / packHeight) - const offsetX = inset + ((availableWidth - (packWidth * scale)) / 2) - const offsetY = inset + ((availableHeight - (packHeight * scale)) / 2) + const scale = fit === 'width' + ? Math.min(availableWidth / packWidth, 1.05) + : Math.min(availableWidth / packWidth, availableHeight / packHeight) + const scaledWidth = packWidth * scale + const scaledHeight = packHeight * scale + const offsetX = inset + ((availableWidth - scaledWidth) / 2) + const offsetY = fit === 'width' + ? inset + : inset + ((availableHeight - scaledHeight) / 2) return placed.map(circle => ({ id: circle.id, @@ -135,3 +161,11 @@ export function packCircles( y: ((circle.y - minY) * scale) + offsetY, })) } + +export function packedBoundsHeight(circles: PackedCircle[], padding: number = 16): number { + if (!circles.length) { + return 0 + } + + return Math.max(...circles.map(circle => circle.y + circle.r)) + padding +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts new file mode 100644 index 000000000..8f423d914 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/useMobileView.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +export const MOBILE_MAX_WIDTH = 744 + +export function useMobileView(): boolean { + const [isMobile, setIsMobile] = useState(() => ( + typeof window !== 'undefined' && window.innerWidth <= MOBILE_MAX_WIDTH + )) + + useEffect(() => { + const update = (): void => { + setIsMobile(window.innerWidth <= MOBILE_MAX_WIDTH) + } + + update() + window.addEventListener('resize', update) + + return () => { + window.removeEventListener('resize', update) + } + }, []) + + return isMobile +}
Rank MemberRatingCountry# of WinsRatingCountry# of Wins
- - {rankIcon} - - -
- -
- +
+ + {rankIcon} + + +
+ + +
- -
- - {member.rating} - - -
- {countryCode && ( -
-
{NUMBER_FORMATTER.format(member.wins)}
+ + {member.rating} + + +
+ {countryCode && ( +
+
+ {NUMBER_FORMATTER.format(member.wins)} +
+
+
+
Rating
+
+ {member.rating} +
+
+
+
Country
+
+ {countryCode && ( +
+
+
+
# of Wins
+
+ {NUMBER_FORMATTER.format(member.wins)} +
+
+
+