From f8469f2751828492e38fe453d4f77015674c1f39 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 24 Jul 2026 07:46:35 +1000 Subject: [PATCH 01/77] Add dashboard custom ranges and bottom timelines --- .../src/lib/services/reports.service.ts | 2 +- .../src/pages/dashboards/DashboardChart.tsx | 4 +- .../dashboards/DashboardDetailPage.spec.tsx | 129 ++++++- .../pages/dashboards/DashboardDetailPage.tsx | 335 ++++++++++++++++-- .../pages/dashboards/Dashboards.module.scss | 114 +++++- .../pages/dashboards/dashboard.config.spec.ts | 21 ++ .../src/pages/dashboards/dashboard.config.ts | 4 +- .../pages/dashboards/dashboard.utils.spec.ts | 56 +++ .../src/pages/dashboards/dashboard.utils.ts | 183 ++++++++++ 9 files changed, 807 insertions(+), 41 deletions(-) create mode 100644 src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index 48cff6f71..d7a7cef30 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -416,7 +416,7 @@ export const fetchDashboards = ( ) /** - * Fetches one dashboard drill-down for the requested six-month UTC range. + * Fetches one dashboard drill-down for the requested UTC range. * * The generic slug maps the result to its exact dashboard response type. * diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx index 05888d4f3..5602f2c28 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx @@ -39,8 +39,8 @@ function getSeriesValue(month: DashboardMonth, key: string): number { * Renders the configured Highcharts visualization for a dashboard dataset. * * @param props Dashboard slug, monthly data, and compact-card presentation flag. - * @returns A stacked column, stacked bar, or grouped bar chart with an - * accessible monthly data table. + * @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 => { diff --git a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx index 540010bab..ebaf7224f 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.spec.tsx @@ -37,13 +37,13 @@ jest.mock('~/libs/ui', () => { return { Button: ( props: PropsWithChildren< - Pick, 'disabled' | 'onClick'> + Pick, 'disabled' | 'onClick' | 'type'> >, ): JSX.Element => ( @@ -159,13 +159,15 @@ describe('Dashboard detail page', () => { .toBeInTheDocument() expect(screen.getByRole('table', { name: 'New Signups by Month monthly data' })) .toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Next 6 Months' })) + expect(screen.getByText('6 months selected')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next Period' })) .toBeDisabled() expect(screen.getByRole('link', { name: 'Back to Dashboards' })) .toHaveAttribute('href', '/reports/dashboards') jest.setSystemTime(new Date('2026-08-01T00:01:00.000Z')) - fireEvent.click(screen.getByRole('button', { name: 'Previous 6 Months' })) + fireEvent.click(screen.getByRole('button', { name: 'Previous Period' })) await flushAsyncUpdates() expect(mockedFetchDashboard) @@ -193,6 +195,125 @@ describe('Dashboard detail page', () => { ) }) + it('applies and exports an inclusive custom month range without fetching drafts', async () => { + renderDetailRoute('new-signups') + await flushAsyncUpdates() + + fireEvent.change(screen.getByLabelText('Date Range'), { + target: { value: 'custom' }, + }) + + const startMonth = screen.getByLabelText('Start month') + const endMonth = screen.getByLabelText('End month') + + expect(startMonth) + .toHaveValue('2026-02') + expect(endMonth) + .toHaveValue('2026-07') + expect(endMonth) + .toHaveAttribute('max', '2026-07') + + fireEvent.change(startMonth, { + target: { value: '2025-07' }, + }) + fireEvent.change(endMonth, { + target: { value: '2026-06' }, + }) + + expect(mockedFetchDashboard) + .toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Apply' })) + await flushAsyncUpdates() + + expect(mockedFetchDashboard) + .toHaveBeenLastCalledWith('new-signups', { + endDate: '2026-07-01', + startDate: '2025-07-01', + }) + expect(screen.getByText('Jul ’25 – Jun ’26')) + .toBeInTheDocument() + expect(screen.getByText('12 months selected')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next Period' })) + .toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Download CSV' })) + await flushAsyncUpdates() + + expect(mockedDownloadDashboardCsv) + .toHaveBeenCalledWith('new-signups', { + endDate: '2026-07-01', + startDate: '2025-07-01', + }) + expect(mockedDownloadBlobFile) + .toHaveBeenCalledWith( + expect.any(Blob), + 'new-signups-2025-07-01-to-2026-07-01.csv', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Previous Period' })) + await flushAsyncUpdates() + + expect(mockedFetchDashboard) + .toHaveBeenLastCalledWith('new-signups', { + endDate: '2025-07-01', + startDate: '2024-07-01', + }) + expect(screen.getByLabelText('Start month')) + .toHaveValue('2024-07') + expect(screen.getByLabelText('End month')) + .toHaveValue('2025-06') + expect(screen.getByRole('button', { name: 'Next Period' })) + .toBeEnabled() + + fireEvent.change(screen.getByLabelText('Date Range'), { + target: { value: 'six-months' }, + }) + await flushAsyncUpdates() + + expect(mockedFetchDashboard) + .toHaveBeenLastCalledWith('new-signups', { + endDate: '2026-08-01', + startDate: '2026-02-01', + }) + expect(screen.queryByLabelText('Start month')) + .not.toBeInTheDocument() + }) + + it('keeps the applied report when a custom range is reversed or in the future', async () => { + renderDetailRoute('new-signups') + await flushAsyncUpdates() + + fireEvent.change(screen.getByLabelText('Date Range'), { + target: { value: 'custom' }, + }) + fireEvent.change(screen.getByLabelText('Start month'), { + target: { value: '2026-07' }, + }) + fireEvent.change(screen.getByLabelText('End month'), { + target: { value: '2026-06' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Apply' })) + + expect(screen.getByRole('alert')) + .toHaveTextContent('Start month must be on or before end month.') + expect(mockedFetchDashboard) + .toHaveBeenCalledTimes(1) + + fireEvent.change(screen.getByLabelText('End month'), { + target: { value: '2026-08' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Apply' })) + + expect(screen.getByRole('alert')) + .toHaveTextContent('End month cannot be later than Jul ’26.') + expect(mockedFetchDashboard) + .toHaveBeenCalledTimes(1) + expect(screen.getByText('Feb ’26 – Jul ’26')) + .toBeInTheDocument() + }) + 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 68677a1d8..2ee58f3ca 100644 --- a/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx +++ b/src/apps/reports/src/pages/dashboards/DashboardDetailPage.tsx @@ -1,6 +1,8 @@ import { + ChangeEvent, ComponentType, FC, + FormEvent, SVGProps, useCallback, useEffect, @@ -40,10 +42,15 @@ import { } from './dashboard.config' import { buildDashboardCsvFileName, + buildDashboardRangeFromMonths, + DashboardRange, formatDashboardMonth, formatDashboardRangeLabel, formatPercentage, getDashboardRange, + getDashboardRangeMonthCount, + getDashboardRangeMonths, + shiftDashboardRange, } from './dashboard.utils' import styles from './Dashboards.module.scss' @@ -59,6 +66,8 @@ type DashboardDetailContentProps = { dashboard: DashboardSlug } +type DashboardRangeMode = 'custom' | 'six-months' + /** * Formats an all-time metric as a locale-aware integer. * @@ -82,6 +91,46 @@ function formatPeakMonth(value: string | null): string { return value ? formatDashboardMonth(value) : '—' } +/** + * Formats the selected dashboard period size for the date-range indicator. + * + * @param monthCount Positive number of calendar months in the applied range. + * @returns A grammatically correct label such as `1 month selected`. + * @throws Does not throw. + */ +function formatSelectedMonthCount(monthCount: number): string { + return `${monthCount} ${monthCount === 1 ? 'month' : 'months'} selected` +} + +/** + * Builds a custom dashboard range that does not extend beyond available months. + * + * @param startMonth Inclusive custom start month in `YYYY-MM` format. + * @param endMonth Inclusive custom end month in `YYYY-MM` format. + * @param latestRange Latest available half-open UTC dashboard range. + * @param latestEndMonth Latest available inclusive month in `YYYY-MM` format. + * @returns Validated custom range ready for data and CSV requests. + * @throws RangeError when the month selection is invalid or extends into the future. + */ +function buildAvailableDashboardRange( + startMonth: string, + endMonth: string, + latestRange: DashboardRange, + latestEndMonth: string, +): DashboardRange { + const customRange = buildDashboardRangeFromMonths(startMonth, endMonth) + + if (customRange.endDate > latestRange.endDate) { + throw new RangeError( + `End month cannot be later than ${ + formatDashboardMonth(`${latestEndMonth}-01`) + }.`, + ) + } + + return customRange +} + /** * Builds the dashboard-specific metric cards shown beside the enlarged chart. * @@ -211,17 +260,38 @@ function buildDashboardMetrics(response: DashboardResponse): DashboardMetric[] { */ const DashboardDetailContent: FC = props => { const definition = dashboardDefinitions[props.dashboard] - const [periodOffset, setPeriodOffset] = useState(0) const rangeReferenceDate = useMemo(() => new Date(), []) - const range = useMemo( - () => getDashboardRange(periodOffset, rangeReferenceDate), - [periodOffset, rangeReferenceDate], + const latestRange = useMemo( + () => getDashboardRange(0, rangeReferenceDate), + [rangeReferenceDate], + ) + const latestMonthSelection = useMemo( + () => getDashboardRangeMonths(latestRange), + [latestRange], ) + const [range, setRange] = useState(latestRange) + const [rangeMode, setRangeMode] = useState('six-months') + const [customStartMonth, setCustomStartMonth] = useState( + latestMonthSelection.startMonth, + ) + const [customEndMonth, setCustomEndMonth] = useState( + latestMonthSelection.endMonth, + ) + const [rangeErrorMessage, setRangeErrorMessage] = useState() const [response, setResponse] = useState() const [errorMessage, setErrorMessage] = useState() const [isDownloading, setIsDownloading] = useState(false) const [isLoading, setIsLoading] = useState(true) const [refreshKey, setRefreshKey] = useState(0) + const rangeMonthCount = useMemo( + () => getDashboardRangeMonthCount(range), + [range], + ) + const nextRange = useMemo( + () => shiftDashboardRange(range, 1), + [range], + ) + const canNavigateNext = nextRange.endDate <= latestRange.endDate useEffect(() => { let isActive = true @@ -256,14 +326,144 @@ const DashboardDetailContent: FC = props => { } }, [props.dashboard, range, refreshKey]) - const handlePreviousPeriod = useCallback(() => { - setPeriodOffset(current => current - 1) + /** + * Applies a month-aligned range and mirrors it into the custom input draft. + * + * @param nextSelectedRange Half-open UTC range to display and export. + * @returns Nothing. + * @throws Propagates invalid month-boundary errors from the range utility. + */ + const applySelectedRange = useCallback(( + nextSelectedRange: DashboardRange, + ): void => { + const monthSelection = getDashboardRangeMonths(nextSelectedRange) + + setRange(nextSelectedRange) + setCustomStartMonth(monthSelection.startMonth) + setCustomEndMonth(monthSelection.endMonth) }, []) + /** + * Moves the report backward by its currently selected month count. + * + * @returns Nothing. + * @throws Does not throw for the validated applied range. + */ + const handlePreviousPeriod = useCallback((): void => { + setRangeErrorMessage(undefined) + applySelectedRange(shiftDashboardRange(range, -1)) + }, [applySelectedRange, range]) + + /** + * Moves the report forward by its selected month count when fully available. + * + * @returns Nothing. + * @throws Does not throw for the validated applied range. + */ const handleNextPeriod = useCallback(() => { - setPeriodOffset(current => Math.min(current + 1, 0)) + if (!canNavigateNext) { + return + } + + setRangeErrorMessage(undefined) + applySelectedRange(nextRange) + }, [ + applySelectedRange, + canNavigateNext, + nextRange, + ]) + + /** + * Changes between the six-month view and editable custom month controls. + * + * Returning to the six-month view immediately restores the latest range; + * entering custom mode keeps the currently displayed range as the draft. + * + * @param event Native range-mode selection event. + * @returns Nothing. + * @throws Does not throw. + */ + const handleRangeModeChange = useCallback(( + event: ChangeEvent, + ): void => { + const nextMode = event.target.value as DashboardRangeMode + + setRangeMode(nextMode) + setRangeErrorMessage(undefined) + + if (nextMode === 'six-months') { + applySelectedRange(latestRange) + } + }, [ + applySelectedRange, + latestRange, + ]) + + /** + * Updates the unapplied custom start-month draft. + * + * @param event Native month input change event. + * @returns Nothing. + * @throws Does not throw. + */ + const handleCustomStartMonthChange = useCallback(( + event: ChangeEvent, + ): void => { + setCustomStartMonth(event.target.value) + setRangeErrorMessage(undefined) }, []) + /** + * Updates the unapplied custom end-month draft. + * + * @param event Native month input change event. + * @returns Nothing. + * @throws Does not throw. + */ + const handleCustomEndMonthChange = useCallback(( + event: ChangeEvent, + ): void => { + setCustomEndMonth(event.target.value) + setRangeErrorMessage(undefined) + }, []) + + /** + * Validates and applies the inclusive custom month selection. + * + * @param event Custom date-range form submission event. + * @returns Nothing. + * @throws Does not throw. Validation failures are rendered beside the inputs. + */ + const handleApplyCustomRange = useCallback(( + event: FormEvent, + ): void => { + event.preventDefault() + + try { + const customRange = buildAvailableDashboardRange( + customStartMonth, + customEndMonth, + latestRange, + latestMonthSelection.endMonth, + ) + + setRangeErrorMessage(undefined) + applySelectedRange(customRange) + } catch (error) { + setRangeErrorMessage( + error instanceof Error && error.message + ? error.message + : 'Choose a valid dashboard month range.', + ) + } + }, [ + applySelectedRange, + customEndMonth, + customStartMonth, + latestMonthSelection.endMonth, + latestRange.endDate, + ]) + const handleRetry = useCallback(() => { setRefreshKey(current => current + 1) }, []) @@ -310,48 +510,129 @@ const DashboardDetailContent: FC = props => {

{definition.subtitle}

-
+
+ +
+ + +
+
+ + + {rangeMode === 'custom' && ( + <> + + to + + + + )} + + {rangeErrorMessage && ( + + {rangeErrorMessage} + + )} +
+ +
{formatDashboardRangeLabel(range)} - - {periodOffset === 0 - ? 'Showing latest 6 months' - : 'Showing previous 6-month period'} - + {formatSelectedMonthCount(rangeMonthCount)}
- -
- +
{errorMessage && !response && (
diff --git a/src/apps/reports/src/pages/dashboards/Dashboards.module.scss b/src/apps/reports/src/pages/dashboards/Dashboards.module.scss index e011c30d6..88358f516 100644 --- a/src/apps/reports/src/pages/dashboards/Dashboards.module.scss +++ b/src/apps/reports/src/pages/dashboards/Dashboards.module.scss @@ -31,7 +31,7 @@ } .headerActions, -.periodControls { +.detailActions { display: flex; flex-wrap: wrap; justify-content: flex-end; @@ -170,8 +170,82 @@ min-width: 260px; } -.periodControls { - flex: 1; +.rangePanel { + display: flex; + min-height: 76px; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 14px 16px; + border: 1px solid #dce1eb; + border-radius: 8px; + background: #f8f9fb; +} + +.rangeForm, +.periodNavigation { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.rangeForm { + min-width: 0; +} + +.rangeModeField { + display: flex; + align-items: center; + gap: 10px; + color: #111b46; + font-size: 13px; + font-weight: 700; + + select { + min-width: 138px; + } +} + +.rangeModeField select, +.monthField input { + height: 40px; + padding: 0 12px; + border: 1px solid #bfc8da; + border-radius: 6px; + outline: none; + background: #fff; + color: #111b46; + font-family: inherit; + font-size: 13px; + font-weight: 600; + + &:focus { + border-color: #0f62fe; + box-shadow: 0 0 0 2px rgba(15, 98, 254, 0.15); + } +} + +.monthField input { + width: 154px; +} + +.rangeSeparator { + color: #34406b; + font-size: 14px; + font-weight: 600; +} + +.rangeInputError { + width: 100%; + color: #c62828; + font-size: 12px; + line-height: 16px; +} + +.periodNavigation { + flex: 0 0 auto; + justify-content: flex-end; } .periodIndicator { @@ -374,7 +448,17 @@ flex-direction: column; } - .periodControls { + .detailActions { + width: 100%; + justify-content: flex-start; + } + + .rangePanel { + align-items: flex-start; + flex-direction: column; + } + + .periodNavigation { width: 100%; justify-content: flex-start; } @@ -420,7 +504,9 @@ } .headerActions, - .periodControls { + .detailActions, + .rangeForm, + .periodNavigation { width: 100%; align-items: stretch; flex-direction: column; @@ -430,6 +516,24 @@ } } + .rangePanel { + padding: 14px; + } + + .rangeModeField { + align-items: stretch; + flex-direction: column; + } + + .rangeModeField select, + .monthField input { + width: 100%; + } + + .rangeSeparator { + text-align: center; + } + .periodIndicator { justify-content: flex-start; padding: 8px 2px; diff --git a/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts b/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts new file mode 100644 index 000000000..a7f0a093b --- /dev/null +++ b/src/apps/reports/src/pages/dashboards/dashboard.config.spec.ts @@ -0,0 +1,21 @@ +import { dashboardDefinitions } from './dashboard.config' + +describe('dashboard chart definitions', () => { + it('uses bottom-timeline column charts for every report', () => { + expect(dashboardDefinitions['new-signups'].chartType) + .toBe('column') + expect(dashboardDefinitions['members-paid'].chartType) + .toBe('column') + expect(dashboardDefinitions['challenge-participation'].chartType) + .toBe('column') + }) + + it('keeps the signup and payment series stacked and participation grouped', () => { + expect(dashboardDefinitions['new-signups'].stacked) + .toBe(true) + expect(dashboardDefinitions['members-paid'].stacked) + .toBe(true) + expect(dashboardDefinitions['challenge-participation'].stacked) + .toBe(false) + }) +}) diff --git a/src/apps/reports/src/pages/dashboards/dashboard.config.ts b/src/apps/reports/src/pages/dashboards/dashboard.config.ts index beff717ff..8d584cd14 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.config.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.config.ts @@ -30,7 +30,7 @@ export type DashboardDefinition = { export const dashboardDefinitions: Record = { 'challenge-participation': { - chartType: 'bar', + chartType: 'column', index: 3, series: [ { @@ -50,7 +50,7 @@ export const dashboardDefinitions: Record = title: 'Challenge Registrants vs Submitters', }, 'members-paid': { - chartType: 'bar', + chartType: 'column', index: 2, series: [ { 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 563b70d39..49de8d0c4 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.utils.spec.ts @@ -1,10 +1,14 @@ import { buildDashboardCsvFileName, + buildDashboardRangeFromMonths, formatCompactInteger, formatDashboardMonth, formatDashboardRangeLabel, formatPercentage, getDashboardRange, + getDashboardRangeMonthCount, + getDashboardRangeMonths, + shiftDashboardRange, } from './dashboard.utils' describe('dashboard date range utilities', () => { @@ -67,6 +71,58 @@ describe('dashboard date range utilities', () => { expect(() => getDashboardRange(-0.5, julyReference)) .toThrow('Dashboard period offset must be an integer.') }) + + it('converts an inclusive custom month selection to exclusive API dates', () => { + expect(buildDashboardRangeFromMonths('2025-07', '2026-06')) + .toEqual({ + endDate: '2026-07-01', + startDate: '2025-07-01', + }) + expect(buildDashboardRangeFromMonths('2025-12', '2025-12')) + .toEqual({ + endDate: '2026-01-01', + startDate: '2025-12-01', + }) + }) + + it('counts, exposes, and shifts the selected calendar months as one period', () => { + const range = { + endDate: '2026-07-01', + startDate: '2025-07-01', + } + + expect(getDashboardRangeMonths(range)) + .toEqual({ + endMonth: '2026-06', + startMonth: '2025-07', + }) + expect(getDashboardRangeMonthCount(range)) + .toBe(12) + expect(shiftDashboardRange(range, -1)) + .toEqual({ + endDate: '2025-07-01', + startDate: '2024-07-01', + }) + }) + + it('rejects incomplete, reversed, and partial-month custom ranges', () => { + expect(() => buildDashboardRangeFromMonths('', '2026-06')) + .toThrow('Choose both a start and end month.') + expect(() => buildDashboardRangeFromMonths('2026-07', '2026-06')) + .toThrow('Start month must be on or before end month.') + expect(() => buildDashboardRangeFromMonths('2026-13', '2027-01')) + .toThrow('Dashboard months must be valid calendar months.') + expect(() => getDashboardRangeMonthCount({ + endDate: '2026-07-15', + startDate: '2025-07-01', + })) + .toThrow('Dashboard month ranges must use first-of-month boundaries.') + expect(() => shiftDashboardRange({ + endDate: '2026-07-01', + startDate: '2025-07-01', + }, 0.5)) + .toThrow('Dashboard period offset must be an integer.') + }) }) describe('dashboard labels and metric formatting', () => { diff --git a/src/apps/reports/src/pages/dashboards/dashboard.utils.ts b/src/apps/reports/src/pages/dashboards/dashboard.utils.ts index 1b5d277c1..daa7d05ea 100644 --- a/src/apps/reports/src/pages/dashboards/dashboard.utils.ts +++ b/src/apps/reports/src/pages/dashboards/dashboard.utils.ts @@ -1,5 +1,6 @@ const DASHBOARD_PERIOD_MONTHS = 6 const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ +const ISO_MONTH_PATTERN = /^(\d{4})-(\d{2})$/ const MONTH_LABELS = [ 'Jan', 'Feb', @@ -38,6 +39,14 @@ export interface DashboardRange { startDate: string } +/** + * Inclusive UTC month keys displayed by the dashboard range controls. + */ +export interface DashboardMonthSelection { + endMonth: string + startMonth: string +} + /** * Creates a new UTC month-start date a number of calendar months away. * @@ -115,6 +124,66 @@ function parseDashboardIsoDate(isoDate: string): Date { return parsed } +/** + * Parses and validates a dashboard month input without local-time conversion. + * + * @param monthKey Month input in exact `YYYY-MM` format. + * @returns A date at midnight UTC on the first day of the selected month. + * @throws RangeError when the value is missing or is not a real calendar month. + * + * Custom range controls use this parser before converting their inclusive end + * month to the API's exclusive date boundary. + */ +function parseDashboardMonthKey(monthKey: string): Date { + const match = ISO_MONTH_PATTERN.exec(monthKey) + + if (!match) { + throw new RangeError('Dashboard months must use YYYY-MM format.') + } + + const [, year, month] = match + const parsed = new Date(Date.UTC( + Number(year), + Number(month) - 1, + 1, + )) + + if (toIsoDate(parsed) + .slice(0, 7) !== monthKey) { + throw new RangeError('Dashboard months must be valid calendar months.') + } + + return parsed +} + +/** + * Validates a dashboard range used by month-granular navigation. + * + * @param range Inclusive start and exclusive end dates at UTC month boundaries. + * @returns Parsed UTC boundaries for calendar-month calculations. + * @throws RangeError when a date is invalid, not a month start, or the range is empty. + * + * Month counts and period shifts share this validation so they cannot silently + * truncate partial-month API ranges. + */ +function parseDashboardMonthRange(range: DashboardRange): { + endDate: Date + startDate: Date +} { + const startDate = parseDashboardIsoDate(range.startDate) + const endDate = parseDashboardIsoDate(range.endDate) + + if (startDate.getUTCDate() !== 1 || endDate.getUTCDate() !== 1) { + throw new RangeError('Dashboard month ranges must use first-of-month boundaries.') + } + + if (endDate.getTime() <= startDate.getTime()) { + throw new RangeError('Dashboard range end date must be after its start date.') + } + + return { endDate, startDate } +} + /** * Resolves a date-like value for a UTC dashboard month label. * @@ -227,6 +296,120 @@ export function getDashboardRange( } } +/** + * Converts inclusive month input values into an API dashboard range. + * + * @param startMonth First selected month in `YYYY-MM` format. + * @param endMonth Last selected month in `YYYY-MM` format. + * @returns A UTC range whose start is inclusive and whose end is the first day + * after the selected end month. + * @throws RangeError when either month is missing or invalid, or the end month + * precedes the start month. + * + * For example, `2025-07` through `2026-06` becomes + * `2025-07-01` through the exclusive boundary `2026-07-01`. + */ +export function buildDashboardRangeFromMonths( + startMonth: string, + endMonth: string, +): DashboardRange { + if (!startMonth || !endMonth) { + throw new RangeError('Choose both a start and end month.') + } + + const startDate = parseDashboardMonthKey(startMonth) + const inclusiveEndDate = parseDashboardMonthKey(endMonth) + + if (inclusiveEndDate.getTime() < startDate.getTime()) { + throw new RangeError('Start month must be on or before end month.') + } + + return { + endDate: toIsoDate(addUtcMonths(inclusiveEndDate, 1)), + startDate: toIsoDate(startDate), + } +} + +/** + * Returns the inclusive month keys represented by an API dashboard range. + * + * @param range Inclusive start and exclusive end dates at UTC month boundaries. + * @returns Start and inclusive end values suitable for native month inputs. + * @throws RangeError when the range is invalid or uses partial-month boundaries. + * + * Range navigation uses these values to keep the editable custom-range draft + * synchronized with the data currently shown. + */ +export function getDashboardRangeMonths( + range: DashboardRange, +): DashboardMonthSelection { + const { + endDate, + startDate, + }: { endDate: Date, startDate: Date } = parseDashboardMonthRange(range) + + return { + endMonth: toIsoDate(addUtcMonths(endDate, -1)) + .slice(0, 7), + startMonth: toIsoDate(startDate) + .slice(0, 7), + } +} + +/** + * Counts the calendar months represented by an API dashboard range. + * + * @param range Inclusive start and exclusive end dates at UTC month boundaries. + * @returns Positive number of selected calendar months. + * @throws RangeError when the range is invalid or uses partial-month boundaries. + * + * Detail dashboards display this count and use it as the custom period size. + */ +export function getDashboardRangeMonthCount(range: DashboardRange): number { + const { + endDate, + startDate, + }: { endDate: Date, startDate: Date } = parseDashboardMonthRange(range) + + return ( + (endDate.getUTCFullYear() - startDate.getUTCFullYear()) * 12 + + endDate.getUTCMonth() + - startDate.getUTCMonth() + ) +} + +/** + * Shifts a dashboard range by whole periods of its current month span. + * + * @param range Inclusive start and exclusive end dates at UTC month boundaries. + * @param periodOffset Number of same-sized periods to move; negative values + * move backward and positive values move forward. + * @returns A new half-open range with the original month count. + * @throws RangeError when the range is invalid or the offset is not an integer. + * + * Previous and next controls use this helper so a custom twelve-month selection + * continues to navigate in twelve-month blocks. + */ +export function shiftDashboardRange( + range: DashboardRange, + periodOffset: number, +): DashboardRange { + if (!Number.isInteger(periodOffset)) { + throw new RangeError('Dashboard period offset must be an integer.') + } + + const { + endDate, + startDate, + }: { endDate: Date, startDate: Date } = parseDashboardMonthRange(range) + const monthOffset = getDashboardRangeMonthCount(range) * periodOffset + + return { + endDate: toIsoDate(addUtcMonths(endDate, monthOffset)), + startDate: toIsoDate(addUtcMonths(startDate, monthOffset)), + } +} + /** * Formats a dashboard date as an abbreviated UTC month and two-digit year. * From 03d2e68a523c3132ad27aa14d9323a2e9f712998 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 27 Jul 2026 18:33:59 +0530 Subject: [PATCH 02/77] PM-5701 Remove timezone from Assignment and Accept popups --- .../AcceptApplicationModal.module.scss | 6 ------ .../AcceptApplicationModal.tsx | 12 ------------ .../components/AssignmentDetailsModal.module.scss | 6 ------ .../components/AssignmentDetailsModal.tsx | 11 ----------- 4 files changed, 35 deletions(-) diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.module.scss b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.module.scss index 9857bfb90..9890328a3 100644 --- a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.module.scss +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.module.scss @@ -40,12 +40,6 @@ margin: 0; } -.timezoneText { - color: #5b5b5b; - font-size: 12px; - margin: 0; -} - .actions { display: flex; gap: 12px; diff --git a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx index b1496262e..cc823a36b 100644 --- a/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx +++ b/src/apps/work/src/lib/components/AcceptApplicationModal/AcceptApplicationModal.tsx @@ -69,12 +69,6 @@ const AcceptApplicationModal: FC = ( const isSubmitting = props.isSubmitting === true - const timezone = useMemo( - () => Intl.DateTimeFormat() - .resolvedOptions() - .timeZone, - [], - ) const agreementRate = useMemo( () => { const parsedStandardHoursPerDay = toPositiveNumberWithMaxDecimalPlaces( @@ -208,12 +202,6 @@ const AcceptApplicationModal: FC = (
-

- Timezone: - {' '} - {timezone} -

- = ( props.initialValue?.standardHoursPerDay || '', ) - const timezone = useMemo( - () => Intl.DateTimeFormat() - .resolvedOptions() - .timeZone, - [], - ) const agreementRate = useMemo( () => { const parsedStandardHoursPerDay = toPositiveNumberWithMaxDecimalPlaces( @@ -204,11 +198,6 @@ export const AssignmentDetailsModal: FC = (
-

- Timezone: - {' '} - {timezone} -

{ From e482df616b5ba4d11575a16dca90a9700fcb4798 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Mon, 27 Jul 2026 18:38:04 +0530 Subject: [PATCH 03/77] PM-5702 Update Modal field label --- .../components/AssignmentDetailsModal.spec.tsx | 2 +- .../components/AssignmentDetailsModal.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx index 62f9adf9d..fd15124bb 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx @@ -68,7 +68,7 @@ describe('AssignmentDetailsModal', () => { } expect(startDateTimeInputProps.label) - .toBe('Engagement start date *') + .toBe('Billing start date *') expect(startDateTimeInputProps.minDate) .toBeUndefined() }) diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx index 0037ce004..12b7d213a 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx @@ -114,7 +114,7 @@ export const AssignmentDetailsModal: FC = ( .toUpperCase() if (!startDate) { - nextErrors.startDate = 'Engagement start date is required.' + nextErrors.startDate = 'Billing start date is required.' } if (parsedDurationMonths === undefined) { @@ -199,7 +199,7 @@ export const AssignmentDetailsModal: FC = (
{ setStartDate(value || undefined) setErrors(previous => ({ From 8810fbebd3cbff8f00527e45c2f521d600cf3080 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Tue, 28 Jul 2026 11:01:26 +0530 Subject: [PATCH 04/77] PM-5704 Engagement details page --- src/apps/work/src/config/routes.config.ts | 1 + .../EngagementCard/EngagementCard.tsx | 7 +- .../EngagementDetailsPage.module.scss | 117 +++++++ .../EngagementDetailsPage.spec.tsx | 194 +++++++++++ .../EngagementDetailsPage.tsx | 314 ++++++++++++++++++ .../EngagementDetailsPage/index.ts | 3 + .../EngagementEditorPage.spec.tsx | 7 +- .../EngagementsListPage.module.scss | 8 + .../EngagementsListPage.spec.tsx | 16 +- .../EngagementsListPage.tsx | 51 +-- src/apps/work/src/pages/engagements/index.ts | 1 + src/apps/work/src/work-app.routes.tsx | 26 +- 12 files changed, 713 insertions(+), 32 deletions(-) create mode 100644 src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss create mode 100644 src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx create mode 100644 src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx create mode 100644 src/apps/work/src/pages/engagements/EngagementDetailsPage/index.ts diff --git a/src/apps/work/src/config/routes.config.ts b/src/apps/work/src/config/routes.config.ts index e9f8ed0ff..cfb444d65 100644 --- a/src/apps/work/src/config/routes.config.ts +++ b/src/apps/work/src/config/routes.config.ts @@ -17,6 +17,7 @@ export const taasCreateRouteId = 'taas-create' export const taasEditRouteId = 'taas-edit' export const engagementsRouteId = 'engagements' export const engagementCreateRouteId = 'engagement-create' +export const engagementDetailRouteId = 'engagement-detail' export const engagementEditRouteId = 'engagement-edit' export const engagementApplicationsRouteId = 'engagement-applications' export const engagementAssignmentsRouteId = 'engagement-assignments' diff --git a/src/apps/work/src/lib/components/EngagementCard/EngagementCard.tsx b/src/apps/work/src/lib/components/EngagementCard/EngagementCard.tsx index ba6148410..d637cffba 100644 --- a/src/apps/work/src/lib/components/EngagementCard/EngagementCard.tsx +++ b/src/apps/work/src/lib/components/EngagementCard/EngagementCard.tsx @@ -1,6 +1,7 @@ import { FC } from 'react' import { Link } from 'react-router-dom' +import { rootRoute } from '../../../config/routes.config' import { Engagement } from '../../models' import { formatAnticipatedStart, @@ -59,13 +60,13 @@ export const EngagementCard: FC = (props: EngagementCardPro
Applications Assignments @@ -73,7 +74,7 @@ export const EngagementCard: FC = (props: EngagementCardPro ? ( Edit diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss new file mode 100644 index 000000000..b2b4a190a --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.module.scss @@ -0,0 +1,117 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 16px; + width: 100%; +} + +.headerActions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.actionLink, +.externalActionLink { + align-items: center; + color: $link-blue-dark; + display: inline-flex; + font-size: 14px; + font-weight: 700; + gap: 4px; + text-decoration: none; + + &:hover, + &:focus { + outline: none; + text-decoration: underline; + } +} + +.externalIcon { + height: 14px; + width: 14px; +} + +.section { + border-bottom: 1px solid #ececec; + padding-bottom: 16px; +} + +.sectionTitle { + font-size: 20px; + margin: 0 0 12px; +} + +.metaGrid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + color: #5b5b5b; + font-size: 12px; + font-weight: 700; +} + +.value { + color: #2a2a2a; + font-size: 14px; + line-height: 1.4; + word-break: break-word; +} + +.description { + color: #2a2a2a; + font-size: 14px; + line-height: 1.5; + + :global(p) { + margin: 0 0 12px; + } + + :global(p:last-child) { + margin-bottom: 0; + } +} + +.skills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.skillChip { + background: #f5f7fa; + border: 1px solid #d8dee8; + border-radius: 999px; + color: #2a2a2a; + font-size: 12px; + padding: 6px 10px; +} + +.membersList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.memberValue { + background: #f5f7fa; + border: 1px solid #d8dee8; + border-radius: 6px; + color: #2a2a2a; + min-height: 44px; + padding: 12px; +} diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx new file mode 100644 index 000000000..25fddd936 --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx @@ -0,0 +1,194 @@ +/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { Context, PropsWithChildren } from 'react' +import { + render, + screen, +} from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WorkAppContextModel } from '../../../lib/models/WorkAppContextModel.model' +import { + useFetchEngagement, + useFetchProject, +} from '../../../lib/hooks' +import { + canCreateEngagement, + canViewAllEngagements, +} from '../../../lib/utils' + +import { EngagementDetailsPage } from './EngagementDetailsPage' + +var mockWorkAppContext: Context + +jest.mock('~/apps/review/src/lib', () => ({ + PageWrapper: (props: PropsWithChildren<{ pageTitle?: string }>) => ( +
+

{props.pageTitle}

+ {props.children} +
+ ), +}), { + virtual: true, +}) +jest.mock('~/libs/ui', () => ({ + Button: (props: { + label: string + onClick?: () => void + }) => ( + + ), + IconOutline: { + ExternalLinkIcon: () => external-link-icon, + }, +}), { + virtual: true, +}) +jest.mock('../../../lib/constants', () => ({ + ENGAGEMENTS_APP_URL: 'https://engagements.example.com', +})) +jest.mock('../../../config/routes.config', () => ({ + rootRoute: '/work', +})) +jest.mock('../../../lib/components', () => ({ + ErrorMessage: (props: { message: string }) =>
{props.message}
, + LoadingSpinner: () =>
Loading
, +})) +jest.mock('../../../lib/contexts', () => { + const React = require('react') as typeof import('react') + + mockWorkAppContext = React.createContext({ + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: undefined, + userRoles: [], + }) + + return { + WorkAppContext: mockWorkAppContext, + } +}) +jest.mock('../../../lib/hooks', () => ({ + useFetchEngagement: jest.fn(), + useFetchProject: jest.fn(), +})) +jest.mock('../../../lib/utils', () => ({ + canCreateEngagement: jest.fn((roles: string[] = []) => ( + roles.includes('administrator') || roles.includes('talent manager') + )), + canViewAllEngagements: jest.fn((roles: string[] = []) => ( + roles.includes('administrator') || roles.includes('talent manager') + )), + formatAnticipatedStart: jest.fn(() => 'Immediate'), + formatDuration: jest.fn(() => '8 weeks'), + formatEngagementStatus: jest.fn(() => 'Open'), + formatLocation: jest.fn(() => 'Remote'), +})) + +const mockedCanCreateEngagement = canCreateEngagement as jest.Mock +const mockedCanViewAllEngagements = canViewAllEngagements as jest.Mock +const mockedUseFetchEngagement = useFetchEngagement as jest.Mock +const mockedUseFetchProject = useFetchProject as jest.Mock + +const defaultContextValue: WorkAppContextModel = { + isAdmin: true, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: { + email: 'admin@example.com', + exp: 0, + handle: 'admin-user', + iat: 0, + roles: ['administrator'], + userId: 12345, + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['administrator'], +} + +function renderPage(): void { + const MockWorkAppContext = mockWorkAppContext + + render( + + + + } + path='/projects/:projectId/engagements/:engagementId/view' + /> + + + , + ) +} + +describe('EngagementDetailsPage', () => { + beforeEach(() => { + mockedCanCreateEngagement.mockImplementation((roles: string[] = []) => ( + roles.includes('administrator') || roles.includes('talent manager') + )) + mockedCanViewAllEngagements.mockImplementation((roles: string[] = []) => ( + roles.includes('administrator') || roles.includes('talent manager') + )) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + project: { + id: 200, + name: 'Payment Testing', + }, + }) + mockedUseFetchEngagement.mockReturnValue({ + engagement: { + anticipatedStart: 'IMMEDIATE', + assignedMemberHandles: [], + assignments: [], + compensationRange: '$600 - $1000', + countries: [], + description: '

Engagement description

', + durationWeeks: 8, + id: '111', + isPrivate: false, + projectId: 200, + projectName: 'Payment Testing', + requiredMemberCount: 1, + role: 'SOFTWARE_DEVELOPER', + skills: [{ id: '1', name: 'React' }], + status: 'OPEN', + timezones: [], + title: 'Frontend Engagement', + workload: 'FULL_TIME', + }, + error: undefined, + isError: false, + isLoading: false, + mutate: jest.fn(), + }) + }) + + it('renders engagement details and portal view post link', () => { + renderPage() + + expect(screen.getByRole('heading', { name: 'Frontend Engagement' })) + .toBeTruthy() + expect(screen.getByText('Software Developer')) + .toBeTruthy() + expect(screen.getByText('React')) + .toBeTruthy() + expect(screen.getByRole('button', { name: 'Edit' })) + .toBeTruthy() + expect(screen.getByRole('link', { name: /View Post/i }) + .getAttribute('href')) + .toBe('https://engagements.example.com/111') + expect(screen.getByRole('link', { name: /View Post/i }) + .getAttribute('target')) + .toBe('_blank') + }) +}) diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx new file mode 100644 index 000000000..9fc857a05 --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx @@ -0,0 +1,314 @@ +/* eslint-disable react/jsx-no-bind */ + +import { + FC, + useContext, + useMemo, +} from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' + +import { PageWrapper } from '~/apps/review/src/lib' +import { Button, IconOutline } from '~/libs/ui' + +import { ENGAGEMENTS_APP_URL } from '../../../lib/constants' +import { + ErrorMessage, + LoadingSpinner, +} from '../../../lib/components' +import { + rootRoute, +} from '../../../config/routes.config' +import { + WorkAppContext, +} from '../../../lib/contexts' +import { + useFetchEngagement, + useFetchProject, +} from '../../../lib/hooks' +import { + Engagement, + WorkAppContextModel, +} from '../../../lib/models' +import { + canCreateEngagement, + canViewAllEngagements, + formatAnticipatedStart, + formatDuration, + formatEngagementStatus, + formatLocation, +} from '../../../lib/utils' + +import styles from './EngagementDetailsPage.module.scss' + +const ROLE_LABELS: Record = { + DATA_ENGINEER: 'Data Engineer', + DATA_SCIENTIST: 'Data Scientist', + DESIGNER: 'Designer', + SOFTWARE_DEVELOPER: 'Software Developer', +} + +const WORKLOAD_LABELS: Record = { + FRACTIONAL: 'Fractional', + FULL_TIME: 'Full-Time', +} + +function getErrorMessage(error: Error | undefined): string { + if (!error) { + return 'Unable to load engagement details.' + } + + return error.message || 'Unable to load engagement details.' +} + +function formatRole(role: string | undefined): string { + if (!role) { + return '-' + } + + const normalized = String(role) + .trim() + .toUpperCase() + + return ROLE_LABELS[normalized] || role +} + +function formatWorkload(workload: string | undefined): string { + if (!workload) { + return '-' + } + + const normalized = String(workload) + .trim() + .toUpperCase() + + return WORKLOAD_LABELS[normalized] || workload +} + +function getExternalEngagementViewUrl(engagement: Engagement): string { + return `${ENGAGEMENTS_APP_URL}/${engagement.id}` +} + +function renderDetailField(label: string, value: string): JSX.Element { + return ( +
+ {label} + {value || '-'} +
+ ) +} + +export const EngagementDetailsPage: FC = () => { + const navigate = useNavigate() + const params: Readonly<{ engagementId?: string; projectId?: string }> = useParams<'engagementId' | 'projectId'>() + + const projectId = params.projectId || '' + const engagementId = params.engagementId + + const workAppContext = useContext(WorkAppContext) + const contextValue = workAppContext as WorkAppContextModel + const canView = canViewAllEngagements(contextValue.userRoles) + const canEdit = canCreateEngagement(contextValue.userRoles) + + const engagementResult = useFetchEngagement(canView ? engagementId : undefined) + const projectResult = useFetchProject(canView ? projectId || undefined : undefined) + + const engagement = engagementResult.engagement + const backUrl = `${rootRoute}/engagements` + const pageTitle = engagement?.title || 'Engagement Details' + const editPath = `${rootRoute}/projects/${projectId}/engagements/${engagement?.id || engagementId}/edit` + + const projectName = useMemo(() => ( + engagement?.projectName + || engagement?.project?.name + || projectResult.project?.name + || (projectId ? `Project ${projectId}` : '-') + ), [ + engagement?.project?.name, + engagement?.projectName, + projectId, + projectResult.project?.name, + ]) + + const skillNames = useMemo(() => ( + (engagement?.skills || []) + .map(skill => skill?.name) + .filter((name): name is string => !!name) + ), [engagement?.skills]) + + const assignedMembers = useMemo(() => ( + (engagement?.assignedMemberHandles || []) + .map(handle => String(handle || '') + .trim()) + .filter(Boolean) + ), [engagement?.assignedMemberHandles]) + + return ( + +
+ {!canView + ? + : undefined} + + {canView && engagementResult.isLoading + ? + : undefined} + + {canView && !engagementResult.isLoading && engagementResult.isError + ? ( + { + engagementResult.mutate() + .catch(() => undefined) + }} + /> + ) + : undefined} + + {canView + && !engagementResult.isLoading + && !engagementResult.isError + && engagement + ? ( + <> +
+ {canEdit + ? ( +
+ +
+

Basic Information

+
+ {renderDetailField('Title', engagement.title || '-')} + {renderDetailField('Status', formatEngagementStatus(engagement.status))} + {renderDetailField( + 'Visibility', + engagement.isPrivate ? 'Private' : 'Public', + )} + {renderDetailField('Duration', formatDuration(engagement))} + {renderDetailField('Role', formatRole(engagement.role))} + {renderDetailField('Workload', formatWorkload(engagement.workload))} + {renderDetailField( + 'Compensation range', + engagement.compensationRange || '-', + )} +
+
+ +
+

Description

+ {engagement.description + ? ( +
+ ) + : -} +
+ +
+

Details

+
+ {renderDetailField( + 'Anticipated Start', + formatAnticipatedStart(engagement.anticipatedStart), + )} + {renderDetailField('Parent Project', projectName)} + {renderDetailField( + 'Required Members', + engagement.requiredMemberCount + ? String(engagement.requiredMemberCount) + : '-', + )} + {renderDetailField('Location', formatLocation(engagement))} +
+ +
+ Skills + {skillNames.length > 0 + ? ( +
+ {skillNames.map(skillName => ( + + {skillName} + + ))} +
+ ) + : -} +
+
+ + {engagement.isPrivate + ? ( +
+

Assigned Members

+ {assignedMembers.length > 0 + ? ( +
+ {assignedMembers.map(memberHandle => ( +
+ {memberHandle} +
+ ))} +
+ ) + : -} +
+ ) + : undefined} + + ) + : undefined} +
+
+ ) +} + +export default EngagementDetailsPage diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/index.ts b/src/apps/work/src/pages/engagements/EngagementDetailsPage/index.ts new file mode 100644 index 000000000..75a51fbb7 --- /dev/null +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/index.ts @@ -0,0 +1,3 @@ +import EngagementDetailsPage from './EngagementDetailsPage' + +export default EngagementDetailsPage diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/EngagementEditorPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/EngagementEditorPage.spec.tsx index c834b4db4..e217d4cbd 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/EngagementEditorPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/EngagementEditorPage.spec.tsx @@ -53,6 +53,9 @@ jest.mock('../../../lib/hooks', () => ({ useFetchEngagement: jest.fn(), useFetchProject: jest.fn(), })) +jest.mock('../../../config/routes.config', () => ({ + rootRoute: '/work', +})) jest.mock('../../../lib/utils', () => ({ canCreateEngagement: jest.fn((roles: string[] = []) => ( roles.includes('administrator') || roles.includes('talent manager') @@ -149,8 +152,8 @@ describe('EngagementEditorPage', () => { it('blocks project managers from opening engagement edit routes', () => { renderPage( - '/projects/123/engagements/engagement-1', - '/projects/:projectId/engagements/:engagementId', + '/projects/123/engagements/engagement-1/edit', + '/projects/:projectId/engagements/:engagementId/edit', projectManagerContextValue, ) diff --git a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.module.scss b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.module.scss index 14d381047..6785445ba 100644 --- a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.module.scss +++ b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.module.scss @@ -163,9 +163,12 @@ .actionLink, .actionButton { + align-items: center; color: $link-blue-dark; + display: inline-flex; font-size: 12px; font-weight: 700; + gap: 4px; text-decoration: none; &:hover, @@ -175,6 +178,11 @@ } } +.externalIcon { + height: 12px; + width: 12px; +} + .actionButton { background: transparent; border: 0; diff --git a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx index c9e3b1722..ce7faeeeb 100644 --- a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx @@ -65,6 +65,7 @@ jest.mock('~/libs/ui', () => ({ ), IconOutline: { DocumentTextIcon: () => document-icon, + ExternalLinkIcon: () => external-link-icon, PencilIcon: () => pencil-icon, UserIcon: () => user-icon, }, @@ -78,6 +79,9 @@ jest.mock('../../../lib/constants', () => ({ ACTIVE: 'active', }, })) +jest.mock('../../../config/routes.config', () => ({ + rootRoute: '/work', +})) jest.mock('../../../lib/components', () => ({ ConfirmationModal: (props: { cancelText?: string @@ -508,12 +512,12 @@ describe('EngagementsListPage', () => { renderPage('/engagements', '/engagements') - expect(screen.getByRole('link', { name: 'View' }) + expect(screen.getByRole('link', { name: /View Post/i }) .getAttribute('href')) .toBe('https://engagements.example.com/plJi6KV_jDjdtowUlQbFx') }) - it('links engagement titles to the assignees page on the all engagements route', () => { + it('links engagement titles to the details page on the all engagements route', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [sampleEngagement], error: undefined, @@ -525,10 +529,10 @@ describe('EngagementsListPage', () => { expect(screen.getByRole('link', { name: sampleEngagement.title }) .getAttribute('href')) - .toBe('/projects/200/engagements/111/assignments') + .toBe('/work/projects/200/engagements/111/view') }) - it('links engagement titles to the assignees page on project engagement routes', () => { + it('links engagement titles to the details page on project engagement routes', () => { mockedUseFetchProject.mockReturnValue({ error: undefined, isLoading: false, @@ -554,7 +558,7 @@ describe('EngagementsListPage', () => { expect(screen.getByRole('link', { name: sampleEngagement.title }) .getAttribute('href')) - .toBe('/projects/200/engagements/111/assignments') + .toBe('/work/projects/200/engagements/111/view') }) it('links zero assigned member counts to the assignees page when completed assignments exist', () => { @@ -592,7 +596,7 @@ describe('EngagementsListPage', () => { .getAllByRole('link', { name: '0' }) expect(zeroCountLinks.some(link => ( - link.getAttribute('href') === '/projects/200/engagements/111/assignments' + link.getAttribute('href') === '/work/projects/200/engagements/111/assignments' ))) .toBe(true) }) diff --git a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx index 18040e052..331c59f55 100644 --- a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx @@ -23,6 +23,9 @@ import { PAGE_SIZE, PROJECT_STATUS, } from '../../../lib/constants' +import { + rootRoute, +} from '../../../config/routes.config' import { WorkAppContext, } from '../../../lib/contexts' @@ -246,7 +249,8 @@ function renderMembersAssignedCell( ? ( { const applicationsCount = getApplicationsCount(engagement) const engagementProjectId = getEngagementProjectId(engagement, fallbackProjectId) - const engagementAssignmentsRoute = engagementProjectId && engagement.id - ? `/projects/${engagementProjectId}/engagements/${engagement.id}/assignments` + const engagementDetailsRoute = engagementProjectId && engagement.id + ? `${rootRoute}/projects/${engagementProjectId}/engagements/${engagement.id}/view` + : undefined + const engagementApplicationsRoute = engagementProjectId && engagement.id + ? `${rootRoute}/projects/${engagementProjectId}` + + `/engagements/${engagement.id}/applications` + : undefined + const engagementEditRoute = engagementProjectId && engagement.id + ? `${rootRoute}/projects/${engagementProjectId}` + + `/engagements/${engagement.id}/edit` : undefined const projectName = getEngagementProjectName( engagement, @@ -321,7 +333,7 @@ function renderEngagementRows( || engagementProjectId || '-' const projectChallengesRoute = engagementProjectId - ? `/projects/${engagementProjectId}/challenges` + ? `${rootRoute}/projects/${engagementProjectId}/challenges` : undefined return ( @@ -336,16 +348,11 @@ function renderEngagementRows( : projectName} - {engagementAssignmentsRoute + {engagementDetailsRoute ? ( {engagement.title || '-'} @@ -355,11 +362,11 @@ function renderEngagementRows( {engagement.isPrivate ? 'Private' : 'Public'} {renderEngagementStatus(engagement.status)} - {engagementProjectId + {engagementApplicationsRoute ? ( {applicationsCount} @@ -375,13 +382,17 @@ function renderEngagementRows( rel='noreferrer noopener' target='_blank' > - View + View Post +
+
+ Review Method + {props.workflow.reviewMethod || 'N/A'} +
Definition URL 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; From d2a78a0b3a2e8b2f3bbc9290327d3ae000229199 Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Wed, 29 Jul 2026 22:26:15 +0530 Subject: [PATCH 10/77] PM-5710 Add extra fields to engagements --- .../form/FormTextField/FormTextField.tsx | 2 +- src/apps/work/src/lib/constants.ts | 6 + .../work/src/lib/models/Engagement.model.ts | 7 + .../lib/services/engagements.service.spec.ts | 6 + .../src/lib/services/engagements.service.ts | 39 +++++ .../src/lib/utils/engagement.utils.spec.ts | 39 +++++ .../work/src/lib/utils/engagement.utils.ts | 136 ++++++++++++++++++ .../EngagementDetailsPage.spec.tsx | 24 +++- .../EngagementDetailsPage.tsx | 36 +++++ .../components/EngagementEditorForm.spec.tsx | 65 +++++++++ .../components/EngagementEditorForm.tsx | 74 ++++++++++ 11 files changed, 429 insertions(+), 5 deletions(-) diff --git a/src/apps/work/src/lib/components/form/FormTextField/FormTextField.tsx b/src/apps/work/src/lib/components/form/FormTextField/FormTextField.tsx index 3be4cc8b7..375ee1cda 100644 --- a/src/apps/work/src/lib/components/form/FormTextField/FormTextField.tsx +++ b/src/apps/work/src/lib/components/form/FormTextField/FormTextField.tsx @@ -27,7 +27,7 @@ export interface FormTextFieldProps { placeholder?: string required?: boolean sanitize?: (value: string) => string - type?: 'number' | 'text' + type?: 'date' | 'number' | 'text' } /** diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index 0191edc31..9f7f2f191 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -351,6 +351,12 @@ export const ENGAGEMENT_ROLES = [ 'DATA_ENGINEER', ] as const +export const ENGAGEMENT_ROLE_LEVELS = [ + 'JUNIOR', + 'MID', + 'SENIOR', +] as const + export const ENGAGEMENT_WORKLOADS = ['FULL_TIME', 'FRACTIONAL'] as const export const ANTICIPATED_START_OPTIONS = ['IMMEDIATE', 'FEW_DAYS', 'FEW_WEEKS'] as const diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index 3bcab8114..c8b64bc99 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -2,6 +2,8 @@ import { Skill } from './Skill.model' export type EngagementRole = 'DESIGNER' | 'SOFTWARE_DEVELOPER' | 'DATA_SCIENTIST' | 'DATA_ENGINEER' +export type EngagementRoleLevel = 'JUNIOR' | 'MID' | 'SENIOR' + export type EngagementWorkload = 'FULL_TIME' | 'FRACTIONAL' export type EngagementAnticipatedStart = 'FEW_DAYS' | 'FEW_WEEKS' | 'IMMEDIATE' @@ -59,6 +61,7 @@ export interface Application { } export interface Engagement { + account?: string anticipatedStart: EngagementAnticipatedStart | string applications?: Application[] applicationsCount?: number @@ -77,9 +80,13 @@ export interface Engagement { } projectId: number | string projectName?: string + receivedDateFromAccount?: string requiredMemberCount: number role: EngagementRole | string + roleLevel?: EngagementRoleLevel | string skills: Skill[] + smu?: string + spoc?: string status: EngagementStatus | string timezones: string[] title: string diff --git a/src/apps/work/src/lib/services/engagements.service.spec.ts b/src/apps/work/src/lib/services/engagements.service.spec.ts index a2dcc7f0a..37a7128ff 100644 --- a/src/apps/work/src/lib/services/engagements.service.spec.ts +++ b/src/apps/work/src/lib/services/engagements.service.spec.ts @@ -40,9 +40,15 @@ jest.mock('../constants', () => ({ })) jest.mock('../utils', () => ({ fromEngagementAnticipatedStartApi: jest.fn((value?: string) => value || ''), + fromEngagementDateInputValue: jest.fn((value?: string) => ( + value + ? `${value}T00:00:00.000Z` + : undefined + )), normalizeEngagement: jest.fn((engagement: unknown) => engagement), toEngagementAnticipatedStartApi: jest.fn((value?: string) => value || ''), toEngagementRoleApi: jest.fn((value?: string) => value || ''), + toEngagementRoleLevelApi: jest.fn((value?: string) => value || ''), toEngagementStatusApi: jest.fn((value?: string) => value || ''), toEngagementWorkloadApi: jest.fn((value?: string) => value || ''), })) diff --git a/src/apps/work/src/lib/services/engagements.service.ts b/src/apps/work/src/lib/services/engagements.service.ts index 076d99bd0..2b4da9631 100644 --- a/src/apps/work/src/lib/services/engagements.service.ts +++ b/src/apps/work/src/lib/services/engagements.service.ts @@ -22,9 +22,11 @@ import { } from '../models' import { fromEngagementAnticipatedStartApi, + fromEngagementDateInputValue, normalizeEngagement, toEngagementAnticipatedStartApi, toEngagementRoleApi, + toEngagementRoleLevelApi, toEngagementStatusApi, toEngagementWorkloadApi, } from '../utils' @@ -286,6 +288,11 @@ function normalizeStatusFilters(status?: string | string[]): string[] { function serializeEngagementPayload(data: EngagementUpsertData): Record { const payload: Record = {} + if (data.account !== undefined) { + payload.account = String(data.account || '') + .trim() + } + if (data.anticipatedStart) { payload.anticipatedStart = toEngagementAnticipatedStartApi(data.anticipatedStart) } @@ -399,6 +406,17 @@ function serializeEngagementPayload(data: EngagementUpsertData): Record { .map(assignment => assignment.memberHandle)) .toEqual(['active_member']) }) + + it('normalizes and formats internal account fields', () => { + const normalized = normalizeEngagement({ + account: 'Acme Corp', + id: 'engagement-1', + receivedDateFromAccount: '2026-07-15T00:00:00.000Z', + roleLevel: 'SENIOR', + smu: 'North America', + spoc: 'Jane Doe', + } as any) + + expect(normalized.account) + .toBe('Acme Corp') + expect(normalized.receivedDateFromAccount) + .toBe('2026-07-15T00:00:00.000Z') + expect(normalized.roleLevel) + .toBe('SENIOR') + expect(normalized.smu) + .toBe('North America') + expect(normalized.spoc) + .toBe('Jane Doe') + expect(formatEngagementRoleLevel('SENIOR')) + .toBe('Senior') + expect(toEngagementRoleLevelApi('Senior')) + .toBe('SENIOR') + expect(fromEngagementRoleLevelApi('MID')) + .toBe('MID') + expect(toEngagementDateInputValue('2026-07-15T12:00:00.000Z')) + .toBe('2026-07-15') + expect(fromEngagementDateInputValue('2026-07-15')) + .toBe('2026-07-15T00:00:00.000Z') + expect(fromEngagementDateInputValue('')) + .toBeUndefined() + }) }) diff --git a/src/apps/work/src/lib/utils/engagement.utils.ts b/src/apps/work/src/lib/utils/engagement.utils.ts index bb3836cf0..38e1bf42a 100644 --- a/src/apps/work/src/lib/utils/engagement.utils.ts +++ b/src/apps/work/src/lib/utils/engagement.utils.ts @@ -2,6 +2,7 @@ import { Engagement, EngagementAnticipatedStart, EngagementRole, + EngagementRoleLevel, EngagementStatus, EngagementWorkload, } from '../models' @@ -53,6 +54,24 @@ const WORKLOAD_FROM_API: Record = { PART_TIME: 'FRACTIONAL', } +const ROLE_LEVEL_TO_API: Record = { + JUNIOR: 'JUNIOR', + MID: 'MID', + SENIOR: 'SENIOR', +} + +const ROLE_LEVEL_FROM_API: Record = { + JUNIOR: 'JUNIOR', + MID: 'MID', + SENIOR: 'SENIOR', +} + +const ROLE_LEVEL_LABELS: Record = { + JUNIOR: 'Junior', + MID: 'Mid', + SENIOR: 'Senior', +} + const ANTICIPATED_START_LABELS: Record = { FEW_DAYS: 'In a few days', FEW_WEEKS: 'In a few weeks', @@ -92,6 +111,86 @@ function toIsoString(value: unknown): string { return '' } +/** + * Converts an API date value into an HTML date-input value (`YYYY-MM-DD`). + * + * @param value ISO date string or Date from the engagements API. + * @returns a calendar date string, or an empty string when the value is blank. + */ +export function toEngagementDateInputValue(value: unknown): string { + if (!value) { + return '' + } + + if (typeof value === 'string') { + const trimmed = value.trim() + + if (!trimmed) { + return '' + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return trimmed + } + + const parsed = new Date(trimmed) + + if (Number.isNaN(parsed.getTime())) { + return '' + } + + const year = parsed.getUTCFullYear() + const month = String(parsed.getUTCMonth() + 1) + .padStart(2, '0') + const day = String(parsed.getUTCDate()) + .padStart(2, '0') + + return `${year}-${month}-${day}` + } + + if (value instanceof Date && !Number.isNaN(value.getTime())) { + const year = value.getUTCFullYear() + const month = String(value.getUTCMonth() + 1) + .padStart(2, '0') + const day = String(value.getUTCDate()) + .padStart(2, '0') + + return `${year}-${month}-${day}` + } + + return '' +} + +/** + * Converts an HTML date-input value into an ISO datetime string for the API. + * + * @param value calendar date string from the engagement editor form. + * @returns an ISO datetime string, or `undefined` when the value is blank/invalid. + */ +export function fromEngagementDateInputValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + + if (!trimmed) { + return undefined + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + return `${trimmed}T00:00:00.000Z` + } + + const parsed = new Date(trimmed) + + if (Number.isNaN(parsed.getTime())) { + return undefined + } + + return parsed.toISOString() +} + function toNumber(value: unknown, fallback: number = 0): number { const parsed = Number(value) @@ -485,6 +584,7 @@ export function normalizeEngagement(data: Partial = {}): Engagement ) return { + account: normalizeString(data.account) || undefined, anticipatedStart, applications: Array.isArray(data.applications) ? data.applications @@ -502,9 +602,15 @@ export function normalizeEngagement(data: Partial = {}): Engagement project, projectId: data.projectId || project?.id || '', projectName, + receivedDateFromAccount: toIsoString(data.receivedDateFromAccount) || undefined, requiredMemberCount: toNumber(data.requiredMemberCount), role, + roleLevel: data.roleLevel + ? fromEngagementRoleLevelApi(data.roleLevel) + : undefined, skills, + smu: normalizeString(data.smu) || undefined, + spoc: normalizeString(data.spoc) || undefined, status, timezones, title: normalizeString(data.title), @@ -735,6 +841,36 @@ export function fromEngagementWorkloadApi(workload: string): EngagementWorkload return WORKLOAD_FROM_API[normalized] || workload } +export function toEngagementRoleLevelApi(roleLevel: string): string { + const normalized = toUpperSnake(normalizeString(roleLevel)) + + if (!normalized) { + return '' + } + + return ROLE_LEVEL_TO_API[normalized] || normalized +} + +export function fromEngagementRoleLevelApi(roleLevel: string): EngagementRoleLevel | string { + const normalized = toUpperSnake(normalizeString(roleLevel)) + + if (!normalized) { + return '' + } + + return ROLE_LEVEL_FROM_API[normalized] || roleLevel +} + +export function formatEngagementRoleLevel(value: string | EngagementRoleLevel | undefined): string { + if (!value) { + return '-' + } + + const normalized = toUpperSnake(String(value)) + + return ROLE_LEVEL_LABELS[normalized] || value +} + export function toEngagementAnticipatedStartApi(value: string): string { const normalized = toUpperSnake(normalizeString(value)) diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx index 25fddd936..e20d49dbb 100644 --- a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.spec.tsx @@ -84,10 +84,11 @@ jest.mock('../../../lib/utils', () => ({ canViewAllEngagements: jest.fn((roles: string[] = []) => ( roles.includes('administrator') || roles.includes('talent manager') )), - formatAnticipatedStart: jest.fn(() => 'Immediate'), - formatDuration: jest.fn(() => '8 weeks'), - formatEngagementStatus: jest.fn(() => 'Open'), - formatLocation: jest.fn(() => 'Remote'), + formatAnticipatedStart: () => 'Immediate', + formatDate: () => 'Jul 15, 2026', + formatDuration: () => '8 weeks', + formatEngagementStatus: () => 'Open', + formatLocation: () => 'Remote', })) const mockedCanCreateEngagement = canCreateEngagement as jest.Mock @@ -147,6 +148,7 @@ describe('EngagementDetailsPage', () => { }) mockedUseFetchEngagement.mockReturnValue({ engagement: { + account: 'Acme Corp', anticipatedStart: 'IMMEDIATE', assignedMemberHandles: [], assignments: [], @@ -158,9 +160,13 @@ describe('EngagementDetailsPage', () => { isPrivate: false, projectId: 200, projectName: 'Payment Testing', + receivedDateFromAccount: '2026-07-15T00:00:00.000Z', requiredMemberCount: 1, role: 'SOFTWARE_DEVELOPER', + roleLevel: 'SENIOR', skills: [{ id: '1', name: 'React' }], + smu: 'North America', + spoc: 'Jane Doe', status: 'OPEN', timezones: [], title: 'Frontend Engagement', @@ -182,6 +188,16 @@ describe('EngagementDetailsPage', () => { .toBeTruthy() expect(screen.getByText('React')) .toBeTruthy() + expect(screen.getByText('Acme Corp')) + .toBeTruthy() + expect(screen.getByText('North America')) + .toBeTruthy() + expect(screen.getByText('Jane Doe')) + .toBeTruthy() + expect(screen.getByText('Senior')) + .toBeTruthy() + expect(screen.getByText('Jul 15, 2026')) + .toBeTruthy() expect(screen.getByRole('button', { name: 'Edit' })) .toBeTruthy() expect(screen.getByRole('link', { name: /View Post/i }) diff --git a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx index 9fc857a05..46c705840 100644 --- a/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx +++ b/src/apps/work/src/pages/engagements/EngagementDetailsPage/EngagementDetailsPage.tsx @@ -33,6 +33,7 @@ import { canCreateEngagement, canViewAllEngagements, formatAnticipatedStart, + formatDate, formatDuration, formatEngagementStatus, formatLocation, @@ -47,6 +48,12 @@ const ROLE_LABELS: Record = { SOFTWARE_DEVELOPER: 'Software Developer', } +const ROLE_LEVEL_LABELS: Record = { + JUNIOR: 'Junior', + MID: 'Mid', + SENIOR: 'Senior', +} + const WORKLOAD_LABELS: Record = { FRACTIONAL: 'Fractional', FULL_TIME: 'Full-Time', @@ -72,6 +79,18 @@ function formatRole(role: string | undefined): string { return ROLE_LABELS[normalized] || role } +function formatRoleLevel(roleLevel: string | undefined): string { + if (!roleLevel) { + return '-' + } + + const normalized = String(roleLevel) + .trim() + .toUpperCase() + + return ROLE_LEVEL_LABELS[normalized] || roleLevel +} + function formatWorkload(workload: string | undefined): string { if (!workload) { return '-' @@ -282,6 +301,23 @@ export const EngagementDetailsPage: FC = () => {
+
+

Internal Account Details

+
+ {renderDetailField( + 'Received Date from Account', + formatDate(engagement.receivedDateFromAccount), + )} + {renderDetailField('Account', engagement.account || '-')} + {renderDetailField('SMU', engagement.smu || '-')} + {renderDetailField('SPOC', engagement.spoc || '-')} + {renderDetailField( + 'Role Level', + formatRoleLevel(engagement.roleLevel), + )} +
+
+ {engagement.isPrivate ? (
diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx index 1f8772347..3e2b8eaf3 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.spec.tsx @@ -142,6 +142,18 @@ jest.mock('../../../../lib/utils', () => ({ ), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), + toEngagementDateInputValue: (value?: string) => { + if (!value) { + return '' + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value + } + + return String(value) + .slice(0, 10) + }, })) jest.mock('~/libs/ui', () => ({ Button: (props: { @@ -320,6 +332,59 @@ describe('EngagementEditorForm', () => { .toBe('Software Developer') }) + it('renders internal account fields and role level options', () => { + render( + + + , + ) + + expect((screen.getByLabelText('Account') as HTMLInputElement).value) + .toBe('Acme Corp') + expect((screen.getByLabelText('SMU') as HTMLInputElement).value) + .toBe('North America') + expect((screen.getByLabelText('SPOC') as HTMLInputElement).value) + .toBe('Jane Doe') + expect((screen.getByLabelText('Received Date from Account') as HTMLInputElement).value) + .toBe('2026-07-15') + expect((screen.getByLabelText('Role Level') as HTMLSelectElement).value) + .toBe('SENIOR') + + const roleLevelField = screen.getByLabelText('Role Level') as HTMLSelectElement + const midOption = Array.from(roleLevelField.options) + .find(option => option.value === 'MID') + + expect(midOption?.text) + .toBe('Mid') + }) + it('renders the selected parent project on the create page', () => { render( diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx index 88e773f87..0cde0015f 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx @@ -21,6 +21,7 @@ import { import { ANTICIPATED_START_OPTIONS, ENGAGEMENT_ROLES, + ENGAGEMENT_ROLE_LEVELS, ENGAGEMENT_WORKLOADS, } from '../../../../lib/constants' import { @@ -53,6 +54,7 @@ import { getCountableEngagementAssignments, showErrorToast, showSuccessToast, + toEngagementDateInputValue, } from '../../../../lib/utils' import { @@ -76,6 +78,7 @@ import { import styles from './EngagementEditorForm.module.scss' export interface EngagementEditorFormData { + account: string anticipatedStart: string assignedMemberHandles: string[] assignmentDetails: AssignmentDetailsFormValue[] @@ -85,9 +88,13 @@ export interface EngagementEditorFormData { durationWeeks: number | string isPrivate: boolean projectId: string + receivedDateFromAccount: string requiredMemberCount: number | string role: string + roleLevel: string skills: Skill[] + smu: string + spoc: string status: string timezones: string[] title: string @@ -358,6 +365,7 @@ function getDefaultValues( const assignmentDefaults = getAssignmentDefaults(defaultEngagement) return { + account: defaultEngagement?.account || '', anticipatedStart: defaultEngagement?.anticipatedStart || ANTICIPATED_START_OPTIONS[0], assignedMemberHandles: assignmentDefaults.assignedMemberHandles, assignmentDetails: assignmentDefaults.assignmentDetails, @@ -369,11 +377,17 @@ function getDefaultValues( : '', isPrivate: defaultEngagement?.isPrivate === true, projectId: getDefaultProjectId(defaultEngagement, projectId), + receivedDateFromAccount: toEngagementDateInputValue( + defaultEngagement?.receivedDateFromAccount, + ), requiredMemberCount: defaultEngagement?.requiredMemberCount ? String(defaultEngagement.requiredMemberCount) : '', role: defaultEngagement?.role || ENGAGEMENT_ROLES[0], + roleLevel: defaultEngagement?.roleLevel || '', skills: defaultEngagement?.skills || [], + smu: defaultEngagement?.smu || '', + spoc: defaultEngagement?.spoc || '', status: defaultEngagement?.status ? formatEngagementStatus(defaultEngagement.status) : 'Open', @@ -409,6 +423,19 @@ function createWorkloadOptions(): FormSelectOption[] { })) } +function createRoleLevelOptions(): FormSelectOption[] { + const labelsByRoleLevel: Record = { + JUNIOR: 'Junior', + MID: 'Mid', + SENIOR: 'Senior', + } + + return ENGAGEMENT_ROLE_LEVELS.map(roleLevel => ({ + label: labelsByRoleLevel[roleLevel] || roleLevel, + value: roleLevel, + })) +} + const MIN_PARENT_PROJECT_SEARCH_LENGTH = 2 /** @@ -514,6 +541,7 @@ function toPayload( const payload: Partial & { assignmentDetails?: SerializedAssignmentDetailsPayload[] } = { + account: values.account, anticipatedStart: values.anticipatedStart, compensationRange: values.compensationRange, countries: values.countries, @@ -521,8 +549,12 @@ function toPayload( durationWeeks: Number(values.durationWeeks), isPrivate: values.isPrivate, projectId: values.projectId, + receivedDateFromAccount: values.receivedDateFromAccount, role: values.role, + roleLevel: values.roleLevel, skills: values.skills, + smu: values.smu, + spoc: values.spoc, status: values.status, timezones: values.timezones, title: values.title, @@ -587,6 +619,7 @@ export const EngagementEditorForm: FC = ( [lockedAssignmentDetails], ) const roleOptions = useMemo(() => createRoleOptions(), []) + const roleLevelOptions = useMemo(() => createRoleLevelOptions(), []) const workloadOptions = useMemo(() => createWorkloadOptions(), []) const currentProjectOption = useMemo( () => createProjectOption( @@ -890,6 +923,47 @@ export const EngagementEditorForm: FC = (
+
+

Internal Account Details

+ +
+ + + + + + + + + +
+
+ Date: Thu, 30 Jul 2026 10:09:19 +1000 Subject: [PATCH 11/77] Fix for PM-5776 --- .../ChallengeDetailsContent.tsx | 7 +- .../TableSubmissionScreening.tsx | 36 +++++--- src/apps/review/src/lib/utils/index.ts | 1 + .../src/lib/utils/screeningRows.spec.ts | 90 +++++++++++++++++++ .../review/src/lib/utils/screeningRows.ts | 48 ++++++++++ 5 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 src/apps/review/src/lib/utils/screeningRows.spec.ts create mode 100644 src/apps/review/src/lib/utils/screeningRows.ts diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index 02da68b9c..e940affaa 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -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 diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx index 7e19acb15..794c94f15 100644 --- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx +++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx @@ -33,14 +33,16 @@ import { import { TableWrapper } from '../TableWrapper' import { SubmissionHistoryModal } from '../SubmissionHistoryModal' import { + challengeHasSubmissionLimit, getHandleUrl, getSubmissionHistoryKey, - hasIsLatestFlag, isReviewPhaseCurrentlyOpen, partitionSubmissionHistory, refreshChallengeReviewData, REOPEN_MESSAGE_OTHER, REOPEN_MESSAGE_SELF, + ScreeningRowsSelection, + selectVisibleScreeningRows, SubmissionHistoryPartition, } from '../../utils' import { @@ -950,18 +952,26 @@ export const TableSubmissionScreening: FC = (props: Props) => { const { historyByMember, latestSubmissionIds }: SubmissionHistoryPartition = submissionHistory - const shouldShowHistoryActions = useMemo( - () => hasIsLatestFlag(primarySubmissionInfos), - [primarySubmissionInfos], + const { + isRestrictedToLatest: shouldShowHistoryActions, + rows: visibleScreenings, + }: ScreeningRowsSelection = useMemo( + () => selectVisibleScreeningRows({ + hasSubmissionLimit: challengeHasSubmissionLimit(challengeInfo), + latestSubmissionIds, + screeningRows: props.screenings, + submissionInfos: primarySubmissionInfos, + }), + [ + challengeInfo, + latestSubmissionIds, + primarySubmissionInfos, + props.screenings, + ], ) - const filteredScreenings = useMemo(() => ( - props.screenings - .filter(screening => latestSubmissionIds.has(screening.submissionId)) - ), [props.screenings, latestSubmissionIds]) - const maxScreenerCount = useMemo( - () => filteredScreenings.reduce( + () => visibleScreenings.reduce( (maxCount, screening) => Math.max( maxCount, resolveScreeningReviewDetails(screening).length, @@ -969,7 +979,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { ), 1, ), - [filteredScreenings], + [visibleScreenings], ) const hasMultipleScreeners = useMemo( @@ -1362,11 +1372,11 @@ export const TableSubmissionScreening: FC = (props: Props) => { )} > {isTablet ? ( - + ) : ( { + it('retains every row for an unlimited challenge without latest flags', () => { + const result = selectVisibleScreeningRows({ + hasSubmissionLimit: false, + latestSubmissionIds, + screeningRows, + submissionInfos: [{}, {}, {}, {}], + }) + + expect(result.isRestrictedToLatest) + .toBe(false) + expect(result.rows) + .toBe(screeningRows) + }) + + it('retains every row for an unlimited challenge with stale latest flags', () => { + const submissionInfos: Array> = [ + { isLatest: false }, + { isLatest: true }, + { isLatest: false }, + { isLatest: true }, + ] + + const result = selectVisibleScreeningRows({ + hasSubmissionLimit: false, + latestSubmissionIds, + screeningRows, + submissionInfos, + }) + + expect(result.isRestrictedToLatest) + .toBe(false) + expect(result.rows) + .toBe(screeningRows) + }) + + it('retains every row for a limited challenge without explicit latest flags', () => { + const result = selectVisibleScreeningRows({ + hasSubmissionLimit: true, + latestSubmissionIds, + screeningRows, + submissionInfos: [{}, {}, {}, {}], + }) + + expect(result.isRestrictedToLatest) + .toBe(false) + expect(result.rows) + .toBe(screeningRows) + }) + + it('retains only explicit latest submissions for a limited challenge', () => { + const submissionInfos: Array> = [ + { isLatest: false }, + { isLatest: true }, + { isLatest: false }, + { isLatest: true }, + ] + + const result = selectVisibleScreeningRows({ + hasSubmissionLimit: true, + latestSubmissionIds, + screeningRows, + submissionInfos, + }) + + expect(result.isRestrictedToLatest) + .toBe(true) + expect(result.rows) + .toEqual([ + screeningRows[1], + screeningRows[3], + ]) + }) +}) diff --git a/src/apps/review/src/lib/utils/screeningRows.ts b/src/apps/review/src/lib/utils/screeningRows.ts new file mode 100644 index 000000000..c47e6a49d --- /dev/null +++ b/src/apps/review/src/lib/utils/screeningRows.ts @@ -0,0 +1,48 @@ +import type { Screening, SubmissionInfo } from '../models' + +import { hasIsLatestFlag } from './submissionHistory' + +export interface ScreeningRowsSelection { + isRestrictedToLatest: boolean + rows: Screening[] +} + +export interface SelectVisibleScreeningRowsOptions { + hasSubmissionLimit: boolean + latestSubmissionIds: ReadonlySet + screeningRows: Screening[] + submissionInfos: Array> +} + +/** + * Select the Screening rows that should be displayed for a challenge. + * + * The Screening table uses this selection for both desktop and mobile views. + * Limited challenges collapse submission history only when the API supplies + * explicit `isLatest` flags. Unlimited challenges, or responses without those + * flags, retain every Screening row. This function performs no I/O and does + * not throw. + * + * @param options visibility inputs for the challenge and its submissions + * @param options.hasSubmissionLimit whether the challenge limits submissions + * @param options.latestSubmissionIds latest submission ids calculated per member + * @param options.screeningRows Screening rows available for display + * @param options.submissionInfos submission metadata containing optional latest flags + * @returns the visible rows and whether submission history was collapsed + */ +export function selectVisibleScreeningRows({ + hasSubmissionLimit, + latestSubmissionIds, + screeningRows, + submissionInfos, +}: SelectVisibleScreeningRowsOptions): ScreeningRowsSelection { + const isRestrictedToLatest = hasSubmissionLimit + && hasIsLatestFlag(submissionInfos) + + return { + isRestrictedToLatest, + rows: isRestrictedToLatest + ? screeningRows.filter(row => latestSubmissionIds.has(row.submissionId)) + : screeningRows, + } +} From ae8aab0ad622bfcb4cc43bb730cc4be2d938bc02 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 11:03:59 +1000 Subject: [PATCH 12/77] PM-5708: Show failed screening results to submitters What was broken Members whose submissions failed screening on completed Design challenges saw an empty Screening tab instead of their result and scorecard link. Root cause The completed-challenge visibility filter returned an empty row set for non-privileged viewers below the screening threshold before the ownership filter could retain the viewer's own submission. What was changed Allow below-threshold viewers to fall through to the existing ownership filter, preserving access to their own screening result while keeping other members' rows hidden. Any added/updated tests Added a TabContentScreening regression test covering a completed challenge with owned and foreign failed submissions. --- .../TabContentScreening.spec.tsx | 113 ++++++++++++++++++ .../TabContentScreening.tsx | 4 - 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentScreening.spec.tsx 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 } From b3c62648310cf049071bc8f13088389c0377ca6e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 13:26:00 +1000 Subject: [PATCH 13/77] PM-5471: add reviewer filter to past challenges What was broken Members could not narrow My Past Challenges to challenges where they served in a reviewer role. Root cause The page had no role control, and its request service and state did not carry resource-role filters to the review API. What was changed Added an All roles/Reviewer filter backed by the eight reviewer resource-role IDs identified for PM-5471. The selection is serialized to the API, retained across sorting and pagination, and removed by Clear. Any added/updated tests Added page and hook tests for the role options, all eight IDs, pagination persistence, and clearing. Updated the review service test for comma-separated query serialization. --- src/apps/review/src/config/index.config.ts | 24 ++ .../lib/hooks/useFetchPastReviews.spec.tsx | 149 ++++++++++++ .../src/lib/hooks/useFetchPastReviews.ts | 4 + .../src/lib/services/reviews.service.spec.ts | 36 ++- .../src/lib/services/reviews.service.ts | 8 +- .../PastReviewsPage/PastReviewsPage.spec.tsx | 214 ++++++++++++++++++ .../PastReviewsPage/PastReviewsPage.tsx | 35 ++- 7 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 src/apps/review/src/lib/hooks/useFetchPastReviews.spec.tsx create mode 100644 src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.spec.tsx diff --git a/src/apps/review/src/config/index.config.ts b/src/apps/review/src/config/index.config.ts index 3d9c8eff9..52643f273 100644 --- a/src/apps/review/src/config/index.config.ts +++ b/src/apps/review/src/config/index.config.ts @@ -19,6 +19,30 @@ 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 PAST_CHALLENGE_ROLE_SELECT_OPTIONS: SelectOption[] = [ + ROLE_SELECT_ALL_OPTION, + { + label: 'Reviewer', + value: REVIEWER_RESOURCE_ROLE_IDS.join(','), + }, +] + export const CHALLENGE_TYPE_SELECT_OPTIONS: SelectOption[] = [ CHALLENGE_TYPE_SELECT_ALL_OPTION, ...[ diff --git a/src/apps/review/src/lib/hooks/useFetchPastReviews.spec.tsx b/src/apps/review/src/lib/hooks/useFetchPastReviews.spec.tsx new file mode 100644 index 000000000..49218d3af --- /dev/null +++ b/src/apps/review/src/lib/hooks/useFetchPastReviews.spec.tsx @@ -0,0 +1,149 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { fetchPastReviews } from '../services' + +import { + DEFAULT_PAST_REVIEWS_PER_PAGE, + useFetchPastReviews, + useFetchPastReviewsProps, +} from './useFetchPastReviews' + +jest.mock('~/libs/shared', () => ({ + handleError: jest.fn(), +}), { virtual: true }) + +jest.mock('../services', () => ({ + fetchPastReviews: jest.fn(), +})) + +jest.mock('./useFetchActiveReviews', () => ({ + transformAssignments: jest.fn() + .mockReturnValue([]), +})) + +const mockedFetchPastReviews = fetchPastReviews as jest.Mock +const reviewerRoleIds: string[] = [ + 'reviewer-role', + 'screening-role', +] + +const TestComponent = (): JSX.Element => { + const { + isLoading, + loadPastReviews, + }: useFetchPastReviewsProps = useFetchPastReviews() + + function loadReviewerChallenges(): void { + loadPastReviews({ + page: 1, + perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + resourceRoleIds: reviewerRoleIds, + }) + .catch(() => undefined) + } + + function loadNextPage(): void { + loadPastReviews({ + page: 2, + perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + }) + .catch(() => undefined) + } + + function clearRoleFilter(): void { + loadPastReviews({ + page: 1, + perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + resourceRoleIds: undefined, + }) + .catch(() => undefined) + } + + return ( + <> + + + +
+ {String(isLoading)} +
+ + ) +} + +describe('useFetchPastReviews role filter', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedFetchPastReviews.mockResolvedValue({ + data: [], + meta: { + page: 1, + perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + totalCount: 0, + totalPages: 0, + }, + }) + }) + + it('persists reviewer role IDs across page changes and supports an explicit clear', async () => { + render() + const loadingIndicator = screen.getByTestId('loading') + + fireEvent.click(screen.getByRole('button', { + name: 'Load reviewer challenges', + })) + await waitFor(() => { + expect(loadingIndicator.textContent) + .toBe('false') + }) + expect(mockedFetchPastReviews) + .toHaveBeenCalledTimes(1) + expect(mockedFetchPastReviews) + .toHaveBeenNthCalledWith(1, expect.objectContaining({ + page: 1, + resourceRoleIds: reviewerRoleIds, + })) + + fireEvent.click(screen.getByRole('button', { + name: 'Load next page', + })) + await waitFor(() => { + expect(loadingIndicator.textContent) + .toBe('false') + }) + expect(mockedFetchPastReviews) + .toHaveBeenCalledTimes(2) + expect(mockedFetchPastReviews) + .toHaveBeenNthCalledWith(2, expect.objectContaining({ + page: 2, + resourceRoleIds: reviewerRoleIds, + })) + + fireEvent.click(screen.getByRole('button', { + name: 'Clear role filter', + })) + await waitFor(() => { + expect(loadingIndicator.textContent) + .toBe('false') + }) + expect(mockedFetchPastReviews) + .toHaveBeenCalledTimes(3) + expect(mockedFetchPastReviews) + .toHaveBeenNthCalledWith(3, expect.objectContaining({ + page: 1, + resourceRoleIds: undefined, + })) + }) +}) diff --git a/src/apps/review/src/lib/hooks/useFetchPastReviews.ts b/src/apps/review/src/lib/hooks/useFetchPastReviews.ts index c1ff7e592..cd25ecf6d 100644 --- a/src/apps/review/src/lib/hooks/useFetchPastReviews.ts +++ b/src/apps/review/src/lib/hooks/useFetchPastReviews.ts @@ -40,6 +40,7 @@ type LoadPastReviewsInternalParams = Required ({ EnvironmentConfig: { @@ -110,3 +113,34 @@ describe('fetchAllProjectResults', () => { .not.toHaveBeenCalled() }) }) + +describe('fetchPastReviews', () => { + beforeEach(() => { + mockedXhrGetAsync.mockReset() + }) + + it('includes reviewer resource role IDs in the server-side filter', async () => { + mockedXhrGetAsync.mockResolvedValue({ + data: [], + meta: { + page: 1, + perPage: 50, + totalCount: 0, + totalPages: 0, + }, + } as never) + + await fetchPastReviews({ + page: 1, + perPage: 50, + resourceRoleIds: ['reviewer-role', 'screening-role'], + }) + + expect(mockedXhrGetAsync) + .toHaveBeenCalledWith( + 'https://api.topcoder.test/v6/my-reviews' + + '?resourceRoleIds=reviewer-role%2Cscreening-role' + + '&page=1&perPage=50&past=true', + ) + }) +}) diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts index 31a71289e..0d1e6a1c8 100644 --- a/src/apps/review/src/lib/services/reviews.service.ts +++ b/src/apps/review/src/lib/services/reviews.service.ts @@ -95,6 +95,7 @@ export interface FetchPastReviewsParams { challengeTrackId?: string challengeName?: string challengeStatus?: string + resourceRoleIds?: string[] page?: number perPage?: number sortBy?: string @@ -106,6 +107,7 @@ export const fetchPastReviews = async ({ challengeTrackId, challengeName, challengeStatus, + resourceRoleIds, page, perPage, sortBy, @@ -117,13 +119,17 @@ export const fetchPastReviews = async ({ ...(challengeTrackId ? { challengeTrackId } : {}), ...(challengeName ? { challengeName } : {}), ...(challengeStatus ? { challengeStatus } : {}), + ...(resourceRoleIds?.length ? { resourceRoleIds } : {}), ...(page ? { page } : {}), ...(perPage ? { perPage } : {}), ...(sortBy ? { sortBy } : {}), ...(sortOrder ? { sortOrder } : {}), past: true, }, - { addQueryPrefix: true }, + { + addQueryPrefix: true, + arrayFormat: 'comma', + }, ) return xhrGetAsync>( diff --git a/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.spec.tsx b/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.spec.tsx new file mode 100644 index 000000000..cdae7c0b2 --- /dev/null +++ b/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.spec.tsx @@ -0,0 +1,214 @@ +/* eslint-disable @typescript-eslint/no-var-requires, global-require */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { + PAST_CHALLENGE_ROLE_SELECT_OPTIONS, + REVIEWER_RESOURCE_ROLE_IDS, +} from '../../../config/index.config' +import { + useFetchChallengeTracks, + useFetchChallengeTypes, + useFetchPastReviews, +} from '../../../lib/hooks' + +import { PastReviewsPage } from './PastReviewsPage' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + REVIEW: { + PROFILE_PAGE_URL: 'https://profiles.example.com', + }, + }, +}), { virtual: true }) + +jest.mock('react-select', () => ({ + __esModule: true, + default: (props: { + inputId?: string + isDisabled?: boolean + onChange: (option?: { label: string; value: string }) => void + options: Array<{ label: string; value: string }> + value?: { label: string; value: string } + }) => { + function handleChange(event: { target: { value: string } }): void { + const selectedOption = props.options.find( + option => option.value === event.target.value, + ) + props.onChange(selectedOption) + } + + return ( + + ) + }, +})) + +jest.mock('~/apps/admin/src/lib', () => ({ + Pagination: () =>
Pagination
, + TableLoading: () =>
Loading
, +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { + label: string + onClick?: () => void + }) => ( + + ), + IconOutline: { + XIcon: () => clear-icon, + }, + InputText: (props: { + name: string + onChange?: (event: { target: { value: string } }) => void + value?: string + }) => ( + + ), +}), { virtual: true }) + +jest.mock('../../../lib', () => { + const React = require('react') as typeof import('react') + + return { + PageWrapper: (props: React.PropsWithChildren) =>
{props.children}
, + ReviewAppContext: React.createContext({ + loginUserInfo: { + userId: '40587818', + }, + }), + TableActiveReviews: () =>
Past reviews
, + TableNoRecord: () =>
No records
, + } +}) + +jest.mock('../../../lib/hooks', () => ({ + useFetchChallengeTracks: jest.fn(), + useFetchChallengeTypes: jest.fn(), + useFetchPastReviews: jest.fn(), +})) + +jest.mock('../../../lib/utils', () => ({ + CHALLENGE_STATUS_SELECT_ALL_OPTION: { + label: 'All statuses', + value: '', + }, + PAST_CHALLENGE_STATUS_OPTIONS: [ + { + label: 'All statuses', + value: '', + }, + ], +})) + +const mockedUseFetchChallengeTracks = useFetchChallengeTracks as jest.Mock +const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock +const mockedUseFetchPastReviews = useFetchPastReviews as jest.Mock +const loadPastReviews = jest.fn() + +describe('PastReviewsPage role filter', () => { + beforeEach(() => { + jest.clearAllMocks() + loadPastReviews.mockResolvedValue(undefined) + mockedUseFetchChallengeTracks.mockReturnValue({ + challengeTracks: [], + isLoading: false, + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [], + isLoading: false, + }) + mockedUseFetchPastReviews.mockReturnValue({ + isLoading: false, + loadPastReviews, + pagination: { + page: 1, + perPage: 50, + totalCount: 0, + totalPages: 1, + }, + pastReviews: [], + }) + }) + + it('offers one aggregate Reviewer option and loads page one with every reviewer role ID', async () => { + render() + + const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement + expect(Array.from(roleSelect.options) + .map(option => option.text)) + .toEqual(['All roles', 'Reviewer']) + expect(PAST_CHALLENGE_ROLE_SELECT_OPTIONS) + .toHaveLength(2) + + fireEvent.change(roleSelect, { + target: { + value: REVIEWER_RESOURCE_ROLE_IDS.join(','), + }, + }) + + await waitFor(() => { + expect(loadPastReviews) + .toHaveBeenLastCalledWith(expect.objectContaining({ + page: 1, + resourceRoleIds: REVIEWER_RESOURCE_ROLE_IDS, + })) + }) + }) + + it('removes the reviewer role IDs when filters are cleared', async () => { + render() + + const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement + fireEvent.change(roleSelect, { + target: { + value: REVIEWER_RESOURCE_ROLE_IDS.join(','), + }, + }) + + await waitFor(() => { + expect(loadPastReviews) + .toHaveBeenLastCalledWith(expect.objectContaining({ + resourceRoleIds: REVIEWER_RESOURCE_ROLE_IDS, + })) + }) + + loadPastReviews.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + + await waitFor(() => { + expect(loadPastReviews) + .toHaveBeenCalledWith(expect.objectContaining({ + page: 1, + resourceRoleIds: undefined, + })) + }) + expect(roleSelect.value) + .toBe('') + }) +}) diff --git a/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.tsx b/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.tsx index b512cfd3a..885f73877 100644 --- a/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.tsx +++ b/src/apps/review/src/pages/past-review-assignments/PastReviewsPage/PastReviewsPage.tsx @@ -18,7 +18,11 @@ import { Pagination, TableLoading } from '~/apps/admin/src/lib' import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib' import { Button, IconOutline, InputText } from '~/libs/ui' -import { CHALLENGE_TYPE_SELECT_ALL_OPTION } from '../../../config/index.config' +import { + CHALLENGE_TYPE_SELECT_ALL_OPTION, + PAST_CHALLENGE_ROLE_SELECT_OPTIONS, + ROLE_SELECT_ALL_OPTION, +} from '../../../config/index.config' import { PageWrapper, ReviewAppContext, @@ -82,6 +86,9 @@ export const PastReviewsPage: FC = (props: Props) => { const [challengeStatus, setChallengeStatus] = useState>( CHALLENGE_STATUS_SELECT_ALL_OPTION, ) + const [challengeRole, setChallengeRole] = useState>( + ROLE_SELECT_ALL_OPTION, + ) const challengeTypeOptions = useMemo(() => { const results: SelectOption[] = [CHALLENGE_TYPE_SELECT_ALL_OPTION] @@ -115,6 +122,14 @@ export const PastReviewsPage: FC = (props: Props) => { const selectedChallengeTrackId = challengeTrack?.value || undefined const selectedChallengeTypeId = challengeType?.value || undefined const selectedChallengeStatus = challengeStatus?.value || undefined + const selectedResourceRoleIds = useMemo( + () => ( + challengeRole?.value + ? challengeRole.value.split(',') + : undefined + ), + [challengeRole], + ) // If the selected type is not allowed for the selected track, reset to All useEffect(() => { @@ -137,6 +152,7 @@ export const PastReviewsPage: FC = (props: Props) => { challengeTypeId: selectedChallengeTypeId || undefined, page: 1, perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + resourceRoleIds: selectedResourceRoleIds, sortBy: sort?.fieldName, sortOrder: sort?.direction, }) @@ -151,6 +167,7 @@ export const PastReviewsPage: FC = (props: Props) => { selectedChallengeTrackId, selectedChallengeTypeId, selectedChallengeStatus, + selectedResourceRoleIds, sort, ]) @@ -163,6 +180,7 @@ export const PastReviewsPage: FC = (props: Props) => { challengeTypeId: selectedChallengeTypeId || undefined, page: nextPage, perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + resourceRoleIds: selectedResourceRoleIds, sortBy: sort?.fieldName, sortOrder: sort?.direction, }) @@ -173,6 +191,7 @@ export const PastReviewsPage: FC = (props: Props) => { selectedChallengeTrackId, selectedChallengeTypeId, selectedChallengeStatus, + selectedResourceRoleIds, sort, ], ) @@ -196,6 +215,7 @@ export const PastReviewsPage: FC = (props: Props) => { setChallengeType(CHALLENGE_TYPE_SELECT_ALL_OPTION) setChallengeName('') setChallengeStatus(CHALLENGE_STATUS_SELECT_ALL_OPTION) + setChallengeRole(ROLE_SELECT_ALL_OPTION) loadPastReviews({ challengeName: undefined, challengeStatus: undefined, @@ -203,6 +223,7 @@ export const PastReviewsPage: FC = (props: Props) => { challengeTypeId: undefined, page: 1, perPage: DEFAULT_PAST_REVIEWS_PER_PAGE, + resourceRoleIds: undefined, sortBy: sort?.fieldName, sortOrder: sort?.direction, }) @@ -236,6 +257,18 @@ export const PastReviewsPage: FC = (props: Props) => { )} /> +
+ + + + ) + }, FormSelectField: function FormSelectField(props: { label: string name: string @@ -213,7 +241,11 @@ jest.mock('./EngagementLocationFields', () => ({ }, })) jest.mock('./EngagementPrivateSection', () => ({ - EngagementPrivateSection: () => <>, + EngagementPrivateSection: (props: { hideCheckbox?: boolean }) => ( + props.hideCheckbox + ?

Assigned Members

+ : <> + ), })) jest.mock('./EngagementSkillsField', () => ({ EngagementSkillsField: function EngagementSkillsField() { @@ -332,6 +364,53 @@ describe('EngagementEditorForm', () => { .toBe('Software Developer') }) + it('hides public posting fields and shows assignments when private is checked', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + expect(screen.getByLabelText('Duration in weeks')) + .toBeTruthy() + expect(screen.getByLabelText('Role')) + .toBeTruthy() + + await user.click(screen.getByLabelText('Private engagement')) + + expect(screen.queryByLabelText('Duration in weeks')) + .toBeNull() + expect(screen.queryByLabelText('Role')) + .toBeNull() + expect(screen.getByRole('heading', { name: 'Assigned Members' })) + .toBeTruthy() + }) + it('renders internal account fields and role level options', () => { render( diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx index 0cde0015f..6ba11ad5d 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx @@ -28,6 +28,7 @@ import { rootRoute, } from '../../../../config/routes.config' import { + FormCheckboxField, FormSelectField, FormSelectOption, FormTextField, @@ -542,23 +543,26 @@ function toPayload( assignmentDetails?: SerializedAssignmentDetailsPayload[] } = { account: values.account, - anticipatedStart: values.anticipatedStart, - compensationRange: values.compensationRange, - countries: values.countries, description: values.description, - durationWeeks: Number(values.durationWeeks), isPrivate: values.isPrivate, projectId: values.projectId, receivedDateFromAccount: values.receivedDateFromAccount, - role: values.role, roleLevel: values.roleLevel, skills: values.skills, smu: values.smu, spoc: values.spoc, status: values.status, - timezones: values.timezones, title: values.title, - workload: values.workload, + } + + if (!values.isPrivate) { + payload.anticipatedStart = values.anticipatedStart + payload.compensationRange = values.compensationRange + payload.countries = values.countries + payload.durationWeeks = Number(values.durationWeeks) + payload.role = values.role + payload.timezones = values.timezones + payload.workload = values.workload } const requiredMemberCount = getPayloadRequiredMemberCount( @@ -828,45 +832,14 @@ export const EngagementEditorForm: FC = (

Basic Information

-
+
- - - - - - - -
- -
= (
-

Details

- -
- -
- -
-
- - - - -
- -
-
- -
-

Internal Account Details

+

Internal Details

= (
- +
+
+ +
+ +
+
+ + + +
+
+
+ +
+

Private

+ 0} + label='Private engagement' + name='isPrivate' + /> +
+ + {!values.isPrivate + ? ( +
+

Details

+ +
+
+ + + + + +
+ +
+
+ ) + : ( + + )}
{saveError diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx index 1778ccc08..c42548f41 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx @@ -36,6 +36,7 @@ interface EngagementPrivateSectionForm { } interface EngagementPrivateSectionProps { + hideCheckbox?: boolean assignmentManagementPath?: string lockedAssignedMemberHandles?: string[] } @@ -194,13 +195,19 @@ export const EngagementPrivateSection: FC = ( return (
-

Private

+ {!props.hideCheckbox + ? ( + <> +

Private

- + + + ) + :

Assigned Members

} {isPrivate ? ( From ce5ed47ce1d064bdf074bc9e258303bfef1ca4a4 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 30 Jul 2026 10:01:05 +0300 Subject: [PATCH 20/77] PM-5695 - no comments for deterministic ai workflows --- .../AiReviewsTable/AiReviewsTable.module.scss | 10 +++ .../AiReviewsTable/AiReviewsTable.tsx | 85 +++++++++++++++---- .../AiFeedback/AiFeedback.tsx | 21 +++-- .../AiFeedbackComments/AiFeedbackComment.tsx | 13 ++- .../src/lib/hooks/useFetchAiWorkflowRuns.ts | 7 ++ 5 files changed, 107 insertions(+), 29 deletions(-) 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..5b0e12b18 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -18,6 +18,7 @@ import { AiWorkflowRun, AiWorkflowRunsResponse, AiWorkflowRunStatusEnum, + AiWorkflowReviewMethod, getAiWorkflowRunsCacheKey, retriggerAiWorkflowRun, useFetchAiWorkflowsRuns, @@ -51,7 +52,7 @@ interface AiReviewerRow { initialScore?: number minScore?: number reviewDate?: string - run?: Pick + run?: Pick score?: number status?: 'failed' | 'failed-score' | 'passed' | 'pending' | 'cancelled' title: string @@ -107,6 +108,11 @@ function formatWeight(value?: number): string { return `${value.toFixed(0)}%` } +function shouldHideComments(run?: Pick): boolean { + return run?.id === '-1' + || run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC +} + function getConfiguredWorkflowName(workflow?: AiReviewConfigWorkflow['workflow']): string | undefined { const configuredName = workflow?.name?.trim() return configuredName || undefined @@ -265,6 +271,12 @@ const AiReviewsTable: FC = props => { } }) + 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) { @@ -455,7 +467,11 @@ const AiReviewsTable: FC = props => {
Reviewer
- + {row.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC ? ( + + ) : ( + + )} {row.title} @@ -473,6 +489,17 @@ const AiReviewsTable: FC = props => {
+
+
Review Date
+
+ {row.reviewDate + ? moment(row.reviewDate) + .local() + .format(TABLE_DATE_FORMAT) + : '-'} +
+
+ {hasConfig && ( <>
@@ -486,17 +513,6 @@ const AiReviewsTable: FC = props => { )} -
-
Review Date
-
- {row.reviewDate - ? moment(row.reviewDate) - .local() - .format(TABLE_DATE_FORMAT) - : '-'} -
-
-
Score
@@ -542,6 +558,22 @@ const AiReviewsTable: FC = props => { />
+ + {shouldHideComments(row.run) && ( +
+
Comments
+
+ + + + {row.run?.commentsCount ?? 0} + + +
+
+ )}
))}
@@ -568,18 +600,19 @@ const AiReviewsTable: FC = props => {
+ {hasConfig && } {hasConfig && } - + {!reviewerRows.length && loading && ( - + )} @@ -588,7 +621,11 @@ const AiReviewsTable: FC = props => { - {hasConfig && } - {hasConfig && } + {hasConfig && } + {hasConfig && } + ))} 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..49f26f2f0 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 @@ -11,6 +11,7 @@ import { getScoreResponseOptions } from '~/apps/review/src/lib/utils' import { EnvironmentConfig } from '~/config' import { Button, IconOutline, Tooltip } from '~/libs/ui' import { useRole } from '~/apps/review/src/lib/hooks' +import { AiWorkflowReviewMethod } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { handleError } from '~/libs/shared/lib/utils/handle-error' import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../ScorecardViewer.context' @@ -68,6 +69,7 @@ const renderAiFeedbackContent = ( onShowReply: () => void, onSubmitReply: (content: string) => Promise, handleCloseReply: () => void, + isDeterministicWorkflow: boolean, ): JSX.Element => ( } @@ -138,14 +140,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 +183,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 +300,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..f4b9c8108 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 : () => setShowReply(prev => !prev)} + onPressEdit={isDeterministicWorkflow ? undefined : onPressEdit} /> { showReply && ( diff --git a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts index df80574bd..f2bd12b3a 100644 --- a/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts +++ b/src/apps/review/src/lib/hooks/useFetchAiWorkflowRuns.ts @@ -19,12 +19,18 @@ export enum AiWorkflowRunStatusEnum { SUCCESS = 'SUCCESS', } +export enum AiWorkflowReviewMethod { + DETERMINISTIC = 'DETERMINISTIC', + AI_ASSISTED = 'AI_ASSISTED' +} + export interface AiWorkflow { id: string; name: string; description: string; scorecard?: Scorecard defUrl: string + reviewMethod?: string llm: { name: string description: string @@ -50,6 +56,7 @@ export interface AiWorkflowRun { input: number output: number } + commentsCount?: number } export interface AiWorkflowRunArtifact { From 04219e03e3037addbcda8fb4cff506c69c807275 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 30 Jul 2026 10:10:38 +0300 Subject: [PATCH 21/77] lint fixes --- .../src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 6 +++--- .../ScorecardQuestion/AiFeedback/AiFeedback.tsx | 3 +-- .../AiFeedbackComments/AiFeedbackComment.tsx | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 5b0e12b18..64adb1cf3 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -15,10 +15,10 @@ import { IconOutline, Tooltip } from '~/libs/ui' import { aiRunFailed, aiRunInProgress, + AiWorkflowReviewMethod, AiWorkflowRun, AiWorkflowRunsResponse, AiWorkflowRunStatusEnum, - AiWorkflowReviewMethod, getAiWorkflowRunsCacheKey, retriggerAiWorkflowRun, useFetchAiWorkflowsRuns, @@ -468,7 +468,7 @@ const AiReviewsTable: FC = props => {
{row.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC ? ( - + ) : ( )} @@ -622,7 +622,7 @@ const AiReviewsTable: FC = props => {
{row.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC || row.run?.id === '-1' ? ( - + ) : ( )} 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 49f26f2f0..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,13 +5,12 @@ 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' import { Button, IconOutline, Tooltip } from '~/libs/ui' import { useRole } from '~/apps/review/src/lib/hooks' -import { AiWorkflowReviewMethod } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { handleError } from '~/libs/shared/lib/utils/handle-error' import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../ScorecardViewer.context' 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 f4b9c8108..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 @@ -102,7 +102,7 @@ export const AiFeedbackComment: FC = props => { feedback={props.feedback} comment={props.comment} actionType='comment' - onPressReply={isDeterministicWorkflow ? undefined : () => setShowReply(prev => !prev)} + onPressReply={isDeterministicWorkflow ? undefined : function () { setShowReply(prev => !prev) }} onPressEdit={isDeterministicWorkflow ? undefined : onPressEdit} /> { From 7a5402469bda6473976a9be406c983817eec262a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 30 Jul 2026 11:56:13 +0300 Subject: [PATCH 22/77] PM-5636 - showcase post card - render content text as plain --- .../ProjectShowcaseCard/ProjectShowcaseCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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..7461374b9 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,12 @@ const ProjectShowcaseCard: FC = props => (
- {props.post.content} +
From e4bf480747a3756cfd462b51d65988edad18e225 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 30 Jul 2026 12:00:06 +0300 Subject: [PATCH 23/77] we actually don't need html content --- .../ProjectShowcaseCard/ProjectShowcaseCard.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 7461374b9..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 @@ -36,12 +36,7 @@ const ProjectShowcaseCard: FC = props => (
-
+ {renderRichTextToPlainText(props.post.content || '')}
From ff60f20fa53c81db35a21d3e3380478961112a6f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 19:02:22 +1000 Subject: [PATCH 24/77] PM-5725: Enable compilation errors in view mode What was broken The "View compilation errors" action for a failed Marathon Match scorer could not be activated when Work Manager displayed the challenge in view mode. Root cause View mode places the scorer inside a disabled fieldset and disables pointer events for that form. The diagnostic action was a native form button, so it was disabled together with the editing controls. What was changed Kept the action visually link-styled while making it an enabled, keyboard-accessible button-role control, and restored pointer events only for that diagnostic action. All scorer editing controls remain disabled in view mode. Any added/updated tests Added a MarathonMatchScorerSection regression test that renders a failed scorer inside a disabled view-mode fieldset, verifies the diagnostic action remains enabled, and confirms the modal displays the compilation output. --- .../MarathonMatchScorerSection.module.scss | 1 + .../MarathonMatchScorerSection.spec.tsx | 50 +++++++++++++++++++ .../MarathonMatchScorerSection.tsx | 24 +++++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss index 43b1cd7c0..8ce1111cd 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss @@ -83,6 +83,7 @@ font: inherit; font-weight: 600; padding: 0; + pointer-events: auto; text-decoration: underline; } diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx index 605b7e589..cf3e2c5d2 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx @@ -1,6 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */ import '@testing-library/jest-dom' import { + fireEvent, render, screen, waitFor, @@ -222,4 +223,53 @@ describe('MarathonMatchScorerSection', () => { ) }) }) + + it('opens compilation errors from a disabled view-mode fieldset', async () => { + const failedTester: MarathonMatchTester = { + ...tester, + compilationError: 'ExampleScorer.java:1: compilation failed', + compilationStatus: 'FAILED', + } + const savedConfig = buildSavedConfig(CHALLENGE_ID, { + compileTimeout: defaults.compileTimeout, + name: 'Marathon Match Scorer', + reviewScorecardId: defaults.reviewScorecardId, + taskDefinitionName: defaults.taskDefinitionName, + taskDefinitionVersion: defaults.taskDefinitionVersion, + testerId: failedTester.id, + testTimeout: defaults.testTimeout, + }) + + mockFetchMarathonMatchConfig.mockResolvedValue(savedConfig) + mockFetchTester.mockResolvedValue(failedTester) + + render( +
+ +
, + ) + + const compilationErrorsControl = await screen.findByRole('button', { + name: 'View compilation errors', + }) + + expect(compilationErrorsControl) + .toBeEnabled() + fireEvent.click(compilationErrorsControl) + + const compilationErrorsDialog = screen.getByRole('dialog') + const compilationErrorsHeading = within(compilationErrorsDialog) + .getByRole('heading', { + name: 'Compilation Errors', + }) + + expect(compilationErrorsHeading) + .toBeInTheDocument() + expect(compilationErrorsDialog) + .toHaveTextContent('ExampleScorer.java:1: compilation failed') + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx index 7ac0570a9..cabfa7f70 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, FC, + KeyboardEvent, useCallback, useEffect, useMemo, @@ -1005,6 +1006,20 @@ export const MarathonMatchScorerSection: FC = ( setShowCompilationErrorsModal(true) }, []) + /** + * Opens compilation diagnostics when the link-styled control is activated from the keyboard. + * @param event Keyboard event from the compilation errors control. + * @returns void + */ + const handleCompilationErrorsKeyDown = useCallback((event: KeyboardEvent): void => { + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + + event.preventDefault() + handleOpenCompilationErrorsModal() + }, [handleOpenCompilationErrorsModal]) + /** * Closes the failed scorer compilation diagnostics modal. * @returns void @@ -1793,13 +1808,16 @@ export const MarathonMatchScorerSection: FC = (
Scorer compilation failed. - +
) From 652a5bd0a1373ad43dbb806e7fba9531f66b8bbe Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 19:17:31 +1000 Subject: [PATCH 25/77] PM-5722: Show final Marathon Match winner scores What was broken The Review app Winners tab displayed 0.00 for completed Marathon Match winners even though their final system scores were available and shown correctly on the Review tab. Root cause The Winners flow trusted the canonical project-result score, which is zero for these Marathon Match results, and stopped applying the exact winning submission's final system review summation. What was changed Preserved canonical winner identity and placement while using the exact matching submission's finite final aggregate score. Provisional scores and scores from sibling submissions remain excluded. Updated the Winners result documentation to describe the score source. Any added/updated tests Added regression coverage for a zero canonical score with a precise final Marathon Match aggregate, including a higher-scoring sibling submission that must not be selected. --- src/apps/review/README.md | 7 +++-- .../hooks/useFetchChallengeResults.spec.ts | 31 +++++++++++++++++++ .../src/lib/hooks/useFetchChallengeResults.ts | 20 ++++++------ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/apps/review/README.md b/src/apps/review/README.md index 2258c46c6..3fbda4d75 100644 --- a/src/apps/review/README.md +++ b/src/apps/review/README.md @@ -28,8 +28,9 @@ 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. diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts index e82b86d7b..228d215f9 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.spec.ts @@ -163,6 +163,37 @@ describe('getSubmissionFinalScoreCandidate', () => { }) describe('buildCanonicalChallengeResults', () => { + it('uses the exact canonical submission final aggregate score for Marathon Match winners', () => { + const finalAggregateScore = 99.50699286094532 + const results = buildCanonicalChallengeResults({ + canonicalResults: [buildProjectResult({ + finalScore: 0, + submissionId: 'canonical-submission', + })], + challengeUuid: 'challenge-id', + memberMapping: {}, + submissions: [ + buildSubmission({ + finalAggregateScore: 100, + id: 'higher-scoring-sibling', + }), + buildSubmission({ + finalAggregateScore, + id: 'canonical-submission', + }), + ], + winners: [buildWinner({ type: 'PLACEMENT' })], + }) + + expect(results) + .toHaveLength(1) + expect(results[0]) + .toMatchObject({ + finalScore: finalAggregateScore, + submissionId: 'canonical-submission', + }) + }) + it('uses the exact canonical submission and ignores checkpoint and duplicate winner rows', () => { const exactReview = buildReview() const siblingReview = buildReview({ diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts index e4d23f593..6dab6b1e6 100644 --- a/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts +++ b/src/apps/review/src/lib/hooks/useFetchChallengeResults.ts @@ -167,10 +167,10 @@ function isFinalPlacementWinner(winner: ChallengeWinner): boolean { /** * Enriches one canonical Review API result with display data from its exact local submission. * - * The canonical result remains authoritative for submission identity, placement, and scores. - * Local data may supply reviews and the submitted date only when its submission id exactly - * matches the canonical id, which prevents a multi-submission winner's sibling submission from - * replacing the downloadable winner. + * The canonical result remains authoritative for submission identity and placement. An exact + * matching submission may supply its final/system aggregate score, reviews, and submitted date, + * which restores Marathon Match scoring without allowing a sibling submission to replace the + * downloadable winner. * * @param params canonical result, matching challenge winner, submissions, reviews, and members. * @returns The display-ready project result, or undefined when the canonical identity is invalid. @@ -193,6 +193,7 @@ const buildProjectResult = ({ const exactSubmission = submissions.find( submission => normalizeIdentifier(submission.id) === canonicalSubmissionId, ) + const finalAggregateScore = toFiniteNumber(exactSubmission?.finalAggregateScore) const fallbackReviews = exactSubmission?.reviews ?? canonicalResult.reviews ?? [] const orderedReviews = orderReviewsByCreatedDate(fallbackReviews) @@ -206,6 +207,7 @@ const buildProjectResult = ({ return adjustProjectResult({ ...canonicalResult, challengeId: normalizeIdentifier(canonicalResult.challengeId) ?? challengeUuid, + finalScore: finalAggregateScore ?? canonicalResult.finalScore, reviews: orderedReviews, submissionId: canonicalSubmissionId, submittedDate: exactSubmission?.submittedDate ?? canonicalResult.submittedDate, @@ -294,11 +296,11 @@ export interface useFetchChallengeResultsProps { /** * Fetches canonical Winners-tab results and enriches them with local display data. * - * The Review API project-result endpoint is authoritative for the winning submission id, - * placement, and scores. Challenge submissions contribute display-only data for the exact - * canonical submission. Loading remains active until both request streams settle. Challenge - * reviews are deliberately not fetched because registered members without submissions may - * download winners but are not authorized to inspect challenge review data. + * The Review API project-result endpoint is authoritative for the winning submission id and + * placement. The exact challenge submission may contribute its final/system aggregate score and + * display data. Loading remains active until both request streams settle. Challenge reviews are + * deliberately not fetched because registered members without submissions may download winners + * but are not authorized to inspect challenge review data. * * @param submissions submissions already available in the challenge detail view. * @returns Canonical display-ready project results and their combined loading state. From 8ba4a8daa35d13757c1ad469e1a09934da344051 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 19:41:30 +1000 Subject: [PATCH 26/77] PM-5771: Correct initial scorecard actions What was broken Pending review scorecards showed a Reopen button before their first commit and labeled the initial task as Edit Review Scorecard. Root cause Reopen visibility only required a persisted review ID, while the header title did not account for the review lifecycle status. Table reopen actions also returned reviews to PENDING, making reopened work indistinguishable from a first fill. What was changed Require a committed review before showing Reopen, label PENDING reviews as Fill Scorecard, and normalize reopened reviews to IN_PROGRESS so they continue to use Edit Review Scorecard. Any added/updated tests Added component tests for committed-only Reopen visibility and the PENDING versus IN_PROGRESS scorecard titles. --- .../ChallengeLinksForAdmin.spec.tsx | 91 +++++++++++++++++++ .../ChallengeLinksForAdmin.tsx | 3 +- .../TableCheckpointSubmissions.tsx | 2 +- .../components/TableReview/TableReview.tsx | 2 +- .../TableSubmissionScreening.tsx | 2 +- .../ReviewScorecardHeader.spec.tsx | 48 ++++++++++ .../ReviewViewer/ReviewScorecardHeader.tsx | 8 +- 7 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 src/apps/review/src/lib/components/ChallengeLinksForAdmin/ChallengeLinksForAdmin.spec.tsx create mode 100644 src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx 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/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx index 88ee54e87..ec93969bc 100644 --- a/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx +++ b/src/apps/review/src/lib/components/TableCheckpointSubmissions/TableCheckpointSubmissions.tsx @@ -176,7 +176,7 @@ export const TableCheckpointSubmissions: FC = (props: Props) => { setIsReopening(true) try { - await updateReview(reviewId, { committed: false, status: 'PENDING' }) + await updateReview(reviewId, { committed: false, status: 'IN_PROGRESS' }) toast.success('Scorecard reopened.') closeReopenDialog() await refreshChallengeReviewData(challengeId) diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx index b9ea03825..fec6728bc 100644 --- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx +++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx @@ -446,7 +446,7 @@ export const TableReview: FC = (props: TableReviewProps) => { try { await updateReview(reviewId, { committed: false, - status: 'PENDING', + status: 'IN_PROGRESS', }) toast.success('Scorecard reopened.') closeReopenDialog() diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx index 794c94f15..62f4617fd 100644 --- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx +++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx @@ -1091,7 +1091,7 @@ export const TableSubmissionScreening: FC = (props: Props) => { setIsReopening(true) try { - await updateReview(reviewId, { committed: false, status: 'PENDING' }) + await updateReview(reviewId, { committed: false, status: 'IN_PROGRESS' }) toast.success('Scorecard reopened.') closeReopenDialog() await refreshChallengeReviewData(challengeId) diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx new file mode 100644 index 000000000..bba569215 --- /dev/null +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx @@ -0,0 +1,48 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' + +import type { ReviewInfo } from '~/apps/review/src/lib/models' + +import { ReviewScorecardHeader } from './ReviewScorecardHeader' + +jest.mock('~/apps/review/src/lib', () => ({ + useChallengeDetailsContext: () => ({ + resources: [], + }), +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/assets/icons', () => ({ + IconDeepseekAi: () => <>, + IconPhaseReview: () => <>, + IconPremium: () => <>, +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/components/ProgressBar', () => ({ + ProgressBar: () => <>, +}), { virtual: true }) + +describe('ReviewScorecardHeader', () => { + it('distinguishes a pending first fill from an in-progress reopened review', () => { + const rendered = render( + , + ) + + expect(screen.getByRole('heading', { name: 'Fill Scorecard' })) + .toBeTruthy() + + rendered.rerender( + , + ) + + expect(screen.getByRole('heading', { name: 'Edit Review Scorecard' })) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx index 3998a967a..ee498c03c 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx @@ -32,6 +32,10 @@ export const ReviewScorecardHeader: FC = (props: Props) => { const reviewerColor = reviewer?.handleColor const llmModelName = props.workflow?.llm?.name || 'N/A' const minimumPassingScore = props.scorecardInfo?.minimumPassingScore ?? 0 + const isPendingReview = (props.reviewInfo?.status ?? '') + .toString() + .trim() + .toUpperCase() === 'PENDING' return (
@@ -43,7 +47,9 @@ export const ReviewScorecardHeader: FC = (props: Props) => {
-

Edit Review Scorecard

+

+ {isPendingReview ? 'Fill Scorecard' : 'Edit Review Scorecard'} +

{reviewerHandle && (
From 2aeaf5453400d2b9bb071f45b35a4d5147a03b41 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 19:56:52 +1000 Subject: [PATCH 27/77] PM-5770: Correct submitter scorecard title What was broken Submitters viewing AI review scorecards saw "Edit Review Scorecard" even though the scorecard is read-only for them. Root cause The review scorecard header used a hard-coded edit label and received no indication that it was rendering the submitter view. What was changed Pass the existing submitter view context to the header and show "Scorecard Details" for submitters while preserving the existing edit label for other roles. Any added/updated tests Added component coverage for submitter and non-submitter scorecard headings. The focused test and all review-app suites pass. The full monorepo run still has 13 unrelated failing suites outside this change. --- .../ReviewScorecardHeader.spec.tsx | 36 +++++++++++++++++++ .../ReviewViewer/ReviewScorecardHeader.tsx | 5 ++- .../components/ReviewViewer/ReviewViewer.tsx | 3 +- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx new file mode 100644 index 000000000..e78c2bef7 --- /dev/null +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx @@ -0,0 +1,36 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { render, screen } from '@testing-library/react' + +import { ReviewScorecardHeader } from './ReviewScorecardHeader' + +jest.mock('~/apps/review/src/lib', () => ({ + useChallengeDetailsContext: () => ({ + resources: [], + }), +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/assets/icons', () => ({ + IconDeepseekAi: () => <>, + IconPhaseReview: () => <>, + IconPremium: () => <>, +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/components/ProgressBar', () => ({ + ProgressBar: () => <>, +}), { virtual: true }) + +describe('ReviewScorecardHeader', () => { + it('labels the submitter scorecard as read-only details', () => { + render() + + expect(screen.getByRole('heading', { name: 'Scorecard Details' })) + .toBeTruthy() + }) + + it('keeps the edit label for non-submitter views', () => { + render() + + expect(screen.getByRole('heading', { name: 'Edit Review Scorecard' })) + .toBeTruthy() + }) +}) diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx index 3998a967a..d0c6e24bb 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx @@ -9,6 +9,7 @@ import { AiWorkflow } from '~/apps/review/src/lib/hooks' import styles from './ReviewScorecardHeader.module.scss' interface Props { + isSubmitterView?: boolean reviewInfo?: ReviewInfo scorecardInfo?: ScorecardInfo workflow?: AiWorkflow @@ -43,7 +44,9 @@ export const ReviewScorecardHeader: FC = (props: Props) => {
-

Edit Review Scorecard

+

+ {props.isSubmitterView ? 'Scorecard Details' : 'Edit Review Scorecard'} +

{reviewerHandle && (
diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx index 710e249c8..4349d4818 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx @@ -18,7 +18,7 @@ import { ChallengeLinks, ConfirmModal, useChallengeDetailsContext } from '~/apps import { useIsEditReview, useIsEditReviewProps } from '~/apps/review/src/lib/hooks/useIsEditReview' import { rootRoute } from '~/apps/review/src/config/routes.config' -import { ADMIN, COPILOT, MANAGER } from '../../../../config/index.config' +import { ADMIN, COPILOT, MANAGER, SUBMITTER } from '../../../../config/index.config' import { useReviewsContext } from '../../ReviewsContext' import { ReviewScorecardHeader } from './ReviewScorecardHeader' @@ -253,6 +253,7 @@ const ReviewViewer: FC = () => { {!isSubmitterPhaseLocked && ( <> Date: Thu, 30 Jul 2026 20:18:43 +1000 Subject: [PATCH 28/77] PM-5765: Add design scorecard fill action What was broken Design challenge reviewers had to select the positive or maximum score for every scorecard question individually. Root cause The platform review app did not carry over the legacy action for defaulting scorecard selections. What was changed Added a Fill Scorecard action for reviewer-like challenge assignments on editable Design scorecards. The action fills unanswered yes/no questions with Yes and numeric scales with their maximum value while preserving existing answers, comments, and unsupported question data. Any added/updated tests Added ScorecardViewer integration coverage for action visibility and form updates. Extended scorecard utility tests for normalized question matching, maximum values, and preservation of existing review data. --- .../ScorecardViewer/ScorecardViewer.spec.tsx | 175 ++++++++++++++++++ .../ScorecardViewer/ScorecardViewer.tsx | 42 ++++- .../Scorecard/ScorecardViewer/utils.spec.ts | 108 ++++++++++- .../Scorecard/ScorecardViewer/utils.ts | 64 +++++++ .../components/ReviewViewer/ReviewViewer.tsx | 17 +- 5 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.spec.tsx diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.spec.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.spec.tsx new file mode 100644 index 000000000..7819baa0c --- /dev/null +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.spec.tsx @@ -0,0 +1,175 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import type { + FormReviews, + ReviewInfo, + ScorecardInfo, +} from '../../../models' + +import ScorecardViewer from './ScorecardViewer' + +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
Loading
, +}), { virtual: true }) + +jest.mock('../../../utils', () => { + const Yup: typeof import('yup') = jest.requireActual('yup') + + return { + formReviewsSchema: Yup.object({ + reviews: Yup.array() + .required(), + }), + roundWith2DecimalPlaces: (value: number): number => Math.round(value * 100) / 100, + } +}) + +jest.mock('../../ConfirmModal', () => ({ + ConfirmModal: () => undefined, +})) + +jest.mock('./ScorecardGroup', () => ({ + ScorecardGroup: () => undefined, +})) + +jest.mock('./ScorecardTotal', () => ({ + ScorecardTotal: () => undefined, +})) + +const scorecard: ScorecardInfo = { + id: 'scorecard-1', + minimumPassingScore: 50, + name: 'Design Review', + scorecardGroups: [{ + id: 'group-1', + name: 'Review', + sections: [{ + id: 'section-1', + name: 'Review', + questions: [ + { + description: 'Meets requirements', + guidelines: '', + id: 'yes-no-question', + requiresUpload: false, + scaleMax: 0, + scaleMin: 0, + sortOrder: 0, + type: 'YES_NO', + weight: 50, + }, + { + description: 'Quality', + guidelines: '', + id: 'scale-question', + requiresUpload: false, + scaleMax: 5, + scaleMin: 1, + sortOrder: 1, + type: 'SCALE', + weight: 50, + }, + ], + sortOrder: 0, + weight: 100, + }], + sortOrder: 0, + weight: 100, + }], +} + +const reviewInfo: ReviewInfo = { + committed: false, + createdAt: '2026-07-30T00:00:00.000Z', + resourceId: 'resource-1', + reviewItems: [ + { + createdAt: '2026-07-30T00:00:00.000Z', + id: 'review-item-1', + initialAnswer: '', + reviewItemComments: [{ + content: 'Keep this comment', + id: 'comment-1', + sortOrder: 0, + type: 'COMMENT', + }], + scorecardQuestionId: 'yes-no-question', + }, + { + createdAt: '2026-07-30T00:00:00.000Z', + id: 'review-item-2', + initialAnswer: '2', + reviewItemComments: [], + scorecardQuestionId: 'scale-question', + }, + ], + scorecardId: 'scorecard-1', + updatedAt: '2026-07-30T00:00:00.000Z', +} + +describe('ScorecardViewer Fill Scorecard action', () => { + it('fills unanswered selections and preserves existing answers and comments', async () => { + const saveReviewInfo = jest.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Fill Scorecard' })) + fireEvent.click(screen.getByRole('button', { name: 'Save as Draft' })) + + await waitFor(() => { + expect(saveReviewInfo) + .toHaveBeenCalledTimes(1) + }) + + const fullReview = saveReviewInfo.mock.calls[0][1] as FormReviews + + expect(fullReview.reviews) + .toEqual([ + expect.objectContaining({ + comments: [expect.objectContaining({ + content: 'Keep this comment', + id: 'comment-1', + })], + id: 'review-item-1', + initialAnswer: 'Yes', + }), + expect.objectContaining({ + id: 'review-item-2', + initialAnswer: '2', + }), + ]) + }) + + it('does not show the action without fill permission or edit access', () => { + const view: ReturnType = render( + , + ) + + expect(screen.queryByRole('button', { name: 'Fill Scorecard' })) + .toBeNull() + + view.rerender( + , + ) + + expect(screen.queryByRole('button', { name: 'Fill Scorecard' })) + .toBeNull() + }) +}) diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.tsx index 89fe27969..d54677d4a 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardViewer.tsx @@ -29,7 +29,7 @@ import { } from './ScorecardViewer.context' import { ScorecardGroup } from './ScorecardGroup' import { ScorecardTotal } from './ScorecardTotal' -import { createReviewItemMapping } from './utils' +import { createReviewItemMapping, fillScorecardWithMaximumAnswers } from './utils' import styles from './ScorecardViewer.module.scss' interface ScorecardViewerProps { @@ -45,6 +45,7 @@ interface ScorecardViewerProps { isSavingAppealResponse?: boolean isSavingManagerComment?: boolean canAddManagerComment?: boolean + canFillScorecard?: boolean setReviewStatus?: (status: ReviewCtxStatus) => void setActionButtons?: (buttons?: ReactNode) => void saveReviewInfo?: ( @@ -145,6 +146,25 @@ const ScorecardViewerContent: FC = props => { props.navigateBack?.() }, []) + /** + * Populate every supported score selection with its highest value. + */ + const handleFillScorecard = useCallback(() => { + if (!form) { + return + } + + const filledForm = fillScorecardWithMaximumAnswers( + form.getValues(), + props.scorecard, + ) + + form.setValue('reviews', filledForm.reviews, { + shouldDirty: true, + shouldValidate: true, + }) + }, [form, props.scorecard]) + const ContainerTag = props.isEdit ? 'form' : 'div' useEffect(() => { @@ -178,6 +198,16 @@ const ScorecardViewerContent: FC = props => { const actionButtons = useMemo(() => (
+ {props.canFillScorecard && ( + + )}
- ), [props.isEdit, handleSaveAsDraft, touchedAllFields, props.isSavingReview]) + ), [ + handleFillScorecard, + handleSaveAsDraft, + props.canFillScorecard, + props.isSavingReview, + touchedAllFields, + ]) useEffect(() => { props.setActionButtons?.(props.isEdit ? actionButtons : ( @@ -212,7 +248,7 @@ const ScorecardViewerContent: FC = props => { )) - }, [actionButtons, props.setActionButtons]) + }, [actionButtons, props.isEdit, props.setActionButtons]) if (props.isLoading) { return diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts index 34cf46f24..ad44f5082 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.spec.ts @@ -1,6 +1,9 @@ -import type { ScorecardInfo } from '../../../models' +import type { FormReviews, ScorecardInfo } from '../../../models' -import { calculateProgressAndScore } from './utils' +import { + calculateProgressAndScore, + fillScorecardWithMaximumAnswers, +} from './utils' jest.mock('../../../utils', () => ({ roundWith2DecimalPlaces: (value: number): number => Math.round(value * 100) / 100, @@ -79,3 +82,104 @@ describe('calculateProgressAndScore', () => { .toBe(98) }) }) + +describe('fillScorecardWithMaximumAnswers', () => { + it('fills unanswered supported questions and preserves existing review data', () => { + const scorecard = buildScorecard() + scorecard.scorecardGroups[0].sections[0].questions = [ + { + ...scorecard.scorecardGroups[0].sections[0].questions[0], + id: ' Yes-No-Question ', + }, + { + ...scorecard.scorecardGroups[0].sections[0].questions[1], + id: 'scale-question', + scaleMax: 5, + scaleMin: 1, + type: 'SCALE', + }, + { + description: 'Test case', + guidelines: 'Test case', + id: 'test-case-question', + requiresUpload: false, + scaleMax: 0, + scaleMin: 0, + sortOrder: 3, + type: 'TEST_CASE', + weight: 0, + }, + ] + const formData: FormReviews = { + reviews: [ + { + comments: [], + id: 'scale-review', + index: 0, + initialAnswer: '', + scorecardQuestionId: 'SCALE-QUESTION', + }, + { + comments: [{ + content: 'Keep this comment', + id: 'comment-1', + index: 0, + type: 'COMMENT', + }], + id: 'yes-no-review', + index: 1, + initialAnswer: '', + scorecardQuestionId: 'yes-no-question', + }, + { + comments: [], + id: 'test-case-review', + index: 2, + initialAnswer: 'Keep this answer', + scorecardQuestionId: 'test-case-question', + }, + { + comments: [], + id: 'unmatched-review', + index: 3, + initialAnswer: 'Keep unmatched', + scorecardQuestionId: 'unmatched-question', + }, + { + comments: [], + id: 'answered-scale-review', + index: 4, + initialAnswer: '2', + scorecardQuestionId: 'scale-question', + }, + { + comments: [], + id: 'answered-yes-no-review', + index: 5, + initialAnswer: 'No', + scorecardQuestionId: 'yes-no-question', + }, + ], + } + + const result = fillScorecardWithMaximumAnswers(formData, scorecard) + + expect(result.reviews) + .toEqual([ + { + ...formData.reviews[0], + initialAnswer: '5', + }, + { + ...formData.reviews[1], + initialAnswer: 'Yes', + }, + formData.reviews[2], + formData.reviews[3], + formData.reviews[4], + formData.reviews[5], + ]) + expect(formData.reviews.map(review => review.initialAnswer)) + .toEqual(['', '', 'Keep this answer', 'Keep unmatched', '2', 'No']) + }) +}) diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts index a2d87f1d1..67c0a8bb5 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/utils.ts @@ -1,6 +1,7 @@ import { filter, reduce } from 'lodash' import { + FormReviews, ReviewItemInfo, Scorecard, ScorecardInfo, @@ -56,6 +57,69 @@ export const createReviewItemMapping = ( return result } +/** + * Fill supported review answers with the maximum value defined by the scorecard. + * The review viewer uses this for its Fill Scorecard action. Review metadata, + * comments, existing answers, unmatched items, and unsupported question types + * remain unchanged. + * + * @param reviewFormData - Current review form values. + * @param scorecard - Scorecard whose questions define the maximum answers. + * @returns A copy of the form values with unanswered YES_NO questions set to + * Yes and unanswered SCALE questions set to their maximum numeric value. This + * function does not throw for unsupported question types. + */ +export const fillScorecardWithMaximumAnswers = ( + reviewFormData: FormReviews, + scorecard: Scorecard | ScorecardInfo, +): FormReviews => { + const maximumAnswers = new Map() + + scorecard.scorecardGroups.forEach(group => { + group.sections.forEach(section => { + section.questions.forEach(question => { + const normalizedQuestionId = normalizeScorecardQuestionId(question.id) + + if (!normalizedQuestionId) { + return + } + + if (question.type === 'YES_NO') { + maximumAnswers.set(normalizedQuestionId, 'Yes') + } else if ( + question.type === 'SCALE' + && question.scaleMax >= question.scaleMin + ) { + maximumAnswers.set(normalizedQuestionId, String(question.scaleMax)) + } + }) + }) + }) + + return { + ...reviewFormData, + reviews: reviewFormData.reviews.map(review => { + if (review.initialAnswer) { + return review + } + + const normalizedQuestionId = normalizeScorecardQuestionId( + review.scorecardQuestionId, + ) + const maximumAnswer = normalizedQuestionId + ? maximumAnswers.get(normalizedQuestionId) + : undefined + + return maximumAnswer === undefined + ? review + : { + ...review, + initialAnswer: maximumAnswer, + } + }), + } +} + export interface ProgressAndScore { reviewProgress: number; totalScore: number; diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx index 710e249c8..0388d14ec 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx @@ -18,7 +18,12 @@ import { ChallengeLinks, ConfirmModal, useChallengeDetailsContext } from '~/apps import { useIsEditReview, useIsEditReviewProps } from '~/apps/review/src/lib/hooks/useIsEditReview' import { rootRoute } from '~/apps/review/src/config/routes.config' -import { ADMIN, COPILOT, MANAGER } from '../../../../config/index.config' +import { + ADMIN, + COPILOT, + DESIGN, + MANAGER, +} from '../../../../config/index.config' import { useReviewsContext } from '../../ReviewsContext' import { ReviewScorecardHeader } from './ReviewScorecardHeader' @@ -37,6 +42,7 @@ const ReviewViewer: FC = () => { const { actionChallengeRole, + hasReviewerRole, myChallengeResources, myChallengeRoles, }: useRoleProps = useRole() @@ -80,6 +86,14 @@ const ReviewViewer: FC = () => { const { challengeInfo, }: ChallengeDetailContextModel = useChallengeDetailsContext() + const canFillScorecard = useMemo( + () => ( + hasReviewerRole + && challengeInfo?.track?.name?.trim() + .toLowerCase() === DESIGN.toLowerCase() + ), + [challengeInfo?.track?.name, hasReviewerRole], + ) const { isEdit: isEditPhase }: useIsEditReviewProps = useIsEditReview() const { @@ -273,6 +287,7 @@ const ReviewViewer: FC = () => { isSavingAppeal={isSavingAppeal} isSavingAppealResponse={isSavingAppealResponse} isSavingManagerComment={isSavingManagerComment} + canFillScorecard={canFillScorecard} canAddManagerComment={ hasChallengeAdminRole || hasTopcoderAdminRole From 370a09eb516f462e600b0cd52d4c7c0e9493c7b0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 21:08:02 +1000 Subject: [PATCH 29/77] PM-5757: Add view-mode footer actions What was broken Read-only challenge views exposed their available actions only in the page header, leaving no action controls at the bottom of long challenge pages. Root cause The challenge form footer is intentionally limited to edit mode, and the read-only page did not render its header action set anywhere after the page content. What was changed Added a read-only footer action group that reuses the existing status, permission, and handler logic for Edit, Launch, Cancel, and Mark Complete. Kept Challenge, Review, and Forum quick links in the header only, right-aligned the new footer group, and updated the challenge editor documentation. Any added/updated tests Updated ChallengeEditorPage coverage to verify matching header and footer actions, footer launch state, action visibility, styling, and exclusion of quick links. The focused 19-test page suite, lint, and production build pass. The full repository suite was also run: 919 tests pass, while 36 pre-existing failures remain across 13 unrelated baseline suites. --- .../ChallengeEditorPage.module.scss | 6 ++ .../ChallengeEditorPage.spec.tsx | 65 ++++++++++++------- .../ChallengeEditorPage.tsx | 21 +++++- .../challenges/ChallengeEditorPage/README.md | 1 + 4 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.module.scss index dee497851..378591105 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.module.scss @@ -83,6 +83,12 @@ justify-content: flex-end; } +.footerActions { + display: flex; + justify-content: flex-end; + width: 100%; +} + .cancelAction { position: relative; } diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx index b736df32f..d4559145b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx @@ -419,7 +419,7 @@ describe('ChallengeEditorPage', () => { .toBe('lg') }) - it('renders a read-only draft challenge view with cancel, launch, and edit header actions', async () => { + it('renders a read-only draft challenge view with header and footer actions', async () => { renderPage( '/projects/123/challenges/456/view', '/projects/:projectId/challenges/:challengeId/view', @@ -437,22 +437,35 @@ describe('ChallengeEditorPage', () => { .toBe('false') expect(screen.getByRole('heading', { name: 'View Edit test' })) .toBeTruthy() - expect(screen.getByRole('button', { name: 'Cancel' })) + const headerActions = within(screen.getByTestId('right-header')) + const footerActions = within(screen.getByRole('group', { + name: 'Challenge footer actions', + })) + + expect(headerActions.getByRole('button', { name: 'Cancel' })) + .toBeTruthy() + expect(headerActions.getByRole('button', { name: 'Launch' })) + .toBeTruthy() + expect(headerActions.getByRole('button', { name: 'Edit' })) .toBeTruthy() - expect(screen.getByRole('button', { name: 'Launch' })) + expect(footerActions.getByRole('button', { name: 'Cancel' })) .toBeTruthy() - expect(screen.getByRole('button', { name: 'Edit' })) + expect(footerActions.getByRole('button', { name: 'Launch' })) + .toBeTruthy() + expect(footerActions.getByRole('button', { name: 'Edit' })) .toBeTruthy() expect( - screen.getByRole('button', { name: 'Edit' }) + footerActions.getByRole('button', { name: 'Edit' }) .getAttribute('data-secondary'), ) .toBe('true') expect( - screen.getByRole('button', { name: 'Edit' }) + footerActions.getByRole('button', { name: 'Edit' }) .getAttribute('data-size'), ) .toBe('lg') + expect(footerActions.queryByRole('link')) + .toBeNull() }) it('disables launch when challenge budget is not approved', async () => { @@ -482,7 +495,10 @@ describe('ChallengeEditorPage', () => { .toBeTruthy() }) - expect((screen.getByRole('button', { name: 'Launch' }) as HTMLButtonElement).disabled) + expect(screen.getAllByRole('button', { name: 'Launch' })) + .toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Launch' }) + .every(button => (button as HTMLButtonElement).disabled)) .toBe(true) }) @@ -513,8 +529,11 @@ describe('ChallengeEditorPage', () => { .toBeTruthy() }) - expect((screen.getByRole('button', { name: 'Launch' }) as HTMLButtonElement).disabled) - .toBe(false) + expect(screen.getAllByRole('button', { name: 'Launch' })) + .toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Launch' }) + .every(button => !(button as HTMLButtonElement).disabled)) + .toBe(true) }) it('enables launch immediately after approving the budget from the form', async () => { @@ -546,13 +565,15 @@ describe('ChallengeEditorPage', () => { .toBeTruthy() }) - expect((screen.getByRole('button', { name: 'Launch' }) as HTMLButtonElement).disabled) + expect(screen.getAllByRole('button', { name: 'Launch' }) + .every(button => (button as HTMLButtonElement).disabled)) .toBe(true) await user.click(screen.getByRole('button', { name: 'Mock approve budget' })) - expect((screen.getByRole('button', { name: 'Launch' }) as HTMLButtonElement).disabled) - .toBe(false) + expect(screen.getAllByRole('button', { name: 'Launch' }) + .every(button => !(button as HTMLButtonElement).disabled)) + .toBe(true) }) it('allows project-scoped challenge views when the user has challenge resource read access', async () => { @@ -651,8 +672,8 @@ describe('ChallengeEditorPage', () => { .toBeTruthy() }) - expect(screen.getByRole('button', { name: 'Edit' })) - .toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Edit' })) + .toHaveLength(2) }, ) @@ -742,8 +763,8 @@ describe('ChallengeEditorPage', () => { .getAttribute('data-edit-mode'), ) .toBe('false') - expect(screen.getByRole('button', { name: 'Edit' })) - .toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Edit' })) + .toHaveLength(2) }) it('does not render a launch action for non-draft challenges in read-only view mode', async () => { @@ -774,8 +795,8 @@ describe('ChallengeEditorPage', () => { expect(screen.queryByRole('button', { name: 'Launch' })) .toBeNull() - expect(screen.getByRole('button', { name: 'Edit' })) - .toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Edit' })) + .toHaveLength(2) }) it('shows mark complete in read-only view mode for active task challenges', async () => { @@ -824,10 +845,10 @@ describe('ChallengeEditorPage', () => { }, }), ) - expect(screen.getByRole('button', { name: 'Mark Complete' })) - .toBeTruthy() - expect(screen.getByRole('button', { name: 'Edit' })) - .toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Mark Complete' })) + .toHaveLength(2) + expect(screen.getAllByRole('button', { name: 'Edit' })) + .toHaveLength(2) }) it('hides the read-only edit action for completed challenges', async () => { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx index 404e5af9e..763807a0d 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -1448,7 +1448,7 @@ export const ChallengeEditorPage: FC = () => { && !!editChallengePath && (!baseProjectAccessState.isDenied || challengeResourceAccess.canWrite) && !isChallengeCompletedOrCancelled(effectiveChallengeStatus) - const rightHeader = renderHeaderAction({ + const challengeActionParams: RenderHeaderActionParams = { canCancelChallenge: canRenderChallengeDetails && canCancelChallenge, canCompleteTask: canRenderChallengeDetails && canCompleteTask, canDeleteChallenge: canRenderChallengeDetails && canDeleteChallenge, @@ -1461,7 +1461,6 @@ export const ChallengeEditorPage: FC = () => { ? persistedChallengeId : undefined, challengeName: launchChallengeName, - challengeQuickLinks, isDeleting, isLaunchDisabled, isLaunching, @@ -1470,7 +1469,14 @@ export const ChallengeEditorPage: FC = () => { onDeleteOpen: handleDeleteOpen, onEditOpen: handleEditOpen, onLaunchOpen: handleLaunchOpen, + } + const rightHeader = renderHeaderAction({ + ...challengeActionParams, + challengeQuickLinks, }) + const footerActions = isViewMode + ? renderHeaderAction(challengeActionParams) + : undefined const deleteModal = renderDeleteModal({ canDeleteChallenge: canRenderChallengeDetails && canDeleteChallenge, challengeName: deleteChallengeName, @@ -1532,6 +1538,17 @@ export const ChallengeEditorPage: FC = () => { onSubmissionsTabClick={handleSubmissionsTabClick} projectId={projectId} /> + {footerActions + ? ( +
+ {footerActions} +
+ ) + : undefined}
{launchModal} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index c8dea91c5..aa4a7a785 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -122,6 +122,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha ## Header Actions - `Launch` is shown on the details tab for `DRAFT` challenges in the header for both view and edit routes, and again in the footer beside `Save Challenge` while editing. +- Read-only view routes repeat the available `Edit`, `Launch`, `Cancel`, and `Mark Complete` actions at the bottom of the page. Challenge quick links remain in the header only. - The work app blocks launch attempts when the parent project billing account is inactive, expired, or has insufficient remaining funds, matching the legacy work-manager launch restriction. - Task challenges cannot be launched until `Assigned Member` is set, which ensures the task is assigned before it becomes publicly visible. - After the challenge PATCH persists a `NEW` to `DRAFT` transition, the editor updates the status From f0133df35b4379d4e39e4b658a9e8d1dcd6c341e Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Thu, 30 Jul 2026 17:05:47 +0530 Subject: [PATCH 30/77] PM-5713 Add shortlisted application status --- .../ApplicationStatusBadge.module.scss | 6 ++++++ .../ApplicationStatusBadge.tsx | 8 ++++++-- .../engagements/src/lib/models/Application.model.ts | 1 + .../engagements/src/lib/utils/application.utils.ts | 12 +++++++++--- .../pages/engagement-detail/EngagementDetailPage.tsx | 7 ++++++- .../src/pages/my-applications/MyApplicationsPage.tsx | 7 ++++++- .../ApplicationDetailModal.module.scss | 5 +++++ .../ApplicationDetailModal.tsx | 4 ++++ src/apps/work/src/lib/constants.ts | 8 +++++++- src/apps/work/src/lib/models/Engagement.model.ts | 2 +- .../ApplicationsListPage.spec.tsx | 2 +- .../ApplicationsListPage/ApplicationsListPage.tsx | 4 ++++ 12 files changed, 56 insertions(+), 10 deletions(-) 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/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.module.scss b/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.module.scss index ae97942fe..64f181d64 100644 --- a/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.module.scss +++ b/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.module.scss @@ -121,6 +121,11 @@ color: #916b00; } +.statusBlue { + background: #dcebff; + color: #0d61bf; +} + .statusGray { background: #edf0f3; color: #4f5964; diff --git a/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.tsx b/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.tsx index acd24766c..6042fca8d 100644 --- a/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.tsx +++ b/src/apps/work/src/lib/components/ApplicationDetailModal/ApplicationDetailModal.tsx @@ -78,6 +78,10 @@ function getStatusPillClass(value?: string): string { return styles.statusYellow } + if (normalizedStatus === 'SHORTLISTED') { + return styles.statusBlue + } + if (normalizedStatus === 'REJECTED') { return styles.statusRed } diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts index 9f7f2f191..1bc2549ce 100644 --- a/src/apps/work/src/lib/constants.ts +++ b/src/apps/work/src/lib/constants.ts @@ -361,6 +361,12 @@ export const ENGAGEMENT_WORKLOADS = ['FULL_TIME', 'FRACTIONAL'] as const export const ANTICIPATED_START_OPTIONS = ['IMMEDIATE', 'FEW_DAYS', 'FEW_WEEKS'] as const -export const APPLICATION_STATUSES = ['SUBMITTED', 'UNDER_REVIEW', 'SELECTED', 'REJECTED'] as const +export const APPLICATION_STATUSES = [ + 'SUBMITTED', + 'UNDER_REVIEW', + 'SHORTLISTED', + 'SELECTED', + 'REJECTED', +] as const export const ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACTIVE', 'TERMINATED'] as const diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index c8b64bc99..ffbd930de 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -16,7 +16,7 @@ export type EngagementStatus = | 'Open' | 'Pending Assignment' -export type ApplicationStatus = 'REJECTED' | 'SELECTED' | 'SUBMITTED' | 'UNDER_REVIEW' +export type ApplicationStatus = 'REJECTED' | 'SELECTED' | 'SHORTLISTED' | 'SUBMITTED' | 'UNDER_REVIEW' export type AssignmentStatus = 'ACTIVE' | 'ASSIGNED' | 'COMPLETED' | 'OFFER_REJECTED' | 'SELECTED' | 'TERMINATED' diff --git a/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.spec.tsx b/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.spec.tsx index dbcd55804..39fcc80db 100644 --- a/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.spec.tsx @@ -98,7 +98,7 @@ jest.mock('~/libs/ui', () => ({ virtual: true, }) jest.mock('../../../lib/constants', () => ({ - APPLICATION_STATUSES: ['SUBMITTED', 'UNDER_REVIEW', 'SELECTED', 'REJECTED'], + APPLICATION_STATUSES: ['SUBMITTED', 'UNDER_REVIEW', 'SHORTLISTED', 'SELECTED', 'REJECTED'], PROFILE_URL: 'https://profiles.example.com', })) jest.mock('../../../lib/components', () => ({ diff --git a/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.tsx b/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.tsx index ea58bbc02..38f214229 100644 --- a/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.tsx +++ b/src/apps/work/src/pages/engagements/ApplicationsListPage/ApplicationsListPage.tsx @@ -113,6 +113,10 @@ function getApplicationStatusPillClass(status: string): string { return styles.statusYellow } + if (normalizedStatus === 'SHORTLISTED') { + return styles.statusBlue + } + if (normalizedStatus === 'REJECTED') { return styles.statusRed } From b47dbf6f1917be8b68d97374201232d7affc233e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 21:40:32 +1000 Subject: [PATCH 31/77] PM-5756: Improve challenge autosave controls What was broken Autosave cleared the form's dirty state, which disabled the manual Save Challenge action and made users unsure whether they could explicitly save. The footer also separated autosave feedback from Save, kept Cancel in the right action group, and could leave "Saved just now" visible indefinitely. Root cause The Save button treated React Hook Form's dirty flag as an availability requirement, while the relative timestamp depended on a future render that was never scheduled. The footer layout grouped Cancel with the save actions instead of grouping save feedback with Save. What was changed - Keep manual Save available for unchanged challenges while retaining in-flight and validation blockers. - Use the Save button as the Saving/Saved status, hold Saved for two seconds, then restore the active save action. - Show the exact localized last-save time immediately, move Cancel left, and group wrapping autosave feedback with Save and Launch. - Update the challenge editor autosave documentation. Any added/updated tests - Added timestamp formatting coverage for unsaved and immediately saved challenges. - Added footer grouping, unchanged manual save, and transient Saved-state coverage. - Updated challenge creation assertions for the transient Saved state. --- .../lib/utils/challenge-editor.utils.spec.ts | 19 ++++ .../src/lib/utils/challenge-editor.utils.ts | 5 - .../challenges/ChallengeEditorPage/README.md | 5 +- .../ChallengeEditorForm.module.scss | 4 + .../components/ChallengeEditorForm.spec.tsx | 94 +++++++++++++++++-- .../components/ChallengeEditorForm.tsx | 84 +++++++++++------ 6 files changed, 167 insertions(+), 44 deletions(-) diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts index b0e556d66..9fffba7eb 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.spec.ts @@ -1,4 +1,5 @@ import { + formatLastSaved, transformChallengeToFormData, transformFormDataToChallenge, } from './challenge-editor.utils' @@ -23,6 +24,24 @@ jest.mock('~/config', () => ({ }), }), { virtual: true }) +describe('formatLastSaved', () => { + it('reports when a challenge has not been saved', () => { + expect(formatLastSaved()) + .toBe('Not saved yet') + }) + + it('shows the exact localized time immediately after a save', () => { + const timestamp = new Date() + const localizedTime = timestamp.toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + }) + + expect(formatLastSaved(timestamp)) + .toBe(`Last saved at ${localizedTime}`) + }) +}) + describe('challenge-editor utils funChallenge mapping', () => { it('defaults funChallenge to false in form data', () => { const result = transformChallengeToFormData({ diff --git a/src/apps/work/src/lib/utils/challenge-editor.utils.ts b/src/apps/work/src/lib/utils/challenge-editor.utils.ts index 0ed5b02de..076b51a3a 100644 --- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts +++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts @@ -935,11 +935,6 @@ export function formatLastSaved(timestamp?: Date): string { return 'Not saved yet' } - const diffMs = Date.now() - timestamp.getTime() - if (diffMs < 5000) { - return 'Saved just now' - } - return `Last saved at ${timestamp.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index c8dea91c5..9f22c2e74 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -54,7 +54,10 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - Autosave keeps the current editor values in place after patch responses so in-flight typing is not replaced by challenge-api normalized content. - Status values: `idle`, `saving`, `saved`, `error`. -- Last save time is shown in the footer. +- The footer keeps Cancel on the left and groups autosave status plus the exact last-save time beside + the manual Save action. +- Manual Save reports `Saving...`, then `Saved` for two seconds after a successful save, before + becoming available again even when the form is unchanged. ## Field Components diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss index 535c4b358..53fb9b7d9 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.module.scss @@ -193,6 +193,7 @@ display: flex; flex-direction: column; gap: 4px; + min-width: 0; } .statusText { @@ -232,7 +233,10 @@ .actions { align-items: center; display: flex; + flex-wrap: wrap; gap: 16px; + justify-content: flex-end; + min-width: 0; } .cancelLink { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index 1357c71bb..e3883cdc2 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -133,7 +133,11 @@ jest.mock('../../../../lib/utils', () => ({ && (project?.members || []).some(member => ( member.userId === userId && member.role === 'manager' )), - formatLastSaved: () => '', + formatLastSaved: (timestamp?: Date) => ( + timestamp + ? 'Last saved at 1:25 PM' + : 'Not saved yet' + ), showErrorToast: jest.fn(), showSuccessToast: jest.fn(), transformChallengeToFormData: (challenge?: Partial) => ({ @@ -1252,26 +1256,39 @@ describe('ChallengeEditorForm', () => { , ) + const cancelButton = screen.getByRole('button', { name: 'Cancel' }) + const saveButton = screen.getByRole('button', { name: 'Save Challenge' }) + const lastSaved = screen.getByText('Not saved yet') + const actionGroup = saveButton.parentElement + expect( - screen.getByRole('button', { name: 'Cancel' }) + cancelButton .getAttribute('data-secondary'), ) .toBe('true') expect( - screen.getByRole('button', { name: 'Cancel' }) + cancelButton .getAttribute('data-size'), ) .toBe('lg') expect( - screen.getByRole('button', { name: 'Save Challenge' }) + saveButton .getAttribute('data-secondary'), ) .toBe('true') expect( - screen.getByRole('button', { name: 'Save Challenge' }) + saveButton .getAttribute('data-size'), ) .toBe('lg') + expect(saveButton) + .toBeEnabled() + expect(actionGroup) + .toContainElement(lastSaved) + expect(actionGroup) + .not.toContainElement(cancelButton) + expect(cancelButton.parentElement) + .toBe(actionGroup?.parentElement) expect( screen.getByRole('button', { name: 'Launch' }) .getAttribute('data-primary'), @@ -1284,6 +1301,65 @@ describe('ChallengeEditorForm', () => { .toBe('lg') }) + it('manually saves an unchanged challenge', async () => { + const user = userEvent.setup() + mockedPatchChallenge.mockResolvedValue(validDraftChallenge) + + render( + + + , + ) + + const saveButton = screen.getByRole('button', { name: 'Save Challenge' }) + + expect(saveButton) + .toBeEnabled() + + await user.click(saveButton) + + await waitFor(() => { + expect(mockedPatchChallenge) + .toHaveBeenCalledWith('12345', expect.objectContaining({ + name: validDraftChallenge.name, + })) + }) + }) + + it('uses the save action for transient autosave feedback before enabling it again', () => { + jest.useFakeTimers() + mockedUseAutosave.mockReturnValue({ + lastSaved: new Date('2026-07-29T13:25:00+03:00'), + saveStatus: 'saved', + }) + + try { + render( + + + , + ) + + expect(screen.getByText('Last saved at 1:25 PM')) + .toBeInTheDocument() + expect(screen.getAllByText('Saved')) + .toHaveLength(1) + expect(screen.getByRole('button', { name: 'Saved' })) + .toBeDisabled() + + act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(screen.queryByRole('button', { name: 'Saved' })) + .toBeNull() + expect(screen.getByRole('button', { name: 'Save Challenge' })) + .toBeEnabled() + } finally { + jest.useRealTimers() + } + }) + it('renders existing challenges as read-only in view mode', () => { render( @@ -3996,8 +4072,8 @@ describe('ChallengeEditorForm', () => { await user.click(screen.getByRole('button', { name: 'New' })) await waitFor(() => { - expect(screen.getByRole('button', { name: 'Save as Draft' })) - .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Saved' })) + .toBeDisabled() }) expect(screen.queryByRole('button', { name: 'New' })) .toBeNull() @@ -4050,8 +4126,8 @@ describe('ChallengeEditorForm', () => { await waitFor(() => { expect(screen.getByText('Specification')) .toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Save as Draft' })) - .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Saved' })) + .toBeDisabled() expect(screen.queryByRole('button', { name: 'New' })) .toBeNull() }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index 19be86970..a6416ca58 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -284,6 +284,7 @@ interface PersistCreatedChallengeCopilotResult { } const SAVE_VALIDATION_ERROR_MESSAGE = 'Please fix validation errors before saving.' +const SAVED_BUTTON_STATE_DURATION_MS = 2000 const DESIGN_WORK_TYPE_REQUIRED_MESSAGE = 'Select a work type' const TASK_ASSIGNED_MEMBER_REQUIRED_FOR_LAUNCH_MESSAGE = 'Assign a member before launching a task challenge.' @@ -1859,6 +1860,10 @@ export const ChallengeEditorForm: FC = ( const [saveError, setSaveError] = useState() const [saveValidationError, setSaveValidationError] = useState() const [saveStatus, setSaveStatus] = useState<'error' | 'idle' | 'saved' | 'saving'>('idle') + const [dismissedSavedAt, setDismissedSavedAt] = useState() + const isSavedButtonStateVisible = saveStatus === 'saved' + && !!lastSaved + && dismissedSavedAt !== lastSaved const [scorerHasUnsavedChanges, setScorerHasUnsavedChanges] = useState(false) const [scorerHasError, setScorerHasError] = useState(false) const [isUpdatingApproval, setIsUpdatingApproval] = useState(false) @@ -3543,6 +3548,21 @@ export const ChallengeEditorForm: FC = ( } }, [autosaveResult.lastSaved, autosaveResult.saveStatus]) + useEffect(() => { + if (!isSavedButtonStateVisible || !lastSaved) { + return undefined + } + + const savedAt = lastSaved + const timeoutId = window.setTimeout(() => { + setDismissedSavedAt(savedAt) + }, SAVED_BUTTON_STATE_DURATION_MS) + + return () => { + window.clearTimeout(timeoutId) + } + }, [isSavedButtonStateVisible, lastSaved]) + const onSubmit = useCallback( async (formData: ChallengeEditorFormData): Promise => { const { @@ -3620,6 +3640,12 @@ export const ChallengeEditorForm: FC = ( () => getSubmitButtonLabel(normalizedChallengeStatus), [normalizedChallengeStatus], ) + const isSaveButtonSaving = isSaving || saveStatus === 'saving' + const displayedSubmitButtonLabel = isSaveButtonSaving + ? 'Saving...' + : isSavedButtonStateVisible + ? 'Saved' + : submitButtonLabel const displayedBillingAccountId = useMemo( (): string => { const billingAccountId = values.billing?.billingAccountId ?? projectBillingAccount?.id @@ -3757,41 +3783,41 @@ export const ChallengeEditorForm: FC = ( const footerSection = !isReadOnly ? (
-
- {statusText - ? {statusText} - : undefined} - {formatLastSaved(lastSaved)} - {saveValidationError - ? {saveValidationError} - : undefined} - {renderSaveError(saveError, linkedSaveErrorTerms)} - {isScorerBlockingChallengeActions - ? ( - - The scorer configuration must be saved and valid before the - {' '} - challenge can be saved or launched. - - ) - : undefined} -
- + + ), }), { virtual: true, }) @@ -158,4 +188,34 @@ describe('CopilotField', () => { .toHaveValue('profile-copilot') }) }) + + it('allows full access project members to assign themselves as copilot', () => { + mockedUseFetchProjectMembers.mockReturnValue({ + isLoading: false, + members: [{ + handle: 'requester', + role: 'manager', + userId: 40158995, + }], + }) + + render() + + expect(screen.getByRole('option', { + name: 'requester', + })) + .toBeInTheDocument() + + const assignYourselfButton = screen.getByRole('button', { + name: 'Assign yourself', + }) + + expect(assignYourselfButton) + .toBeEnabled() + + fireEvent.click(assignYourselfButton) + + expect(screen.getByLabelText('Copilot Field')) + .toHaveValue('requester') + }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/CopilotField/CopilotField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/CopilotField/CopilotField.tsx index ff98b0016..068aa55d1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/CopilotField/CopilotField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/CopilotField/CopilotField.tsx @@ -73,7 +73,14 @@ function normalizeProjectCopilotHandle(member: ProjectMember): string | undefine .toLowerCase() const handle = normalizeHandle(member.handle) - if (normalizedRole !== PROJECT_ROLES.COPILOT || !handle) { + if ( + normalizedRole !== PROJECT_ROLES.COPILOT + && normalizedRole !== PROJECT_ROLES.MANAGER + ) { + return undefined + } + + if (!handle) { return undefined } From aa141fe80d25a483d1218e6e3635b34984b603b1 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 30 Jul 2026 23:14:27 +1000 Subject: [PATCH 34/77] PM-5764: Fix screening scorecard labels What was broken Screening and checkpoint screening scorecards showed "Edit Review Scorecard" and identified the assigned member as a reviewer. Root cause The scorecard header copy was hard-coded for review phases even though the viewer already resolves the review phase type. What was changed Passed the existing resolved phase type into the header and show "Complete Screening Scorecard" with "Screener:" only for screening phase types. Other phase types retain the current review copy. Any added/updated tests Added ReviewScorecardHeader coverage for Screening, Checkpoint Screening, Review, Checkpoint Review, and the unresolved fallback. The focused tests, lint, and production build pass. The repository-wide test command retains the same 13 failing suites and 36 failing tests as untouched origin/dev; this branch adds five passing tests and no new failures. --- .../ReviewScorecardHeader.spec.tsx | 102 ++++++++++++++++++ .../ReviewViewer/ReviewScorecardHeader.tsx | 13 ++- .../components/ReviewViewer/ReviewViewer.tsx | 3 +- 3 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx new file mode 100644 index 000000000..29c516e62 --- /dev/null +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.spec.tsx @@ -0,0 +1,102 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +import type { ReviewInfo } from '~/apps/review/src/lib/models' +import type { UseReviewEditAccessResult } from '~/apps/review/src/lib/hooks' + +import { ReviewScorecardHeader } from './ReviewScorecardHeader' + +const mockUseChallengeDetailsContext = jest.fn() + +jest.mock('~/apps/review/src/lib', () => ({ + useChallengeDetailsContext: () => mockUseChallengeDetailsContext(), +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/components/ProgressBar', () => ({ + ProgressBar: () =>
, +}), { virtual: true }) + +jest.mock('~/apps/review/src/lib/assets/icons', () => ({ + IconDeepseekAi: () => , + IconPhaseReview: () => , + IconPremium: () => , +}), { virtual: true }) + +const reviewInfo = { + resourceId: 'resource-1', +} as ReviewInfo + +type ReviewPhaseType = UseReviewEditAccessResult['reviewPhaseType'] + +/** + * Render the scorecard header with a normalized review phase type for label assertions. + * @param reviewPhaseType phase classification returned by the review edit-access hook + * @returns nothing; the rendered component is queried through Testing Library's screen + * @throws This helper does not throw. + */ +const renderHeader = (reviewPhaseType?: ReviewPhaseType): void => { + render( + , + ) +} + +describe('ReviewScorecardHeader', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseChallengeDetailsContext.mockReturnValue({ + resources: [ + { + handleColor: '#2a2a2a', + id: 'resource-1', + memberHandle: 'darakmember', + }, + ], + }) + }) + + it.each([ + 'screening', + 'checkpoint screening', + ] as ReviewPhaseType[])('shows screening labels for the %s phase', reviewPhaseType => { + renderHeader(reviewPhaseType) + + expect(screen.getByRole('heading', { name: 'Complete Screening Scorecard' })) + .toBeInTheDocument() + expect(screen.getByText('Screener:')) + .toBeInTheDocument() + expect(screen.getByText('darakmember')) + .toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Edit Review Scorecard' })) + .not + .toBeInTheDocument() + expect(screen.queryByText('Reviewer:')) + .not + .toBeInTheDocument() + }) + + it.each([ + 'review', + 'checkpoint review', + undefined, + ] as ReviewPhaseType[])('keeps review labels for the %s phase', reviewPhaseType => { + renderHeader(reviewPhaseType) + + expect(screen.getByRole('heading', { name: 'Edit Review Scorecard' })) + .toBeInTheDocument() + expect(screen.getByText('Reviewer:')) + .toBeInTheDocument() + expect(screen.getByText('darakmember')) + .toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Complete Screening Scorecard' })) + .not + .toBeInTheDocument() + expect(screen.queryByText('Screener:')) + .not + .toBeInTheDocument() + }) +}) diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx index 3998a967a..0a5e1ce8f 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewScorecardHeader.tsx @@ -4,7 +4,7 @@ import { useChallengeDetailsContext } from '~/apps/review/src/lib' import { ProgressBar } from '~/apps/review/src/lib/components/ProgressBar' import { IconDeepseekAi, IconPhaseReview, IconPremium } from '~/apps/review/src/lib/assets/icons' import { ChallengeDetailContextModel, ReviewInfo, ScorecardInfo } from '~/apps/review/src/lib/models' -import { AiWorkflow } from '~/apps/review/src/lib/hooks' +import { AiWorkflow, UseReviewEditAccessResult } from '~/apps/review/src/lib/hooks' import styles from './ReviewScorecardHeader.module.scss' @@ -13,6 +13,7 @@ interface Props { scorecardInfo?: ScorecardInfo workflow?: AiWorkflow reviewProgress?: number + reviewPhaseType?: UseReviewEditAccessResult['reviewPhaseType'] } export const ReviewScorecardHeader: FC = (props: Props) => { @@ -32,6 +33,12 @@ export const ReviewScorecardHeader: FC = (props: Props) => { const reviewerColor = reviewer?.handleColor const llmModelName = props.workflow?.llm?.name || 'N/A' const minimumPassingScore = props.scorecardInfo?.minimumPassingScore ?? 0 + const isScreeningPhase = props.reviewPhaseType === 'screening' + || props.reviewPhaseType === 'checkpoint screening' + const scorecardTitle = isScreeningPhase + ? 'Complete Screening Scorecard' + : 'Edit Review Scorecard' + const reviewerLabel = isScreeningPhase ? 'Screener:' : 'Reviewer:' return (
@@ -43,14 +50,14 @@ export const ReviewScorecardHeader: FC = (props: Props) => {
-

Edit Review Scorecard

+

{scorecardTitle}

{reviewerHandle && (
- Reviewer: + {reviewerLabel} {reviewerHandle} diff --git a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx index 710e249c8..fe0232e15 100644 --- a/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx +++ b/src/apps/review/src/pages/reviews/components/ReviewViewer/ReviewViewer.tsx @@ -136,7 +136,7 @@ const ReviewViewer: FC = () => { [submitterLockedPhaseName], ) - const { isEdit }: UseReviewEditAccessResult = useReviewEditAccess({ + const { isEdit, reviewPhaseType }: UseReviewEditAccessResult = useReviewEditAccess({ challengeInfo, isEditPhase, isReviewCompleted, @@ -257,6 +257,7 @@ const ReviewViewer: FC = () => { scorecardInfo={scorecardInfo} workflow={workflow} reviewProgress={reviewStatus?.progress ?? reviewInfo?.reviewProgress ?? 0} + reviewPhaseType={reviewPhaseType} /> Date: Thu, 30 Jul 2026 23:33:39 +1000 Subject: [PATCH 35/77] PM-5767: Show submission download preloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was broken Submission downloads provided no clear in-view feedback while the browser waited for the file response on slower connections. Root cause The review app tracked pending downloads correctly, but rendered an unlabelled action spinner at the bottom of the phase content where it could be outside the viewport. What was changed Replaced the local action spinner with the shared full-viewport loading overlay and the text “Download starting”. The existing pending state clears the indicator when the browser download begins or the request fails. Any added/updated tests Added ChallengeDetailsContent coverage for showing the overlay while a submission request is pending and removing it after the pending state clears. The focused test and all review-app tests pass. The full monorepo command still reports 13 unrelated baseline suite failures in the work, engagements, and wallet-admin apps. --- .../ChallengeDetailsContent.spec.tsx | 123 ++++++++++++++++++ .../ChallengeDetailsContent.tsx | 8 +- 2 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.spec.tsx 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 e940affaa..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 { @@ -599,7 +599,9 @@ export const ChallengeDetailsContent: FC = (props: Props) => { renderSelectedTab() )} - {isDownloadingSubmissionBool && } + {isDownloadingSubmissionBool && ( + + )} ) } From c9fa5b44f78d64118a5f2acf82c1d6f3adf98209 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 31 Jul 2026 00:02:37 +1000 Subject: [PATCH 36/77] PM-5773: expose score override controls What was broken Copilot managers who clicked Edit Scorecard only saw a collapsed Add a Manager Comment action, so the score selector was not presented as part of entering edit mode. Root cause Manager edit mode revealed the existing score override component but left its combined score and required-comment form collapsed. The same manager mode is also used for appeal responses, so it could not safely be treated as the reviewer bulk-edit form. What was changed Pass a scorecard-edit-specific auto-open flag through the scorecard viewer context and open the existing per-item score/comment form immediately for normal Edit Scorecard actions. Appeal-response mode remains collapsed and continues using the existing review-item PATCH flow. Any added/updated tests Added a ReviewManagerComment regression test covering collapsed appeal mode, automatic score-control display in scorecard edit mode, and closing the form when edit mode ends. --- .../ReviewManagerComment.spec.tsx | 135 ++++++++++++++++++ .../ReviewManagerComment.tsx | 5 + .../ScorecardViewer.context.tsx | 4 + .../ScorecardViewer/ScorecardViewer.tsx | 2 + .../components/ReviewViewer/ReviewViewer.tsx | 1 + 5 files changed, 147 insertions(+) create mode 100644 src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/ReviewResponse/ReviewManagerComment/ReviewManagerComment.spec.tsx 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: () =>
AI ReviewerReview DateWeightMin ScoreReview Date Score ResultComments
Loading...Loading...
- + {row.run?.workflow?.reviewMethod === AiWorkflowReviewMethod.DETERMINISTIC || row.run?.id === '-1' ? ( + + ) : ( + + )} @@ -607,8 +644,6 @@ const AiReviewsTable: FC = props => { )}
{formatWeight(row.weight)}{formatScore(row.minScore)} {row.reviewDate && ( moment(row.reviewDate) @@ -616,6 +651,8 @@ const AiReviewsTable: FC = props => { .format(TABLE_DATE_FORMAT) )} {formatWeight(row.weight)}{formatScore(row.minScore)} {typeof row.score === 'number' ? ( row.workflowId ? ( @@ -654,6 +691,18 @@ const AiReviewsTable: FC = props => { } /> + {shouldHideComments(row.run) ? '' : ( + + + + {row.run?.commentsCount ?? 0} + + + )} +