diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 9cbcf5209..355361aa7 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -1,34 +1,45 @@ name: Trivy Scanner -permissions: - contents: read - security-events: write on: push: branches: - main + - master - dev + - develop pull_request: + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + jobs: trivy-scan: - name: Use Trivy + name: Trivy SAST and SCA runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v4 - name: Run Trivy scanner in repo mode - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: + version: "v0.73.0" scan-type: "fs" + scan-ref: "." ignore-unfixed: true format: "sarif" output: "trivy-results.sarif" severity: "CRITICAL,HIGH,UNKNOWN" + limit-severities-for-sarif: true scanners: vuln,secret,misconfig,license + trivyignores: ".trivyignore.yaml" github-pat: ${{ secrets.GITHUB_TOKEN }} - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 + if: always() + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: "trivy-results.sarif" diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 000000000..9dc128a2f --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,31 @@ +# React Router GHSA-qwww-vcr4-c8h2 affects only applications using its unstable +# React Server Components APIs. This project is a browser-only CRA application +# and has no RSC dependencies, configuration, or API usage. React Router 8 also +# requires React 19 and a newer Node runtime, so keep this exception pinned to +# the reviewed package version and lockfile entry. +vulnerabilities: + - id: GHSA-qwww-vcr4-c8h2 + paths: + - "yarn.lock" + purls: + - "pkg:npm/react-router@7.18.2" + statement: "Not applicable: this browser SPA does not use React Router's unstable RSC APIs." + +# Stripe publishable keys are client-side identifiers, not secret API keys. Keep +# this exception limited to the three environment files that intentionally +# configure the browser application. +secrets: + - id: stripe-publishable-token + paths: + - ".environments/.env.dev" + - ".environments/.env.qa" + - ".environments/.env.prod" + statement: "These are intentional Stripe publishable keys used by the client application." + +# ISC is the declared license for this project. Trivy reports the project-level +# license against the dependency lockfile, so scope the exception to that path. +licenses: + - id: ISC + paths: + - "yarn.lock" + statement: "ISC is the approved license for this project." diff --git a/craco.config.js b/craco.config.js index 2472daed2..ac51ba108 100644 --- a/craco.config.js +++ b/craco.config.js @@ -21,6 +21,47 @@ function withNodeModulesWatchIgnore(ignored) { ]; } +/** + * Converts CRA's webpack-dev-server v4 hook registrations into v5 middleware entries. + * + * @param {Function|undefined} hook - Legacy CRA middleware hook. + * @param {object} devServer - Active webpack-dev-server instance. + * @param {string} namePrefix - Stable prefix used for middleware diagnostics. + * @returns {Array} Middleware entries accepted by webpack-dev-server v5. + */ +function collectLegacyMiddlewares(hook, devServer, namePrefix) { + if (!hook) { + return []; + } + + const originalApp = devServer.app; + const appProxy = Object.create(originalApp); + const registrations = []; + + appProxy.use = (...args) => { + registrations.push(args); + return appProxy; + }; + + devServer.app = appProxy; + try { + hook(devServer); + } finally { + devServer.app = originalApp; + } + + return registrations.flatMap((registration, registrationIndex) => { + const args = [...registration]; + const path = typeof args[0] === 'string' ? args.shift() : undefined; + + return args.map((middleware, middlewareIndex) => ({ + name: `${namePrefix}-${registrationIndex}-${middlewareIndex}`, + ...(path ? { path } : {}), + middleware, + })); + }); +} + /** * Preserves CRA's dev-server static config while disabling public asset watches. * @@ -29,14 +70,53 @@ function withNodeModulesWatchIgnore(ignored) { * @throws This function does not throw. */ function configureDevServer(devServerConfig) { + const { + https, + onAfterSetupMiddleware, + onBeforeSetupMiddleware, + setupMiddlewares, + ...supportedConfig + } = devServerConfig; const staticConfig = devServerConfig.static || {}; return { - ...devServerConfig, + ...supportedConfig, + ...(https ? { + server: { + type: 'https', + options: typeof https === 'object' ? https : {}, + }, + } : {}), static: { ...staticConfig, watch: false, }, + setupMiddlewares: (middlewares, devServer) => { + // CRA 5 still calls the webpack-dev-server v4 shutdown method. + if (!devServer.close && devServer.stopCallback) { + devServer.close = devServer.stopCallback.bind(devServer); + } + + const configuredMiddlewares = setupMiddlewares + ? setupMiddlewares(middlewares, devServer) + : middlewares; + const beforeMiddlewares = collectLegacyMiddlewares( + onBeforeSetupMiddleware, + devServer, + 'cra-before-setup', + ); + const afterMiddlewares = collectLegacyMiddlewares( + onAfterSetupMiddleware, + devServer, + 'cra-after-setup', + ); + + return [ + ...beforeMiddlewares, + ...configuredMiddlewares, + ...afterMiddlewares, + ]; + }, }; } @@ -72,6 +152,15 @@ const localIdentName = isProd const resolve = dir => path.resolve(__dirname, dir); module.exports = { + jest: { + configure: { + moduleNameMapper: { + // Jest 27 cannot resolve React Router 7's nested conditional export. + '^react-router/dom$': '/test/react-router-dom.cjs', + }, + }, + }, + style: { modules: { localIdentName, diff --git a/package.json b/package.json index 30a3d3a35..bf291bac4 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@uiw/react-codemirror": "^4.25.8", "amazon-s3-uri": "^0.1.1", "apexcharts": "^3.54.1", - "axios": "^1.15.0", + "axios": "^1.19.0", "browser-cookies": "^1.2.0", "city-timezones": "^1.3.2", "classnames": "^2.5.1", @@ -52,7 +52,7 @@ "crypto-js": "^4.2.0", "customize-cra": "^1.0.0", "date-fns": "^2.30.0", - "dompurify": "^2.5.8", + "dompurify": "^3.4.13", "draft-js": "^0.11.7", "draft-js-export-html": "^1.4.1", "draft-js-markdown-shortcuts-plugin": "^0.6.1", @@ -68,7 +68,7 @@ "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", "lodash": "^4.18.1", - "markdown-it": "^13.0.2", + "markdown-it": "^14.3.0", "marked": "4.3.0", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", @@ -97,7 +97,7 @@ "react-redux-toastr": "^7.6.13", "react-responsive": "^9.0.2", "react-responsive-modal": "^6.4.2", - "react-router-dom": "^6.30.2", + "react-router-dom": "^7.18.2", "react-scripts": "5.0.1", "react-select": "^5.10.2", "react-spinners": "^0.17.0", @@ -116,7 +116,7 @@ "remark-gfm": "^3.0.1", "remark-parse": "^11.0.0", "remove": "^0.1.5", - "sanitize-html": "^2.17.0", + "sanitize-html": "^2.17.6", "sass": "^1.95.0", "styled-components": "^5.3.11", "swr": "^1.3.0", @@ -128,7 +128,7 @@ "yup": "^1.7.1" }, "devDependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.6", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-runtime": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -147,12 +147,10 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.6.1", - "@types/axios": "^0.14.4", - "@types/dompurify": "^2.4.0", "@types/highlightjs": "^9.12.6", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.21", - "@types/markdown-it": "^12.2.3", + "@types/markdown-it": "^14.1.2", "@types/marked": "4.3.2", "@types/node": "^18.19.130", "@types/reach__router": "^1.3.15", @@ -162,30 +160,24 @@ "@types/react-gtm-module": "^2.0.4", "@types/react-helmet": "^6.1.11", "@types/react-redux-toastr": "^7.6.6", - "@types/react-router-dom": "^5.3.3", "@types/redux-actions": "2.6.5", "@types/redux-logger": "^3.0.13", "@types/redux-promise": "^0.5.32", "@types/sanitize-html": "^2.16.0", "@types/systemjs": "^6.15.4", "@types/testing-library__jest-dom": "^5.14.9", - "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@wdio/junit-reporter": "^7.40.0", "autoprefixer": "^10.4.22", "babel-eslint": "^11.0.0-beta.2", "babel-jest": "^29.7.0", - "babel-plugin-inline-react-svg": "^2.0.2", "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-named-exports-order": "^0.0.2", - "babel-plugin-react-css-modules": "^5.2.6", "concurrently": "^7.6.0", "craco-css-modules": "^1.0.6", "craco-plugin-env": "^1.0.5", - "craco-resolve-url-loader": "^1.0.0", "cross-env": "^7.0.3", - "css-loader": "3.6.0", "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-react-app": "^7.0.1", @@ -202,7 +194,6 @@ "istanbul-lib-coverage": "^3.2.2", "jest": "^29.7.0", "jest-cli": "^29.7.0", - "lint-staged": "^13.3.0", "nyc": "^15.1.0", "postcss-loader": "^4.3.0", "postcss-scss": "^3.0.5", @@ -218,15 +209,32 @@ "style-loader": "^3.3.4", "systemjs-webpack-interop": "^2.3.7", "tsconfig-paths-webpack-plugin": "^4.2.0", - "typed-scss-modules": "^7.1.4", "webpack": "^5.103.0", "webpack-cli": "^4.10.0", - "webpack-config-single-spa-react": "^4.0.5", - "webpack-dev-server": "^4.15.2", + "webpack-dev-server": "^5.2.6", "webpack-merge": "^5.10.0" }, "resolutions": { "@types/react": "18.3.27", + "@babel/core": "7.29.7", + "@tootallnate/once": "2.0.1", + "axios": "1.19.0", + "body-parser": "1.20.6", + "**/esbuild": "0.25.12", + "@jackwilsdon/craco-use-babelrc/@craco/craco/cross-spawn": "6.0.6", + "draft-js/immutable": "4.3.9", + "draft-js-plugins-editor/immutable": "4.3.9", + "draft-js-markdown-shortcuts-plugin/**/immutable": "4.3.9", + "sass/immutable": "5.1.9", + "react-scripts/resolve-url-loader": "5.0.0", + "react-scripts/resolve-url-loader/postcss": "8.5.25", + "react-scripts/**/serialize-javascript": "7.0.7", + "@svgr/plugin-svgo": "6.5.1", + "react-scripts/**/svgo": "2.8.3", + "**/tar": "7.5.22", + "underscore": "1.13.8", + "**/uuid": "11.1.1", + "react-scripts/webpack-dev-server": "5.2.6", "string-width": "4.2.0", "node-fetch": "2.6.7", "nth-check": "2.0.1", diff --git a/src/apps/accounts/src/lib/components/setting-section/SettingSection.spec.tsx b/src/apps/accounts/src/lib/components/setting-section/SettingSection.spec.tsx new file mode 100644 index 000000000..b3b74a33e --- /dev/null +++ b/src/apps/accounts/src/lib/components/setting-section/SettingSection.spec.tsx @@ -0,0 +1,21 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { RenderResult } from '@testing-library/react' +import { render, screen } from '@testing-library/react' + +import SettingSection from './SettingSection' + +describe('SettingSection', () => { + it('renders user-provided information as text instead of HTML', () => { + const unsafeInfo: string = '' + const view: RenderResult = render( + , + ) + + expect(screen.getByText(unsafeInfo)) + .toBeInTheDocument() + expect(view.container.querySelector('img')) + .not + .toBeInTheDocument() + }) +}) diff --git a/src/apps/accounts/src/lib/components/setting-section/SettingSection.tsx b/src/apps/accounts/src/lib/components/setting-section/SettingSection.tsx index 380286fdf..12e32057f 100644 --- a/src/apps/accounts/src/lib/components/setting-section/SettingSection.tsx +++ b/src/apps/accounts/src/lib/components/setting-section/SettingSection.tsx @@ -17,10 +17,7 @@ const SettingSection: FC = (props: SettingSectionProps) =>

{props.title}

-

+

{props.infoText || ''}

{props.actionElement} diff --git a/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx index 4e50753f1..39a5c7575 100644 --- a/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx +++ b/src/apps/admin/src/ai/review-workflows/AiReviewWorkflowsPage.tsx @@ -170,6 +170,15 @@ export const AiReviewWorkflowsPage: FC = () => { }, type: 'element', }, + { + defaultSortDirection: 'asc', + label: 'Review Method', + propertyName: 'reviewMethod', + renderer: (data: AiWorkflow) => ( +
{data.reviewMethod || 'N/A'}
+ ), + type: 'element', + }, ], []) const columnsMobile = useMemo[][]>( diff --git a/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx index bc0bba7f8..511bf5a18 100644 --- a/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx +++ b/src/apps/admin/src/ai/review-workflows/WorkflowDetailsModal.tsx @@ -30,6 +30,10 @@ const GeneralSection: FC = (props: SectionProps) => ( {props.workflow.disabled ? 'Inactive' : 'Active'} +
+ Review Method + {props.workflow.reviewMethod || 'N/A'} +
Definition URL diff --git a/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.spec.tsx b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.spec.tsx new file mode 100644 index 000000000..db42240da --- /dev/null +++ b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.spec.tsx @@ -0,0 +1,266 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Navigator, + Route, + Router, + Routes, + useLocation, +} from 'react-router-dom' + +import SystemAdminTabs from './SystemAdminTabs' + +jest.mock('~/config', () => ({ + AppSubdomain: { + admin: 'system-admin', + }, + EnvironmentConfig: { + SUBDOMAIN: 'system-admin', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + useProfileContext: () => ({ + profile: { + roles: ['administrator'], + }, + }), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + TabsNavbar: (props: { + defaultActive: string + onChange: (tabId: string) => void + onChildChange: (tabId: string, childTabId: string) => void + tabs: Array<{ + children?: Array<{ + id: string + title: string + }> + id: string + title: string + }> + }) => { + const React = jest.requireActual('react') as typeof import('react') + + return React.createElement( + 'div', + undefined, + React.createElement( + 'span', + { 'data-testid': 'active-tab' }, + props.defaultActive, + ), + props.tabs.map(tab => ( + tab.children + ? React.createElement( + 'div', + { key: tab.id }, + tab.children.map(child => React.createElement( + 'button', + { + key: child.id, + onClick: () => props.onChildChange(tab.id, child.id), + type: 'button', + }, + child.title, + )), + ) + : React.createElement( + 'button', + { + key: tab.id, + onClick: () => props.onChange(tab.id), + type: 'button', + }, + tab.title, + ) + )), + ) + }, +}), { + virtual: true, +}) + +jest.mock('./config', () => ({ + getSystemAdminTabs: () => [ + { + id: 'challenge-management', + title: 'Challenge Management', + }, + { + id: 'user-management', + title: 'User Management', + }, + { + id: 'review-management', + title: 'Review Management', + }, + { + children: [ + { + id: 'billing-account/clients', + title: 'Clients', + }, + ], + id: 'billing-account', + title: 'Billing Account', + }, + { + children: [ + { + id: 'permission-management/groups', + title: 'Groups', + }, + ], + id: 'permission-management', + title: 'Permission Management', + }, + { + children: [ + { + id: 'platform/skills', + title: 'Skills', + }, + ], + id: 'platform', + title: 'Platform', + }, + { + id: 'payments', + title: 'Payments', + }, + { + children: [ + { + id: 'ai/review-templates', + title: 'AI Review Templates', + }, + ], + id: 'ai', + title: 'AI', + }, + ], + getTabIdFromPathName: (pathname: string) => [ + 'challenge-management', + 'user-management', + 'review-management', + 'billing-account', + 'permission-management', + 'platform', + 'payments', + 'ai', + ].find(tabId => pathname.includes(`/${tabId}`)) ?? 'challenge-management', +})) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +function renderSystemAdminTabs(pathname: string): void { + render( + + + + + + + )} + /> + + , + ) +} + +describe('SystemAdminTabs', () => { + it('keeps the clicked tab active while the router location update is pending', () => { + const navigator: Navigator = { + createHref: jest.fn(() => ''), + go: jest.fn(), + push: jest.fn(), + replace: jest.fn(), + } + const view = render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'User Management' })) + + expect(navigator.push) + .toHaveBeenCalledTimes(1) + expect(screen.getByTestId('active-tab').textContent) + .toBe('user-management') + + view.rerender( + + + , + ) + + expect(screen.getByTestId('active-tab').textContent) + .toBe('user-management') + + view.rerender( + + + , + ) + + expect(screen.getByTestId('active-tab').textContent) + .toBe('challenge-management') + }) + + it('navigates top-level tabs from the app root when rendered in a wildcard route', () => { + renderSystemAdminTabs( + '/challenge-management/user-management/user-management/review-management', + ) + + const destinations: Array<[string, string]> = [ + ['User Management', '/user-management'], + ['Review Management', '/review-management'], + ['Challenge Management', '/challenge-management'], + ['Payments', '/payments'], + ] + + destinations.forEach(([tabTitle, expectedPath]) => { + fireEvent.click(screen.getByRole('button', { name: tabTitle })) + + expect(screen.getByTestId('location-pathname').textContent) + .toBe(expectedPath) + }) + }) + + it('navigates child tabs from the app root when rendered in a wildcard route', () => { + renderSystemAdminTabs('/challenge-management/challenge-id/manage-user') + + const destinations: Array<[string, string]> = [ + ['Clients', '/billing-account/clients'], + ['Groups', '/permission-management/groups'], + ['Skills', '/platform/skills'], + ['AI Review Templates', '/ai/review-templates'], + ] + + destinations.forEach(([tabTitle, expectedPath]) => { + fireEvent.click(screen.getByRole('button', { name: tabTitle })) + + expect(screen.getByTestId('location-pathname').textContent) + .toBe(expectedPath) + }) + }) +}) diff --git a/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx index f70b82493..d8176444a 100644 --- a/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx +++ b/src/apps/admin/src/lib/components/common/Tab/SystemAdminTabs.tsx @@ -4,6 +4,8 @@ import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom' import { ProfileContextData, useProfileContext } from '~/libs/core' import { TabsNavbar } from '~/libs/ui' +import { rootRoute } from '../../../../config/routes.config' + import { getSystemAdminTabs, getTabIdFromPathName } from './config' import styles from './SystemAdminTabs.module.scss' @@ -22,21 +24,18 @@ const SystemAdminTabs: FC = () => { function handleTabChange(tabId: string): void { setActiveTab(tabId) - navigate(tabId) + navigate(`${rootRoute}/${tabId}`) } function handleChildTabChange(tabId: string, childTabId: string): void { setActiveTab(tabId) - navigate(childTabId) + navigate(`${rootRoute}/${childTabId}`) } - // If url is changed by navigator on different tabs, we need set activeTab + // Keep browser navigation in sync without reacting to the optimistic click state. useEffect(() => { - const pathTabId = getTabIdFromPathName(pathname, tabs) - if (pathTabId !== activeTab) { - setActiveTab(pathTabId) - } - }, [activeTab, pathname, tabs]) + setActiveTab(activeTabPathName) + }, [activeTabPathName]) if (!tabs.length) { return <> diff --git a/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts b/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts index e5b131b31..23ed4928e 100644 --- a/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts +++ b/src/apps/admin/src/lib/hooks/useDownloadSubmission.ts @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react' import { some } from 'lodash' -import { downloadSubmissionFile } from '../services' +import { getSubmissionDownloadUrl } from '../services' import { handleError } from '../utils' import { IsRemovingType } from '../models' @@ -15,8 +15,9 @@ export interface useDownloadSubmissionProps { } /** - * Download submission - * @returns download info + * Requests signed submission URLs and starts browser-managed downloads. + * + * @returns The download callback and its per-submission loading state. */ export function useDownloadSubmission(): useDownloadSubmissionProps { const [isLoading, setIsLoading] = useState({}) @@ -30,16 +31,15 @@ export function useDownloadSubmission(): useDownloadSubmissionProps { ...previous, [submissionId]: true, })) - downloadSubmissionFile(submissionId) - .then((data: Blob) => { + getSubmissionDownloadUrl(submissionId) + .then((downloadUrl: string) => { setIsLoading(previous => ({ ...previous, [submissionId]: false, })) - const url = window.URL.createObjectURL(new Blob([data])) const link = document.createElement('a') - link.href = url + link.href = downloadUrl link.setAttribute('download', `submission-${submissionId}.zip`) document.body.appendChild(link) link.click() diff --git a/src/apps/admin/src/lib/services/ai-workflows.service.ts b/src/apps/admin/src/lib/services/ai-workflows.service.ts index 200006844..b7b015adc 100644 --- a/src/apps/admin/src/lib/services/ai-workflows.service.ts +++ b/src/apps/admin/src/lib/services/ai-workflows.service.ts @@ -47,6 +47,7 @@ export interface AiWorkflow { gitWorkflowId: string; gitOwnerRepo: string; scorecardId: string; + reviewMethod: string; disabled: boolean; createdAt: string; createdBy: string; diff --git a/src/apps/admin/src/lib/services/submissions.service.ts b/src/apps/admin/src/lib/services/submissions.service.ts index 52bbcd3ec..09ea5e9a1 100644 --- a/src/apps/admin/src/lib/services/submissions.service.ts +++ b/src/apps/admin/src/lib/services/submissions.service.ts @@ -5,7 +5,6 @@ import { EnvironmentConfig } from '~/config' import { xhrDeleteAsync, xhrGetAsync, - xhrGetBlobAsync, xhrPostAsync, } from '~/libs/core' @@ -97,17 +96,28 @@ export const removeSubmission = async ( } /** - * Download submission file + * Request a short-lived URL for downloading a clean submission directly from storage. + * * @param submissionId submission id - * @returns resolves to the submission file + * @returns resolves to the signed submission download URL + * @throws rejects when the submission id is empty or the Review API fails or omits the signed URL */ -export const downloadSubmissionFile = async ( +export const getSubmissionDownloadUrl = async ( submissionId: string, -): Promise => { - const results = await xhrGetBlobAsync( - `${EnvironmentConfig.API.V6}/submissions/${submissionId}/download`, +): Promise => { + const normalizedSubmissionId = submissionId.trim() + if (!normalizedSubmissionId) { + throw new Error('Submission id is required') + } + + const results = await xhrGetAsync<{ url?: unknown }>( + `${EnvironmentConfig.API.V6}/submissions/${encodeURIComponent(normalizedSubmissionId)}/download-url`, ) - return results + if (typeof results?.url !== 'string' || !results.url.trim()) { + throw new Error('Submission download URL is missing') + } + + return results.url.trim() } /** 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 new file mode 100644 index 000000000..1dd411b39 --- /dev/null +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.spec.tsx @@ -0,0 +1,112 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Route, + Routes, + useLocation, +} from 'react-router-dom' + +import NavTabs from './NavTabs' + +jest.mock('~/config', () => ({ + AppSubdomain: { + customer: 'customer', + }, + EnvironmentConfig: { + SUBDOMAIN: 'customer', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/shared/lib/hooks', () => ({ + useClickOutside: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ExternalLinkIcon: () => external-link, + }, +}), { + virtual: true, +}) + +jest.mock('../../contexts', () => { + const React = jest.requireActual('react') as typeof import('react') + + return { + CustomerPortalAppContext: React.createContext({ + loginUserInfo: { + roles: ['administrator'], + }, + }), + } +}) + +jest.mock('./config', () => ({ + getTabIdFromPathName: () => 'talent-search', + getTabsConfig: () => [ + { + id: 'talent-search', + title: 'Talent Search', + }, + { + id: 'showcase', + title: 'Showcase', + }, + { + id: 'flexi-talent', + title: 'Flexi-Talent', + }, + ], +})) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +function renderNavTabs(pathname: string): void { + render( + + + + + + + )} + /> + + , + ) +} + +describe('Customer Portal NavTabs', () => { + it('navigates tabs from the app root when rendered in a wildcard route', () => { + renderNavTabs('/talent-search/showcase/flexi-talent') + + const destinations: Array<[string, string]> = [ + ['Showcase', '/showcase'], + ['Flexi-Talent', '/flexi-talent'], + ['Talent Search', '/talent-search'], + ] + + destinations.forEach(([tabTitle, expectedPath]) => { + fireEvent.click(screen.getByText(tabTitle)) + + expect(screen.getByTestId('location-pathname').textContent) + .toBe(expectedPath) + }) + }) +}) 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 5e87e8ea8..ea751bff7 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/customer-portal/src/lib/components/NavTabs/NavTabs.tsx @@ -20,6 +20,7 @@ import { IconOutline } from '~/libs/ui' import { CustomerPortalAppContext } from '../../contexts' import { CustomerPortalAppContextModel } from '../../models' import { PRIVILEGED_ROLES } from '../../../config/index.config' +import { rootRoute } from '../../../config/routes.config' import { getTabIdFromPathName, getTabsConfig } from './config' import styles from './NavTabs.module.scss' @@ -79,7 +80,7 @@ const NavTabs: FC = () => { setActiveTab(tabId) setIsOpen(false) - navigate(tabId) + navigate(`${rootRoute}/${tabId}`) }, [navigate], ) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx index ae002d240..b2cbd6cf0 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames' import { ProjectShowcasePost } from '~/apps/work/src/lib' import { IconOutline, LinkButton } from '~/libs/ui' +import { renderRichTextToPlainText } from '~/libs/shared' import { toClassName } from '../utils' import { getPostRoute } from '../project-showcase.routes' @@ -35,7 +36,7 @@ const ProjectShowcaseCard: FC = props => (
- {props.post.content} + {renderRichTextToPlainText(props.post.content || '')}
diff --git a/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.spec.tsx b/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.spec.tsx new file mode 100644 index 000000000..aff292e39 --- /dev/null +++ b/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.spec.tsx @@ -0,0 +1,53 @@ +/* eslint-disable import/first, import/no-extraneous-dependencies */ +/* eslint-disable ordered-imports/ordered-imports, react/function-component-definition */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' + +jest.mock('../MarkdownAccordion', () => ( + props: PropsWithChildren, +): JSX.Element => <>{props.children}) + +jest.mock('../MarkdownCode', () => ( + props: PropsWithChildren, +): JSX.Element => <>{props.children}) + +jest.mock('../MarkdownImages', () => ( + props: PropsWithChildren, +): JSX.Element => <>{props.children}) + +jest.mock('../MarkdownLink', () => ( + props: PropsWithChildren, +): JSX.Element => <>{props.children}) + +import { Renderer } from './renderer' + +describe('Markdown renderer', () => { + it('extracts the outer tag from multiline generated HTML', () => { + render( + <> + {Renderer.getInstance() + .render('First line\nsecond line')} + , + ) + + expect(screen.getByText(/First line/).tagName) + .toBe('P') + expect(screen.getByText(/First line/)) + .toHaveTextContent('First line second line') + }) + + it('handles long repeated text without ambiguous regular-expression matching', () => { + const repeatedText: string = `${'n'.repeat(50_000)}!` + + render( + <> + {Renderer.getInstance() + .render(repeatedText)} + , + ) + + expect(screen.getByText(repeatedText).tagName) + .toBe('P') + }) +}) diff --git a/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.tsx b/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.tsx index b6404b366..97503129d 100644 --- a/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.tsx +++ b/src/apps/dev-center/src/dev-center-lib/MarkdownDoc/markdownRenderer/renderer.tsx @@ -265,7 +265,7 @@ export class Renderer implements MarkdownRenderer { ) => { htmlString = htmlString.trim() const tagRegExp: RegExp - = /^<([a-zA-Z0-9]+)\b[^>]*?>(.|n)*?<\/\1>$/g + = /^<([a-zA-Z0-9]+)\b/ const matches: RegExpExecArray | null = tagRegExp.exec(htmlString) return matches ? matches[1] : '' } diff --git a/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.module.scss b/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.module.scss index de2e8a7c3..7e0db3067 100644 --- a/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.module.scss +++ b/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.module.scss @@ -37,6 +37,12 @@ border-color: $orange-100; } +.status-shortlisted { + background: $blue-25; + color: $blue-140; + border-color: $blue-100; +} + .status-selected { background: $turq-25; color: $turq-180; diff --git a/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.tsx b/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.tsx index cc30e662c..219563b41 100644 --- a/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.tsx +++ b/src/apps/engagements/src/components/application-status-badge/ApplicationStatusBadge.tsx @@ -13,6 +13,7 @@ interface ApplicationStatusBadgeProps { const APPLICATION_STATUS_LABELS: Record = { [ApplicationStatus.SUBMITTED]: 'Submitted', [ApplicationStatus.UNDER_REVIEW]: 'Under Review', + [ApplicationStatus.SHORTLISTED]: 'Shortlisted', [ApplicationStatus.SELECTED]: 'Selected', [ApplicationStatus.REJECTED]: 'Rejected', } @@ -20,14 +21,17 @@ const APPLICATION_STATUS_LABELS: Record = { const ApplicationStatusBadge: FC = ( props: ApplicationStatusBadgeProps, ) => { - const label = APPLICATION_STATUS_LABELS[props.status] ?? props.status + const normalizedStatus = String(props.status || '') + .trim() + .toLowerCase() as ApplicationStatus + const label = APPLICATION_STATUS_LABELS[normalizedStatus] ?? props.status const size = props.size ?? 'md' return ( diff --git a/src/apps/engagements/src/lib/models/Application.model.ts b/src/apps/engagements/src/lib/models/Application.model.ts index 4d763ec4b..b642e3968 100644 --- a/src/apps/engagements/src/lib/models/Application.model.ts +++ b/src/apps/engagements/src/lib/models/Application.model.ts @@ -3,6 +3,7 @@ import { Engagement } from './Engagement.model' export enum ApplicationStatus { SUBMITTED = 'submitted', UNDER_REVIEW = 'under_review', + SHORTLISTED = 'shortlisted', SELECTED = 'selected', REJECTED = 'rejected', } diff --git a/src/apps/engagements/src/lib/utils/application.utils.ts b/src/apps/engagements/src/lib/utils/application.utils.ts index 696647a58..7f4867158 100644 --- a/src/apps/engagements/src/lib/utils/application.utils.ts +++ b/src/apps/engagements/src/lib/utils/application.utils.ts @@ -58,9 +58,15 @@ export const formatApplicationDate = (dateString: string): string => { return `${months} months ago` } -export const isApplicationActive = (status: ApplicationStatus): boolean => ( - status === ApplicationStatus.SUBMITTED || status === ApplicationStatus.UNDER_REVIEW -) +export const isApplicationActive = (status: ApplicationStatus): boolean => { + const normalizedStatus = String(status || '') + .trim() + .toLowerCase() + + return normalizedStatus === ApplicationStatus.SUBMITTED + || normalizedStatus === ApplicationStatus.UNDER_REVIEW + || normalizedStatus === ApplicationStatus.SHORTLISTED +} export const truncateText = (text: string, maxLength: number): string => { if (!text) { diff --git a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx index d06386feb..80380baa6 100644 --- a/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx +++ b/src/apps/engagements/src/pages/engagement-detail/EngagementDetailPage.tsx @@ -33,6 +33,7 @@ const Markdown = ReactMarkdown as unknown as FC const APPLICATION_STATUS_LABELS: Record = { [ApplicationStatus.SUBMITTED]: 'Submitted', [ApplicationStatus.UNDER_REVIEW]: 'Under review', + [ApplicationStatus.SHORTLISTED]: 'Shortlisted', [ApplicationStatus.SELECTED]: 'Selected', [ApplicationStatus.REJECTED]: 'Rejected', } @@ -161,7 +162,11 @@ const getApplicationStatusLabel = (application?: Application): string | undefine return undefined } - return APPLICATION_STATUS_LABELS[application.status] + const normalizedStatus = String(application.status) + .trim() + .toLowerCase() as ApplicationStatus + + return APPLICATION_STATUS_LABELS[normalizedStatus] ?? formatEnumLabel(application.status) } const getApiErrorMessage = (error: any): string | undefined => { diff --git a/src/apps/engagements/src/pages/my-applications/MyApplicationsPage.tsx b/src/apps/engagements/src/pages/my-applications/MyApplicationsPage.tsx index 136fe7d36..0f2deacec 100644 --- a/src/apps/engagements/src/pages/my-applications/MyApplicationsPage.tsx +++ b/src/apps/engagements/src/pages/my-applications/MyApplicationsPage.tsx @@ -18,7 +18,11 @@ import styles from './MyApplicationsPage.module.scss' type StatusFilterValue = ApplicationStatus | 'active' | 'past' -const ACTIVE_STATUSES = [ApplicationStatus.SUBMITTED, ApplicationStatus.UNDER_REVIEW] +const ACTIVE_STATUSES = [ + ApplicationStatus.SUBMITTED, + ApplicationStatus.UNDER_REVIEW, + ApplicationStatus.SHORTLISTED, +] const PAST_STATUSES = [ApplicationStatus.SELECTED, ApplicationStatus.REJECTED] const PER_PAGE = APPLICATIONS_PER_PAGE @@ -44,6 +48,7 @@ const MyApplicationsPage: FC = () => { { label: 'All Active', value: 'active' }, { label: 'Submitted', value: ApplicationStatus.SUBMITTED }, { label: 'Under Review', value: ApplicationStatus.UNDER_REVIEW }, + { label: 'Shortlisted', value: ApplicationStatus.SHORTLISTED }, { label: 'All Past', value: 'past' }, { label: 'Selected', value: ApplicationStatus.SELECTED }, { label: 'Rejected', value: ApplicationStatus.REJECTED }, diff --git a/src/apps/onboarding/src/onboarding.routes.spec.tsx b/src/apps/onboarding/src/onboarding.routes.spec.tsx new file mode 100644 index 000000000..7a4acd23f --- /dev/null +++ b/src/apps/onboarding/src/onboarding.routes.spec.tsx @@ -0,0 +1,63 @@ +/* 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 { onboardingRoutes } from './onboarding.routes' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + SUBDOMAIN: 'onboarding', + }, +}), { + virtual: true, +}) + +jest.mock('~/config/constants', () => ({ + AppSubdomain: { + onboarding: 'onboarding', + }, + ToolTitle: { + onboarding: 'Onboarding', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): undefined => undefined, + UserRole: { + member: 'Topcoder User', + }, +}), { + virtual: true, +}) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +describe('onboarding routes', () => { + it('redirects a deeply nested invalid path to skills from the app root', async () => { + const fallbackRoute = onboardingRoutes[0].children + ?.find(route => route.route === '/*') + + render( + + + } path='/skills' /> + + + , + ) + + expect((await screen.findByTestId('location-pathname')).textContent) + .toBe('/skills') + }) +}) diff --git a/src/apps/onboarding/src/onboarding.routes.tsx b/src/apps/onboarding/src/onboarding.routes.tsx index 74e40b25e..57048bdfc 100644 --- a/src/apps/onboarding/src/onboarding.routes.tsx +++ b/src/apps/onboarding/src/onboarding.routes.tsx @@ -46,7 +46,7 @@ export const onboardingRoutes: ReadonlyArray = [ route: '/personalization', }, { - element: , + element: , route: '/*', }, ], diff --git a/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.spec.tsx b/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.spec.tsx new file mode 100644 index 000000000..6cdd99d43 --- /dev/null +++ b/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.spec.tsx @@ -0,0 +1,162 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren, ReactNode } from 'react' +import type { RenderResult } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import ModifyMemberPhotoModal from './ModifyMemberPhotoModal' + +jest.mock('~/libs/core', () => ({ + updateMemberPhotoAsync: jest.fn(), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren<{ buttons: ReactNode }>): JSX.Element => ( +
+ {props.children} + {props.buttons} +
+ ), + Button: (props: { + disabled?: boolean + label: string + onClick: () => void + }): JSX.Element => ( + + ), +}), { virtual: true }) + +const pngSignature: ReadonlyArray = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] +const originalCreateImageBitmap: PropertyDescriptor | undefined + = Object.getOwnPropertyDescriptor(window, 'createImageBitmap') +const originalCreateObjectUrl: PropertyDescriptor | undefined + = Object.getOwnPropertyDescriptor(URL, 'createObjectURL') +const originalRevokeObjectUrl: PropertyDescriptor | undefined + = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL') + +const mockCreateImageBitmap = jest.fn() +const mockCreateObjectUrl = jest.fn() +const mockRevokeObjectUrl = jest.fn() +const mockDrawImage = jest.fn() +const mockCloseBitmap = jest.fn() + +function restoreProperty( + target: object, + property: string, + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor) { + Object.defineProperty(target, property, descriptor) + } else { + Reflect.deleteProperty(target, property) + } +} + +describe('ModifyMemberPhotoModal image preview', () => { + beforeEach(() => { + jest.clearAllMocks() + Object.defineProperty(window, 'createImageBitmap', { + configurable: true, + value: mockCreateImageBitmap, + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: mockCreateObjectUrl, + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: mockRevokeObjectUrl, + }) + + mockCreateImageBitmap.mockResolvedValue({ + close: mockCloseBitmap, + height: 1_000, + width: 4_000, + } as unknown as ImageBitmap) + }) + + afterEach(() => { + jest.restoreAllMocks() + restoreProperty(window, 'createImageBitmap', originalCreateImageBitmap) + restoreProperty(URL, 'createObjectURL', originalCreateObjectUrl) + restoreProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl) + }) + + it('renders a canvas-reencoded preview and revokes its URL on cleanup', async () => { + const sanitizedPreview: Blob = new Blob(['sanitized'], { type: 'image/png' }) + jest.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue({ drawImage: mockDrawImage } as unknown as CanvasRenderingContext2D) + jest.spyOn(HTMLCanvasElement.prototype, 'toBlob') + .mockImplementation((callback: BlobCallback) => callback(sanitizedPreview)) + mockCreateObjectUrl.mockReturnValue('blob:sanitized-preview') + + const view: RenderResult = render( + , + ) + const fileInput: HTMLInputElement + = view.container.querySelector('input[type="file"]') as HTMLInputElement + const selectedFile: File = new File( + [new Uint8Array([...pngSignature, 0x00])], + 'profile.png', + { type: 'image/png' }, + ) + + fireEvent.change(fileInput, { target: { files: [selectedFile] } }) + + expect(screen.getByRole('button', { name: 'Save profile picture' })) + .toBeDisabled() + + await waitFor(() => expect(screen.getByRole('img', { name: 'preview' })) + .toHaveAttribute('src', 'blob:sanitized-preview')) + + expect(mockCreateImageBitmap) + .toHaveBeenCalledWith(selectedFile) + expect(mockDrawImage) + .toHaveBeenCalledWith(expect.anything(), 0, 0, 2_048, 512) + expect(mockCreateObjectUrl) + .toHaveBeenCalledWith(sanitizedPreview) + expect(screen.getByRole('button', { name: 'Save profile picture' })) + .toBeEnabled() + expect(mockCloseBitmap) + .toHaveBeenCalledTimes(1) + + view.unmount() + + expect(mockRevokeObjectUrl) + .toHaveBeenCalledWith('blob:sanitized-preview') + }) + + it('rejects a file whose content does not match its image MIME type', async () => { + const view: RenderResult = render( + , + ) + const fileInput: HTMLInputElement + = view.container.querySelector('input[type="file"]') as HTMLInputElement + const disguisedHtml: File = new File( + [''], + 'profile.png', + { type: 'image/png' }, + ) + + fireEvent.change(fileInput, { target: { files: [disguisedHtml] } }) + + expect(await screen.findByText('Please select a valid PNG or JPG image.')) + .toBeInTheDocument() + expect(screen.queryByRole('img', { name: 'preview' })) + .not + .toBeInTheDocument() + expect(mockCreateImageBitmap) + .not + .toHaveBeenCalled() + }) +}) diff --git a/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.tsx b/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.tsx index 7a9f2d6a4..e13eebc89 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/ModifyMemberPhotoModal/ModifyMemberPhotoModal.tsx @@ -1,4 +1,12 @@ -import { Dispatch, FC, MutableRefObject, SetStateAction, useRef, useState } from 'react' +import { + Dispatch, + FC, + MutableRefObject, + SetStateAction, + useEffect, + useRef, + useState, +} from 'react' import { toast } from 'react-toastify' import { BaseModal, Button } from '~/libs/ui' @@ -6,6 +14,91 @@ import { updateMemberPhotoAsync, UserProfile } from '~/libs/core' import styles from './ModifyMemberPhotoModal.module.scss' +const MAX_PHOTO_SIZE_BYTES: number = 2_000_000 +const MAX_PREVIEW_DIMENSION: number = 2_048 +const PNG_SIGNATURE: ReadonlyArray = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + +interface PhotoPreview { + readonly file: File + readonly url: string +} + +function hasExpectedRasterSignature(file: File, signature: Uint8Array): boolean { + if (file.type === 'image/png') { + return PNG_SIGNATURE.every((byte: number, index: number) => signature[index] === byte) + } + + return file.type === 'image/jpeg' + && signature[0] === 0xFF + && signature[1] === 0xD8 + && signature[2] === 0xFF +} + +function readFileSignature(file: File): Promise { + return new Promise((resolve, reject) => { + const reader: FileReader = new FileReader() + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + resolve(new Uint8Array(reader.result)) + } else { + reject(new Error('The selected image signature could not be read.')) + } + } + + reader.onerror = () => reject(reader.error || new Error('The selected image could not be read.')) + reader.readAsArrayBuffer(file.slice(0, PNG_SIGNATURE.length)) + }) +} + +async function createValidatedRasterPreviewUrl(file: File): Promise { + const signature: Uint8Array = await readFileSignature(file) + if (!hasExpectedRasterSignature(file, signature)) { + throw new Error('The selected file does not have a valid PNG or JPEG signature.') + } + + const bitmap: ImageBitmap = await window.createImageBitmap(file) + + try { + if (!bitmap.width || !bitmap.height) { + throw new Error('The selected image has invalid dimensions.') + } + + const previewScale: number = Math.min( + 1, + MAX_PREVIEW_DIMENSION / bitmap.width, + MAX_PREVIEW_DIMENSION / bitmap.height, + ) + const canvas: HTMLCanvasElement = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(bitmap.width * previewScale)) + canvas.height = Math.max(1, Math.round(bitmap.height * previewScale)) + + const context: CanvasRenderingContext2D | null = canvas.getContext('2d') + if (!context) { + throw new Error('An image preview could not be created.') + } + + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height) + + const previewBlob: Blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (blob: Blob | null) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('An image preview could not be encoded.')) + } + }, + file.type, + 0.9, + ) + }) + + return URL.createObjectURL(previewBlob) + } finally { + bitmap.close() + } +} + interface ModifyMemberPhotoModalProps { onClose: () => void onSave: () => void @@ -19,15 +112,50 @@ const ModifyMemberPhotoModal: FC = (props: ModifyMe const [file, setFile]: [File | undefined, Dispatch>] = useState(undefined) + const [preview, setPreview]: [PhotoPreview | undefined, Dispatch>] + = useState(undefined) + const fileElRef: MutableRefObject = useRef() const [fileSelectError, setFileSelectError]: [string | undefined, Dispatch>] = useState() + useEffect(() => { + let isActive: boolean = true + let previewUrl: string | undefined + + setPreview(undefined) + + if (file) { + createValidatedRasterPreviewUrl(file) + .then((url: string) => { + previewUrl = url + if (isActive) { + setPreview({ file, url }) + } else { + URL.revokeObjectURL(url) + } + }) + .catch(() => { + if (isActive) { + setFile(undefined) + setFileSelectError('Please select a valid PNG or JPG image.') + } + }) + } + + return () => { + isActive = false + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + } + }, [file]) + function handleModifyPhotoSave(): void { const formData: FormData = new FormData() - if (file) { + if (file && preview?.file === file) { formData.append('photo', file) setIsSaving(true) @@ -52,8 +180,9 @@ const ModifyMemberPhotoModal: FC = (props: ModifyMe const pickedFile: File | undefined = event.target.files?.[0] if (pickedFile) { - if (pickedFile?.size < 2000000) { // max 2mb limit + if (pickedFile.size <= MAX_PHOTO_SIZE_BYTES) { if (pickedFile.type !== 'image/png' && pickedFile.type !== 'image/jpeg') { + setFile(undefined) setFileSelectError('Please select a PNG or JPG image.') return } @@ -61,6 +190,7 @@ const ModifyMemberPhotoModal: FC = (props: ModifyMe setFile(pickedFile) setFileSelectError(undefined) } else { + setFile(undefined) setFileSelectError('Please select an image that is less than 2MB.') } } else { @@ -86,7 +216,7 @@ const ModifyMemberPhotoModal: FC = (props: ModifyMe label='Save profile picture' onClick={handleModifyPhotoSave} primary - disabled={isSaving || !file} + disabled={isSaving || !file || preview?.file !== file} />
)} @@ -112,9 +242,9 @@ const ModifyMemberPhotoModal: FC = (props: ModifyMe } { - file && ( + file && preview?.file === file && (
- preview + preview
) } diff --git a/src/apps/reports/src/config/routes.config.spec.ts b/src/apps/reports/src/config/routes.config.spec.ts index bd6c48004..c3afbf5af 100644 --- a/src/apps/reports/src/config/routes.config.spec.ts +++ b/src/apps/reports/src/config/routes.config.spec.ts @@ -35,6 +35,8 @@ describe('Reports route configuration', () => { expect(dashboardRouteSlugs) .toEqual({ challengeParticipation: 'challenge-participation', + memberPaymentByCustomer: 'member-payment-by-customer', + memberPaymentByMonth: 'member-payment-by-month', membersPaid: 'members-paid', newSignups: 'new-signups', }) diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index eba262bac..2c8be7069 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -27,6 +27,8 @@ export const talentPageRouteId = 'talent' export const dashboardRouteSlugs = { challengeParticipation: 'challenge-participation', + memberPaymentByCustomer: 'member-payment-by-customer', + memberPaymentByMonth: 'member-payment-by-month', membersPaid: 'members-paid', newSignups: 'new-signups', } as const diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index f5e29d4b3..02e2ae1d3 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -30,6 +30,11 @@ export type { DashboardResponseBySlug, DashboardSlug, DashboardsResponse, + MemberPaymentByCustomerDashboard, + MemberPaymentByCustomerMonth, + MemberPaymentByMonthDashboard, + MemberPaymentByMonthMonth, + MemberPaymentCustomerSeries, MembersPaidDashboard, MembersPaidMonth, MembersPaidSummary, diff --git a/src/apps/reports/src/lib/services/reports.service.spec.ts b/src/apps/reports/src/lib/services/reports.service.spec.ts index 3bfda3b81..9d173a7dc 100644 --- a/src/apps/reports/src/lib/services/reports.service.spec.ts +++ b/src/apps/reports/src/lib/services/reports.service.spec.ts @@ -45,6 +45,10 @@ describe('reports dashboard service paths', () => { .toBe('/dashboard/new-signups') expect(buildDashboardPath({}, 'challenge-participation', true)) .toBe('/dashboard/challenge-participation/export') + expect(buildDashboardPath({}, 'member-payment-by-month')) + .toBe('/dashboard/member-payment-by-month') + expect(buildDashboardPath({}, 'member-payment-by-customer', true)) + .toBe('/dashboard/member-payment-by-customer/export') }) it('adds trimmed UTC range boundaries in stable query order', () => { diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index d7a7cef30..2fcc0a6b8 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -97,6 +97,36 @@ export interface MembersPaidDashboard extends DashboardRange { summary: MembersPaidSummary } +export interface MemberPaymentByMonthMonth { + challenge: number + engagement: number + month: string + taas: number + task: number +} + +export interface MemberPaymentByMonthDashboard extends DashboardRange { + dashboard: 'member-payment-by-month' + months: MemberPaymentByMonthMonth[] +} + +export interface MemberPaymentCustomerSeries { + customerId: string | null + key: string + label: string +} + +export interface MemberPaymentByCustomerMonth { + month: string + values: Record +} + +export interface MemberPaymentByCustomerDashboard extends DashboardRange { + dashboard: 'member-payment-by-customer' + months: MemberPaymentByCustomerMonth[] + series: MemberPaymentCustomerSeries[] +} + export interface ChallengeParticipationMonth { month: string registrants: number @@ -119,12 +149,16 @@ export interface ChallengeParticipationDashboard extends DashboardRange { export interface DashboardsResponse { challengeParticipation: ChallengeParticipationDashboard + memberPaymentByCustomer: MemberPaymentByCustomerDashboard + memberPaymentByMonth: MemberPaymentByMonthDashboard membersPaid: MembersPaidDashboard newSignups: NewSignupsDashboard } export interface DashboardResponseBySlug { 'challenge-participation': ChallengeParticipationDashboard + 'member-payment-by-customer': MemberPaymentByCustomerDashboard + 'member-payment-by-month': MemberPaymentByMonthDashboard 'members-paid': MembersPaidDashboard 'new-signups': NewSignupsDashboard } @@ -403,7 +437,7 @@ export const downloadReportAsCsv = (path: string): Promise => ( ) /** - * Fetches all dashboard cards for the requested six-month UTC range. + * Fetches all dashboard cards for the requested UTC range. * * @param query Optional inclusive start and exclusive end date filters. * @returns Aggregate dashboard data used by the Dashboards landing page. diff --git a/src/apps/reports/src/pages/dashboards/DashboardCard.tsx b/src/apps/reports/src/pages/dashboards/DashboardCard.tsx index fe7285650..868a582e4 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardCard.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardCard.tsx @@ -47,8 +47,7 @@ export const DashboardCard: FC = props => {
diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx new file mode 100644 index 000000000..bc9eb3227 --- /dev/null +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx @@ -0,0 +1,161 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { + render, + screen, + within, +} from '@testing-library/react' +import Highcharts from 'highcharts' + +import { + MemberPaymentByCustomerDashboard, + NewSignupsDashboard, +} from '../../lib/services' + +import { DashboardChart } from './DashboardChart' + +jest.mock('highcharts-react-official', () => ({ + __esModule: true, + default: (props: { + options: { + series?: Array<{ data?: number[]; name?: string }> + tooltip?: { pointFormat?: string } + } + }): JSX.Element => { + const series = props.options.series || [] + + return ( +
item.data))} + data-series-names={series.map(item => item.name) + .join('|')} + data-testid='dashboard-chart' + data-tooltip={props.options.tooltip?.pointFormat} + /> + ) + }, +})) + +const customerPaymentResponse: MemberPaymentByCustomerDashboard = { + dashboard: 'member-payment-by-customer', + endDate: '2026-03-01T00:00:00.000Z', + months: [ + { + month: '2026-01-01', + values: { + 'customer-a': 125_000, + 'customer-b': 80_000, + 'other-customers': 20_000, + }, + }, + { + month: '2026-02-01', + values: { + 'customer-a': 140_000, + 'customer-b': 0, + 'other-customers': 25_000, + }, + }, + ], + series: [ + { + customerId: 'customer-id-a', + key: 'customer-a', + label: 'Customer A', + }, + { + customerId: 'customer-id-b', + key: 'customer-b', + label: 'Customer B', + }, + { + customerId: null, // eslint-disable-line unicorn/no-null + key: 'other-customers', + label: 'Other Customers', + }, + ], + startDate: '2026-01-01T00:00:00.000Z', +} + +const signupResponse: NewSignupsDashboard = { + dashboard: 'new-signups', + endDate: '2026-02-01T00:00:00.000Z', + months: [{ + activated: 90, + month: '2026-01-01', + notActivated: 10, + }], + startDate: '2026-01-01T00:00:00.000Z', + summary: { + activatedMembers: 90, + activationRate: 90, + notActivatedMembers: 10, + peakMonth: '2026-01-01', + peakMonthSignups: 100, + totalSignups: 100, + }, +} + +const pointValueToken = '{point.y:,.0f}' +const countTooltipValue = `${pointValueToken}` +const currencyTooltipValue = `$${pointValueToken}` + +describe('DashboardChart', () => { + it('formats tooltip thousands with commas', () => { + expect(Highcharts.getOptions().lang?.thousandsSep) + .toBe(',') + expect(Highcharts.numberFormat(9_189, 0)) + .toBe('9,189') + }) + + it('renders API-ordered customer series as currency data', () => { + render() + + const chart = screen.getByTestId('dashboard-chart') + const table = screen.getByRole('table', { + name: 'Member Payment $ by Customer monthly data', + }) + + expect(chart) + .toHaveAttribute( + 'data-series-names', + 'Customer A|Customer B|Other Customers', + ) + expect(chart) + .toHaveAttribute( + 'data-series-data', + '[[125000,140000],[80000,0],[20000,25000]]', + ) + expect(chart.getAttribute('data-tooltip')) + .toContain(currencyTooltipValue) + expect(within(table) + .getByRole('columnheader', { name: 'Customer A' })) + .toBeInTheDocument() + expect(within(table) + .getByRole('columnheader', { name: 'Other Customers' })) + .toBeInTheDocument() + expect(within(table) + .getByRole('cell', { name: '$125,000' })) + .toBeInTheDocument() + expect(within(table) + .getByRole('cell', { name: '$0' })) + .toBeInTheDocument() + }) + + it('keeps existing count dashboards unit-free', () => { + render() + + const chart = screen.getByTestId('dashboard-chart') + const table = screen.getByRole('table', { + name: 'New Signups by Month monthly data', + }) + + expect(chart.getAttribute('data-tooltip')) + .toContain(countTooltipValue) + expect(chart.getAttribute('data-tooltip')) + .not.toContain(currencyTooltipValue) + expect(within(table) + .getByRole('cell', { name: '90' })) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx index 5602f2c28..54182e43a 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx @@ -2,24 +2,29 @@ import { FC, useMemo } from 'react' import Highcharts from 'highcharts' import HighchartsReact from 'highcharts-react-official' -import { - DashboardSlug, -} from '../../lib/services' - import { dashboardDefinitions, DashboardMonth, + DashboardResponse, + getDashboardSeries, } from './dashboard.config' import { + formatCompactCurrency, formatCompactInteger, + formatDashboardCurrency, formatDashboardMonth, } from './dashboard.utils' import styles from './Dashboards.module.scss' +Highcharts.setOptions({ + lang: { + thousandsSep: ',', + }, +}) + type DashboardChartProps = { compact?: boolean - dashboard: DashboardSlug - months: DashboardMonth[] + response: DashboardResponse } /** @@ -31,23 +36,30 @@ type DashboardChartProps = { * @throws Does not throw. */ function getSeriesValue(month: DashboardMonth, key: string): number { - const value = (month as unknown as Record)[key] + const monthRecord = month as unknown as Record + const values = monthRecord.values + const value = values && typeof values === 'object' + ? (values as Record)[key] + : monthRecord[key] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 } /** * Renders the configured Highcharts visualization for a dashboard dataset. * - * @param props Dashboard slug, monthly data, and compact-card presentation flag. + * @param props Dashboard response and compact-card presentation flag. * @returns A stacked or grouped column chart with month categories along the * bottom axis and an accessible monthly data table. * @throws Does not throw. Invalid or absent point values are rendered as zero. */ export const DashboardChart: FC = props => { - const definition = dashboardDefinitions[props.dashboard] - const hasData = props.months.some(month => ( - definition.series.some(series => getSeriesValue(month, series.key) > 0) + const definition = dashboardDefinitions[props.response.dashboard] + const seriesDefinitions = getDashboardSeries(props.response) + const hasData = props.response.months.some(month => ( + seriesDefinitions.some(series => getSeriesValue(month, series.key) > 0) )) + const isCurrency = definition.valueType === 'currency' const options = useMemo(() => ({ accessibility: { @@ -62,7 +74,7 @@ export const DashboardChart: FC = props => { : [16, 8, 8, 8], type: definition.chartType, }, - colors: definition.series.map(series => series.color), + colors: seriesDefinitions.map(series => series.color), credits: { enabled: false, }, @@ -97,9 +109,9 @@ export const DashboardChart: FC = props => { }, }, }, - series: definition.series.map(series => ({ + series: seriesDefinitions.map(series => ({ color: series.color, - data: props.months.map(month => getSeriesValue(month, series.key)), + data: props.response.months.map(month => getSeriesValue(month, series.key)), name: series.label, type: definition.chartType, })) as Highcharts.SeriesOptionsType[], @@ -108,11 +120,12 @@ export const DashboardChart: FC = props => { }, tooltip: { headerFormat: '{point.key}
', - pointFormat: ' {series.name}: {point.y:,.0f}
', + pointFormat: ' ' + + `{series.name}: ${isCurrency ? '$' : ''}{point.y:,.0f}
`, shared: true, }, xAxis: { - categories: props.months.map(month => formatDashboardMonth(month.month)), + categories: props.response.months.map(month => formatDashboardMonth(month.month)), labels: { style: { color: '#111b46', @@ -122,7 +135,7 @@ export const DashboardChart: FC = props => { lineColor: '#dce1eb', tickColor: '#dce1eb', title: { - text: undefined, + text: definition.xAxisTitle, }, }, yAxis: { @@ -132,7 +145,11 @@ export const DashboardChart: FC = props => { // Highcharts supplies the axis-label context through `this`. formatter() { // eslint-disable-next-line react/no-this-in-sfc - return formatCompactInteger(Number(this.value)) + const axisValue = Number(this.value) + + return isCurrency + ? formatCompactCurrency(axisValue) + : formatCompactInteger(axisValue) }, style: { color: '#111b46', @@ -146,8 +163,10 @@ export const DashboardChart: FC = props => { }, }), [ definition, + isCurrency, props.compact, - props.months, + props.response, + seriesDefinitions, ]) if (!hasData) { @@ -172,19 +191,23 @@ export const DashboardChart: FC = props => { Month - {definition.series.map(series => ( + {seriesDefinitions.map(series => ( {series.label} ))} - {props.months.map(month => ( + {props.response.months.map(month => ( {formatDashboardMonth(month.month)} - {definition.series.map(series => ( + {seriesDefinitions.map(series => ( - {getSeriesValue(month, series.key) - .toLocaleString('en-US')} + {isCurrency + ? formatDashboardCurrency( + getSeriesValue(month, series.key), + ) + : getSeriesValue(month, series.key) + .toLocaleString('en-US')} ))} diff --git a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx index ebaf7224f..a0b368e89 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx @@ -21,6 +21,7 @@ import { downloadBlobFile, downloadDashboardCsv, fetchDashboard, + MemberPaymentByCustomerDashboard, NewSignupsDashboard, } from '../../lib/services' @@ -90,6 +91,31 @@ const signupResponse: NewSignupsDashboard = { }, } +const customerPaymentResponse: MemberPaymentByCustomerDashboard = { + dashboard: 'member-payment-by-customer', + endDate: '2026-08-01T00:00:00.000Z', + months: [{ + month: '2026-07-01', + values: { + 'customer-1': 125_000, + 'other-customers': 20_000, + }, + }], + series: [ + { + customerId: 'customer-id-1', + key: 'customer-1', + label: 'Customer A', + }, + { + customerId: null, // eslint-disable-line unicorn/no-null + key: 'other-customers', + label: 'Other Customers', + }, + ], + startDate: '2026-02-01T00:00:00.000Z', +} + const mockedFetchDashboard = fetchDashboard as jest.Mock const mockedDownloadDashboardCsv = downloadDashboardCsv as jest.Mock const mockedDownloadBlobFile = downloadBlobFile as jest.Mock @@ -191,7 +217,7 @@ describe('Dashboard detail page', () => { expect(mockedDownloadBlobFile) .toHaveBeenCalledWith( expect.any(Blob), - 'new-signups-2025-08-01-to-2026-02-01.csv', + 'new-signups-2025-08-01-to-2026-01-31.csv', ) }) @@ -249,7 +275,7 @@ describe('Dashboard detail page', () => { expect(mockedDownloadBlobFile) .toHaveBeenCalledWith( expect.any(Blob), - 'new-signups-2025-07-01-to-2026-07-01.csv', + 'new-signups-2025-07-01-to-2026-06-30.csv', ) fireEvent.click(screen.getByRole('button', { name: 'Previous Period' })) @@ -314,6 +340,50 @@ describe('Dashboard detail page', () => { .toBeInTheDocument() }) + it('renders and exports a customer payment dashboard without invented summary metrics', async () => { + mockedFetchDashboard.mockResolvedValue(customerPaymentResponse) + + renderDetailRoute('member-payment-by-customer') + await flushAsyncUpdates() + + expect(mockedFetchDashboard) + .toHaveBeenCalledWith('member-payment-by-customer', { + endDate: '2026-08-01', + startDate: '2026-02-01', + }) + expect(screen.getByRole('heading', { name: 'Member Payment $ by Customer' })) + .toBeInTheDocument() + expect(screen.getByRole('table', { + name: 'Member Payment $ by Customer monthly data', + })) + .toBeInTheDocument() + expect(screen.getByText('Customer A')) + .toBeInTheDocument() + expect(screen.getByText('Other Customers')) + .toBeInTheDocument() + expect(screen.getByText('$125,000')) + .toBeInTheDocument() + expect(screen.queryByLabelText('Member Payment $ by Customer summary metrics')) + .not.toBeInTheDocument() + expect(screen.getByLabelText('Member Payment $ by Customer chart') + .closest('section')) + .toHaveClass('detailGridFullWidth') + + fireEvent.click(screen.getByRole('button', { name: 'Download CSV' })) + await flushAsyncUpdates() + + expect(mockedDownloadDashboardCsv) + .toHaveBeenCalledWith('member-payment-by-customer', { + endDate: '2026-08-01', + startDate: '2026-02-01', + }) + expect(mockedDownloadBlobFile) + .toHaveBeenCalledWith( + expect.any(Blob), + 'member-payment-by-customer-2026-02-01-to-2026-07-31.csv', + ) + }) + it('redirects unknown dashboard slugs to the dashboard landing page', async () => { renderDetailRoute('unknown-dashboard') diff --git a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx index 2ee58f3ca..452e0de7d 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx @@ -140,6 +140,9 @@ function buildAvailableDashboardRange( */ function buildDashboardMetrics(response: DashboardResponse): DashboardMetric[] { switch (response.dashboard) { + case 'member-payment-by-customer': + case 'member-payment-by-month': + return [] case 'members-paid': return [ { @@ -649,38 +652,45 @@ const DashboardDetailContent: FC = props => { {response && ( <> -
+
- + {!!metrics.length && ( + + )}
diff --git a/src/apps/reports/src/pages/dashboards/Dashboards.module.scss b/src/apps/reports/src/pages/dashboards/Dashboards.module.scss index 88358f516..3d6b2376f 100644 --- a/src/apps/reports/src/pages/dashboards/Dashboards.module.scss +++ b/src/apps/reports/src/pages/dashboards/Dashboards.module.scss @@ -41,7 +41,7 @@ .dashboardGrid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 24px; } @@ -50,12 +50,17 @@ min-width: 0; min-height: 420px; flex-direction: column; + grid-column: span 2; border: 1px solid #dce1eb; border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(18, 24, 40, 0.05); } +.dashboardCard:nth-child(n + 4) { + grid-column: span 3; +} + .cardHeader { display: flex; justify-content: space-between; @@ -69,6 +74,7 @@ font-size: 16px; font-weight: 700; line-height: 22px; + text-transform: none; } p { @@ -168,6 +174,10 @@ .detailHeading { min-width: 260px; + + h1 { + text-transform: none; + } } .rangePanel { @@ -291,6 +301,10 @@ gap: 28px; } +.detailGridFullWidth { + grid-template-columns: minmax(0, 1fr); +} + .detailChart, .metricsPanel { min-width: 0; @@ -443,6 +457,11 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboardCard, + .dashboardCard:nth-child(n + 4) { + grid-column: auto; + } + .detailHeader { align-items: flex-start; flex-direction: column; diff --git a/src/apps/reports/src/pages/dashboards/DashboardsPage.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardsPage.spec.tsx index cd0b657a3..ba2cdba2a 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardsPage.spec.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardsPage.spec.tsx @@ -84,6 +84,42 @@ const dashboardResponse: DashboardsResponse = { totalUniqueSubmitters: 625, }, }, + memberPaymentByCustomer: { + dashboard: 'member-payment-by-customer', + endDate: '2026-08-01T00:00:00.000Z', + months: [{ + month: '2026-07-01', + values: { + 'customer-1': 125_000, + 'other-customers': 20_000, + }, + }], + series: [ + { + customerId: 'customer-id-1', + key: 'customer-1', + label: 'Customer A', + }, + { + customerId: null, // eslint-disable-line unicorn/no-null + key: 'other-customers', + label: 'Other Customers', + }, + ], + startDate: '2026-02-01T00:00:00.000Z', + }, + memberPaymentByMonth: { + dashboard: 'member-payment-by-month', + endDate: '2026-08-01T00:00:00.000Z', + months: [{ + challenge: 75_000, + engagement: 25_000, + month: '2026-07-01', + taas: 200_000, + task: 100_000, + }], + startDate: '2026-02-01T00:00:00.000Z', + }, membersPaid: { dashboard: 'members-paid', endDate: '2026-08-01T00:00:00.000Z', @@ -172,10 +208,22 @@ describe('Dashboards landing page', () => { .toBeInTheDocument() expect(screen.getByRole('heading', { name: /Challenge Registrants vs Submitters/ })) .toBeInTheDocument() + expect(screen.getByRole('heading', { name: /Member Payment \$ by Month/ })) + .toBeInTheDocument() + expect(screen.getByRole('heading', { name: /Member Payment \$ by Customer/ })) + .toBeInTheDocument() expect(screen.getAllByTestId('dashboard-chart')) - .toHaveLength(3) + .toHaveLength(5) expect(screen.getAllByRole('table')) - .toHaveLength(3) + .toHaveLength(5) + const detailLinks = screen.getAllByRole('link', { name: 'View full dashboard' }) + + expect(detailLinks) + .toHaveLength(5) + expect(detailLinks[3]) + .toHaveAttribute('href', '/reports/dashboards/member-payment-by-month') + expect(detailLinks[4]) + .toHaveAttribute('href', '/reports/dashboards/member-payment-by-customer') expect(mockedFetchDashboards) .toHaveBeenCalledWith({ endDate: '2026-08-01', @@ -201,7 +249,7 @@ describe('Dashboards landing page', () => { expect(mockedDownloadBlobFile) .toHaveBeenCalledWith( expect.any(Blob), - 'reports-dashboards-2026-02-01-to-2026-08-01.csv', + 'reports-dashboards-2026-02-01-to-2026-07-31.csv', ) }) }) diff --git a/src/apps/reports/src/pages/dashboards/DashboardsPage.tsx b/src/apps/reports/src/pages/dashboards/DashboardsPage.tsx index 480db6caa..09ea58126 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardsPage.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardsPage.tsx @@ -50,7 +50,7 @@ function getDashboardErrorMessage(error: unknown): string { } /** - * Reports dashboard landing page with the latest six months of all three widgets. + * Reports dashboard landing page with the latest six months of all widgets. * * The page supports an explicit refresh and a consolidated CSV export for the * same UTC range shown by the cards. diff --git a/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts b/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts index a7f0a093b..3c191cbcb 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts @@ -1,4 +1,7 @@ -import { dashboardDefinitions } from './dashboard.config' +import { + dashboardDefinitions, + getDashboardSeries, +} from './dashboard.config' describe('dashboard chart definitions', () => { it('uses bottom-timeline column charts for every report', () => { @@ -8,6 +11,10 @@ describe('dashboard chart definitions', () => { .toBe('column') expect(dashboardDefinitions['challenge-participation'].chartType) .toBe('column') + expect(dashboardDefinitions['member-payment-by-month'].chartType) + .toBe('column') + expect(dashboardDefinitions['member-payment-by-customer'].chartType) + .toBe('column') }) it('keeps the signup and payment series stacked and participation grouped', () => { @@ -17,5 +24,72 @@ describe('dashboard chart definitions', () => { .toBe(true) expect(dashboardDefinitions['challenge-participation'].stacked) .toBe(false) + expect(dashboardDefinitions['member-payment-by-month'].stacked) + .toBe(true) + expect(dashboardDefinitions['member-payment-by-customer'].stacked) + .toBe(true) + }) + + it('configures the payment value cards with their requested copy and currency mode', () => { + expect(dashboardDefinitions['member-payment-by-month']) + .toMatchObject({ + index: 4, + subtitle: 'Split by Payment Types (TAAS, Task, Contest, Engagement)', + title: 'Member Payment $ by Month', + valueType: 'currency', + }) + expect(dashboardDefinitions['member-payment-by-month'].series) + .toEqual([ + expect.objectContaining({ key: 'taas', label: 'TAAS' }), + expect.objectContaining({ key: 'task', label: 'Task' }), + expect.objectContaining({ key: 'challenge', label: 'Contest' }), + expect.objectContaining({ key: 'engagement', label: 'Engagement' }), + ]) + expect(dashboardDefinitions['member-payment-by-customer']) + .toMatchObject({ + index: 5, + subtitle: 'Monthly payment value split by customer spending', + title: 'Member Payment $ by Customer', + valueType: 'currency', + }) + }) + + it('preserves dynamic customer order and assigns the Other Customers color', () => { + const series = getDashboardSeries({ + dashboard: 'member-payment-by-customer', + endDate: '2026-07-01T00:00:00.000Z', + months: [], + series: [ + { + customerId: '1', + key: 'customer-1', + label: 'Customer A', + }, + { + customerId: '2', + key: 'customer-2', + label: 'Customer B', + }, + { + customerId: null, // eslint-disable-line unicorn/no-null + key: 'other-customers', + label: 'Other Customers', + }, + ], + startDate: '2026-01-01T00:00:00.000Z', + }) + + expect(series.map(item => item.label)) + .toEqual([ + 'Customer A', + 'Customer B', + 'Other Customers', + ]) + expect(series.map(item => item.color)) + .toEqual([ + '#0f62fe', + '#6aae3f', + '#d7476f', + ]) }) }) diff --git a/src/apps/reports/src/pages/dashboards/dashboard.config.ts b/src/apps/reports/src/pages/dashboards/dashboard.config.ts index 8d584cd14..d2a3e122f 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.config.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.config.ts @@ -2,12 +2,16 @@ import { ChallengeParticipationDashboard, DashboardSlug, DashboardsResponse, + MemberPaymentByCustomerDashboard, + MemberPaymentByMonthDashboard, MembersPaidDashboard, NewSignupsDashboard, } from '../../lib/services' export type DashboardResponse = NewSignupsDashboard | MembersPaidDashboard + | MemberPaymentByMonthDashboard + | MemberPaymentByCustomerDashboard | ChallengeParticipationDashboard export type DashboardMonth = DashboardResponse['months'][number] @@ -26,8 +30,19 @@ export type DashboardDefinition = { stacked: boolean subtitle: string title: string + valueType: 'count' | 'currency' + xAxisTitle?: string } +const customerSeriesColors: string[] = [ + '#0f62fe', + '#6aae3f', + '#6c5ce7', + '#ff8a00', + '#00a6ce', +] +const otherCustomersColor = '#d7476f' + export const dashboardDefinitions: Record = { 'challenge-participation': { chartType: 'column', @@ -48,6 +63,50 @@ export const dashboardDefinitions: Record = stacked: false, subtitle: 'Monthly unique registrants and submitters', title: 'Challenge Registrants vs Submitters', + valueType: 'count', + }, + 'member-payment-by-customer': { + chartType: 'column', + index: 5, + series: [], + slug: 'member-payment-by-customer', + stacked: true, + subtitle: 'Monthly payment value split by customer spending', + title: 'Member Payment $ by Customer', + valueType: 'currency', + xAxisTitle: 'Month', + }, + 'member-payment-by-month': { + chartType: 'column', + index: 4, + series: [ + { + color: '#0f62fe', + key: 'taas', + label: 'TAAS', + }, + { + color: '#6aae3f', + key: 'task', + label: 'Task', + }, + { + color: '#6c5ce7', + key: 'challenge', + label: 'Contest', + }, + { + color: '#ff8a00', + key: 'engagement', + label: 'Engagement', + }, + ], + slug: 'member-payment-by-month', + stacked: true, + subtitle: 'Split by Payment Types (TAAS, Task, Contest, Engagement)', + title: 'Member Payment $ by Month', + valueType: 'currency', + xAxisTitle: 'Month', }, 'members-paid': { chartType: 'column', @@ -78,6 +137,7 @@ export const dashboardDefinitions: Record = stacked: true, subtitle: 'Split by payment type (TaaS, Task, Challenge, Engagement)', title: 'Unique Members Paid per Month', + valueType: 'count', }, 'new-signups': { chartType: 'column', @@ -98,6 +158,7 @@ export const dashboardDefinitions: Record = stacked: true, subtitle: 'Split by Activated vs Not Activated Members', title: 'New Signups by Month', + valueType: 'count', }, } @@ -105,19 +166,48 @@ export const dashboardSlugs: DashboardSlug[] = [ 'new-signups', 'members-paid', 'challenge-participation', + 'member-payment-by-month', + 'member-payment-by-customer', ] /** * Determines whether an unknown route value is a supported dashboard slug. * * @param value Candidate route parameter. - * @returns True when the value identifies one of the three reports dashboards. + * @returns True when the value identifies one of the reports dashboards. * @throws Does not throw. */ export function isDashboardSlug(value?: string): value is DashboardSlug { return dashboardSlugs.includes(value as DashboardSlug) } +/** + * Resolves the visible series for a dashboard response. + * + * Static dashboards use their configured fields. Customer payment dashboards + * preserve the API's ranked series order and assign the reporting palette, + * including the dedicated Other Customers color. + * + * @param response Dashboard API response whose series will be rendered. + * @returns Ordered chart-series definitions with UI colors. + * @throws Does not throw. + */ +export function getDashboardSeries( + response: DashboardResponse, +): DashboardSeriesDefinition[] { + if (response.dashboard !== 'member-payment-by-customer') { + return dashboardDefinitions[response.dashboard].series + } + + return response.series.map((series, index) => ({ + color: series.key === 'other-customers' + ? otherCustomersColor + : customerSeriesColors[index % customerSeriesColors.length], + key: series.key, + label: series.label, + })) +} + /** * Selects one dashboard response from the landing-page aggregate. * @@ -135,6 +225,10 @@ export function getDashboardResponse( return dashboards.membersPaid case 'challenge-participation': return dashboards.challengeParticipation + case 'member-payment-by-month': + return dashboards.memberPaymentByMonth + case 'member-payment-by-customer': + return dashboards.memberPaymentByCustomer case 'new-signups': default: return dashboards.newSignups diff --git a/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts b/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts index 49de8d0c4..1afbcf296 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts @@ -1,7 +1,9 @@ import { buildDashboardCsvFileName, buildDashboardRangeFromMonths, + formatCompactCurrency, formatCompactInteger, + formatDashboardCurrency, formatDashboardMonth, formatDashboardRangeLabel, formatPercentage, @@ -175,6 +177,19 @@ describe('dashboard labels and metric formatting', () => { .toBe('2M') }) + it('formats compact and full rounded dashboard currency values', () => { + expect(formatCompactCurrency(0)) + .toBe('$0') + expect(formatCompactCurrency(18_214)) + .toBe('$18.2K') + expect(formatCompactCurrency(2_000_000)) + .toBe('$2M') + expect(formatDashboardCurrency(18_214.49)) + .toBe('$18,214') + expect(formatDashboardCurrency(18_214.5)) + .toBe('$18,215') + }) + it('formats percentage-point values with at most one decimal place', () => { expect(formatPercentage(0)) .toBe('0%') @@ -186,7 +201,7 @@ describe('dashboard labels and metric formatting', () => { }) describe('dashboard CSV filenames', () => { - it('combines a normalized slug with the exact API request range', () => { + it('shows the inclusive final date instead of the exclusive API boundary', () => { expect(buildDashboardCsvFileName( 'Challenge Registrants / Submitters', { @@ -194,7 +209,7 @@ describe('dashboard CSV filenames', () => { startDate: '2026-02-01', }, )) - .toBe('challenge-registrants-submitters-2026-02-01-to-2026-08-01.csv') + .toBe('challenge-registrants-submitters-2026-02-01-to-2026-07-31.csv') }) it('uses the reports dashboard stem for the landing-page aggregate', () => { @@ -205,7 +220,7 @@ describe('dashboard CSV filenames', () => { startDate: '2026-02-01', }, )) - .toBe('reports-dashboards-2026-02-01-to-2026-08-01.csv') + .toBe('reports-dashboards-2026-02-01-to-2026-07-31.csv') }) it('rejects empty slugs and invalid ranges', () => { diff --git a/src/apps/reports/src/pages/dashboards/dashboard.utils.ts b/src/apps/reports/src/pages/dashboards/dashboard.utils.ts index daa7d05ea..c40fb6ccc 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.utils.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.utils.ts @@ -467,6 +467,41 @@ export function formatCompactInteger(value: number): string { return compactIntegerFormatter.format(integerValue) } +/** + * Formats a dashboard dollar value using compact English notation. + * + * @param value Numeric dollar value to round and abbreviate. + * @returns A compact currency label such as `$18.2K` or `$2M`. + * @throws RangeError when the value is NaN or infinite. + * + * Payment dashboard chart axes use this formatter while count dashboards retain + * their existing unit-free labels. + */ +export function formatCompactCurrency(value: number): string { + return `$${formatCompactInteger(requireFiniteNumber(value, 'Dashboard currency'))}` +} + +/** + * Formats a dashboard dollar value as a rounded US currency amount. + * + * @param value Numeric dollar value to format. + * @returns A currency label such as `$18,214`. + * @throws RangeError when the value is NaN or infinite. + * + * Payment dashboard accessible tables use full values instead of compact axis + * notation so assistive-technology users receive the underlying amount. + */ +export function formatDashboardCurrency(value: number): string { + const roundedValue = Math.round(requireFiniteNumber(value, 'Dashboard currency')) + + return roundedValue.toLocaleString('en-US', { + currency: 'USD', + maximumFractionDigits: 0, + minimumFractionDigits: 0, + style: 'currency', + }) +} + /** * Formats dashboard percentage points with at most one decimal place. * @@ -491,24 +526,26 @@ export function formatPercentage(percentage: number): string { * Pass `all` for the landing-page aggregate export. * @param range Dashboard request range with an exclusive `endDate`. * @returns A normalized filename such as - * `new-signups-2026-02-01-to-2026-08-01.csv`. The `all` slug produces a + * `new-signups-2026-02-01-to-2026-07-31.csv`. The `all` slug produces a * `reports-dashboards-...csv` filename. * @throws RangeError when the slug or range dates are invalid, or the range is empty. * - * Detail dashboard downloads use the filename alongside the same date range - * supplied to the CSV endpoint. + * Detail dashboard downloads keep the API's half-open request range while the + * filename presents both boundaries as inclusive calendar dates. */ export function buildDashboardCsvFileName( dashboardSlug: string, range: DashboardRange, ): string { formatDashboardRangeLabel(range) + const inclusiveEndDate = parseDashboardIsoDate(range.endDate) + inclusiveEndDate.setUTCDate(inclusiveEndDate.getUTCDate() - 1) return [ normalizeDashboardSlug(dashboardSlug), range.startDate, 'to', - range.endDate, + toIsoDate(inclusiveEndDate), ].join('-') .concat('.csv') } diff --git a/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx new file mode 100644 index 000000000..05dbbfec6 --- /dev/null +++ b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx @@ -0,0 +1,140 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { + ButtonHTMLAttributes, + ChangeEvent, + PropsWithChildren, +} from 'react' +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Route, + Routes, + useLocation, +} from 'react-router-dom' + +import { fetchReportsIndex } from '../../lib/services' + +import { ReportsPage } from './ReportsPage' + +type MockSelectProps = { + disabled?: boolean + label?: string + name?: string + onChange?: (event: ChangeEvent) => void + options?: Array<{ label: string, value: string }> + placeholder?: string + value?: string +} + +jest.mock('~/config', () => ({ + AppSubdomain: { reports: 'reports' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, +}), { virtual: true }) + +jest.mock('~/apps/admin/src/lib', () => ({ + Pagination: (): JSX.Element => <>, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren): JSX.Element =>
{props.children}
, + Button: ( + props: PropsWithChildren, 'disabled' | 'onClick'>>, + ): JSX.Element => ( + + ), + IconOutline: { + InformationCircleIcon: (): JSX.Element => , + }, + InputDatePicker: (): JSX.Element => , + InputSelect: (props: MockSelectProps): JSX.Element => ( + + ), + InputText: (): JSX.Element => , + LoadingSpinner: (): JSX.Element =>
Loading reports
, + PageTitle: (): JSX.Element => <>, + Tooltip: (props: PropsWithChildren): JSX.Element => <>{props.children}, +}), { virtual: true }) + +jest.mock('../../lib/services', () => ({ + downloadBlobFile: jest.fn(), + downloadReportAsCsv: jest.fn(), + downloadReportAsJson: jest.fn(), + fetchReportJson: jest.fn(), + fetchReportsIndex: jest.fn(), +})) + +jest.mock('../../lib/utils', () => ({ + handleError: jest.fn(), +})) + +const mockedFetchReportsIndex = fetchReportsIndex as jest.Mock + +const LocationProbe = (): JSX.Element => { + const { pathname }: { pathname: string } = useLocation() + + return {pathname} +} + +describe('Reports page navigation', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedFetchReportsIndex.mockResolvedValue({ + identity: { + basePath: '/identity', + label: 'Identity', + reports: [{ + method: 'POST', + name: 'Users by Handles', + path: '/identity/users-by-handles', + }], + }, + }) + }) + + it('opens Bulk Member Lookup from the Reports app root', async () => { + render( + + + + + + + )} + /> + + , + ) + + fireEvent.change(await screen.findByLabelText('Report category'), { + target: { value: '/identity' }, + }) + fireEvent.change(screen.getByLabelText('Report'), { + target: { value: '/identity/users-by-handles' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Open Bulk Member Lookup' })) + + expect(screen.getByTestId('location')) + .toHaveTextContent('/reports/bulk-member-lookup') + }) +}) diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx index a31638598..eeeecefea 100644 --- a/src/apps/reports/src/pages/reports/ReportsPage.tsx +++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx @@ -25,7 +25,10 @@ import { } from '~/libs/ui' import { Pagination } from '~/apps/admin/src/lib' -import { bulkMemberLookupRouteId } from '../../config/routes.config' +import { + buildReportsPath, + bulkMemberLookupRouteId, +} from '../../config/routes.config' import { handleError } from '../../lib/utils' import { BillingAccountDetail, @@ -927,7 +930,7 @@ const ReportsPageContent: FC = props => { }, [buildReportPathWithParams, hasInvalidParameterValues, parameterValues.challengeId, selectedReport]) const handleOpenBulkMemberLookup = useCallback(() => { - navigate(bulkMemberLookupRouteId) + navigate(buildReportsPath(bulkMemberLookupRouteId)) }, [navigate]) const handleResetFilters = useCallback(() => { diff --git a/src/apps/review/README.md b/src/apps/review/README.md index 2258c46c6..6cc05479b 100644 --- a/src/apps/review/README.md +++ b/src/apps/review/README.md @@ -28,8 +28,11 @@ sudo yarn start - Each final-placement winner is matched by normalized member ID and placement. The endpoint's `submissionId` is authoritative for display and download; another submission from the same member is never substituted based on score or recency. -- Local submission and review data may enrich the submitted date and reviews only when the local - submission ID exactly matches the canonical ID. Missing or malformed canonical results are - omitted safely. +- Local submission data may supply a final/system aggregate score, submitted date, and reviews only + when the local submission ID exactly matches the canonical ID. This preserves Marathon Match + system scores without accepting provisional or sibling-submission scores. Missing or malformed + canonical results are omitted safely. - Canonical `PLACEMENT` winner types are shown. Untyped and contest-submission winner types remain supported for legacy challenge records, while checkpoint winner types are excluded. +- Checkpoint winners remain separate from final placements and are identified by member ID in the + Checkpoint Review table. The winner indicator appears only on rows that passed Checkpoint Review. diff --git a/src/apps/review/src/config/index.config.ts b/src/apps/review/src/config/index.config.ts index 3d9c8eff9..51816d4d3 100644 --- a/src/apps/review/src/config/index.config.ts +++ b/src/apps/review/src/config/index.config.ts @@ -19,6 +19,41 @@ export const CHALLENGE_TYPE_SELECT_ALL_OPTION: SelectOption = { value: '', } +export const ROLE_SELECT_ALL_OPTION: SelectOption = { + label: 'All roles', + value: '', +} + +export const REVIEWER_RESOURCE_ROLE_IDS = [ + '318b9c07-079a-42d9-a81f-b96be1dc1099', + '3970272b-85b4-48d8-8439-672b4f6031bd', + '3eedd4a4-3c68-4f68-8de4-a1ca5c2055e5', + '4857fd2e-d9d2-44bb-a429-f75b7c5d5feb', + 'ac953811-8268-403a-ac06-fd88a100c9c7', + 'caf7b717-3dee-41e0-8bf8-3217cc5a878c', + 'e0544b94-6420-4afc-8f63-238eddc751b9', + 'f6df7212-b9d6-4193-bfb1-b383586fce63', +] + +export const COPILOT_RESOURCE_ROLE_ID = 'cfe12b3f-2a24-4639-9d8b-ec86726f76bd' +export const SUBMITTER_RESOURCE_ROLE_ID = '732339e7-8e30-49d7-9198-cccf9451e221' + +export const PAST_CHALLENGE_ROLE_SELECT_OPTIONS: SelectOption[] = [ + ROLE_SELECT_ALL_OPTION, + { + label: 'Reviewer', + value: REVIEWER_RESOURCE_ROLE_IDS.join(','), + }, + { + label: 'Copilot', + value: COPILOT_RESOURCE_ROLE_ID, + }, + { + label: 'Submitter', + value: SUBMITTER_RESOURCE_ROLE_ID, + }, +] + export const CHALLENGE_TYPE_SELECT_OPTIONS: SelectOption[] = [ CHALLENGE_TYPE_SELECT_ALL_OPTION, ...[ diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index c3294b1b3..dd3991085 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -84,6 +84,12 @@ } } +.commentValue { + display: flex; + align-items: center; + gap: $sp-1; +} + .aiReviewer { display: flex; align-items: center; @@ -93,6 +99,10 @@ display: flex; align-items: center; flex: 0 0; + + svg { + color: $teal-160; + } } .workflowName { diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index a78b49b35..3ee7fcf49 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -15,6 +15,7 @@ import { IconOutline, Tooltip } from '~/libs/ui' import { aiRunFailed, aiRunInProgress, + AiWorkflowReviewMethod, AiWorkflowRun, AiWorkflowRunsResponse, AiWorkflowRunStatusEnum, @@ -51,12 +52,13 @@ interface AiReviewerRow { initialScore?: number minScore?: number reviewDate?: string - run?: Pick + run?: Pick score?: number status?: 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' title: string weight?: number workflowId?: string + workflow?: AiWorkflowRun['workflow'] } const stopPropagation = (ev: ReactMouseEvent): void => { @@ -107,6 +109,12 @@ function formatWeight(value?: number): string { return `${value.toFixed(0)}%` } +function shouldHideComments(run?: Pick): boolean { + return !run + || run?.id === '-1' + || run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC +} + function getConfiguredWorkflowName(workflow?: AiReviewConfigWorkflow['workflow']): string | undefined { const configuredName = workflow?.name?.trim() return configuredName || undefined @@ -241,6 +249,7 @@ const AiReviewsTable: FC = props => { const configured = configuredWorkflows.find(item => item.workflowId === workflowId) const fromDecision = decisionWorkflowRows.find(item => item.workflowId === workflowId) const run = runsByWorkflowId.get(workflowId) + const workflow = run?.workflow ?? configured?.workflow as AiWorkflowRun['workflow'] const minScore = fromDecision?.minimumPassingScore ?? configured?.workflow?.scorecard?.minimumPassingScore @@ -261,13 +270,28 @@ const AiReviewsTable: FC = props => { status, title: getConfiguredWorkflowName(configured?.workflow) ?? run?.workflow?.name ?? 'AI Review', weight: fromDecision?.weightPercent ?? configured?.weightPercent, + workflow, workflowId, } }) + rows.sort((a, b) => { + const aDeterministic = a.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC ? 1 : 0 + const bDeterministic = b.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC ? 1 : 0 + return aDeterministic - bDeterministic + }) + const hasVirusScan = rows.some(row => row.title.toLowerCase() === 'virus scan') if (!hasVirusScan) { + const workflow: AiWorkflowRun['workflow'] = { + description: '', + name: 'Virus Scan', + scorecard: { + minimumPassingScore: 100, + }, + } as AiWorkflowRun['workflow'] + rows.push({ id: 'virus-scan-fallback', minScore: hasConfig ? 100 : undefined, @@ -276,13 +300,7 @@ const AiReviewsTable: FC = props => { id: '-1', score: props.submission.virusScan === true ? 100 : 0, status: AiWorkflowRunStatusEnum.SUCCESS, - workflow: { - description: '', - name: 'Virus Scan', - scorecard: { - minimumPassingScore: 100, - }, - } as AiWorkflowRun['workflow'], + workflow, }, score: props.submission.virusScan === undefined ? undefined @@ -292,6 +310,7 @@ const AiReviewsTable: FC = props => { ), title: 'Virus Scan', weight: hasConfig ? 0 : undefined, + workflow, }) } @@ -455,7 +474,11 @@ const AiReviewsTable: FC = props => {
Reviewer
- + {(row.run?.workflow ?? row.workflow)?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC ? ( + + ) : ( + + )} {row.title} @@ -473,6 +496,17 @@ const AiReviewsTable: FC = props => {
+
+
Review Date
+
+ {row.reviewDate + ? moment(row.reviewDate) + .local() + .format(TABLE_DATE_FORMAT) + : '-'} +
+
+ {hasConfig && ( <>
@@ -486,17 +520,6 @@ const AiReviewsTable: FC = props => { )} -
-
Review Date
-
- {row.reviewDate - ? moment(row.reviewDate) - .local() - .format(TABLE_DATE_FORMAT) - : '-'} -
-
-
Score
@@ -542,6 +565,22 @@ const AiReviewsTable: FC = props => { />
+ + {!shouldHideComments(row.run) && ( +
+
Comments
+
+ + + + {row.run?.commentsCount ?? 0} + + +
+
+ )}
))}
@@ -568,18 +607,19 @@ const AiReviewsTable: FC = props => { AI Reviewer + Review Date {hasConfig && Weight} {hasConfig && Min Score} - Review Date Score Result + Comments {!reviewerRows.length && loading && ( - Loading... + Loading... )} @@ -588,7 +628,11 @@ const AiReviewsTable: FC = props => {
- + {(row.run?.workflow || row.workflow)?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC || row.run?.id === '-1' ? ( + + ) : ( + + )} @@ -607,8 +651,6 @@ const AiReviewsTable: FC = props => { )}
- {hasConfig && {formatWeight(row.weight)}} - {hasConfig && {formatScore(row.minScore)}} {row.reviewDate && ( moment(row.reviewDate) @@ -616,6 +658,8 @@ const AiReviewsTable: FC = props => { .format(TABLE_DATE_FORMAT) )} + {hasConfig && {formatWeight(row.weight)}} + {hasConfig && {formatScore(row.minScore)}} {typeof row.score === 'number' ? ( row.workflowId ? ( @@ -654,6 +698,18 @@ const AiReviewsTable: FC = props => { } /> + + {!shouldHideComments(row.run) && ( + + + + {row.run?.commentsCount ?? 0} + + + )} + ))} diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.spec.tsx new file mode 100644 index 000000000..586ec44be --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.spec.tsx @@ -0,0 +1,123 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { ComponentProps } from 'react' +import { render, screen } from '@testing-library/react' + +import { ChallengeDetailsContent } from './ChallengeDetailsContent' + +const mockUseDownloadSubmission = jest.fn() + +jest.mock('~/libs/ui', () => ({ + LoadingSpinner: (props: { message?: string; overlay?: boolean }) => ( +
+ {props.message} +
+ ), +}), { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({ + aiReviewConfig: undefined, + challengeInfo: undefined, + myResources: [], + }), + } +}) + +jest.mock('../../hooks', () => ({ + useDownloadSubmission: () => mockUseDownloadSubmission(), + useRole: () => ({ + actionChallengeRole: undefined, + }), + useSubmissionDownloadAccess: () => ({ + currentMemberId: undefined, + }), +})) + +jest.mock('../../hooks/useFetchChallengeResults', () => ({ + useFetchChallengeResults: () => ({ + isLoading: false, + projectResults: [], + }), +})) + +jest.mock('./TabContentAiApproval', () => () => undefined) +jest.mock('./TabContentApproval', () => () => undefined) +jest.mock('./TabContentCheckpoint', () => () => undefined) +jest.mock('./TabContentIterativeReview', () => () => undefined) +jest.mock('./TabContentRegistration', () => ({ + __esModule: true, + default: () =>
Registration content
, +})) +jest.mock('./TabContentReview', () => () => undefined) +jest.mock('./TabContentScreening', () => () => undefined) +jest.mock('./TabContentSubmissions', () => () => undefined) +jest.mock('./TabContentWinners', () => () => undefined) +jest.mock('../TableNoRecord', () => ({ + TableNoRecord: (noRecordProps: { message?: string }) => ( +
{noRecordProps.message}
+ ), +})) + +const props: ComponentProps = { + approvalMinimumPassingScore: undefined, + approvalReviews: [], + checkpoint: [], + checkpointReview: [], + checkpointReviewMinimumPassingScore: undefined, + checkpointScreeningMinimumPassingScore: undefined, + isActiveChallenge: true, + isLoadingSubmission: false, + mappingReviewAppeal: {}, + postMortemMinimumPassingScore: undefined, + postMortemReviews: [], + review: [], + reviewMinimumPassingScore: undefined, + screening: [], + screeningMinimumPassingScore: undefined, + selectedTab: 'Registration', + submissions: [], + submitterReviews: [], +} + +describe('ChallengeDetailsContent', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseDownloadSubmission.mockReturnValue({ + downloadSubmission: jest.fn(), + isLoading: {}, + isLoadingBool: false, + }) + }) + + it('shows download-starting feedback only while a submission request is pending', () => { + mockUseDownloadSubmission.mockReturnValue({ + downloadSubmission: jest.fn(), + isLoading: { + 'submission-1': true, + }, + isLoadingBool: true, + }) + + const renderResult: ReturnType + = render() + + const indicator = screen.getByText('Download starting') + expect(indicator.dataset.overlay) + .toBe('true') + + mockUseDownloadSubmission.mockReturnValue({ + downloadSubmission: jest.fn(), + isLoading: { + 'submission-1': false, + }, + isLoadingBool: false, + }) + renderResult.rerender() + + expect(screen.queryByText('Download starting')) + .toBeNull() + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index 02da68b9c..d27d26f95 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -1,11 +1,11 @@ /* eslint-disable complexity */ /** - * Challenge Details Content. + * Renders the selected challenge phase and submission-download feedback. */ import { FC, ReactNode, useCallback, useContext, useMemo } from 'react' import { toast } from 'react-toastify' -import { ActionLoading } from '~/apps/admin/src/lib' +import { LoadingSpinner } from '~/libs/ui' import { ChallengeDetailContext } from '../../contexts' import { @@ -29,7 +29,6 @@ import { } from '../../hooks/useFetchChallengeResults' import { ITERATIVE_REVIEW, SUBMITTER } from '../../../config/index.config' import { TableNoRecord } from '../TableNoRecord' -import { hasIsLatestFlag } from '../../utils' import { isContestReviewPhaseSubmission, shouldIncludeInReviewPhase, @@ -118,11 +117,7 @@ const buildScreeningRows = ({ currentMemberId, }: BuildScreeningRowsParams): Screening[] => { if (actionChallengeRole === SUBMITTER && currentMemberId) { - const mySubmissions = screening.filter(entry => entry.memberId === currentMemberId) - - return hasIsLatestFlag(mySubmissions) - ? mySubmissions.filter(submission => submission.isLatest === true) - : mySubmissions + return screening.filter(entry => entry.memberId === currentMemberId) } return screening @@ -604,7 +599,9 @@ export const ChallengeDetailsContent: FC = (props: Props) => { renderSelectedTab() )} - {isDownloadingSubmissionBool && } + {isDownloadingSubmissionBool && ( + + )} ) } diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.spec.tsx new file mode 100644 index 000000000..749e704d2 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.spec.tsx @@ -0,0 +1,141 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' + +import { ChallengeDetailContext } from '../../contexts' +import type { + BackendSubmission, + ChallengeDetailContextModel, + ChallengeInfo, +} from '../../models' + +import { TabContentAiApproval } from './TabContentAiApproval' + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + } +}) + +jest.mock('react-router-dom', () => ({ + useNavigate: () => jest.fn(), +})) + +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
Loading
, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Table: (props: { + columns: Array<{ + columnId?: string + renderer?: (row: { submission: BackendSubmission }) => JSX.Element + }> + data: Array<{ submission: BackendSubmission }> + }) => { + const statusColumn = props.columns.find(column => column.columnId === 'status') + + return ( +
+ {props.data.map(row => ( +
+ {statusColumn?.renderer?.(row)} +
+ ))} +
+ ) + }, +}), { virtual: true }) + +jest.mock('../../hooks', () => ({ + useRole: () => ({ + isPrivilegedRole: false, + }), +})) + +jest.mock('../../hooks/useRolePermissions', () => ({ + useRolePermissions: () => ({ + ownedMemberIds: new Set(), + }), +})) + +jest.mock('../../hooks/useSubmissionDownloadAccess', () => ({ + useSubmissionDownloadAccess: () => ({ + getRestrictionMessageForMember: () => undefined, + isSubmissionDownloadRestricted: false, + isSubmissionDownloadRestrictedForMember: () => false, + restrictionMessage: '', + shouldRestrictSubmitterToOwnSubmission: false, + }), +})) + +jest.mock('../CollapsibleAiReviewsRow', () => ({ + CollapsibleAiReviewsRow: () =>
AI reviews
, +})) + +jest.mock('../common', () => ({ + renderSubmissionIdCell: () =>
Submission
, +})) + +jest.mock('../TableNoRecord', () => ({ + TableNoRecord: (props: { message: string }) =>
{props.message}
, +})) + +jest.mock('../TableWrapper', () => ({ + TableWrapper: (props: PropsWithChildren<{ className?: string }>) => ( +
{props.children}
+ ), +})) + +const challengeInfo = { + phases: [], + reviewers: [], +} as unknown as ChallengeInfo + +const challengeContext = { + aiReviewDecisionsBySubmissionId: {}, + challengeInfo, +} as unknown as ChallengeDetailContextModel + +/** + * Builds a minimal file-submission fixture for Approval status rendering tests. + * + * @param id - Unique submission identifier rendered by the table. + * @param virusScan - Whether the submission passed its virus scan. + * @returns A latest contest submission with the requested virus-scan result. + */ +function buildSubmission(id: string, virusScan: boolean): BackendSubmission { + return { + createdAt: '2026-07-08T02:45:00.000Z', + id, + isFileSubmission: true, + isLatest: true, + type: 'CONTEST_SUBMISSION', + virusScan, + } as BackendSubmission +} + +describe('TabContentAiApproval', () => { + it('shows an infected submission status instead of pending', () => { + render( + + + , + ) + + expect(screen.getByText('Infected')) + .toBeTruthy() + expect(screen.getByText('Pending')) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx index 48d9bec64..6dd8cae3b 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentAiApproval.tsx @@ -174,11 +174,15 @@ export const TabContentAiApproval: FC = (props: Props) => { columnId: 'status', label: 'Status', renderer: (row: SubmissionRowData) => { - const status = row.decision?.status ?? 'PENDING' + const status = row.submission.isFileSubmission !== false + && row.submission.virusScan === false + ? 'INFECTED' + : row.decision?.status ?? 'PENDING' const statusMap: Record = { ERROR: { className: styles.statusError, label: 'Error' }, FAILED: { className: styles.statusFailed, label: 'Failed' }, HUMAN_OVERRIDE: { className: styles.statusOverride, label: 'Override' }, + INFECTED: { className: styles.statusFailed, label: 'Infected' }, PASSED: { className: styles.statusPassed, label: 'Passed' }, PENDING: { className: styles.statusPending, label: 'Pending' }, } diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx index b97df3859..41e5ae051 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx @@ -94,11 +94,12 @@ const resolveSubmissionReviewScore = ( submission: SubmissionInfo, preferAggregateScore: boolean, ): number | undefined => { - const aggregateScore = parseScoreValue(submission.aggregateScore) - if (preferAggregateScore && aggregateScore !== undefined) { - return aggregateScore + if (preferAggregateScore) { + return parseScoreValue(submission.finalAggregateScore) } + const aggregateScore = parseScoreValue(submission.aggregateScore) + const reviewResultScores = Array.isArray(submission.reviews) ? submission.reviews .map(review => parseScoreValue(review?.score)) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.spec.tsx new file mode 100644 index 000000000..0e87a7767 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.spec.tsx @@ -0,0 +1,113 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render } from '@testing-library/react' + +import { ChallengeDetailContext } from '../../contexts' +import type { + ChallengeDetailContextModel, + ChallengeInfo, + Screening, +} from '../../models' + +import { TabContentScreening } from './TabContentScreening' + +const mockUseRole = jest.fn() +const mockTableSubmissionScreening = jest.fn() + +jest.mock('~/libs/core', () => ({ + getRatingColor: () => '#2a2a2a', +}), { virtual: true }) + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + } +}) + +jest.mock('../../hooks', () => ({ + useRole: () => mockUseRole(), +})) + +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
Loading
, +}), { virtual: true }) + +jest.mock('../TableNoRecord', () => ({ + TableNoRecord: (props: { message: string }) =>
{props.message}
, +})) + +jest.mock('../TableSubmissionScreening', () => ({ + TableSubmissionScreening: (props: { screenings: Screening[] }) => { + mockTableSubmissionScreening(props) + return
{props.screenings.length}
+ }, +})) + +const ownFailedScreening = { + challengeId: 'challenge-id', + createdAt: '2026-07-23T05:41:00.000Z', + memberId: 'member-current', + phaseName: 'Screening', + result: 'NO PASS', + reviewId: 'review-own', + score: '46.67', + submissionId: 'submission-own', +} as Screening + +const foreignFailedScreening = { + ...ownFailedScreening, + memberId: 'member-other', + reviewId: 'review-other', + submissionId: 'submission-other', +} as Screening + +const challengeInfo = { + status: 'Completed', +} as ChallengeInfo + +const challengeContext = { + challengeInfo, + myResources: [ + { + memberId: 'member-current', + roleName: 'Submitter', + }, + ], +} as ChallengeDetailContextModel + +describe('TabContentScreening', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseRole.mockReturnValue({ + actionChallengeRole: 'Submitter', + hasReviewerRole: false, + isPrivilegedRole: false, + reviewerResourceIds: new Set(), + screenerResourceIds: new Set(), + }) + }) + + it('shows a failed submitter their own screening result for a completed challenge', () => { + render( + + + , + ) + + expect(mockTableSubmissionScreening) + .toHaveBeenLastCalledWith(expect.objectContaining({ + screenings: [ownFailedScreening], + })) + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.tsx index e88c4afdb..d23a0c112 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.tsx @@ -87,10 +87,6 @@ export const TabContentScreening: FC = (props: Props) => { }) const canSeeAll = isPrivilegedRole || hasReviewerRole - if (isChallengeCompleted && !canSeeAll && !hasPassedScreeningThreshold) { - return [] - } - if (canSeeAll || (isChallengeCompleted && hasPassedScreeningThreshold)) { return phaseValidatedRows } diff --git a/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.spec.tsx b/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.spec.tsx new file mode 100644 index 000000000..e798e5397 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.spec.tsx @@ -0,0 +1,91 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' + +import { ChallengeDetailContext } from '../../contexts' +import type { + ChallengeDetailContextModel, + ReviewInfo, +} from '../../models' + +import { ChallengeLinksForAdmin } from './ChallengeLinksForAdmin' + +jest.mock('../../contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + + return { + ChallengeDetailContext: React.createContext({}), + } +}) + +jest.mock('../../hooks', () => ({ + useAppNavigate: () => jest.fn(), +})) + +jest.mock('../../utils', () => ({ + filterResources: () => [], + isReviewPhase: () => true, +})) + +jest.mock('../ConfirmModal', () => ({ + ConfirmModal: () => <>, +})) + +jest.mock('../DialogContactManager', () => ({ + DialogContactManager: () => <>, +})) + +jest.mock('../DialogPayments', () => ({ + DialogPayments: () => <>, +})) + +const challengeContext = { + challengeInfo: { + currentPhase: 'Review', + currentPhaseObject: { + id: 'review-phase', + isOpen: true, + name: 'Review', + }, + status: 'ACTIVE', + }, + myResources: [], +} as unknown as ChallengeDetailContextModel + +const reviewInfo = { + committed: false, + id: 'review-id', + phaseId: 'review-phase', +} as ReviewInfo + +describe('ChallengeLinksForAdmin', () => { + it('shows Reopen only after the review has been committed', () => { + const rendered = render( + + + , + ) + + expect(screen.queryByRole('button', { name: 'Reopen' })) + .toBeNull() + + rendered.rerender( + + + , + ) + + expect(screen.getByRole('button', { name: 'Reopen' })) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.tsx b/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.tsx index d044d59fa..d9441f8c3 100644 --- a/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.tsx +++ b/src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.tsx @@ -67,7 +67,7 @@ export const ChallengeLinksForAdmin: FC = (props: Props) => { ) const canShowReopenButton = useMemo(() => { - if (!props.reviewInfo?.id) { + if (!props.reviewInfo?.id || !props.reviewInfo.committed) { return false } @@ -105,6 +105,7 @@ export const ChallengeLinksForAdmin: FC = (props: Props) => { challengeInfo?.currentPhaseObject?.id, challengeInfo?.currentPhaseObject?.isOpen, challengeInfo?.status, + props.reviewInfo?.committed, props.reviewInfo?.id, props.reviewInfo?.phaseId, ]) diff --git a/src/apps/review/src/lib/components/NavTabs/NavTabs.spec.tsx b/src/apps/review/src/lib/components/NavTabs/NavTabs.spec.tsx new file mode 100644 index 000000000..bb83d372f --- /dev/null +++ b/src/apps/review/src/lib/components/NavTabs/NavTabs.spec.tsx @@ -0,0 +1,120 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Route, + Routes, + useLocation, +} from 'react-router-dom' + +import NavTabs from './NavTabs' + +jest.mock('~/config', () => ({ + AppSubdomain: { + review: 'review', + }, + EnvironmentConfig: { + REVIEW: { + OPPORTUNITIES_URL: 'https://example.com/opportunities', + }, + SUBDOMAIN: 'review', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/shared/lib/hooks', () => ({ + useClickOutside: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ExternalLinkIcon: () => external-link, + }, +}), { + virtual: true, +}) + +jest.mock('../../contexts', () => { + const React = jest.requireActual('react') as typeof import('react') + + return { + ReviewAppContext: React.createContext({ + loginUserInfo: { + roles: ['administrator'], + }, + }), + } +}) + +jest.mock('./config', () => ({ + getTabIdFromPathName: () => 'active-challenges', + getTabsConfig: () => [ + { + id: 'active-challenges', + title: 'Active Challenges', + }, + { + id: 'past-challenges', + title: 'Past Challenges', + }, + { + id: 'open-opportunities', + title: 'Open Opportunities', + url: 'https://example.com/opportunities', + }, + { + id: 'scorecard', + title: 'Scorecards', + }, + ], +})) + +const LocationViewer = (): JSX.Element => { + const location = useLocation() + + return
{location.pathname}
+} + +function renderNavTabs(pathname: string): void { + render( + + + + + + + )} + /> + + , + ) +} + +describe('Review NavTabs', () => { + it('navigates internal tabs from the app root when rendered in a wildcard route', () => { + renderNavTabs('/active-challenges/challenge-id/challenge-details/reviews/submission-id') + + const destinations: Array<[string, string]> = [ + ['Past Challenges', '/past-challenges'], + ['Scorecards', '/scorecard'], + ['Active Challenges', '/active-challenges'], + ] + + destinations.forEach(([tabTitle, expectedPath]) => { + fireEvent.click(screen.getByText(tabTitle)) + + expect(screen.getByTestId('location-pathname').textContent) + .toBe(expectedPath) + }) + }) +}) diff --git a/src/apps/review/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/review/src/lib/components/NavTabs/NavTabs.tsx index e73136817..f21fe45b8 100644 --- a/src/apps/review/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/review/src/lib/components/NavTabs/NavTabs.tsx @@ -19,6 +19,7 @@ import { IconOutline } from '~/libs/ui' import { ReviewAppContext } from '../../contexts' import { ReviewAppContextModel } from '../../models' +import { rootRoute } from '../../../config/routes.config' import { getTabIdFromPathName, getTabsConfig } from './config' import styles from './NavTabs.module.scss' @@ -70,7 +71,7 @@ const NavTabs: FC = () => { setActiveTab(tabId) setIsOpen(false) - navigate(tabId) + navigate(`${rootRoute}/${tabId}`) }, [navigate], ) diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index 0b03cf92e..2af31bd5b 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -5,7 +5,7 @@ import { IconAiReview } from '~/apps/review/src/lib/assets/icons' import { ReviewsContextModel, ScorecardQuestion } from '~/apps/review/src/lib/models' import { createFeedbackComment, updateRunItemScore } from '~/apps/review/src/lib/services' import { getAiReviewDecisionsCacheKey } from '~/apps/review/src/lib/services/aiReview.service' -import { getAiWorkflowRunsCacheKey } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' +import { AiWorkflowReviewMethod, getAiWorkflowRunsCacheKey } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext' import { getScoreResponseOptions } from '~/apps/review/src/lib/utils' import { EnvironmentConfig } from '~/config' @@ -68,6 +68,7 @@ const renderAiFeedbackContent = ( onShowReply: () => void, onSubmitReply: (content: string) => Promise, handleCloseReply: () => void, + isDeterministicWorkflow: boolean, ): JSX.Element => ( } @@ -138,14 +139,11 @@ const renderAiFeedbackContent = ( + - {commentsArr.length > 0 && ( - - )} - - {showReply && ( + {showReply && !isDeterministicWorkflow && ( = props => { submissionId, aiReviewConfig, }: ReviewsContextModel = useReviewsContext() + + const isDeterministicWorkflow = workflowRun?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC const { isPrivilegedRole }: { isPrivilegedRole: boolean } = useRole() const [showReply, setShowReply] = useState(false) const [isUpdatingScore, setIsUpdatingScore] = useState(false) @@ -182,17 +182,20 @@ const AiFeedback: FC = props => { const commentsArr: any[] = (feedback?.comments) || [] const onShowReply = useCallback(() => { + if (isDeterministicWorkflow) return setShowReply(prevShowReply => !prevShowReply) - }, []) + }, [isDeterministicWorkflow]) const onSubmitReply = useCallback(async (content: string) => { + if (isDeterministicWorkflow) return + await createFeedbackComment(workflowId as string, workflowRun?.id as string, feedback?.id, { content, }) // eslint-disable-next-line max-len await mutate(`${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${workflowRun?.id}/items?[${workflowRun?.status}]`) setShowReply(false) - }, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id]) + }, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id, isDeterministicWorkflow]) const isYesNo = props.question.type === 'YES_NO' const hasQuestionScoreEditAccess = isPrivilegedRole @@ -296,6 +299,7 @@ const AiFeedback: FC = props => { onShowReply, onSubmitReply, handleCloseReply, + isDeterministicWorkflow, ) } diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackComments/AiFeedbackComment.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackComments/AiFeedbackComment.tsx index 438f6512d..55256754a 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackComments/AiFeedbackComment.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackComments/AiFeedbackComment.tsx @@ -6,6 +6,7 @@ import moment from 'moment' import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext' import { createFeedbackComment, updateRunItemComment } from '~/apps/review/src/lib/services' import { AiFeedbackItem, ReviewsContextModel } from '~/apps/review/src/lib/models' +import { AiWorkflowReviewMethod } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { EnvironmentConfig } from '~/config' import { AiFeedbackActions } from '../AiFeedbackActions/AiFeedbackActions' @@ -25,6 +26,7 @@ export const AiFeedbackComment: FC = props => { const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext() const [editMode, setEditMode] = useState(false) const [showReply, setShowReply] = useState(false) + const isDeterministicWorkflow = workflowRun?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC const onPressEdit = useCallback(() => { setEditMode(true) @@ -32,6 +34,8 @@ export const AiFeedbackComment: FC = props => { }, []) const onSubmitReply = useCallback(async (content: string, comment: AiFeedbackCommentType) => { + if (isDeterministicWorkflow) return + await createFeedbackComment(workflowId as string, workflowRun?.id as string, props.feedback?.id, { content, parentId: comment.id, @@ -39,16 +43,18 @@ export const AiFeedbackComment: FC = props => { // eslint-disable-next-line max-len await mutate(`${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${workflowRun?.id}/items?[${workflowRun?.status}]`) setShowReply(false) - }, [workflowId, workflowRun?.id, props.feedback?.id]) + }, [workflowId, workflowRun?.id, props.feedback?.id, isDeterministicWorkflow]) const onEditReply = useCallback(async (content: string, comment: AiFeedbackCommentType) => { + if (isDeterministicWorkflow) return + await updateRunItemComment(workflowId as string, workflowRun?.id as string, props.feedback?.id, comment.id, { content, }) // eslint-disable-next-line max-len await mutate(`${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${workflowRun?.id}/items?[${workflowRun?.status}]`) setEditMode(false) - }, [workflowId, workflowRun?.id, props.feedback?.id]) + }, [workflowId, workflowRun?.id, props.feedback?.id, isDeterministicWorkflow]) return (
= props => { feedback={props.feedback} comment={props.comment} actionType='comment' - onPressEdit={onPressEdit} + onPressReply={isDeterministicWorkflow ? undefined : function () { setShowReply(prev => !prev) }} + onPressEdit={isDeterministicWorkflow ? undefined : onPressEdit} /> { showReply && ( diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.spec.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.spec.tsx new file mode 100644 index 000000000..9afff9c08 --- /dev/null +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.spec.tsx @@ -0,0 +1,135 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen, waitFor } from '@testing-library/react' + +import type { + ReviewItemInfo, + ScorecardQuestion, +} from '../../../../../../models' +import type { ScorecardViewerContextValue } from '../../../ScorecardViewer.context' + +import ReviewManagerComment from './ReviewManagerComment' + +const mockUseScorecardViewerContext = jest.fn() + +jest.mock('../../../ScorecardViewer.context', () => ({ + useScorecardViewerContext: () => mockUseScorecardViewerContext(), +})) + +jest.mock('~/apps/review/src/lib/assets/icons', () => ({ + IconPhaseReview: () => , +}), { virtual: true }) + +jest.mock('../../../../../../utils', () => { + const Yup: typeof import('yup') = jest.requireActual('yup') + + return { + formManagerCommentSchema: Yup.object({ + finalScore: Yup.string() + .required(), + response: Yup.string() + .required(), + }), + getScoreResponseOptions: () => [ + { + label: '9', + value: '9', + }, + ], + } +}) + +jest.mock('../../../../../FieldMarkdownEditor', () => ({ + FieldMarkdownEditor: () =>