-
-
- Submission type
-
-
- {props.isLoadingChallenge
- ? 'Loading challenge phases...'
- : props.submissionTypeLabel}
-
-
= (
onClick={props.handleUploadSubmission}
disabled={
props.isUploading
- || props.isLoadingChallenge
|| props.isLoadingSubmitters
|| !props.selectedHandle?.value
|| !props.selectedFile
@@ -271,18 +256,6 @@ export const ManageSubmissionPage: FC = (props: Props) => {
challengeInfo,
}: useFetchChallengeProps = useFetchChallenge(challengeId)
const isMM = useMemo(() => checkIsMM(challengeInfo), [challengeInfo])
- const manualUploadSubmissionType = useMemo(
- () => resolveManualUploadSubmissionType(challengeInfo),
- [challengeInfo],
- )
- const manualUploadSubmissionTypeLabel = useMemo(
- () => (
- manualUploadSubmissionType === 'CHECKPOINT_SUBMISSION'
- ? 'Checkpoint Submission'
- : 'Submission'
- ),
- [manualUploadSubmissionType],
- )
const submissionReprocessTopic = useMemo(
() => getSubmissionReprocessTopic(challengeInfo),
[challengeInfo],
@@ -356,12 +329,7 @@ export const ManageSubmissionPage: FC = (props: Props) => {
)
const handleUploadSubmission = useCallback(async () => {
- if (
- !challengeId
- || !challengeInfo
- || !selectedFile
- || !selectedHandle?.value
- ) {
+ if (!challengeId || !selectedFile || !selectedHandle?.value) {
return
}
@@ -373,7 +341,6 @@ export const ManageSubmissionPage: FC = (props: Props) => {
fileName: selectedFile.name,
memberHandle: String(selectedHandle.label),
memberId: selectedHandle.value,
- type: manualUploadSubmissionType,
})
toast.success('Submission uploaded successfully', {
@@ -387,15 +354,7 @@ export const ManageSubmissionPage: FC = (props: Props) => {
} finally {
setIsUploading(false)
}
- }, [
- challengeId,
- challengeInfo,
- manualUploadSubmissionType,
- refresh,
- resetUploadForm,
- selectedFile,
- selectedHandle,
- ])
+ }, [challengeId, selectedFile, selectedHandle, resetUploadForm, refresh])
useEffect(() => {
let isCancelled = false
@@ -456,7 +415,7 @@ export const ManageSubmissionPage: FC = (props: Props) => {
Upload submission
@@ -500,9 +459,7 @@ export const ManageSubmissionPage: FC = (props: Props) => {
selectedHandle={selectedHandle}
setSelectedHandle={setSelectedHandle}
isUploading={isUploading}
- isLoadingChallenge={isLoadingChallenge}
isLoadingSubmitters={isLoadingSubmitters}
- submissionTypeLabel={manualUploadSubmissionTypeLabel}
submitterOptions={submitterOptions}
handleFileChange={handleFileChange}
selectedFile={selectedFile}
diff --git a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts
index 1aa72a376..1154c6cbf 100644
--- a/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts
+++ b/src/apps/admin/src/lib/components/common/Tab/config/system-admin-tabs-config.ts
@@ -1,7 +1,7 @@
import _ from 'lodash'
import { TabsNavItem } from '~/libs/ui'
-import { isAdministrator } from '~/apps/admin/src/lib/utils/access'
+import { isAdministrator } from '~/apps/admin/src/lib/utils'
import {
billingAccountRouteId,
defaultReviewersRouteId,
diff --git a/src/apps/admin/src/lib/utils/challenge.spec.ts b/src/apps/admin/src/lib/utils/challenge.spec.ts
deleted file mode 100644
index 0e438946b..000000000
--- a/src/apps/admin/src/lib/utils/challenge.spec.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Challenge } from '../models'
-
-import { resolveManualUploadSubmissionType } from './challenge'
-
-jest.mock('~/config', () => ({
- EnvironmentConfig: {
- ADMIN: {},
- },
-}), { virtual: true })
-
-describe('challenge utils', () => {
- describe('resolveManualUploadSubmissionType', () => {
- it('returns checkpoint submission when checkpoint screening is open', () => {
- const challenge = {
- phases: [
- {
- isOpen: true,
- name: 'Checkpoint Screening',
- scheduledEndDate: '2026-04-15T00:00:00.000Z',
- },
- ],
- } as Challenge
-
- expect(resolveManualUploadSubmissionType(challenge))
- .toBe('CHECKPOINT_SUBMISSION')
- })
-
- it('returns checkpoint submission when checkpoint review is open', () => {
- const challenge = {
- phases: [
- {
- isOpen: true,
- name: 'Checkpoint Review',
- scheduledEndDate: '2026-04-15T00:00:00.000Z',
- },
- ],
- } as Challenge
-
- expect(resolveManualUploadSubmissionType(challenge))
- .toBe('CHECKPOINT_SUBMISSION')
- })
-
- it('returns contest submission when checkpoint phases are closed', () => {
- const challenge = {
- phases: [
- {
- isOpen: false,
- name: 'Checkpoint Screening',
- scheduledEndDate: '2026-04-15T00:00:00.000Z',
- },
- {
- isOpen: true,
- name: 'Screening',
- scheduledEndDate: '2026-04-15T00:00:00.000Z',
- },
- ],
- } as Challenge
-
- expect(resolveManualUploadSubmissionType(challenge))
- .toBe('CONTEST_SUBMISSION')
- })
- })
-})
diff --git a/src/apps/admin/src/lib/utils/challenge.ts b/src/apps/admin/src/lib/utils/challenge.ts
index 5c05e5ba2..864a9f965 100644
--- a/src/apps/admin/src/lib/utils/challenge.ts
+++ b/src/apps/admin/src/lib/utils/challenge.ts
@@ -7,18 +7,6 @@ import _ from 'lodash'
import { SUBMISSION_REPROCESS_TOPICS } from '../../config/busEvent.config'
import { Challenge, MemberSubmission } from '../models'
-const CONTEST_SUBMISSION_TYPE = 'CONTEST_SUBMISSION'
-const CHECKPOINT_SUBMISSION_TYPE = 'CHECKPOINT_SUBMISSION'
-const CHECKPOINT_MANUAL_UPLOAD_PHASES = new Set([
- 'checkpoint screening',
- 'checkpoint review',
-])
-
-const normalizePhaseName = (value?: string): string => (
- value?.trim()
- .toLowerCase() ?? ''
-)
-
/**
* Check if the challenge is a marathon match challenge
* @param challenge challenge info
@@ -55,29 +43,6 @@ export function getSubmissionReprocessTopic(
return undefined
}
-/**
- * Resolve the submission type used by the admin manual-upload flow.
- * Checkpoint uploads are only valid once checkpoint review phases are active,
- * so the admin UI switches to checkpoint submission type whenever the
- * challenge is currently in Checkpoint Screening or Checkpoint Review.
- * @param challenge challenge info
- * @returns submission type expected by review-api-v6 manual upload endpoint
- */
-export function resolveManualUploadSubmissionType(
- challenge?: Challenge,
-): string {
- const hasOpenCheckpointManualUploadPhase = (challenge?.phases ?? []).some(
- phase => phase?.isOpen
- && CHECKPOINT_MANUAL_UPLOAD_PHASES.has(
- normalizePhaseName(phase?.name),
- ),
- )
-
- return hasOpenCheckpointManualUploadPhase
- ? CHECKPOINT_SUBMISSION_TYPE
- : CONTEST_SUBMISSION_TYPE
-}
-
/**
* Process each submission rank of MM challenge
* @param submissions the array of submissions
diff --git a/src/apps/admin/src/lib/utils/index.ts b/src/apps/admin/src/lib/utils/index.ts
index 869914c86..5e827a00b 100644
--- a/src/apps/admin/src/lib/utils/index.ts
+++ b/src/apps/admin/src/lib/utils/index.ts
@@ -6,9 +6,3 @@ export * from './number'
export * from './string'
export * from './others'
export * from './access'
-export {
- administratorOnlyRoles,
- adminReportsAccessRoles,
- canAccessAdminReports,
- isAdministrator,
-} from './access'
diff --git a/src/apps/copilots/src/services/copilot-opportunities.ts b/src/apps/copilots/src/services/copilot-opportunities.ts
index 4437b2aaf..3446a9ef6 100644
--- a/src/apps/copilots/src/services/copilot-opportunities.ts
+++ b/src/apps/copilots/src/services/copilot-opportunities.ts
@@ -12,11 +12,6 @@ export const copilotBaseUrl = `${EnvironmentConfig.API.V6}/projects`
const PAGE_SIZE = 20
-type CopilotApplicationApiResponse = Omit & {
- id: number | string
- userId: number | string
-}
-
/**
* Creates a CopilotOpportunity object by merging the provided data and its nested data,
* setting specific properties, and formatting the createdAt date.
@@ -32,23 +27,6 @@ function copilotOpportunityFactory(data: any): CopilotOpportunity {
}
}
-/**
- * Normalizes copilot application identifiers that the API may serialize as strings.
- *
- * The copilot opportunity UI joins applications to member records and the signed-in profile
- * using numeric ids, so we coerce the API payload into the shape expected by the UI.
- *
- * @param data - Raw copilot application returned by the API.
- * @returns A copilot application with numeric identifiers.
- */
-function copilotApplicationFactory(data: CopilotApplicationApiResponse): CopilotApplication {
- return {
- ...data,
- id: Number(data.id),
- userId: Number(data.userId),
- }
-}
-
export interface CopilotOpportunitiesResponse {
isValidating: boolean;
data: CopilotOpportunity[];
@@ -166,8 +144,8 @@ export const useCopilotApplications = (opportunityId?: string): CopilotApplicati
? buildUrl(`${copilotBaseUrl}/copilots/opportunity/${opportunityId}/applications`)
: undefined
- const fetcher = (urlp: string): Promise => xhrGetAsync(urlp)
- .then(data => data.map(copilotApplicationFactory))
+ const fetcher = (urlp: string): Promise => xhrGetAsync(urlp)
+ .then(data => data)
.catch(() => [])
return useSWR(url, fetcher)
diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts
index 316357915..e83343378 100644
--- a/src/apps/customer-portal/src/config/routes.config.ts
+++ b/src/apps/customer-portal/src/config/routes.config.ts
@@ -9,3 +9,4 @@ export const rootRoute: string
: `/${AppSubdomain.customer}`
export const talentSearchRouteId = 'talent-search'
+export const profileCompletionRouteId = 'profile-completion'
diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx
index 2ee282745..fd5b6e0e1 100644
--- a/src/apps/customer-portal/src/customer-portal.routes.tsx
+++ b/src/apps/customer-portal/src/customer-portal.routes.tsx
@@ -11,10 +11,11 @@ import {
} from '~/libs/core'
import {
+ profileCompletionRouteId,
rootRoute,
- talentSearchRouteId,
} from './config/routes.config'
import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes'
+import { customerPortalProfileCompletionRoutes } from './pages/profile-completion/profile-completion.routes'
const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp'))
@@ -27,9 +28,10 @@ export const customerPortalRoutes: ReadonlyArray = [
children: [
{
authRequired: true,
- element: ,
+ element: ,
route: '',
},
+ ...customerPortalProfileCompletionRoutes,
...customerPortalTalentSearchRoutes,
],
domain: AppSubdomain.customer,
diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts
index c76f23ed4..d1292ef52 100644
--- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts
+++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts
@@ -2,6 +2,7 @@ import _ from 'lodash'
import { TabsNavItem } from '~/libs/ui'
import {
+ profileCompletionRouteId,
talentSearchRouteId,
} from '~/apps/customer-portal/src/config/routes.config'
@@ -9,6 +10,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri
const tabs: TabsNavItem[] = [
...(!isUnprivilegedUser ? [{
+ id: profileCompletionRouteId,
+ title: 'Profile Completion',
+ }, {
id: talentSearchRouteId,
title: 'Talent Search',
}] : []),
diff --git a/src/apps/customer-portal/src/lib/services/index.ts b/src/apps/customer-portal/src/lib/services/index.ts
index 9e33c22a5..d67943da3 100644
--- a/src/apps/customer-portal/src/lib/services/index.ts
+++ b/src/apps/customer-portal/src/lib/services/index.ts
@@ -1 +1,2 @@
+export * from './profileCompletion.service'
export * from './talentSearch.service'
diff --git a/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts
new file mode 100644
index 000000000..f0674b639
--- /dev/null
+++ b/src/apps/customer-portal/src/lib/services/profileCompletion.service.ts
@@ -0,0 +1,142 @@
+import { EnvironmentConfig } from '~/config'
+import { UserSkill, xhrGetAsync } from '~/libs/core'
+
+export type CompletedProfile = {
+ countryCode?: string
+ countryName?: string
+ city?: string
+ firstName?: string
+ handle: string
+ lastName?: string
+ photoURL?: string
+ skillCount?: number
+ userId?: number | string
+ isOpenToWork?: boolean | null
+ openToWork?: {
+ availability?: string
+ preferredRoles?: string[]
+ } | null
+}
+
+export type CompletedProfilesResponse = {
+ data: CompletedProfile[]
+ page: number
+ perPage: number
+ total: number
+ totalPages: number
+}
+
+export const DEFAULT_PAGE_SIZE = 50
+
+function normalizeToList(raw: any): any[] {
+ if (Array.isArray(raw)) {
+ return raw
+ }
+
+ if (Array.isArray(raw?.data)) {
+ return raw.data
+ }
+
+ if (Array.isArray(raw?.result?.content)) {
+ return raw.result.content
+ }
+
+ if (Array.isArray(raw?.result)) {
+ return raw.result
+ }
+
+ return []
+}
+
+function normalizeCompletedProfilesResponse(
+ raw: any,
+ fallbackPage: number,
+ fallbackPerPage: number,
+): CompletedProfilesResponse {
+ if (raw && Array.isArray(raw.data)) {
+ const total: number = Number(raw.total ?? raw.data.length)
+ const perPage: number = Number(raw.perPage ?? fallbackPerPage)
+ const page: number = Number(raw.page ?? fallbackPage)
+ const safePerPage = Number.isFinite(perPage) ? Math.max(perPage, 1) : fallbackPerPage
+ const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : raw.data.length
+
+ return {
+ data: raw.data,
+ page: Number.isFinite(page) ? Math.max(page, 1) : fallbackPage,
+ perPage: safePerPage,
+ total: safeTotal,
+ totalPages: Number.isFinite(raw.totalPages)
+ ? Math.max(Number(raw.totalPages), 1)
+ : Math.max(Math.ceil(safeTotal / safePerPage), 1),
+ }
+ }
+
+ const rows = normalizeToList(raw)
+ const total = Number(raw?.total ?? rows.length)
+ const safeTotal = Number.isFinite(total) ? Math.max(total, 0) : rows.length
+
+ return {
+ data: rows,
+ page: fallbackPage,
+ perPage: fallbackPerPage,
+ total: safeTotal,
+ totalPages: Math.max(Math.ceil(safeTotal / fallbackPerPage), 1),
+ }
+}
+
+export type OpenToWorkFilter = 'all' | 'yes' | 'no'
+
+export async function fetchCompletedProfiles(
+ countryCode: string | undefined,
+ page: number,
+ perPage: number,
+ openToWorkFilter?: OpenToWorkFilter,
+ skillIds?: string[],
+): Promise {
+ const queryParams = new URLSearchParams({
+ page: String(page),
+ perPage: String(perPage),
+ })
+
+ if (countryCode) {
+ queryParams.set('countryCode', countryCode)
+ }
+
+ if (openToWorkFilter === 'yes') {
+ queryParams.set('openToWork', 'true')
+ }
+
+ if (openToWorkFilter === 'no') {
+ queryParams.set('openToWork', 'false')
+ }
+
+ if (Array.isArray(skillIds) && skillIds.length > 0) {
+ skillIds.forEach(id => {
+ if (id) {
+ queryParams.append('skillId', String(id))
+ }
+ })
+ }
+
+ const response = await xhrGetAsync(
+ `${EnvironmentConfig.REPORTS_API}/topcoder/completed-profiles?${queryParams.toString()}`,
+ )
+
+ return normalizeCompletedProfilesResponse(response, page, perPage)
+}
+
+export async function fetchMemberSkillsData(userId: string | number | undefined): Promise {
+ if (!userId) {
+ return []
+ }
+
+ const baseUrl = `${EnvironmentConfig.API.V5}/standardized-skills`
+ const url = `${baseUrl}/user-skills/${userId}?disablePagination=true`
+
+ try {
+ return await xhrGetAsync(url)
+ } catch {
+ // If skills API fails, return empty array to not block the page
+ return []
+ }
+}
diff --git a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts
index 7191672b1..a8fc905ed 100644
--- a/src/apps/customer-portal/src/lib/services/talentSearch.service.ts
+++ b/src/apps/customer-portal/src/lib/services/talentSearch.service.ts
@@ -21,20 +21,16 @@ export type SearchTalent = {
}
export type MemberSearchPayload = {
- countries?: string[]
limit: number
- openToWork?: boolean
+ openToWork: boolean
page: number
- profileComplete?: boolean
- recentlyActive?: boolean
- sortBy?: 'handle' | 'matchIndex'
- sortOrder?: 'asc' | 'desc'
+ recentlyActive: boolean
skillSearchType: 'OR'
skills: Array<{
id: string
wins: number
}>
- verifiedProfile?: boolean
+ verifiedProfile: boolean
}
export type MemberSearchResponse = {
diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss
new file mode 100644
index 000000000..ec7051428
--- /dev/null
+++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.module.scss
@@ -0,0 +1,218 @@
+@import '@libs/ui/styles/includes';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: $sp-4;
+}
+
+.headerRow {
+ display: flex;
+ align-items: flex-end;
+ gap: $sp-4;
+ justify-content: space-between;
+
+ @include ltemd {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+.filterWrapper {
+ display: flex;
+ gap: $sp-4;
+
+ :global([class*='__value-container']) {
+ min-height: 18px;
+ }
+
+ @include ltemd {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filterWrap {
+ min-width: 280px;
+ max-width: 360px;
+
+ @include ltemd {
+ max-width: unset;
+ min-width: unset;
+ width: 100%;
+ }
+ }
+}
+
+.counterCard {
+ border: 1px solid $black-20;
+ border-radius: $sp-2;
+ background: $tc-white;
+ padding: $sp-4;
+ min-width: 260px;
+ display: flex;
+ flex-direction: column;
+ gap: $sp-1;
+}
+
+.counterLabel {
+ color: $black-60;
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.counterValue {
+ color: $black-100;
+ font-size: 32px;
+ line-height: 36px;
+ font-weight: 700;
+ font-family: 'Nunito Sans', sans-serif;
+}
+
+.loadingWrap {
+ position: relative;
+ height: 90px;
+
+ .spinner {
+ background: none;
+ }
+}
+
+.errorMessage {
+ color: $red-100;
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 700;
+}
+
+.emptyMessage {
+ color: $black-60;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.tableWrap {
+ overflow: auto;
+ border: 1px solid $black-20;
+ border-radius: $sp-2;
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 1120px;
+ }
+
+ th,
+ td {
+ text-align: left;
+ padding: $sp-3 $sp-4;
+ border-bottom: 1px solid $black-20;
+ font-size: 14px;
+ line-height: 20px;
+ }
+
+ th {
+ color: $black-100;
+ font-weight: 700;
+ background: $black-5;
+ }
+
+ td {
+ color: $black-100;
+ vertical-align: middle;
+ }
+
+ tr:last-child td {
+ border-bottom: 0;
+ }
+}
+
+.memberCell {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+}
+
+.avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 1px solid $black-20;
+}
+
+.paginationRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: $sp-3;
+
+ @include ltemd {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+.paginationInfo {
+ color: $black-60;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.paginationButtons {
+ display: flex;
+ align-items: center;
+ gap: $sp-2;
+}
+
+.skillsList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $sp-2;
+}
+
+.skillTag {
+ display: inline-block;
+ background: $black-5;
+ border: 1px solid $black-20;
+ border-radius: $sp-1;
+ padding: $sp-1 $sp-2;
+ font-size: 12px;
+ line-height: 16px;
+ color: $black-80;
+ white-space: nowrap;
+}
+
+.moreIndicator {
+ display: inline-block;
+ background: $black-5;
+ border: 1px solid $black-20;
+ border-radius: $sp-1;
+ padding: $sp-1 $sp-2;
+ font-size: 12px;
+ line-height: 16px;
+ color: $black-80;
+ font-weight: 700;
+ min-width: 24px;
+ text-align: center;
+ cursor: help;
+}
+
+.link {
+ display: flex;
+ gap: $sp-1;
+ text-decoration: underline;
+ color: $link-blue;
+ cursor: pointer;
+}
+
+.openToWorkYes {
+ color: $green-100;
+ font-weight: 600;
+}
+
+.openToWorkNo {
+ color: $red-100;
+ font-weight: 600;
+}
diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx
new file mode 100644
index 000000000..0434e1826
--- /dev/null
+++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/ProfileCompletionPage.tsx
@@ -0,0 +1,409 @@
+/* eslint-disable react/jsx-no-bind */
+/* eslint-disable no-await-in-loop */
+/* eslint-disable complexity */
+import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react'
+import useSWR, { SWRResponse } from 'swr'
+
+import { EnvironmentConfig } from '~/config'
+import { CountryLookup, useCountryLookup, UserSkill, UserSkillDisplayModes } from '~/libs/core'
+import {
+ Button,
+ InputMultiselect,
+ InputMultiselectOption,
+ InputSelect,
+ InputSelectOption,
+ LoadingSpinner,
+ Tooltip,
+} from '~/libs/ui'
+import { fetchSkillAutocompleteOptions } from '~/libs/shared'
+import { getPreferredRoleLabelByValue } from '~/libs/shared/lib/utils/roles'
+
+import { PageWrapper } from '../../../lib'
+import {
+ CompletedProfilesResponse,
+ DEFAULT_PAGE_SIZE,
+ fetchCompletedProfiles,
+ fetchMemberSkillsData,
+ type OpenToWorkFilter,
+} from '../../../lib/services/profileCompletion.service'
+
+import styles from './ProfileCompletionPage.module.scss'
+
+const DISPLAY_SKILLS_COUNT = 5
+
+export const ProfileCompletionPage: FC = () => {
+ const [selectedCountry, setSelectedCountry] = useState('all')
+ const [currentPage, setCurrentPage] = useState(1)
+ const [selectedOpenToWork, setSelectedOpenToWork] = useState('all')
+ const [selectedSkills, setSelectedSkills] = useState([])
+ const [memberSkills, setMemberSkills] = useState>(new Map())
+ const [skillOptionsLoading, setSkillOptionsLoading] = useState(false)
+ const countryLookup: CountryLookup[] | undefined = useCountryLookup()
+
+ const countryCodeFilter = selectedCountry === 'all' ? undefined : selectedCountry
+
+ const loadSkillOptions = useCallback(async (query: string): Promise => {
+ setSkillOptionsLoading(true)
+ try {
+ return await fetchSkillAutocompleteOptions(query)
+ } catch {
+ return []
+ } finally {
+ setSkillOptionsLoading(false)
+ }
+ }, [])
+
+ const { data, error, isValidating }: SWRResponse = useSWR(
+ // eslint-disable-next-line max-len
+ `customer-portal-completed-profiles:${countryCodeFilter || 'all'}:${selectedOpenToWork}:${currentPage}:${DEFAULT_PAGE_SIZE}:${selectedSkills.map(skill => skill.value)
+ .sort()
+ .join(',')}`,
+ () => fetchCompletedProfiles(
+ countryCodeFilter,
+ currentPage,
+ DEFAULT_PAGE_SIZE,
+ selectedOpenToWork,
+ selectedSkills.map(skill => skill.value),
+ ),
+ {
+ revalidateOnFocus: false,
+ },
+ )
+
+ // Fetch member skills for all profiles on the current page
+ useEffect(() => {
+ if (!data?.data || data.data.length === 0) return
+
+ const fetchAllMemberSkills = async (): Promise => {
+ const skillsMap = new Map()
+
+ for (const profile of data.data) {
+ if (profile.userId && !memberSkills.has(profile.userId)) {
+ const skills = await fetchMemberSkillsData(profile.userId)
+ skillsMap.set(profile.userId, skills)
+ }
+ }
+
+ if (skillsMap.size > 0) {
+ setMemberSkills(prevSkills => {
+ const newMap = new Map(prevSkills)
+ skillsMap.forEach((skills, userId) => {
+ newMap.set(userId, skills)
+ })
+ return newMap
+ })
+ }
+ }
+
+ fetchAllMemberSkills()
+ }, [data?.data])
+
+ const countryMap = useMemo(() => {
+ const map = new Map()
+ const countries = countryLookup || []
+
+ countries.forEach((country: CountryLookup) => {
+ if (country.countryCode) {
+ map.set(country.countryCode, country.country)
+ }
+ })
+
+ return map
+ }, [countryLookup])
+
+ const countryOptions = useMemo(() => {
+ const staticOptions = (countryLookup || [])
+ .filter(country => !!country.countryCode)
+ .map(country => ({
+ label: country.country,
+ value: country.countryCode,
+ }))
+ .sort((a, b) => String(a.label)
+ .localeCompare(String(b.label)))
+
+ const seen = new Set(staticOptions.map(option => option.value))
+ const dynamicOptions = (data?.data || [])
+ .filter(profile => !!profile.countryCode && !seen.has(String(profile.countryCode)))
+ .map(profile => ({
+ label: (
+ countryMap.get(String(profile.countryCode))
+ || profile.countryName
+ || String(profile.countryCode)
+ ),
+ value: String(profile.countryCode),
+ }))
+ .sort((a, b) => String(a.label)
+ .localeCompare(String(b.label)))
+
+ return [
+ {
+ label: 'All Countries',
+ value: 'all',
+ },
+ ...staticOptions,
+ ...dynamicOptions,
+ ]
+ }, [countryLookup, countryMap, data?.data])
+
+ const profiles = data?.data || []
+ const totalProfiles = data?.total || 0
+ const totalPages = data?.totalPages || 1
+
+ const displayedRows = useMemo(() => profiles
+ .map(profile => {
+ const userSkills = profile.userId ? (memberSkills.get(profile.userId) || []) : []
+
+ // Prioritize principal skills, then add additional skills
+ const principalSkills = [
+ ...userSkills.filter(skill => skill.displayMode?.name === UserSkillDisplayModes.principal),
+ ]
+
+ const displayedSkills = principalSkills.slice(0, DISPLAY_SKILLS_COUNT)
+ const remainingSkillsText = principalSkills.slice(DISPLAY_SKILLS_COUNT)
+ .map(skill => skill.name)
+ .filter(Boolean)
+ .join(', ')
+ const additionalSkillsCount = Math.max(0, principalSkills.length - DISPLAY_SKILLS_COUNT)
+
+ const isOpenToWork = profile.isOpenToWork === true
+ const openToWorkLabel = isOpenToWork ? 'Yes' : 'No'
+ const openToWorkRolesText = profile.openToWork?.preferredRoles && profile.openToWork.preferredRoles.length
+ ? profile.openToWork.preferredRoles.map(getPreferredRoleLabelByValue)
+ .filter(Boolean)
+ .join(', ')
+ : 'No role preferences set'
+
+ return {
+ ...profile,
+ additionalSkillsCount,
+ countryLabel: profile.countryCode
+ ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode
+ : profile.countryName || '-',
+ displayedSkills,
+ fullName: [profile.firstName, profile.lastName].filter(Boolean)
+ .join(' ')
+ .trim(),
+ isOpenToWork,
+ locationLabel: [profile.city, profile.countryCode
+ ? countryMap.get(profile.countryCode) || profile.countryName || profile.countryCode
+ : profile.countryName]
+ .filter(Boolean)
+ .join(', '),
+ openToWorkLabel,
+ openToWorkRolesText,
+ remainingSkillsText,
+ }
+ })
+ .sort((a, b) => a.handle.localeCompare(b.handle)), [profiles, countryMap, memberSkills])
+
+ const isPreviousDisabled = currentPage <= 1 || isValidating
+ const isNextDisabled = isValidating || currentPage >= totalPages
+
+ return (
+
+
+
+
+ ) => {
+ setSelectedCountry(event.target.value || 'all')
+ setCurrentPage(1)
+ }}
+ placeholder='Select country'
+ />
+
+
+ ) => {
+ setSelectedOpenToWork((event.target.value || 'all') as OpenToWorkFilter)
+ setCurrentPage(1)
+ }}
+ placeholder='Select'
+ />
+
+
+ ) => {
+ const value = (event.target.value || []) as InputMultiselectOption[]
+ setSelectedSkills(value)
+ setCurrentPage(1)
+ }}
+ />
+
+
+
+ Fully Completed Profiles
+ {totalProfiles}
+
+
+
+ {isValidating && !data && (
+
+
+
+ )}
+
+ {!isValidating && !!error && (
+
+ Failed to load profile completion data.
+
+ )}
+
+ {!error && !isValidating && displayedRows.length === 0 && (
+
+ No fully completed profiles found for the selected country.
+
+ )}
+
+ {!error && displayedRows.length > 0 && (
+ <>
+
+
+
+
+ Member
+ Handle
+ Location
+ Open to Work
+ Principal Skills
+ {' '}
+
+
+
+ {displayedRows.map(profile => (
+
+
+
+ {profile.photoURL && (
+
+ )}
+
{profile.fullName || '-'}
+
+
+
+
+ {profile.handle}
+
+
+ {profile.locationLabel || profile.countryLabel}
+
+ {
+ profile.openToWorkLabel === 'Yes' ? (
+
+
+ {profile.openToWorkLabel}
+
+
+ ) : (
+
+ {profile.openToWorkLabel}
+
+ )
+ }
+
+
+ {profile.displayedSkills && profile.displayedSkills.length > 0 ? (
+
+ {profile.displayedSkills.map(skill => (
+
+ {skill.name}
+
+ ))}
+ {profile.additionalSkillsCount > 0 && (
+
+
+ +
+ {profile.additionalSkillsCount}
+ {' '}
+ skills
+
+
+ )}
+
+ ) : (
+ '-'
+ )}
+
+
+
+ Go to profile
+
+
+
+ ))}
+
+
+
+
+
+ Page
+ {' '}
+ {currentPage}
+ {' '}
+ of
+ {' '}
+ {totalPages}
+
+
+ setCurrentPage(previousPage => Math.max(previousPage - 1, 1))}
+ >
+ Previous
+
+ setCurrentPage(previousPage => Math.min(previousPage + 1, totalPages))}
+ >
+ Next
+
+
+
+ >
+ )}
+
+ )
+}
+
+export default ProfileCompletionPage
diff --git a/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts
new file mode 100644
index 000000000..4d99c8c31
--- /dev/null
+++ b/src/apps/customer-portal/src/pages/profile-completion/ProfileCompletionPage/index.ts
@@ -0,0 +1 @@
+export { default as ProfileCompletionPage } from './ProfileCompletionPage'
diff --git a/src/apps/customer-portal/src/pages/profile-completion/index.ts b/src/apps/customer-portal/src/pages/profile-completion/index.ts
new file mode 100644
index 000000000..73dcadd92
--- /dev/null
+++ b/src/apps/customer-portal/src/pages/profile-completion/index.ts
@@ -0,0 +1 @@
+export * from './ProfileCompletionPage'
diff --git a/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx
new file mode 100644
index 000000000..42042bc0f
--- /dev/null
+++ b/src/apps/customer-portal/src/pages/profile-completion/profile-completion.routes.tsx
@@ -0,0 +1,26 @@
+import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core'
+
+import { profileCompletionRouteId } from '../../config/routes.config'
+
+const ProfileCompletionPage: LazyLoadedComponent = lazyLoad(
+ () => import('./ProfileCompletionPage'),
+ 'ProfileCompletionPage',
+)
+
+export const profileCompletionChildRoutes = [
+ {
+ authRequired: true,
+ element: ,
+ id: 'profile-completion-page',
+ route: '',
+ },
+]
+
+export const customerPortalProfileCompletionRoutes = [
+ {
+ children: [...profileCompletionChildRoutes],
+ element: getRoutesContainer(profileCompletionChildRoutes),
+ id: profileCompletionRouteId,
+ route: profileCompletionRouteId,
+ },
+]
diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss
index 22116c969..5a6dd85e6 100644
--- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss
+++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss
@@ -3,20 +3,17 @@
.container {
display: flex;
flex-direction: column;
+ gap: $sp-4;
}
:global([class*='ContentLayout-module_content-outer']) {
- margin: 0 auto 0 !important;
+ margin: 24px auto 0 !important;
}
:global([class*='ContentLayout-module_content__']) {
padding-bottom: 0 !important;
}
-:global([class*='BreadCrumb-module_breadcrumb']) {
- display: none;
-}
-
.pageArea {
position: relative;
@include substractPagePaddings;
@@ -35,9 +32,9 @@
.pageBody {
display: grid;
grid-template-columns: 443px 1fr;
- gap: 24px;
+ gap: 40px;
margin-top: -232px;
- padding: $sp-2 $sp-8 $sp-10 $sp-8;
+ padding: $sp-4 $sp-12 $sp-14 $sp-12;
position: relative;
z-index: 1;
font-family: $font-roboto;
@@ -65,17 +62,17 @@
background: $tc-white;
border: 0;
border-radius: 16px;
- padding: 20px;
+ padding: 32px;
display: flex;
flex-direction: column;
- gap: $sp-2;
+ gap: $sp-3;
}
.sidebar .panel + .panel {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
- padding-top: 14px;
+ padding-top: 20px;
}
.panelTitle {
@@ -110,9 +107,9 @@
}
.jobDescriptionField {
- margin-top: 0;
+ margin-top: 0.5rem;
:global(textarea) {
- min-height: 170px;
+ min-height: 442px;
resize: vertical;
color: $black-100;
font-size: 14px;
@@ -122,7 +119,7 @@
.aiActions {
display: flex;
- gap: $sp-1;
+ gap: $sp-2;
:global(button) {
font-size: 14px;
@@ -137,7 +134,6 @@
}
.filterBlock {
- margin-bottom: 2px;
:global([class*='__value-container']) {
min-height: 18px;
}
@@ -179,8 +175,8 @@
align-items: center;
gap: $sp-2;
color: $black-100;
- font-size: 15px;
- line-height: 21px;
+ font-size: 16px;
+ line-height: 24px;
font-weight: 400;
position: relative;
cursor: pointer;
@@ -246,10 +242,8 @@
}
.clearFiltersWrap {
- margin-top: 6px;
+ margin-top: 10px;
align-self: flex-start;
- display: flex;
- gap: $sp-2;
:global(button) {
font-size: 14px;
@@ -286,7 +280,7 @@
align-items: center;
justify-content: space-between;
gap: $sp-3;
- padding-top: 0;
+ padding-top: 8px;
@include ltemd {
flex-direction: column;
diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx
index b45a923d7..2b710831c 100644
--- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx
+++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx
@@ -1,6 +1,6 @@
/* eslint-disable complexity */
/* eslint-disable react/jsx-no-bind */
-import { ChangeEvent, FC, FocusEvent, useCallback, useMemo, useRef, useState } from 'react'
+import { ChangeEvent, FC, FocusEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import classNames from 'classnames'
import { CountryLookup, useCountryLookup } from '~/libs/core'
@@ -9,6 +9,8 @@ import {
IconOutline,
InputMultiselect,
InputMultiselectOption,
+ InputSelect,
+ InputSelectOption,
InputTextarea,
Tooltip,
} from '~/libs/ui'
@@ -22,12 +24,12 @@ import {
searchMembers,
SearchTalent,
} from '../../../lib'
+import personSearchImage from '../../../lib/assets/person-search.png'
import styles from './TalentSearchPage.module.scss'
export const TalentSearchPage: FC = () => {
- const searchGenerationRef = useRef(0)
-
+ const skipNextAutoSearchRef = useRef(false)
const [lastSearchedDescription, setLastSearchedDescription] = useState('')
const countryLookup: CountryLookup[] | undefined = useCountryLookup()
const [jobDescription, setJobDescription] = useState('')
@@ -36,8 +38,7 @@ export const TalentSearchPage: FC = () => {
const [hasSearched, setHasSearched] = useState(false)
const [skillOptionsLoading, setSkillOptionsLoading] = useState(false)
const [selectedSkills, setSelectedSkills] = useState([])
- const [selectedCountries, setSelectedCountries] = useState([])
- const [onlyProfileComplete, setOnlyProfileComplete] = useState(false)
+ const [selectedCountry, setSelectedCountry] = useState('all')
const [onlyOpenToWork, setOnlyOpenToWork] = useState(true)
const [onlyActive, setOnlyActive] = useState(true)
const [isSearchingMembers, setIsSearchingMembers] = useState(false)
@@ -45,73 +46,46 @@ export const TalentSearchPage: FC = () => {
const [results, setResults] = useState([])
const [totalResults, setTotalResults] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
- const [lastAppliedSearchSignature, setLastAppliedSearchSignature] = useState('')
- const [showSkillMatchOnCards, setShowSkillMatchOnCards] = useState(false)
- const countryNameByCode = useMemo((): Map => new Map(
- (countryLookup || [])
- .filter(country => country.countryCode && country.country)
- .map(country => [country.countryCode.toUpperCase(), country.country]),
- ), [countryLookup])
- const countryFilterOptions = useMemo(
- (): InputMultiselectOption[] => (countryLookup || [])
- .map(country => ({
- label: country.country,
- value: country.countryCode,
- }))
- .filter(option => option.label && option.value)
- .sort((a, b) => String(a.label)
- .localeCompare(String(b.label))),
+ const [isLoading, setIsLoading] = useState(false)
+ // const breadCrumb = useMemo(
+ // () => [{ index: 1, label: 'Talent Search' }],
+ // [],
+ // )
+ const countryOptions = useMemo(
+ (): InputSelectOption[] => [
+ { label: 'All Countries', value: 'all' },
+ ...((countryLookup || [])
+ .map(country => ({
+ label: country.country,
+ value: country.countryCode,
+ }))
+ .filter(option => option.label && option.value)
+ .sort((a, b) => String(a.label)
+ .localeCompare(String(b.label)))),
+ ],
[countryLookup],
)
- const selectedCountryCodesList = useMemo(
- (): string[] => selectedCountries
- .map(country => String(country.value || '')
- .trim()
- .toUpperCase())
- .filter(Boolean),
- [selectedCountries],
- )
- const shouldShowIntroState = !hasSearched
- const currentSearchSignature = useMemo(
- (): string => JSON.stringify({
- countries: selectedCountryCodesList
- .slice()
- .sort(),
- openToWork: onlyOpenToWork,
- profileComplete: onlyProfileComplete,
- recentlyActive: onlyActive,
- skills: selectedSkills
- .map(skill => String(skill.value || '')
- .trim())
- .filter(Boolean)
- .sort(),
- }),
- [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountryCodesList, selectedSkills],
- )
+ const filteredResults = useMemo(() => results.filter(talent => {
+ if (selectedCountry !== 'all') {
+ const selectedCountryOption = countryOptions.find(option => option.value === selectedCountry)
+ const selectedCountryName = typeof selectedCountryOption?.label === 'string'
+ ? selectedCountryOption.label
+ : ''
+ const normalizedLocation = talent.location.toLowerCase()
- // Order comes from reports-api (sortBy/sortOrder on each request) so pagination stays globally consistent.
- const displayedResults = results
+ if (!selectedCountryName || !normalizedLocation.includes(selectedCountryName.toLowerCase())) {
+ return false
+ }
+ }
- const foundMembersCount = totalResults || displayedResults.length
- const displayedResultsWithCountryName = useMemo(
- () => displayedResults.map(talent => {
- const code = String(talent.location || '')
- .trim()
- .toUpperCase()
- const countryName = countryNameByCode.get(code)
+ if (onlyActive && !talent.isRecentlyActive) {
+ return false
+ }
- if (!countryName) {
- return talent
- }
+ return true
+ }), [countryOptions, onlyActive, results, selectedCountry])
- return {
- ...talent,
- location: countryName,
- }
- }),
- [countryNameByCode, displayedResults],
- )
const hasMoreResults = results.length < totalResults
const loadSkillOptions = useCallback(async (query: string): Promise => {
@@ -124,43 +98,26 @@ export const TalentSearchPage: FC = () => {
setSkillOptionsLoading(false)
}
}, [])
- const loadCountryOptions = useCallback(async (query: string): Promise => {
- const normalizedQuery = query.trim()
- .toLowerCase()
- if (!normalizedQuery) {
- return countryFilterOptions
- }
-
- return countryFilterOptions.filter(option => String(option.label || '')
- .toLowerCase()
- .includes(normalizedQuery))
- }, [countryFilterOptions])
const runMemberSearch = useCallback(async (
skillsToSearch: InputMultiselectOption[],
overrides?: {
append?: boolean
- countries?: string[]
- generation?: number
openToWork?: boolean
page?: number
- profileComplete?: boolean
recentlyActive?: boolean
},
): Promise => {
const append = overrides?.append === true
-
- const countries = (overrides?.countries ?? selectedCountryCodesList)
- .filter(Boolean)
- const generation = overrides?.generation
const openToWork = overrides?.openToWork ?? onlyOpenToWork
const page = overrides?.page ?? 1
- const profileComplete = overrides?.profileComplete ?? onlyProfileComplete
const recentlyActive = overrides?.recentlyActive ?? onlyActive
- const hasSkills = skillsToSearch.length > 0
+
const payload: MemberSearchPayload = {
limit: MEMBER_SEARCH_LIMIT,
+ openToWork,
page,
+ recentlyActive,
skills: skillsToSearch
.map(skill => String(skill.value || '')
.trim())
@@ -170,41 +127,22 @@ export const TalentSearchPage: FC = () => {
wins: 1,
})),
skillSearchType: 'OR',
- sortBy: hasSkills ? 'matchIndex' : 'handle',
- sortOrder: hasSkills ? 'desc' : 'asc',
- }
-
- if (countries.length > 0) {
- payload.countries = countries
- }
-
- if (openToWork) {
- payload.openToWork = true
- }
-
- if (profileComplete) {
- payload.profileComplete = true
- }
-
- if (recentlyActive) {
- payload.recentlyActive = true
+ verifiedProfile: true,
}
if (append) {
setIsLoadingMore(true)
} else {
setIsSearchingMembers(true)
+ setIsLoading(true)
}
setErrorMessage('')
+
try {
const response = await searchMembers(payload)
- // If generation was provided and has changed, discard stale results
- if (generation !== undefined && searchGenerationRef.current !== generation) {
- return false
- }
-
const fetchedData = Array.isArray(response?.data) ? response.data : []
+
setResults(prevResults => {
if (!append) {
return fetchedData
@@ -218,6 +156,7 @@ export const TalentSearchPage: FC = () => {
merged.push(item)
}
})
+
return merged
})
setTotalResults(Number(response?.total || 0))
@@ -238,16 +177,20 @@ export const TalentSearchPage: FC = () => {
setIsLoadingMore(false)
} else {
setIsSearchingMembers(false)
+ setIsLoading(false)
}
}
- }, [onlyActive, onlyOpenToWork, onlyProfileComplete, selectedCountryCodesList])
+ }, [onlyActive, onlyOpenToWork])
const clearAllFilters = useCallback((): void => {
- setSelectedCountries([])
- setOnlyProfileComplete(false)
+ setSelectedCountry('all')
setOnlyOpenToWork(true)
setOnlyActive(true)
setSelectedSkills([])
+ setHasSearched(false)
+ setResults([])
+ setTotalResults(0)
+ setCurrentPage(1)
setErrorMessage('')
setLastSearchedDescription('')
}, [])
@@ -258,15 +201,11 @@ export const TalentSearchPage: FC = () => {
return
}
- const generation = searchGenerationRef.current
-
setErrorMessage('')
setIsExtractingSkills(true)
try {
const extractedSkillsResult = await extractSkillsFromText(normalizedDescription)
- if (searchGenerationRef.current !== generation) return
-
const extractedSkills = Array.isArray(extractedSkillsResult?.matches)
? extractedSkillsResult.matches
: []
@@ -292,51 +231,52 @@ export const TalentSearchPage: FC = () => {
setSelectedSkills(extractedOptions)
if (extractedOptions.length === 0) {
+ setResults([])
+ setTotalResults(0)
+ setHasSearched(true)
setErrorMessage('No skills were extracted from the job description.')
return
}
- setLastSearchedDescription(normalizedDescription)
+ setHasSearched(true)
+ skipNextAutoSearchRef.current = true
+ const searchSucceeded = await runMemberSearch(extractedOptions, { page: 1 })
+ if (searchSucceeded) {
+ setLastSearchedDescription(normalizedDescription)
+ }
} catch {
- if (searchGenerationRef.current !== generation) return
+ // Prevent stale auto-search when extraction fails and loading flips to false.
+ skipNextAutoSearchRef.current = true
setErrorMessage('Failed to extract skills. Please try again.')
+ setHasSearched(true)
} finally {
setIsExtractingSkills(false)
}
- }, [isExtractingSkills, jobDescription])
+ }, [isExtractingSkills, jobDescription, runMemberSearch])
- const handleSearch = useCallback(async (): Promise => {
- if (isSearchingMembers || selectedSkills.length === 0) {
+ useEffect(() => {
+ if (!hasSearched || isExtractingSkills || selectedSkills.length === 0) {
return
}
- setHasSearched(true)
- const hadSkills = selectedSkills.length > 0
- const searchSucceeded = await runMemberSearch(selectedSkills, {
- countries: selectedCountryCodesList,
- openToWork: onlyOpenToWork,
- page: 1,
- profileComplete: onlyProfileComplete,
- recentlyActive: onlyActive,
- })
- if (searchSucceeded) {
- setLastAppliedSearchSignature(currentSearchSignature)
- setShowSkillMatchOnCards(hadSkills)
+ if (skipNextAutoSearchRef.current) {
+ skipNextAutoSearchRef.current = false
+ return
}
+
+ runMemberSearch(selectedSkills)
}, [
- currentSearchSignature,
- isSearchingMembers,
+ hasSearched,
+ isExtractingSkills,
onlyActive,
onlyOpenToWork,
- onlyProfileComplete,
runMemberSearch,
- selectedCountryCodesList,
selectedSkills,
])
const handleLoadMore = useCallback((): void => {
- if (isLoadingMore || isSearchingMembers || !hasMoreResults) {
+ if (isLoadingMore || isSearchingMembers || !hasMoreResults || selectedSkills.length === 0) {
return
}
@@ -345,7 +285,7 @@ export const TalentSearchPage: FC = () => {
page: currentPage + 1,
})
}, [currentPage, hasMoreResults, isLoadingMore, isSearchingMembers, runMemberSearch, selectedSkills])
- const isAiExtractButtonDisabled = useMemo(
+ const isSearchButtonDisabled = useMemo(
() => isExtractingSkills
|| !jobDescription.trim()
|| jobDescription.trim() === lastSearchedDescription,
@@ -353,7 +293,7 @@ export const TalentSearchPage: FC = () => {
)
return (
@@ -362,12 +302,20 @@ export const TalentSearchPage: FC = () => {
+
+
+ AI Search
+
+
) => {
setJobDescription(event.target.value)
}}
@@ -377,7 +325,6 @@ export const TalentSearchPage: FC = () => {
secondary
disabled={isExtractingSkills}
onClick={() => {
- searchGenerationRef.current += 1
setJobDescription('')
setErrorMessage('')
setLastSearchedDescription('')
@@ -387,10 +334,10 @@ export const TalentSearchPage: FC = () => {
- {isExtractingSkills ? 'Analyzing...' : 'AI Skill Extract'}
+ {isExtractingSkills ? 'Analyzing...' : 'Search'}
{errorMessage && (
@@ -399,6 +346,7 @@ export const TalentSearchPage: FC = () => {
+ Filter
{
onChange={(event: ChangeEvent) => {
const value = (event.target.value || []) as InputMultiselectOption[]
setSelectedSkills(value)
+ setHasSearched(value.length > 0)
if (value.length === 0) {
setLastSearchedDescription('')
}
@@ -418,15 +367,13 @@ export const TalentSearchPage: FC = () => {
/>
-
) => {
- const value = (event.target.value || []) as InputMultiselectOption[]
- setSelectedCountries(value)
+ setSelectedCountry(event.target.value || 'all')
}}
placeholder='Select country'
/>
@@ -472,33 +419,10 @@ export const TalentSearchPage: FC = () => {
-
- ) => {
- setOnlyProfileComplete(event.target.checked)
- }}
- />
-
- 100% Profile complete
-
Clear Filters
-
- Search
-
@@ -506,63 +430,62 @@ export const TalentSearchPage: FC = () => {
- {shouldShowIntroState && (
+ {!hasSearched && (
+
+
Find the right talent
- Paste a job description to AI-extract skills, or enter skills manually
- to find talents
+ Paste a job description on the left and hit
+ Search
+ - Our AI will match you with the
+ best candidates from our network.
)}
- {!shouldShowIntroState && (
+ {hasSearched && (
- {!isSearchingMembers && (
-
-
- We have found
-
- {`${foundMembersCount} members`}
-
- that match your search.
-
-
- )}
- {isSearchingMembers && (
+ {isLoading ? (
Searching talent...
- )}
- {!isSearchingMembers && displayedResults.length === 0 && (
-
-
No matching talent found
-
Try changing filters or using a different job description.
-
- )}
- {!isSearchingMembers && displayedResults.length > 0 && (
+ ) : (
<>
-
- {displayedResultsWithCountryName.map(talent => (
-
- ))}
-
- {hasMoreResults && (
-
-
- {isLoadingMore ? 'Loading...' : 'Load More Members'}
-
+
+ {filteredResults.length === 0 ? (
+
+
No matching talent found
+
Try changing filters or using a different job description.
+ ) : (
+ <>
+
+ {filteredResults.map(talent => (
+
+ ))}
+
+ {hasMoreResults && (
+
+
+ {isLoadingMore ? 'Loading...' : 'Load More Members'}
+
+
+ )}
+ >
)}
>
)}
diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss
index 2cea74382..83b5982ba 100644
--- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss
+++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.module.scss
@@ -194,10 +194,6 @@
font-weight: 400;
}
-.cardFooterWithoutMatch {
- justify-content: flex-end;
-}
-
.footerMatched {
display: flex;
align-items: center;
diff --git a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx
index 2b37a9c8b..7d636e228 100644
--- a/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx
+++ b/src/apps/customer-portal/src/pages/talent-search/components/TalentResultCard/TalentResultCard.tsx
@@ -30,7 +30,6 @@ interface TalentResultCardTalent {
interface TalentResultCardProps {
talent: TalentResultCardTalent
- showSkillMatch: boolean
}
function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCardTalent['matchedSkills'] {
@@ -46,19 +45,6 @@ function getUniqueMatchedSkills(talent: TalentResultCardTalent): TalentResultCar
})
}
-function matchedSkillStatsLabel(skill: MatchedSkill): string {
- const parts: string[] = []
- if (skill.wins > 0) {
- parts.push(`${skill.wins} wins`)
- }
-
- if (skill.submitted > 0) {
- parts.push(`${skill.submitted} submissions`)
- }
-
- return parts.length > 0 ? `: ${parts.join(', ')}` : ''
-}
-
function buildMatchedSkillsTooltipContent(
count: number,
skills: MatchedSkill[],
@@ -72,7 +58,7 @@ function buildMatchedSkillsTooltipContent(
{skills.map((skill: MatchedSkill) => (
{skill.name}
- {matchedSkillStatsLabel(skill)}
+ {`: ${skill.wins} wins, ${skill.submitted} submissions`}
))}
@@ -82,7 +68,6 @@ function buildMatchedSkillsTooltipContent(
export const TalentResultCard: FC
= (props: TalentResultCardProps) => {
const talent: TalentResultCardTalent = props.talent
- const showSkillMatch = props.showSkillMatch
const uniqueSkills = useMemo(() => getUniqueMatchedSkills(talent), [talent])
const isVerifiedProfile = talent.isVerified === true
const displayName = String(talent.name || '')
@@ -120,11 +105,9 @@ export const TalentResultCard: FC = (props: TalentResultC
{displayHandle}
- {showSkillMatch && (
-
- {`${talent.matchIndex}% Match`}
-
- )}
+
+ {`${talent.matchIndex}% Match`}
+
{talent.name}
@@ -165,29 +148,27 @@ export const TalentResultCard: FC = (props: TalentResultC
-
- {showSkillMatch && (
-
-
- {`${uniqueSkills.length} ${matchedSkillLabel}`}
-
- {uniqueSkills.length > 0 && (
-
+
+
+ {`${uniqueSkills.length} ${matchedSkillLabel}`}
+
+ {uniqueSkills.length > 0 && (
+
+
-
-
-
-
- )}
-
- )}
+
+
+
+ )}
+
({
}) => (
{props.engagement.title}
- {props.assignment?.status}
{props.assignment?.status?.toLowerCase() === 'selected' && (
Accept Offer
@@ -200,50 +199,4 @@ describe('MyAssignmentsPage', () => {
expect(screen.getAllByText(gateMessage))
.toHaveLength(1)
})
-
- it('groups selected and assigned assignments before completed and terminated assignments', async () => {
- mockGetMyAssignedEngagements.mockResolvedValue({
- data: [
- buildEngagement('eng-completed', 'Completed Engagement', 'completed'),
- buildEngagement('eng-selected', 'Selected Engagement', 'selected'),
- buildEngagement('eng-terminated', 'Terminated Engagement', 'terminated'),
- buildEngagement('eng-assigned', 'Assigned Engagement', 'assigned'),
- ],
- page: 1,
- perPage: 20,
- total: 4,
- totalPages: 1,
- })
-
- render( )
-
- const activeHeading = await screen.findByRole('heading', { name: 'Active' })
- const pastHeading = screen.getByRole('heading', { name: 'Past' })
- const activeSection = activeHeading.closest('section') as HTMLElement
- const pastSection = pastHeading.closest('section') as HTMLElement
-
- const headingLabels = screen.getAllByRole('heading')
- .map(heading => heading.textContent)
-
- expect(headingLabels.indexOf('Active'))
- .toBeLessThan(headingLabels.indexOf('Past'))
- expect(within(activeSection)
- .getByText('Selected Engagement'))
- .toBeInTheDocument()
- expect(within(activeSection)
- .getByText('Assigned Engagement'))
- .toBeInTheDocument()
- expect(within(activeSection)
- .queryByText('Completed Engagement'))
- .not.toBeInTheDocument()
- expect(within(activeSection)
- .queryByText('Terminated Engagement'))
- .not.toBeInTheDocument()
- expect(within(pastSection)
- .getByText('Completed Engagement'))
- .toBeInTheDocument()
- expect(within(pastSection)
- .getByText('Terminated Engagement'))
- .toBeInTheDocument()
- })
})
diff --git a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
index 2b0dba9fd..93331f4fa 100644
--- a/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
+++ b/src/apps/engagements/src/pages/my-assignments/MyAssignmentsPage.tsx
@@ -30,85 +30,12 @@ const PER_PAGE = APPLICATIONS_PER_PAGE
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const IP_ADDRESS_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
const PROFILE_GATE_ERROR_MESSAGE = 'Your profile must be 100% complete before accepting this offer.'
-const ACTIVE_ASSIGNMENT_STATUSES = ['selected', 'assigned']
-const PAST_ASSIGNMENT_STATUSES = ['completed', 'terminated']
type ProfileGateState = {
engagementId: string
message: string
}
-type AssignmentSections = {
- active: Engagement[]
- past: Engagement[]
-}
-
-/**
- * Normalizes assignment status values before section matching.
- * @param status Raw assignment status from the API response.
- * @returns Lower-case status key used by the My Assignments sections, or undefined when the status is empty.
- */
-const getAssignmentStatusKey = (status?: string): string | undefined => {
- const normalized = status?.trim()
- .toLowerCase()
-
- return normalized || undefined
-}
-
-/**
- * Selects the assignment record that should drive a member's assignment card.
- * @param engagement Engagement returned by the my-assignments endpoint.
- * @param userId Authenticated member id whose assignment should be displayed.
- * @returns The member's active assignment when present, otherwise their first past or available assignment.
- */
-const getPreferredUserAssignment = (
- engagement: Engagement,
- userId?: number,
-): EngagementAssignment | undefined => {
- if (!userId) {
- return undefined
- }
-
- const userAssignments = engagement.assignments?.filter(
- candidate => candidate.memberId === String(userId),
- ) ?? []
-
- return userAssignments.find(
- candidate => ACTIVE_ASSIGNMENT_STATUSES.includes(
- getAssignmentStatusKey(candidate.status) ?? '',
- ),
- ) ?? userAssignments.find(
- candidate => PAST_ASSIGNMENT_STATUSES.includes(
- getAssignmentStatusKey(candidate.status) ?? '',
- ),
- ) ?? userAssignments[0]
-}
-
-/**
- * Splits engagements into the Active and Past sections requested for My Assignments.
- * @param assignments Engagements returned by the my-assignments endpoint.
- * @param getAssignment Resolves the displayed assignment for a given engagement.
- * @returns Engagement groups for active selected/assigned work and past completed/terminated work.
- */
-const getAssignmentSections = (
- assignments: Engagement[],
- getAssignment: (engagement: Engagement) => EngagementAssignment | undefined,
-): AssignmentSections => assignments.reduce((sections, engagement) => {
- const assignment = getAssignment(engagement)
- const status = getAssignmentStatusKey(assignment?.status)
-
- if (status && PAST_ASSIGNMENT_STATUSES.includes(status)) {
- sections.past.push(engagement)
- } else {
- sections.active.push(engagement)
- }
-
- return sections
-}, {
- active: [],
- past: [],
-})
-
const getBaseDomainFromHostname = (hostname: string): string | undefined => {
const normalized = hostname.trim()
.toLowerCase()
@@ -257,9 +184,15 @@ const MyAssignmentsPage: FC = () => {
window.open(`mailto:${contactEmail}`, '_blank')
}, [])
- const getUserAssignment = useCallback((engagement: Engagement): EngagementAssignment | undefined => (
- getPreferredUserAssignment(engagement, userId)
- ), [userId])
+ const getUserAssignment = useCallback((engagement: Engagement): EngagementAssignment | undefined => {
+ if (!userId) {
+ return undefined
+ }
+
+ return engagement.assignments?.find(
+ candidate => candidate.memberId === String(userId),
+ )
+ }, [userId])
const handleDocumentExperience = useCallback((engagement: Engagement) => {
const assignment = getUserAssignment(engagement)
@@ -369,10 +302,6 @@ const MyAssignmentsPage: FC = () => {
const skeletonCards = useMemo(() => Array.from({ length: 6 }, (_, index) => index), [])
const showEmptyState = getShowEmptyState(loading, error, assignments)
- const assignmentSections = useMemo(
- () => getAssignmentSections(assignments, getUserAssignment),
- [assignments, getUserAssignment],
- )
const showExperienceModal = useMemo(
() => [selectedEngagement, selectedAssignmentId].every(Boolean),
[selectedEngagement, selectedAssignmentId],
@@ -416,90 +345,66 @@ const MyAssignmentsPage: FC = () => {
)}
- {!error && loading && (
+ {!error && (
- {skeletonCards.map(card => (
+ {loading ? skeletonCards.map(card => (
- ))}
-
- )}
- {!error && !loading && (
-
- {[
- {
- engagements: assignmentSections.active,
- id: 'active',
- title: 'Active',
- },
- {
- engagements: assignmentSections.past,
- id: 'past',
- title: 'Past',
- },
- ].filter(section => section.engagements.length > 0)
- .map(section => (
-
- {section.title}
-
- {section.engagements.map(engagement => {
- const contactEmail = normalizeContactEmail(engagement.createdByEmail)
- const assignment = getUserAssignment(engagement)
- const handleDocumentExperienceClick = function (): void {
- handleDocumentExperience(engagement)
- }
-
- const handleAcceptOfferClick = function (): void {
- setProfileGateState(undefined)
-
- if (profileCompleteness?.isLoading) {
- return
- }
-
- if (
- profileCompleteness
- && typeof profileCompleteness.percent === 'number'
- && profileCompleteness.percent < 100
- ) {
- setProfileGateState({
- engagementId: engagement.id,
- message: PROFILE_GATE_ERROR_MESSAGE,
- })
- return
- }
-
- startTermsAgreementFlow(() => {
- handleOpenOfferModal(engagement, 'accept')
- })
- }
-
- const handleRejectOfferClick = function (): void {
- handleOpenOfferModal(engagement, 'reject')
- }
-
- return (
-
- )
- })}
-
-
- ))}
+ )) : assignments.map(engagement => {
+ const contactEmail = normalizeContactEmail(engagement.createdByEmail)
+ const assignment = getUserAssignment(engagement)
+ const handleDocumentExperienceClick = function (): void {
+ handleDocumentExperience(engagement)
+ }
+
+ const handleAcceptOfferClick = function (): void {
+ setProfileGateState(undefined)
+
+ if (profileCompleteness?.isLoading) {
+ return
+ }
+
+ if (
+ profileCompleteness
+ && typeof profileCompleteness.percent === 'number'
+ && profileCompleteness.percent < 100
+ ) {
+ setProfileGateState({
+ engagementId: engagement.id,
+ message: PROFILE_GATE_ERROR_MESSAGE,
+ })
+ return
+ }
+
+ startTermsAgreementFlow(() => {
+ handleOpenOfferModal(engagement, 'accept')
+ })
+ }
+
+ const handleRejectOfferClick = function (): void {
+ handleOpenOfferModal(engagement, 'reject')
+ }
+
+ return (
+
+ )
+ })}
)}
{!error && assignments.length > 0 && (
diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts
index 914a95cd9..fb7d07091 100644
--- a/src/apps/reports/src/config/routes.config.ts
+++ b/src/apps/reports/src/config/routes.config.ts
@@ -10,4 +10,3 @@ export const rootRoute: string
export const reportsPageRouteId = 'reports'
export const bulkMemberLookupRouteId = 'bulk-member-lookup'
-export const billingAccountsPageRouteId = 'billing-accounts'
diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
index 2a73aa4fe..cb0c2e34a 100644
--- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
+++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
@@ -15,11 +15,7 @@ import classNames from 'classnames'
import { useClickOutside } from '~/libs/shared/lib/hooks'
import { TabsNavItem } from '~/libs/ui'
-import {
- billingAccountsPageRouteId,
- bulkMemberLookupRouteId,
- reportsPageRouteId,
-} from '../../../config/routes.config'
+import { bulkMemberLookupRouteId, reportsPageRouteId } from '../../../config/routes.config'
import styles from './NavTabs.module.scss'
@@ -38,10 +34,6 @@ const NavTabs: FC = () => {
id: bulkMemberLookupRouteId,
title: 'Bulk Member Lookup',
},
- {
- id: billingAccountsPageRouteId,
- title: 'Billing Accounts',
- },
], [])
const activeTabPathName: string = useMemo
(() => {
diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts
index 567bbcf6a..b2c6dc18b 100644
--- a/src/apps/reports/src/lib/services/index.ts
+++ b/src/apps/reports/src/lib/services/index.ts
@@ -2,7 +2,6 @@ export {
downloadBlobFile,
downloadReportAsCsv,
downloadReportAsJson,
- fetchReportJson,
fetchReportsIndex,
postReportAsCsv,
postReportAsJson,
@@ -11,12 +10,8 @@ export {
} from './reports.service'
export type {
- BillingAccountDetail,
- BillingAccountProfileResponse,
- BillingAccountsViewData,
ReportDefinition,
ReportGroup,
ReportParameter,
ReportsIndexResponse,
- SfdcBillingAccountPaymentRow,
} from './reports.service'
diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts
index a6c4ed649..d752087c8 100644
--- a/src/apps/reports/src/lib/services/reports.service.ts
+++ b/src/apps/reports/src/lib/services/reports.service.ts
@@ -28,46 +28,6 @@ export type ReportGroup = {
export type ReportsIndexResponse = Record
-export type BillingAccountDetail = {
- name: string
- description: string | null
- subcontractingEndCustomer: string | null
- status: string
- startDate: string | null
- endDate: string | null
- budget: string | number
- markup: string | number
-}
-
-export type SfdcBillingAccountPaymentRow = {
- paymentId: string
- paymentDate: string
- billingAccountId: string
- paymentStatus: string
- challengeFee: string | number
- paymentAmount: string | number
- challengeId: string
- category: string
- isTask: boolean
- challengeName: string | null
- challengeStatus: string | null
- winnerHandle: string
- winnerId: string
- winnerFirstName: string
- winnerLastName: string
-}
-
-/** Response from GET /sfdc/billing-accounts */
-export type BillingAccountProfileResponse = {
- billingAccount?: BillingAccountDetail
-}
-
-/** Billing Accounts in-app view: profile + rows from GET /sfdc/payments */
-export type BillingAccountsViewData = {
- billingAccount?: BillingAccountDetail
- payments: SfdcBillingAccountPaymentRow[]
-}
-
const reportsDownloadClient: AxiosInstance = xhrCreateInstance()
const buildReportUrl = (path: string): string => {
@@ -177,16 +137,6 @@ export const downloadReportAsJson = (path: string): Promise => (
downloadReportBlob(path, 'application/json')
)
-export const fetchReportJson = async (path: string): Promise => {
- if (!path) {
- throw new Error('Report path is required')
- }
-
- const normalizedPath = path.startsWith('/') ? path : `/${path}`
- const url = `${EnvironmentConfig.API.V6}/reports${normalizedPath}`
- return xhrGetAsync(url, reportsDownloadClient)
-}
-
export const downloadReportAsCsv = (path: string): Promise => (
downloadReportBlob(path, 'text/csv')
)
diff --git a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx b/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx
deleted file mode 100644
index fc399e312..000000000
--- a/src/apps/reports/src/pages/reports/BillingAccountsPage.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import { BillingAccountsPage } from './ReportsPage'
-
-export default BillingAccountsPage
diff --git a/src/apps/reports/src/pages/reports/ReportsPage.module.scss b/src/apps/reports/src/pages/reports/ReportsPage.module.scss
index e282cf8d2..e804f2221 100644
--- a/src/apps/reports/src/pages/reports/ReportsPage.module.scss
+++ b/src/apps/reports/src/pages/reports/ReportsPage.module.scss
@@ -26,97 +26,26 @@
gap: 4px;
}
-.filtersPanel {
- margin-top: 12px;
- padding: 16px;
- border: 1px solid #e4e6e9;
- border-radius: 8px;
- background: #fcfcfd;
-}
-
.params {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 30px 50px;
- margin-top: 0;
- align-items: start;
-}
-
-@media (max-width: 1200px) {
- .params {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 760px) {
- .params {
- grid-template-columns: 1fr;
- }
-}
-
-.paramCard {
- display: grid;
- grid-template-rows: auto auto;
- row-gap: 8px;
- align-content: start;
-}
-
-.paramHeader {
- min-height: 28px;
-}
-
-.paramTitleRow {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
-}
-
-.paramHeaderActions {
- display: flex;
- align-items: center;
- gap: 8px;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 16px;
+ margin-top: 12px;
}
.paramLabel {
font-weight: 600;
- color: #2f3338;
}
-.paramTypePill {
- font-size: 11px;
- color: #5e6369;
- background: #f3f4f6;
- border-radius: 999px;
- padding: 2px 8px;
- white-space: nowrap;
-}
-
-.actionsBar {
- display: flex;
- justify-content: flex-start;
- margin-top: 18px;
- padding-top: 14px;
- border-top: 1px solid #eceef1;
+.paramMeta {
+ color: #6b6f75;
+ font-size: 12px;
}
-.paramInfoButton {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- border: 0;
- padding: 0;
- border-radius: 50%;
- background: transparent;
+.paramHint {
color: #6b6f75;
- cursor: pointer;
-
- svg {
- width: 16px;
- height: 16px;
- }
+ font-size: 12px;
+ font-style: italic;
}
.reportTitle {
@@ -165,95 +94,3 @@
font-style: italic;
color: #6b6f75;
}
-
-.billingSummary {
- margin-top: 8px;
- padding: 16px;
- border: 1px solid #e4e6e9;
- border-radius: 6px;
- background: #fafbfc;
-}
-
-.billingSummaryTitle {
- font-weight: 600;
- margin-bottom: 12px;
-}
-
-.billingDetailGrid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
- gap: 12px 24px;
-}
-
-.billingDetailItem {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.billingDetailLabel {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: #6b6f75;
-}
-
-.billingDetailValue {
- font-size: 14px;
- color: #1a1d21;
- word-break: break-word;
-}
-
-.billingMissingNotice {
- margin-top: 8px;
- padding: 12px;
- border-radius: 4px;
- background: #f3f4f6;
- color: #494f55;
- font-size: 14px;
-}
-
-.paymentsSection {
- margin-top: 24px;
-}
-
-.paymentsSectionTitle {
- font-weight: 600;
- margin-bottom: 8px;
-}
-
-.tableWrap {
- overflow-x: auto;
- border: 1px solid #e4e6e9;
- border-radius: 6px;
-}
-
-.paymentsTable {
- width: 100%;
- border-collapse: collapse;
- font-size: 13px;
-}
-
-.paymentsTable th,
-.paymentsTable td {
- padding: 8px 10px;
- text-align: left;
- border-bottom: 1px solid #e4e6e9;
- vertical-align: top;
-}
-
-.paymentsTable th {
- background: #f3f4f6;
- font-weight: 600;
- white-space: nowrap;
-}
-
-.paymentsTable tbody tr:last-child td {
- border-bottom: none;
-}
-
-.paymentsEmpty {
- margin-top: 8px;
- color: #6b6f75;
- font-style: italic;
-}
diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx
index 861372464..b00f5cf2f 100644
--- a/src/apps/reports/src/pages/reports/ReportsPage.tsx
+++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx
@@ -1,32 +1,19 @@
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react'
import { NavigateFunction, useNavigate } from 'react-router-dom'
-import {
- Button,
- IconOutline,
- InputSelect,
- InputSelectOption,
- InputText,
- LoadingSpinner,
- PageTitle,
- Tooltip,
-} from '~/libs/ui'
+import { Button, InputSelect, InputSelectOption, InputText, LoadingSpinner, PageTitle } from '~/libs/ui'
import { bulkMemberLookupRouteId } from '../../config/routes.config'
import { handleError } from '../../lib/utils'
import {
- BillingAccountProfileResponse,
- BillingAccountsViewData,
downloadBlobFile,
downloadReportAsCsv,
downloadReportAsJson,
- fetchReportJson,
fetchReportsIndex,
ReportDefinition,
ReportGroup,
ReportParameter,
ReportsIndexResponse,
- SfdcBillingAccountPaymentRow,
} from '../../lib/services'
import { getReportParameterValidationError } from './reports-page.validation'
@@ -34,203 +21,6 @@ import styles from './ReportsPage.module.scss'
const pageTitle = 'Reports'
const bulkMembersByHandlesPath = '/identity/users-by-handles'
-const BILLING_ACCOUNTS_REPORT_PATH = '/sfdc/billing-accounts'
-const SFDC_PAYMENTS_REPORT_PATH = '/sfdc/payments'
-const BILLING_ACCOUNTS_REPORT_DEFINITION: ReportDefinition = {
- description: 'View billing-account details and SFDC payments by billing account ID.',
- method: 'GET',
- name: 'Billing Accounts',
- parameters: [
- {
- description: 'Billing account ID',
- location: 'query',
- name: 'billingAccountId',
- required: true,
- type: 'string',
- },
- {
- description: 'Optional start date for payment filtering (ISO 8601)',
- location: 'query',
- name: 'startDate',
- type: 'date',
- },
- {
- description: 'Optional end date for payment filtering (ISO 8601)',
- location: 'query',
- name: 'endDate',
- type: 'date',
- },
- ],
- path: BILLING_ACCOUNTS_REPORT_PATH,
-}
-
-type ReportsPageTab = 'reports' | 'billingAccounts'
-
-const buildSfdcPaymentsQueryPath = (
- billingAccountId: string,
- startDate?: string,
- endDate?: string,
-): string => {
- const query = new URLSearchParams()
- query.append('billingAccountIds', billingAccountId.trim())
- const start = startDate?.trim()
- const end = endDate?.trim()
-
- if (start) {
- query.append('startDate', start)
- }
-
- if (end) {
- query.append('endDate', end)
- }
-
- return `${SFDC_PAYMENTS_REPORT_PATH}?${query.toString()}`
-}
-
-const formatReportCell = (value: unknown): string => {
- if (value === null || value === undefined || value === '') {
- return '—'
- }
-
- if (typeof value === 'boolean') {
- return value ? 'Yes' : 'No'
- }
-
- return String(value)
-}
-
-const formatPaymentDate = (iso: string): string => {
- const parsed = Date.parse(iso)
-
- if (Number.isNaN(parsed)) {
- return iso
- }
-
- return new Date(parsed)
- .toLocaleString()
-}
-
-const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [
- { key: 'paymentId', label: 'Payment ID' },
- { key: 'paymentDate', label: 'Payment date' },
- { key: 'billingAccountId', label: 'Billing account ID' },
- { key: 'paymentStatus', label: 'Status' },
- { key: 'challengeFee', label: 'Challenge fee' },
- { key: 'paymentAmount', label: 'Payment amount' },
- { key: 'challengeId', label: 'Challenge ID' },
- { key: 'category', label: 'Category' },
- { key: 'isTask', label: 'Task' },
- { key: 'challengeName', label: 'Challenge name' },
- { key: 'challengeStatus', label: 'Challenge status' },
- { key: 'winnerHandle', label: 'Winner handle' },
- { key: 'winnerId', label: 'Winner ID' },
- { key: 'winnerFirstName', label: 'Winner first name' },
- { key: 'winnerLastName', label: 'Winner last name' },
-]
-
-const BillingAccountReportResults = (
- props: { data: BillingAccountsViewData },
-): JSX.Element => {
- const billingAccount: BillingAccountsViewData['billingAccount'] = props.data.billingAccount
- const payments: BillingAccountsViewData['payments'] = props.data.payments
-
- return (
-
-
-
Billing account
- {billingAccount ? (
-
-
- Name
- {billingAccount.name}
-
-
- Description
-
- {formatReportCell(billingAccount.description)}
-
-
-
- Subcontracting end customer
-
- {formatReportCell(billingAccount.subcontractingEndCustomer)}
-
-
-
- Status
- {billingAccount.status}
-
-
- Start date
-
- {billingAccount.startDate
- ? formatPaymentDate(String(billingAccount.startDate))
- : '—'}
-
-
-
- End date
-
- {billingAccount.endDate
- ? formatPaymentDate(String(billingAccount.endDate))
- : '—'}
-
-
-
- Budget
-
- {formatReportCell(billingAccount.budget)}
-
-
-
- Markup
-
- {formatReportCell(billingAccount.markup)}
-
-
-
- ) : (
-
- No billing account profile was found for this ID. Payments for this account may still
- appear below.
-
- )}
-
-
-
-
Payments
- {payments.length === 0 ? (
-
No payments matched the selected filters.
- ) : (
-
-
-
-
- {PAYMENT_TABLE_COLUMNS.map(col => (
- {col.label}
- ))}
-
-
-
- {payments.map(row => (
-
- {PAYMENT_TABLE_COLUMNS.map(col => (
-
- {col.key === 'paymentDate'
- ? formatPaymentDate(String(row[col.key]))
- : formatReportCell(row[col.key])}
-
- ))}
-
- ))}
-
-
-
- )}
-
-
- )
-}
const buildDownloadName = (
name: string,
@@ -258,34 +48,12 @@ const formatMethod = (method?: string): string => (
method ? method.toUpperCase() : 'GET'
)
-const formatParameterLabel = (name: string): string => (
- name
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
- .replace(/Ids\b/g, 'IDs')
- .replace(/^./, char => char.toUpperCase())
-)
-
-const buildParameterTooltipContent = (parameter: ReportParameter): JSX.Element => (
- <>
- {parameter.description?.trim() || 'No description available.'}
-
- {`Location: ${parameter.location || 'query'} (${parameter.name})`}
-
- >
-)
-
-const EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE: BillingAccountProfileResponse = {
- billingAccount: undefined,
-}
-
type ReportActionsProps = {
handleCsvDownload: () => void
handleJsonDownload: () => void
- handleResetFilters: () => void
handleOpenBulkMemberLookup: () => void
isDownloadDisabled: boolean
isHandleLookupPostReport: boolean
- isResetDisabled: boolean
isPostReport: boolean
}
@@ -326,13 +94,6 @@ const ReportActions = (props: ReportActionsProps): JSX.Element => {
>
Download as CSV
-
- Reset Filters
-
)
}
@@ -364,67 +125,50 @@ const SelectedReportSection = (props: SelectedReportSectionProps): JSX.Element =
- {(props.selectedReport.parameters?.length ?? 0) > 0 ? (
-
-
- {props.selectedReport.parameters?.map(parameter => (
-
-
-
-
- {formatParameterLabel(parameter.name)}
- {parameter.required ? ' *' : ''}
-
-
-
{parameter.type}
-
-
-
-
-
-
-
-
- {props.renderParameterInput(parameter)}
+ {(props.selectedReport.parameters?.length ?? 0) > 0 && (
+
+ {props.selectedReport.parameters?.map(parameter => (
+
+
+ {parameter.name}
+ {parameter.required ? ' *' : ''}
- ))}
-
-
- {props.reportActions}
-
+ {parameter.description && (
+
{parameter.description}
+ )}
+
+ Location:
+ {' '}
+ {parameter.location || 'query'}
+ {' '}
+ • Type:
+ {' '}
+ {parameter.type}
+
+ {parameter.type.endsWith('[]') && (
+
+ Use comma-separated values for lists.
+
+ )}
+ {props.renderParameterInput(parameter)}
+
+ ))}
- ) : (
- props.reportActions
)}
+
+ {props.reportActions}
>
)
}
-type ReportsPageContentProps = {
- initialTab: ReportsPageTab
-}
-
-// eslint-disable-next-line complexity
-const ReportsPageContent: FC
= props => {
+export const ReportsPage: FC = () => {
const navigate: NavigateFunction = useNavigate()
- const [activeTab] = useState(props.initialTab)
const [reportsIndex, setReportsIndex] = useState({})
const [selectedBasePath, setSelectedBasePath] = useState('')
const [selectedReportPath, setSelectedReportPath] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [downloadingFormat, setDownloadingFormat] = useState<'json' | 'csv' | undefined>(undefined)
const [parameterValues, setParameterValues] = useState>({})
- const [billingAccountViewData, setBillingAccountViewData] = useState<
- BillingAccountsViewData | undefined
- >(undefined)
- const [isBillingAccountViewLoading, setIsBillingAccountViewLoading] = useState(false)
useEffect(() => {
let isMounted = true
@@ -486,21 +230,15 @@ const ReportsPageContent: FC = props => {
selectedGroup?.reports?.find(report => report.path === selectedReportPath)
), [selectedGroup, selectedReportPath])
- const selectedReportForForm = activeTab === 'billingAccounts'
- ? BILLING_ACCOUNTS_REPORT_DEFINITION
- : selectedReport
-
const handleBasePathChange = useCallback((event: ChangeEvent) => {
setSelectedBasePath(event.target.value)
setSelectedReportPath('')
setParameterValues({})
- setBillingAccountViewData(undefined)
}, [])
const handleReportChange = useCallback((event: ChangeEvent) => {
setSelectedReportPath(event.target.value)
setParameterValues({})
- setBillingAccountViewData(undefined)
}, [])
const handleParameterChange = useCallback((event: ChangeEvent) => {
@@ -553,7 +291,7 @@ const ReportsPageContent: FC = props => {
}, [parameterValues])
const parameterErrors = useMemo>(() => (
- (selectedReportForForm?.parameters ?? []).reduce>((errors, parameter) => {
+ (selectedReport?.parameters ?? []).reduce>((errors, parameter) => {
const error = getReportParameterValidationError(parameter, parameterValues[parameter.name])
if (error) {
@@ -562,55 +300,12 @@ const ReportsPageContent: FC = props => {
return errors
}, {})
- ), [parameterValues, selectedReportForForm])
+ ), [parameterValues, selectedReport])
const hasInvalidParameterValues = useMemo(() => (
Object.keys(parameterErrors).length > 0
), [parameterErrors])
- const handleBillingAccountView = useCallback(async () => {
- if (activeTab !== 'billingAccounts' || hasInvalidParameterValues) {
- return
- }
-
- const billingAccountId = parameterValues.billingAccountId?.trim()
-
- if (!billingAccountId) {
- return
- }
-
- try {
- setIsBillingAccountViewLoading(true)
- const profileQuery = new URLSearchParams({ billingAccountId })
- const profilePath = `${BILLING_ACCOUNTS_REPORT_PATH}?${profileQuery.toString()}`
- const paymentsPath = buildSfdcPaymentsQueryPath(
- billingAccountId,
- parameterValues.startDate,
- parameterValues.endDate,
- )
-
- const paymentsPromise = fetchReportJson(paymentsPath)
- const profilePromise = fetchReportJson(profilePath)
- .catch(() => EMPTY_BILLING_ACCOUNT_PROFILE_RESPONSE)
- const [profile, payments] = await Promise.all([profilePromise, paymentsPromise])
-
- setBillingAccountViewData({
- billingAccount: profile.billingAccount,
- payments,
- })
- } catch (error) {
- handleError(error)
- } finally {
- setIsBillingAccountViewLoading(false)
- }
- }, [
- activeTab,
- hasInvalidParameterValues,
- parameterValues.billingAccountId,
- parameterValues.endDate,
- parameterValues.startDate,
- ])
-
const handleDownload = useCallback(async (format: 'json' | 'csv') => {
if (!selectedReport || hasInvalidParameterValues) {
return
@@ -643,24 +338,18 @@ const ReportsPageContent: FC = props => {
navigate(bulkMemberLookupRouteId)
}, [navigate])
- const handleResetFilters = useCallback(() => {
- setParameterValues({})
- setBillingAccountViewData(undefined)
- }, [])
-
const isDownloading = downloadingFormat !== undefined
- const isBusy = isDownloading || isBillingAccountViewLoading
const requiredParamsMissing = useMemo(() => {
- const params = selectedReportForForm?.parameters ?? []
+ const params = selectedReport?.parameters ?? []
return params.some(param => param.required && !(parameterValues[param.name]?.trim()))
- }, [parameterValues, selectedReportForForm])
+ }, [parameterValues, selectedReport])
const hasUnresolvedPathParams = useMemo(() => (
- (selectedReportForForm?.parameters ?? [])
+ (selectedReport?.parameters ?? [])
.filter(param => param.location === 'path')
.some(param => !parameterValues[param.name]?.trim())
- ), [parameterValues, selectedReportForForm])
+ ), [parameterValues, selectedReport])
const isPostReport = selectedReport?.method?.toUpperCase() === 'POST'
const isHandleLookupPostReport = isPostReport && selectedReport.path === bulkMembersByHandlesPath
@@ -671,14 +360,6 @@ const ReportsPageContent: FC = props => {
|| hasInvalidParameterValues
|| hasUnresolvedPathParams
- const billingAccountViewDisabled = !selectedReportForForm
- || isDownloading
- || isBillingAccountViewLoading
- || requiredParamsMissing
- || hasInvalidParameterValues
- || hasUnresolvedPathParams
- const isResetDisabled = Object.keys(parameterValues).length === 0
-
const handleJsonDownload = useCallback(() => {
handleDownload('json')
}, [handleDownload])
@@ -687,41 +368,20 @@ const ReportsPageContent: FC = props => {
handleDownload('csv')
}, [handleDownload])
- const billingAccountReportActions = (
-
-
- View
-
-
- Reset Filters
-
-
- )
-
const reportActions = (
)
const renderParameterInput = useCallback((parameter: ReportParameter) => {
const commonProps = {
- label: formatParameterLabel(parameter.name),
+ label: parameter.name,
name: parameter.name,
placeholder: parameter.type === 'date'
? 'YYYY-MM-DD'
@@ -763,7 +423,6 @@ const ReportsPageContent: FC = props => {
return (
= props => {
return (
<>
- {isBusy && (
-
+ {isDownloading && (
+
)}
{pageTitle}
- {activeTab === 'reports'
- ? 'Select a base path to view available reports. Choose a report, '
- + 'fill required parameters, and download JSON or CSV from the reports API.'
- : 'Enter a billing account ID and optional start/end dates, then click View '
- + 'to load billing account payment data.'}
+ Select a base path to view the available reports. After choosing a report, provide any
+ required parameters and download the data as JSON or CSV directly from the reports API.
{isLoading ? (
@@ -798,56 +451,42 @@ const ReportsPageContent: FC
= props => {
) : (
<>
- {activeTab === 'reports' ? (
- <>
- {basePathOptions.length ? (
-
-
-
- {selectedGroup && (
-
- )}
-
- ) : (
-
- No reports are currently available.
-
- )}
-
-
+
- >
+
+ {selectedGroup && (
+
+ )}
+
) : (
-
+
+ No reports are currently available.
+
)}
- {activeTab === 'billingAccounts' && billingAccountViewData ? (
-
- ) : undefined}
+
>
)}
@@ -855,12 +494,4 @@ const ReportsPageContent: FC
= props => {
)
}
-export const ReportsPage: FC = () => (
-
-)
-
-export const BillingAccountsPage: FC = () => (
-
-)
-
export default ReportsPage
diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx
index 01c720710..df4b56ecd 100644
--- a/src/apps/reports/src/reports-app.routes.tsx
+++ b/src/apps/reports/src/reports-app.routes.tsx
@@ -11,7 +11,6 @@ import {
} from '~/libs/core'
import {
- billingAccountsPageRouteId,
bulkMemberLookupRouteId,
reportsPageRouteId,
rootRoute,
@@ -22,9 +21,6 @@ const ReportsPage: LazyLoadedComponent = lazyLoad(
() => import('./pages/reports/ReportsPage'),
'ReportsPage',
)
-const BillingAccountsPage: LazyLoadedComponent = lazyLoad(
- () => import('./pages/reports/BillingAccountsPage'),
-)
const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad(
() => import('./pages/bulk-member-lookup/BulkMemberLookupPage'),
'BulkMemberLookupPage',
@@ -47,11 +43,6 @@ export const reportsRoutes: ReadonlyArray = [
element: ,
route: reportsPageRouteId,
},
- {
- authRequired: true,
- element: ,
- route: billingAccountsPageRouteId,
- },
{
authRequired: true,
element: ,
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
index e91f51241..234849df5 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx
@@ -275,8 +275,8 @@ const AiReviewsTable: FC = props => {
const failedReviewersText = failedGatingReviewers.length
? `Gating Reviewers failed: ${failedGatingReviewers.join(', ')}.
- This submission is automatically failed regardless of Overall Score.`
- : `This submission is failed because ${hasSubmitterRole ? 'your' : 'the'}
+ This submission failed regardless of Overall Score because it failed one or more of AI Gating Reviews.`
+ : `This submission failed because ${hasSubmitterRole ? 'your' : 'the'}
Overall Score is bellow minimum threshold.`
// Message text varies by role
@@ -293,7 +293,7 @@ const AiReviewsTable: FC = props => {
}
if (hasSubmitterRole) {
- return 'Submission Locked - Your submission will not be reviewed in the Review Phase.'
+ return 'Submission Locked - Your submission won\'t be reviewed during the Review Phase.'
}
return 'Submission Locked - This submission doesn\'t have to be reviewed in Review Phase.'
@@ -315,7 +315,6 @@ const AiReviewsTable: FC = props => {
{!reviewerRows.length && loading && (
Loading...
)}
-
{reviewerRows.map(row => (
- status?: 'passed' | 'pending' | 'failed-score' | 'failed' | 'human-override'
+ status?: 'passed' | 'pending' | 'failed-score' | 'failed'
score?: number
hideLabel?: boolean
showScore?: boolean
@@ -85,16 +85,6 @@ export const AiWorkflowRunStatus: FC
= props => {
action={props.action}
/>
)}
- {displayStatus === 'human-override' && (
- }
- hideLabel={props.hideLabel}
- status={displayStatus}
- label='Unlocked'
- score={score}
- action={props.action}
- />
- )}
>
)
}
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
index 107b2b86b..b416ee905 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
+++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.module.scss
@@ -29,11 +29,6 @@
color: #e9ecef;
border-color: #e9ecef;
}
-
- &.human-override {
- color: #7B61FF;
- border-color: #7B61FF;
- }
}
.aiIcon {
diff --git a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
index 404fa19ca..0458c02c9 100644
--- a/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
+++ b/src/apps/review/src/lib/components/AiReviewsTable/StatusLabel.tsx
@@ -8,7 +8,7 @@ interface StatusLabelProps {
hideLabel?: boolean
label?: string
score?: number
- status: 'pending' | 'failed' | 'passed' | 'failed-score' | 'human-override'
+ status: 'pending' | 'failed' | 'passed' | 'failed-score'
action?: ReactNode
isAiIcon?: boolean
}
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts
index 40498ddc9..338c4cfe0 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.spec.ts
@@ -119,128 +119,6 @@ describe('filterIterativeReviewRows', () => {
.toBe('iterative-phase-1')
})
- it('maps phase-less AI-failed submissions to iterative tabs by submission order', () => {
- const multiIterativePhases: BackendPhase[] = [
- createPhase('submission-1', 'Submission'),
- createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'),
- createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'),
- createPhase('review-1', 'Review', 'review-phase-1'),
- ]
-
- const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer')
- const firstAiFailedSubmission: SubmissionInfo = {
- ...createSubmission(iterativeReviewer.id),
- id: 'submission-1',
- status: 'AI_FAILED_REVIEW',
- submittedDate: '2026-04-01T04:56:13.405Z',
- }
-
- const secondAiFailedSubmission: SubmissionInfo = {
- ...createSubmission(iterativeReviewer.id),
- id: 'submission-2',
- status: 'AI_FAILED_REVIEW',
- submittedDate: '2026-04-01T04:57:13.405Z',
- }
-
- const phase1Results = filterIterativeReviewRows({
- challengePhases: multiIterativePhases,
- isPostMortemPhase: false,
- phaseIdFilter: 'iterative-1',
- reviewerResources: [iterativeReviewer],
- sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission],
- })
-
- const phase2Results = filterIterativeReviewRows({
- challengePhases: multiIterativePhases,
- isPostMortemPhase: false,
- phaseIdFilter: 'iterative-2',
- reviewerResources: [iterativeReviewer],
- sourceRows: [secondAiFailedSubmission, firstAiFailedSubmission],
- })
-
- expect(phase1Results)
- .toHaveLength(1)
- expect(phase1Results[0].id)
- .toBe('submission-1')
-
- expect(phase2Results)
- .toHaveLength(1)
- expect(phase2Results[0].id)
- .toBe('submission-2')
- })
-
- it(
- 'skips iterative phases that already have assigned reviews when mapping phase-less AI-failed submissions',
- () => {
- const multiIterativePhases: BackendPhase[] = [
- createPhase('submission-1', 'Submission'),
- createPhase('iterative-1', 'Iterative Review', 'iterative-phase-1'),
- createPhase('iterative-2', 'Iterative Review', 'iterative-phase-2'),
- createPhase('review-1', 'Review', 'review-phase-1'),
- ]
-
- const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer')
- const assignedSubmission: SubmissionInfo = {
- ...createSubmission('assigned-reviewer', 'iterative-phase-1'),
- id: 'assigned-submission',
- }
-
- const aiFailedSubmission: SubmissionInfo = {
- ...createSubmission(iterativeReviewer.id),
- id: 'ai-failed-submission',
- status: 'AI_FAILED_REVIEW',
- submittedDate: '2026-04-01T04:56:13.405Z',
- }
-
- const phase1Results = filterIterativeReviewRows({
- challengePhases: multiIterativePhases,
- isPostMortemPhase: false,
- phaseIdFilter: 'iterative-1',
- reviewerResources: [iterativeReviewer],
- sourceRows: [assignedSubmission, aiFailedSubmission],
- })
-
- const phase2Results = filterIterativeReviewRows({
- challengePhases: multiIterativePhases,
- isPostMortemPhase: false,
- phaseIdFilter: 'iterative-2',
- reviewerResources: [iterativeReviewer],
- sourceRows: [assignedSubmission, aiFailedSubmission],
- })
-
- expect(phase1Results)
- .toHaveLength(1)
- expect(phase1Results[0].id)
- .toBe('assigned-submission')
-
- expect(phase2Results)
- .toHaveLength(1)
- expect(phase2Results[0].id)
- .toBe('ai-failed-submission')
- },
- )
-
- it('keeps a phase-less AI-failed submission when only one iterative phase exists', () => {
- const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer')
- const aiFailedSubmission: SubmissionInfo = {
- ...createSubmission(iterativeReviewer.id),
- status: 'AI_FAILED_REVIEW',
- }
-
- const results = filterIterativeReviewRows({
- challengePhases,
- isPostMortemPhase: false,
- phaseIdFilter: 'iterative-1',
- reviewerResources: [iterativeReviewer],
- sourceRows: [aiFailedSubmission],
- })
-
- expect(results)
- .toHaveLength(1)
- expect(results[0].id)
- .toBe(`submission-${iterativeReviewer.id}`)
- })
-
it('limits completed F2F rows to the supplied winning submission ids', () => {
const iterativeReviewer = createResource('iterative-resource-1', 'Iterative Reviewer')
const losingReviewer = createResource('iterative-resource-2', 'Iterative Reviewer')
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts
index 64c26f29e..64940ae67 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/iterativeReviewFiltering.ts
@@ -142,167 +142,6 @@ function countIterativeReviewPhases(challengePhases: BackendPhase[] | undefined)
.includes('iterative review')).length
}
-interface OrderedIterativePhase {
- id: string
- phaseTypeId?: string
-}
-
-function getOrderedIterativePhases(challengePhases: BackendPhase[] | undefined): OrderedIterativePhase[] {
- const iterativePhases = (challengePhases ?? [])
- .map((phase, index) => ({
- id: normalizeIdentifier(phase.id),
- index,
- phaseTypeId: normalizeIdentifier(phase.phaseId),
- startedAt: parseSortableDate(phase.actualStartDate ?? phase.scheduledStartDate),
- }))
- .filter(phase => Boolean(phase.id))
- .filter(phase => (challengePhases?.[phase.index].name ?? '')
- .toLowerCase()
- .includes('iterative review'))
- .sort((left, right) => {
- const leftStartedAt = Number.isFinite(left.startedAt)
- ? left.startedAt
- : Number.POSITIVE_INFINITY
- const rightStartedAt = Number.isFinite(right.startedAt)
- ? right.startedAt
- : Number.POSITIVE_INFINITY
-
- if (leftStartedAt !== rightStartedAt) {
- return leftStartedAt - rightStartedAt
- }
-
- return left.index - right.index
- })
-
- return iterativePhases
- .map(phase => ({
- id: phase.id,
- phaseTypeId: phase.phaseTypeId,
- } as OrderedIterativePhase))
- .filter((phase): phase is OrderedIterativePhase => Boolean(phase.id))
-}
-
-function getAiFailedSubmissionIdsForSelectedIterativePhase(
- sourceRows: SubmissionInfo[],
- challengePhases: BackendPhase[] | undefined,
- phaseIdFilterSet: Set,
- aiReviewDecisionsBySubmissionId?: Record,
-): Set {
- const orderedIterativePhases = getOrderedIterativePhases(challengePhases)
- if (!orderedIterativePhases.length) {
- return new Set()
- }
-
- const assignedIterativePhaseIds = new Set()
- sourceRows.forEach(submission => {
- const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId)
- if (!reviewPhaseId) {
- return
- }
-
- const matchedByPhaseId = orderedIterativePhases.find(phase => phase.id === reviewPhaseId)
- if (matchedByPhaseId) {
- assignedIterativePhaseIds.add(matchedByPhaseId.id)
- return
- }
-
- const matchedByPhaseTypeId = orderedIterativePhases.filter(
- phase => phase.phaseTypeId === reviewPhaseId,
- )
- if (matchedByPhaseTypeId.length === 1) {
- assignedIterativePhaseIds.add(matchedByPhaseTypeId[0].id)
- }
- })
-
- const unassignedIterativePhaseIds = orderedIterativePhases
- .map(phase => phase.id)
- .filter(phaseId => !assignedIterativePhaseIds.has(phaseId))
-
- if (!unassignedIterativePhaseIds.length) {
- return new Set()
- }
-
- const selectedPhase = orderedIterativePhases.find(phase => phaseIdFilterSet.has(phase.id))
- ?? (() => {
- const matchedByPhaseTypeId = orderedIterativePhases.filter(
- phase => Boolean(phase.phaseTypeId && phaseIdFilterSet.has(phase.phaseTypeId)),
- )
-
- if (matchedByPhaseTypeId.length === 1) {
- return matchedByPhaseTypeId[0]
- }
-
- return undefined
- })()
-
- const aiFailedRows = sourceRows
- .filter(submission => !normalizeIdentifier(submission.review?.phaseId))
- .filter(submission => shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId))
- .map((submission, index) => ({
- index,
- reviewCreatedAt: parseSortableDate(submission.review?.createdAt),
- submission,
- submittedAt: parseSortableDate(submission.submittedDate),
- }))
- .sort((left, right) => {
- const leftSubmittedAt = Number.isFinite(left.submittedAt)
- ? left.submittedAt
- : Number.POSITIVE_INFINITY
- const rightSubmittedAt = Number.isFinite(right.submittedAt)
- ? right.submittedAt
- : Number.POSITIVE_INFINITY
-
- if (leftSubmittedAt !== rightSubmittedAt) {
- return leftSubmittedAt - rightSubmittedAt
- }
-
- const leftReviewCreatedAt = Number.isFinite(left.reviewCreatedAt)
- ? left.reviewCreatedAt
- : Number.POSITIVE_INFINITY
- const rightReviewCreatedAt = Number.isFinite(right.reviewCreatedAt)
- ? right.reviewCreatedAt
- : Number.POSITIVE_INFINITY
-
- if (leftReviewCreatedAt !== rightReviewCreatedAt) {
- return leftReviewCreatedAt - rightReviewCreatedAt
- }
-
- return left.index - right.index
- })
-
- if (!aiFailedRows.length) {
- return new Set()
- }
-
- if (!selectedPhase) {
- return new Set()
- }
-
- const selectedPhaseIndex = unassignedIterativePhaseIds.findIndex(phaseId => phaseId === selectedPhase.id)
- if (selectedPhaseIndex < 0) {
- return new Set()
- }
-
- if (unassignedIterativePhaseIds.length === 1) {
- return new Set(
- aiFailedRows
- .map(row => normalizeIdentifier(row.submission.id))
- .filter((id): id is string => Boolean(id)),
- )
- }
-
- const isLastIterativePhase = selectedPhaseIndex === unassignedIterativePhaseIds.length - 1
- const assignedRows = isLastIterativePhase
- ? aiFailedRows.slice(selectedPhaseIndex)
- : aiFailedRows.slice(selectedPhaseIndex, selectedPhaseIndex + 1)
-
- return new Set(
- assignedRows
- .map(row => normalizeIdentifier(row.submission.id))
- .filter((id): id is string => Boolean(id)),
- )
-}
-
/**
* Collect resource ids assigned to iterative-review roles.
*
@@ -449,13 +288,6 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs):
const iterativeReviewerResourceIds = collectIterativeReviewerResourceIds(reviewerResources)
if (phaseIdFilterSet?.size) {
- const aiFailedSubmissionIdsForSelectedPhase = getAiFailedSubmissionIdsForSelectedIterativePhase(
- sourceRows,
- challengePhases,
- phaseIdFilterSet,
- aiReviewDecisionsBySubmissionId,
- )
-
const filteredRows = sourceRows.filter(submission => {
const reviewPhaseId = normalizeIdentifier(submission.review?.phaseId)
if (reviewPhaseId) {
@@ -463,8 +295,7 @@ export function filterIterativeReviewRows(args: FilterIterativeReviewRowsArgs):
}
if (shouldTreatAsAiFailedSubmission(submission, aiReviewDecisionsBySubmissionId)) {
- const submissionId = normalizeIdentifier(submission.id)
- return submissionId ? aiFailedSubmissionIdsForSelectedPhase.has(submissionId) : false
+ return true
}
// New WM F2F flows can surface assigned submissions before the review row has a phase id.
diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss
deleted file mode 100644
index 8f61ea643..000000000
--- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.module.scss
+++ /dev/null
@@ -1,22 +0,0 @@
-@import '@libs/ui/styles/includes';
-
-.container {
- align-items: center;
- background: #fff4f4;
- border: 1px solid #f2c9c9;
- border-radius: 8px;
- color: #8a1f1f;
- display: flex;
- gap: $sp-3;
- justify-content: space-between;
- margin-top: $sp-4;
- padding: $sp-3;
-}
-
-.message {
- font-family: "Nunito Sans", sans-serif;
- font-size: 14px;
- font-weight: 700;
- line-height: 20px;
- margin: 0;
-}
diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx b/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx
deleted file mode 100644
index 0066ca59e..000000000
--- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/ChallengeScopedErrorState.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { FC } from 'react'
-
-import { Button } from '~/libs/ui'
-
-import styles from './ChallengeScopedErrorState.module.scss'
-
-interface ChallengeScopedErrorStateProps {
- message?: string
- onRetry: () => void
-}
-
-/**
- * Renders the shared retryable error state for challenge-scoped route fetches.
- *
- * @param props.message optional message shown in the error panel.
- * @param props.onRetry callback used by route pages to revalidate failed challenge data.
- * @returns a generic route-level error panel with a retry action.
- */
-export const ChallengeScopedErrorState: FC = (
- props: ChallengeScopedErrorStateProps,
-) => (
-
-
- {props.message ?? 'Something went wrong while loading the challenge. Please try again.'}
-
-
- Retry
-
-
-)
-
-export default ChallengeScopedErrorState
diff --git a/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts b/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts
deleted file mode 100644
index fea79cdd9..000000000
--- a/src/apps/review/src/lib/components/ChallengeScopedErrorState/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './ChallengeScopedErrorState'
diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
index 084022e09..326e86db1 100644
--- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
+++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx
@@ -26,7 +26,7 @@ interface CollapsibleAiReviewsRowProps {
export function normalizeDecisionStatus(
status?: AiReviewDecisionStatus,
-): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' {
+): 'passed' | 'failed-score' | 'pending' | 'failed' {
if (!status || status === 'PENDING') {
return 'pending'
}
@@ -43,10 +43,6 @@ export function normalizeDecisionStatus(
return 'failed'
}
- if (status === 'HUMAN_OVERRIDE') {
- return 'human-override'
- }
-
return 'pending'
}
diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss
index a1e5a0a2e..544b430b9 100644
--- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss
+++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss
@@ -34,15 +34,6 @@ $error-line-height: 14px;
pointer-events: none;
}
- &.readOnly {
- :global {
- .editor-statusbar,
- .editor-toolbar {
- pointer-events: none;
- }
- }
- }
-
:global {
.EasyMDEContainer {
height: 100px;
diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx
deleted file mode 100644
index be151abb1..000000000
--- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.spec.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import { render, waitFor } from '@testing-library/react'
-
-import { FieldMarkdownEditor } from './FieldMarkdownEditor'
-
-const mockEasyMDEInstances: any[] = []
-
-jest.mock('~/apps/admin/src/lib/hooks', () => {
- const React: typeof import('react') = jest.requireActual('react')
-
- return {
- useOnComponentDidMount: (onMounted: () => void): void => {
- React.useEffect(() => {
- onMounted()
- }, [])
- },
- }
-}, { virtual: true })
-
-jest.mock('../../contexts', () => {
- const React: typeof import('react') = jest.requireActual('react')
-
- return {
- ChallengeDetailContext: React.createContext({
- challengeId: 'challenge-id',
- }),
- }
-})
-
-jest.mock('../../services', () => ({
- uploadReviewAttachment: jest.fn(),
-}))
-
-jest.mock('../../utils', () => ({
- humanFileSize: jest.fn(() => '1 KB'),
-}))
-
-jest.mock('easymde', () => {
- class EasyMDEMock {
- constructor(options: any) {
- const wrapper = globalThis.document.createElement('div')
- wrapper.appendChild(globalThis.document.createElement('div'))
-
- let editorValue = options.initialValue ?? ''
- const imageInput = { value: '' }
- const codemirror = {
- focus: jest.fn(),
- getCursor: jest.fn(() => ({
- ch: 0,
- line: 0,
- })),
- getLine: jest.fn(() => ''),
- getSelection: jest.fn(() => ''),
- getTokenAt: jest.fn(() => ({
- type: '',
- })),
- getValue: jest.fn(() => editorValue),
- getWrapperElement: jest.fn(() => wrapper),
- indexFromPos: jest.fn(() => 0),
- on: jest.fn(),
- replaceRange: jest.fn(),
- replaceSelection: jest.fn(),
- setOption: jest.fn(),
- setSelection: jest.fn(),
- }
- Object.assign(this, {
- codemirror,
- gui: {
- toolbar: {
- getElementsByClassName: jest.fn(() => [imageInput]),
- },
- },
- options,
- updateStatusBar: jest.fn(),
- value: jest.fn((incomingValue?: string) => {
- if (incomingValue === undefined) {
- return editorValue
- }
-
- editorValue = incomingValue
- return undefined
- }),
- })
-
- mockEasyMDEInstances.push(this)
- }
- }
-
- Object.assign(EasyMDEMock, {
- drawImage: jest.fn(),
- drawLink: jest.fn(),
- drawTable: jest.fn(),
- drawUploadedImage: jest.fn(),
- toggleBlockquote: jest.fn(),
- toggleCodeBlock: jest.fn(),
- toggleHeading1: jest.fn(),
- toggleHeading2: jest.fn(),
- toggleHeading3: jest.fn(),
- toggleOrderedList: jest.fn(),
- toggleStrikethrough: jest.fn(),
- toggleUnorderedList: jest.fn(),
- })
-
- return {
- __esModule: true,
- default: EasyMDEMock,
- }
-})
-
-describe('FieldMarkdownEditor', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- mockEasyMDEInstances.length = 0
- })
-
- it('uses the latest read-only state for the EasyMDE upload callback', async () => {
- const uploadAttachment = jest.fn()
- .mockResolvedValue({
- url: 'https://example.com/uploaded.png',
- })
-
- const rendered: ReturnType = render(
- ,
- )
-
- await waitFor(() => {
- expect(mockEasyMDEInstances)
- .toHaveLength(1)
- })
-
- const easyMDE = mockEasyMDEInstances[0]
- rendered.rerender(
- ,
- )
-
- await easyMDE.options.imageUploadFunction(
- new File(['image'], 'uploaded.png', { type: 'image/png' }),
- )
-
- expect(uploadAttachment)
- .not
- .toHaveBeenCalled()
- })
-
- it('installs EasyMDE upload handlers so editable transitions can upload', async () => {
- render( )
-
- await waitFor(() => {
- expect(mockEasyMDEInstances)
- .toHaveLength(1)
- })
-
- expect(mockEasyMDEInstances[0].options.uploadImage)
- .toBe(true)
- })
-})
diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx
index 3484a0c04..37dea39b8 100644
--- a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx
+++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx
@@ -51,7 +51,6 @@ interface Props {
maxCharactersAllowed?: number
textareaId?: string
ariaLabel?: string
- readOnly?: boolean
}
const errorMessages = {
fileTooLarge:
@@ -153,24 +152,10 @@ const toggleStrategy = {
}
type CodeMirrorType = keyof typeof stateStrategy | 'variable-2'
-type UploadImageHandler = (file: File) => Promise
-
-const readOnlyUploadEvents = [
- 'dragend',
- 'dragenter',
- 'dragleave',
- 'dragover',
- 'drop',
- 'paste',
-]
export const FieldMarkdownEditor: FC = (props: Props) => {
const elementRef = useRef(null)
const easyMDE = useRef(null)
- const customUploadImageRef = useRef(async () => undefined)
- const isReadOnlyRef = useRef(false)
- const isReadOnly = !!props.disabled || !!props.readOnly
- isReadOnlyRef.current = isReadOnly
const [remainingCharacters, setRemainingCharacters] = useState(
(props.maxCharactersAllowed || 0) - (props.initialValue?.length || 0),
)
@@ -542,10 +527,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => {
* Upload image
*/
const customUploadImage = useCallback(async (file: File) => {
- if (isReadOnlyRef.current) {
- return
- }
-
const editor = easyMDE.current
if (!editor) {
return
@@ -670,7 +651,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => {
uploadAttachment,
uploadCategory,
])
- customUploadImageRef.current = customUploadImage
useOnComponentDidMount(() => {
easyMDE.current = new EasyMDE({
@@ -693,7 +673,7 @@ export const FieldMarkdownEditor: FC = (props: Props) => {
sbProgress: 'Uploading #file_name#: #progress#%',
sizeUnits: ' B, KB, MB',
},
- imageUploadFunction: file => customUploadImageRef.current(file),
+ imageUploadFunction: file => customUploadImage(file),
initialValue: props.initialValue ?? '',
insertTexts: {
file: ['[](', '#url#)'],
@@ -888,39 +868,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => {
})
})
- useEffect(() => {
- if (!easyMDE.current) {
- return undefined
- }
-
- easyMDE.current.codemirror.setOption('readOnly', isReadOnly
- ? 'nocursor'
- : false)
-
- const wrapper = easyMDE.current.codemirror.getWrapperElement()
- const blockReadOnlyUpload = (event: Event): void => {
- if (!isReadOnlyRef.current) {
- return
- }
-
- event.preventDefault()
- event.stopPropagation()
- event.stopImmediatePropagation()
- }
-
- if (isReadOnly) {
- readOnlyUploadEvents.forEach(eventName => {
- wrapper.addEventListener(eventName, blockReadOnlyUpload, true)
- })
- }
-
- return () => {
- readOnlyUploadEvents.forEach(eventName => {
- wrapper.removeEventListener(eventName, blockReadOnlyUpload, true)
- })
- }
- }, [isReadOnly])
-
useEffect(() => {
if (!easyMDE.current) {
return
@@ -939,7 +886,6 @@ export const FieldMarkdownEditor: FC = (props: Props) => {
className={classNames(styles.container, props.className, {
[styles.isError]: !!props.error,
[styles.disabled]: !!props.disabled,
- [styles.readOnly]: !!props.readOnly,
[styles.showBorder]: !!props.showBorder,
})}
>
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 0aa841c61..a9bf48993 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
@@ -33,7 +33,7 @@ const AiFeedback: FC = props => {
const commentsArr: any[] = (feedback?.comments) || []
const onShowReply = useCallback(() => {
- setShowReply(prevShowReply => !prevShowReply)
+ setShowReply(!showReply)
}, [])
const onSubmitReply = useCallback(async (content: string) => {
@@ -87,13 +87,9 @@ const AiFeedback: FC = props => {
- {commentsArr.length > 0 && (
-
- )}
-
{
showReply && (
= props => {
/>
)
}
+
+ {commentsArr.length > 0 && (
+
+ )}
)
}
diff --git a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
index 52beb361c..4f0607625 100644
--- a/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
+++ b/src/apps/review/src/lib/components/SubmissionHistoryModal/SubmissionHistoryModal.tsx
@@ -104,7 +104,7 @@ function formatScore(value?: number | null): string {
function normalizeDecisionStatus(
status?: string | null,
-): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' {
+): 'passed' | 'failed-score' | 'pending' | 'failed' {
if (!status || status === 'PENDING') {
return 'pending'
}
@@ -121,10 +121,6 @@ function normalizeDecisionStatus(
return 'failed'
}
- if (status === 'HUMAN_OVERRIDE') {
- return 'human-override'
- }
-
return 'pending'
}
diff --git a/src/apps/review/src/lib/components/index.ts b/src/apps/review/src/lib/components/index.ts
index 8d4cfa287..70c559437 100644
--- a/src/apps/review/src/lib/components/index.ts
+++ b/src/apps/review/src/lib/components/index.ts
@@ -17,7 +17,6 @@ export * from './TableReview'
export * from './TableReviewForSubmitter'
export * from './TableIterativeReview'
export * from './ChallengeDetailsContent'
-export * from './ChallengeScopedErrorState'
export * from './ChallengeTimeline'
export * from './ConfirmModal'
export * from './ScorecardsFilter'
diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts
index 7560bb931..e314c37c4 100644
--- a/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts
+++ b/src/apps/review/src/lib/contexts/ChallengeDetailContext.ts
@@ -10,12 +10,7 @@ export const ChallengeDetailContext: Context
aiReviewDecisionsBySubmissionId: {},
challengeId: undefined,
challengeInfo: undefined,
- challengeInfoError: undefined,
- challengeResourcesError: undefined,
- challengeScopedFetchError: undefined,
challengeSubmissions: [],
- challengeSubmissionsError: undefined,
- hasChallengeScopedFetchError: false,
isLoadingAiReviewConfig: false,
isLoadingAiReviewDecisions: false,
isLoadingChallengeInfo: false,
@@ -26,6 +21,5 @@ export const ChallengeDetailContext: Context
registrants: [],
resourceMemberIdMapping: {},
resources: [],
- retryChallengeScopedFetches: () => undefined,
reviewers: [],
})
diff --git a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
index cf662918f..8a64fb033 100644
--- a/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
+++ b/src/apps/review/src/lib/contexts/ChallengeDetailContextProvider.tsx
@@ -1,7 +1,7 @@
/**
* Context provider for challenge detail page
*/
-import { FC, PropsWithChildren, useCallback, useContext, useMemo } from 'react'
+import { FC, PropsWithChildren, useContext, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { convertBackendSubmissionToSubmissionInfo } from '../models'
@@ -35,9 +35,7 @@ export const ChallengeDetailContextProvider: FC = props => {
// fetch challenge info
const {
challengeInfo,
- error: challengeInfoError,
isLoading: isLoadingChallengeInfo,
- retry: retryChallengeInfo,
}: useFetchChallengeInfoProps = useFetchChallengeInfo(challengeId)
// fetch challenge resources
@@ -47,9 +45,7 @@ export const ChallengeDetailContextProvider: FC = props => {
reviewers,
myResources,
myRoles,
- error: challengeResourcesError,
isLoading: isLoadingChallengeResources,
- retry: retryChallengeResources,
resourceMemberIdMapping,
}: useFetchChallengeResourcesProps = useFetchChallengeResources(challengeId)
const submissionViewer = useMemo(
@@ -84,13 +80,15 @@ export const ChallengeDetailContextProvider: FC = props => {
return { isCompleted, isDesign, submissionsViewable }
},
- [challengeInfo],
+ [
+ challengeInfo?.track?.name,
+ challengeInfo?.status,
+ challengeInfo?.metadata,
+ ],
)
const {
challengeSubmissions,
- error: challengeSubmissionsError,
isLoading: isLoadingChallengeSubmissions,
- retry: retryChallengeSubmissions,
}: useFetchChallengeSubmissionsProps = useFetchChallengeSubmissions(
challengeId,
submissionViewer,
@@ -137,22 +135,6 @@ export const ChallengeDetailContextProvider: FC = props => {
() => isLoadingChallengeInfo,
[isLoadingChallengeInfo],
)
- const challengeScopedFetchError = useMemo(
- () => challengeInfoError ?? challengeResourcesError ?? challengeSubmissionsError,
- [challengeInfoError, challengeResourcesError, challengeSubmissionsError],
- )
- const retryChallengeScopedFetches = useCallback((): void => {
- Promise.resolve(retryChallengeInfo())
- .catch(() => undefined)
- Promise.resolve(retryChallengeResources())
- .catch(() => undefined)
- Promise.resolve(retryChallengeSubmissions())
- .catch(() => undefined)
- }, [
- retryChallengeInfo,
- retryChallengeResources,
- retryChallengeSubmissions,
- ])
const value = useMemo(
() => ({
@@ -160,12 +142,7 @@ export const ChallengeDetailContextProvider: FC = props => {
aiReviewDecisionsBySubmissionId,
challengeId,
challengeInfo: enrichedChallengeInfo,
- challengeInfoError,
- challengeResourcesError,
- challengeScopedFetchError,
challengeSubmissions,
- challengeSubmissionsError,
- hasChallengeScopedFetchError: !!challengeScopedFetchError,
isLoadingAiReviewConfig,
isLoadingAiReviewDecisions,
isLoadingChallengeInfo: isLoadingChallengeInfoCombined,
@@ -176,17 +153,12 @@ export const ChallengeDetailContextProvider: FC = props => {
registrants,
resourceMemberIdMapping,
resources,
- retryChallengeScopedFetches,
reviewers,
}),
[
challengeId,
enrichedChallengeInfo,
- challengeInfoError,
- challengeResourcesError,
- challengeScopedFetchError,
challengeSubmissions,
- challengeSubmissionsError,
isLoadingChallengeInfoCombined,
isLoadingChallengeResources,
isLoadingChallengeSubmissions,
@@ -197,7 +169,6 @@ export const ChallengeDetailContextProvider: FC = props => {
myResources,
myRoles,
registrants,
- retryChallengeScopedFetches,
resourceMemberIdMapping,
resources,
reviewers,
diff --git a/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx b/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx
deleted file mode 100644
index 5ff7d42cb..000000000
--- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.spec.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import {
- fireEvent,
- render,
- screen,
- waitFor,
-} from '@testing-library/react'
-
-import {
- fetchActiveReviews,
-} from '../services'
-
-import { useFetchActiveReviews } from './useFetchActiveReviews'
-import type { useFetchActiveReviewsProps } from './useFetchActiveReviews'
-
-jest.mock('~/config', () => ({
- EnvironmentConfig: {
- REVIEW: {
- PROFILE_PAGE_URL: 'https://profiles.example.com',
- },
- },
-}), { virtual: true })
-
-jest.mock('~/libs/core', () => ({
- getRatingColor: jest.fn()
- .mockReturnValue('#000000'),
-}), { virtual: true })
-
-jest.mock('~/libs/shared', () => ({
- handleError: jest.fn(),
-}), { virtual: true })
-
-jest.mock('../services', () => ({
- fetchActiveReviews: jest.fn(),
-}))
-
-const mockedFetchActiveReviews = fetchActiveReviews as jest.Mock
-
-const TestComponent = (): JSX.Element => {
- const {
- activeReviews,
- loadActiveReviews,
- }: useFetchActiveReviewsProps = useFetchActiveReviews()
- const reviewNames: string = activeReviews.map(review => review.name)
- .join(', ')
-
- function handleLoad(): void {
- loadActiveReviews({
- page: 1,
- perPage: 50,
- })
- .catch(() => undefined)
- }
-
- return (
- <>
-
- Load
-
- {reviewNames || 'No active reviews'}
- >
- )
-}
-
-describe('useFetchActiveReviews', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('clears cached review assignments when a fresh load errors', async () => {
- mockedFetchActiveReviews
- .mockResolvedValueOnce({
- data: [
- {
- challengeId: 'challenge-1',
- challengeName: 'Restricted Challenge',
- status: 'ACTIVE',
- },
- ],
- meta: {
- page: 1,
- perPage: 50,
- totalCount: 1,
- totalPages: 1,
- },
- } as never)
- .mockRejectedValueOnce(new Error('Forbidden'))
-
- render( )
-
- fireEvent.click(screen.getByRole('button', { name: 'Load' }))
-
- await waitFor(() => {
- expect(screen.getByText('Restricted Challenge'))
- .toBeTruthy()
- })
-
- fireEvent.click(screen.getByRole('button', { name: 'Load' }))
-
- await waitFor(() => {
- expect(screen.queryByText('Restricted Challenge'))
- .toBeNull()
- })
- expect(screen.getByText('No active reviews'))
- .toBeTruthy()
- })
-})
diff --git a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts
index 643e2f058..e12ee1d3a 100644
--- a/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts
+++ b/src/apps/review/src/lib/hooks/useFetchActiveReviews.ts
@@ -267,13 +267,6 @@ export function useFetchActiveReviews(): useFetchActiveReviewsProps {
})
} catch (error) {
if (latestRequestKeyRef.current === requestKey) {
- setActiveReviews([])
- setPagination({
- page: mergedParams.page,
- perPage: mergedParams.perPage,
- totalCount: 0,
- totalPages: 1,
- })
handleError(error)
}
} finally {
diff --git a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts
index 016a68832..e07cfbaff 100644
--- a/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts
+++ b/src/apps/review/src/lib/hooks/useFetchAppealQueue.ts
@@ -7,7 +7,7 @@ import { filter } from 'lodash'
import {
MappingReviewAppeal,
} from '../models'
-import { fetchAllAppealsWithReviewIds } from '../services'
+import { fetchAppealsWithReviewId } from '../services'
export interface useFetchAppealQueueProps {
mappingReviewAppeal: MappingReviewAppeal // from review id to appeal info
@@ -35,11 +35,9 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps {
return
}
- const nextIds = Array.from(new Set(idLoadQueue.current))
- .filter(id => !mappingReviewAppealRef.current[id])
- idLoadQueue.current = []
-
- if (!nextIds.length) {
+ const nextId = idLoadQueue.current[0]
+ idLoadQueue.current = idLoadQueue.current.slice(1)
+ if (mappingReviewAppealRef.current[nextId]) {
fetchNextDataInQueue()
return
}
@@ -51,29 +49,24 @@ export function useFetchAppealQueue(): useFetchAppealQueueProps {
}
const fetchDataFail = (): void => {
- nextIds.forEach(id => {
- mappingReviewAppealRef.current[id] = {
- finishAppeals: 0,
- totalAppeals: 0,
- }
- })
+ mappingReviewAppealRef.current[nextId] = {
+ finishAppeals: 0,
+ totalAppeals: 0,
+ }
setMappingReviewAppeal({
...mappingReviewAppealRef.current,
})
finish()
}
- fetchAllAppealsWithReviewIds(nextIds)
+ // Fetch appeal datas
+ fetchAppealsWithReviewId(1, 100, nextId)
.then(res => {
- nextIds.forEach(id => {
- const reviewAppeals = res.filter(item => item.reviewId === id)
-
- mappingReviewAppealRef.current[id] = {
- finishAppeals: filter(reviewAppeals, item => !!item.appealResponse)
- .length,
- totalAppeals: reviewAppeals.length,
- }
- })
+ mappingReviewAppealRef.current[nextId] = {
+ finishAppeals: filter(res, item => !!item.appealResponse)
+ .length,
+ totalAppeals: res.length,
+ }
setMappingReviewAppeal({
...mappingReviewAppealRef.current,
})
diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts
index 31956bf61..ca990b158 100644
--- a/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts
+++ b/src/apps/review/src/lib/hooks/useFetchChallengeInfo.ts
@@ -16,10 +16,7 @@ import { fetchChallengeInfoById } from '../services'
export interface useFetchChallengeInfoProps {
challengeInfo: ChallengeInfo | undefined
- error: Error | undefined
- isError: boolean
isLoading: boolean
- retry: () => Promise
}
/**
@@ -35,7 +32,6 @@ export function useFetchChallengeInfo(
data: challengeInfo,
error: fetchChallengeInfoError,
isValidating: isLoading,
- mutate,
}: SWRResponse = useSWR(
`challengeBaseUrl/challenges/${challengeId}`,
{
@@ -52,12 +48,7 @@ export function useFetchChallengeInfo(
}, [fetchChallengeInfoError])
return {
- challengeInfo: fetchChallengeInfoError
- ? undefined
- : challengeInfo,
- error: fetchChallengeInfoError,
- isError: !!fetchChallengeInfoError,
+ challengeInfo,
isLoading,
- retry: () => mutate(),
}
}
diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts b/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts
index 63bf0d831..7ac6f4b6b 100644
--- a/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts
+++ b/src/apps/review/src/lib/hooks/useFetchChallengeResources.ts
@@ -17,10 +17,7 @@ export interface useFetchChallengeResourcesProps {
reviewers: BackendResource[]
myResources: BackendResource[]
myRoles: string[]
- error: Error | undefined
- isError: boolean
isLoading: boolean
- retry: () => Promise
resourceMemberIdMapping: {
[memberId: string]: BackendResource
}
@@ -46,7 +43,6 @@ export function useFetchChallengeResources(
data: resources,
error: fetchResourcesError,
isValidating: isLoading,
- mutate,
}: SWRResponse = useSWR(
`resourceBaseUrl/resources?challengeId=${challengeId}`,
{
@@ -124,27 +120,12 @@ export function useFetchChallengeResources(
}, [resourcesWithRoleName, resourceRoleReviewer])
return {
- error: fetchResourcesError,
- isError: !!fetchResourcesError,
isLoading,
- myResources: fetchResourcesError
- ? []
- : myResources,
- myRoles: fetchResourcesError
- ? []
- : myRoles,
- registrants: fetchResourcesError
- ? []
- : registrants,
- resourceMemberIdMapping: fetchResourcesError
- ? {}
- : resourceMemberIdMapping,
- resources: fetchResourcesError
- ? []
- : resourcesWithRoleName,
- retry: () => mutate(),
- reviewers: fetchResourcesError
- ? []
- : reviewers,
+ myResources,
+ myRoles,
+ registrants,
+ resourceMemberIdMapping,
+ resources: resourcesWithRoleName,
+ reviewers,
}
}
diff --git a/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts b/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts
index 78d8c4552..172f199f0 100644
--- a/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts
+++ b/src/apps/review/src/lib/hooks/useFetchChallengeSubmissions.ts
@@ -14,10 +14,7 @@ export interface useFetchChallengeSubmissionsProps {
challengeSubmissions: BackendSubmission[]
deletedLegacySubmissionIds: Set
deletedSubmissionIds: Set
- error: Error | undefined
- isError: boolean
isLoading: boolean
- retry: () => Promise
}
interface ChallengeSubmissionsMemoResult {
@@ -55,7 +52,6 @@ export function useFetchChallengeSubmissions(
data: challengeSubmissions,
error,
isValidating: isLoading,
- mutate,
}: SWRResponse = useSWR<
BackendSubmission[],
Error
@@ -155,7 +151,8 @@ export function useFetchChallengeSubmissions(
const allowViewAllSubmissionsForDesign = useMemo(
() => Boolean(
- challengeVisibility?.isDesign
+ challengeVisibility
+ && challengeVisibility.isDesign
&& challengeVisibility.isCompleted
&& challengeVisibility.submissionsViewable,
),
@@ -249,25 +246,10 @@ export function useFetchChallengeSubmissions(
allowViewAllSubmissionsForDesign,
])
- if (error) {
- return {
- challengeSubmissions: [],
- deletedLegacySubmissionIds: new Set(),
- deletedSubmissionIds: new Set(),
- error,
- isError: true,
- isLoading,
- retry: () => mutate(),
- }
- }
-
return {
challengeSubmissions: filteredSubmissions,
deletedLegacySubmissionIds,
deletedSubmissionIds,
- error,
- isError: false,
isLoading,
- retry: () => mutate(),
}
}
diff --git a/src/apps/review/src/lib/models/AppealInfo.model.ts b/src/apps/review/src/lib/models/AppealInfo.model.ts
index b516e048a..db6aaf197 100644
--- a/src/apps/review/src/lib/models/AppealInfo.model.ts
+++ b/src/apps/review/src/lib/models/AppealInfo.model.ts
@@ -5,7 +5,6 @@ import { BackendAppealResponse } from './BackendAppealResponse.model'
*/
export interface AppealInfo {
id: string
- reviewId?: string
reviewItemCommentId: string
content: string
appealResponse?: BackendAppealResponse
diff --git a/src/apps/review/src/lib/models/BackendAppeal.model.ts b/src/apps/review/src/lib/models/BackendAppeal.model.ts
index 39f63033f..45d043294 100644
--- a/src/apps/review/src/lib/models/BackendAppeal.model.ts
+++ b/src/apps/review/src/lib/models/BackendAppeal.model.ts
@@ -12,7 +12,6 @@ export interface BackendAppealBase {
export interface BackendAppeal extends BackendAppealBase {
resourceId: string
id: string
- reviewId?: string
appealResponse?: BackendAppealResponse
createdAt: string
createdBy: string
@@ -31,7 +30,6 @@ export function convertBackendAppeal(data: BackendAppeal): AppealInfo {
appealResponse: data.appealResponse,
content: data.content,
id: data.id,
- reviewId: data.reviewId,
reviewItemCommentId: data.reviewItemCommentId,
}
}
diff --git a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
index 0208d4549..5f5b3491d 100644
--- a/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
+++ b/src/apps/review/src/lib/models/ChallengeDetailContextModel.model.ts
@@ -8,17 +8,11 @@ import { AiReviewConfig, AiReviewDecision } from './AiReview.model'
*/
export interface ChallengeDetailContextModel {
challengeId?: string
- challengeInfoError?: Error
isLoadingChallengeInfo: boolean
- challengeResourcesError?: Error
isLoadingChallengeResources: boolean
challengeInfo?: ChallengeInfo
challengeSubmissions: BackendSubmission[]
- challengeSubmissionsError?: Error
isLoadingChallengeSubmissions: boolean
- challengeScopedFetchError?: Error
- hasChallengeScopedFetchError: boolean
- retryChallengeScopedFetches: () => void
myResources: BackendResource[]
myRoles: string[]
resources: BackendResource[]
diff --git a/src/apps/review/src/lib/services/reviews.service.ts b/src/apps/review/src/lib/services/reviews.service.ts
index 9ee2995b0..00debc4cb 100644
--- a/src/apps/review/src/lib/services/reviews.service.ts
+++ b/src/apps/review/src/lib/services/reviews.service.ts
@@ -455,33 +455,20 @@ export const fetchAllChallengeReviews = async (
* @param resourceId resource id
* @returns resolves to the array of appeals
*/
-const fetchAppealsPage = async (
+export const fetchAppeals = async (
page: number,
perPage: number,
- filters: {
- resourceId?: string
- reviewId?: string
- reviewIds?: string[]
- challengeId?: string
- },
-): Promise> => (
- xhrGetAsync>(
+ resourceId: string,
+): Promise => {
+ const results = await xhrGetAsync<
+ BackendResponseWithMeta
+ >(
`${EnvironmentConfig.API.V6}/appeals?${qs.stringify({
page,
perPage,
- ...filters,
- }, {
- arrayFormat: 'comma',
+ resourceId,
})}`,
)
-)
-
-export const fetchAppeals = async (
- page: number,
- perPage: number,
- resourceId: string,
-): Promise => {
- const results = await fetchAppealsPage(page, perPage, { resourceId })
return results.data.map(convertBackendAppeal)
}
@@ -497,68 +484,19 @@ export const fetchAppealsWithReviewId = async (
page: number,
perPage: number,
reviewId: string,
-): Promise => fetchAppealsPage(page, perPage, { reviewId })
- .then(results => results.data.map(convertBackendAppeal))
-
-/**
- * Fetch appeals with review ids
- *
- * @param page current page
- * @param perPage number of item per page
- * @param reviewIds review ids
- * @returns resolves to the array of appeals
- */
-export const fetchAppealsWithReviewIds = async (
- page: number,
- perPage: number,
- reviewIds: string[],
): Promise => {
- const results = await fetchAppealsPage(page, perPage, { reviewIds })
+ const results = await xhrGetAsync<
+ BackendResponseWithMeta
+ >(
+ `${EnvironmentConfig.API.V6}/appeals?${qs.stringify({
+ page,
+ perPage,
+ reviewId,
+ })}`,
+ )
return results.data.map(convertBackendAppeal)
}
-/**
- * Fetch all appeals with review ids using API pagination metadata.
- *
- * @param reviewIds review ids
- * @param perPage number of items per page
- * @returns resolves to the array of appeals
- */
-export const fetchAllAppealsWithReviewIds = async (
- reviewIds: string[],
- perPage = 500,
-): Promise => {
- if (!reviewIds.length) {
- return []
- }
-
- const safePerPage = Number.isFinite(perPage) && perPage > 0 ? perPage : 500
- const firstPage = await fetchAppealsPage(1, safePerPage, { reviewIds })
- const combined = [...firstPage.data]
- const totalPages = Math.max(firstPage.meta?.totalPages ?? 1, 1)
-
- const fetchRemainingPages = async (
- page: number,
- currentTotal: number,
- ): Promise => {
- if (page > currentTotal) {
- return
- }
-
- const nextPage = await fetchAppealsPage(page, safePerPage, { reviewIds })
- combined.push(...nextPage.data)
- const nextTotal = typeof nextPage.meta?.totalPages === 'number'
- ? Math.max(nextPage.meta.totalPages, currentTotal)
- : currentTotal
-
- await fetchRemainingPages(page + 1, nextTotal)
- }
-
- await fetchRemainingPages(2, totalPages)
-
- return combined.map(convertBackendAppeal)
-}
-
/**
* Create review
*
diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts
index 64ee4197d..8b4366a94 100644
--- a/src/apps/review/src/lib/utils/challenge.spec.ts
+++ b/src/apps/review/src/lib/utils/challenge.spec.ts
@@ -230,29 +230,6 @@ describe('challenge phase tab helpers', () => {
.toBe(false)
})
- it('force-shows winners for past challenges with winners even when a phase remains open', () => {
- expect(shouldAllowWinnersTabForPastChallenge({
- phases: [
- createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', {
- isOpen: true,
- }),
- ],
- status: 'COMPLETED',
- }))
- .toBe(false)
-
- expect(shouldForceWinnersTabForPastChallenge({
- phases: [
- createBackendPhase('iterative-1', 'Iterative Review', '2026-04-20T00:00:00Z', {
- isOpen: true,
- }),
- ],
- status: 'COMPLETED',
- winners: [{ handle: 'winner-one', placement: 1, userId: 1 }],
- }))
- .toBe(true)
- })
-
it('keeps winners hidden when a follow-up approval review is still pending', () => {
const challengeInfo = {
phases: [
diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts
index a4e45352f..44aa240e8 100644
--- a/src/apps/review/src/lib/utils/challenge.ts
+++ b/src/apps/review/src/lib/utils/challenge.ts
@@ -743,15 +743,11 @@ export function shouldForceWinnersTabForPastChallenge(
challengeInfo?: WinnersTabFallbackChallengeInfo,
approvalReviews?: ApprovalReviewStatusLike[] | null,
): boolean {
- if (!isPastChallengeStatus(challengeInfo?.status)) {
- return false
- }
-
if (!(challengeInfo?.winners?.length)) {
return false
}
- return !hasPendingApprovalReview(approvalReviews)
+ return shouldAllowWinnersTabForPastChallenge(challengeInfo, approvalReviews)
}
export function isReviewPhaseCurrentlyOpen(
diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx
deleted file mode 100644
index 487a24495..000000000
--- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.route.spec.tsx
+++ /dev/null
@@ -1,412 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import type { PropsWithChildren, ReactNode } from 'react'
-import {
- fireEvent,
- render,
- screen,
- waitFor,
-} from '@testing-library/react'
-import { MemoryRouter, Route, Routes } from 'react-router-dom'
-
-import {
- useFetchAiReviewConfig,
- useFetchAiReviewDecisions,
- useFetchAiWorkflowsRuns,
- useFetchChallengeInfo,
- useFetchChallengeResources,
- useFetchChallengeSubmissions,
- useFetchScreeningReview,
- useFetchSubmissionInfo,
- useRole,
-} from '../../../lib/hooks'
-import { ReviewAppContext } from '../../../lib/contexts/ReviewAppContext'
-import { ChallengeDetailContextProvider } from '../../../lib/contexts/ChallengeDetailContextProvider'
-import { ReviewsContextProvider } from '../../reviews/ReviewsContext'
-import { ReviewsViewer } from '../../reviews/ReviewsViewer'
-
-import { ChallengeDetailsPage } from './ChallengeDetailsPage'
-
-jest.mock('~/config', () => ({
- AppSubdomain: {
- review: 'review',
- },
- EnvironmentConfig: {
- REVIEW: {
- CHALLENGE_PAGE_URL: 'https://community.example.com/challenges',
- },
- SUBDOMAIN: 'review',
- },
-}), { virtual: true })
-
-jest.mock('~/apps/admin/src/lib', () => ({
- TableLoading: () => Loading
,
-}), { virtual: true })
-
-jest.mock('~/apps/admin/src/lib/utils', () => ({
- handleError: jest.fn(),
-}), { virtual: true })
-
-jest.mock('~/libs/core', () => ({
- getRatingColor: jest.fn()
- .mockReturnValue('#000000'),
- UserRole: {
- administrator: 'administrator',
- projectManager: 'projectManager',
- },
- xhrGetAsync: jest.fn(),
-}), { virtual: true })
-
-jest.mock('~/libs/ui', () => ({
- BaseModal: (props: PropsWithChildren<{ open?: boolean }>) => (
- props.open ? {props.children}
: undefined
- ),
- Button: (props: PropsWithChildren<{
- label?: string
- onClick?: () => void
- }>) => (
-
- {props.children ?? props.label}
-
- ),
- InputCheckbox: () => ,
- InputDatePicker: () => ,
- InputText: () => ,
-}), { virtual: true })
-
-jest.mock('../../../lib', () => {
- const challengeDetailContext = jest.requireActual('../../../lib/contexts/ChallengeDetailContext')
- const reviewAppContext = jest.requireActual('../../../lib/contexts/ReviewAppContext')
-
- return {
- ...challengeDetailContext,
- ...reviewAppContext,
- ChallengeDetailsContent: () => Challenge details content
,
- ChallengeLinks: () => Challenge links
,
- ChallengePhaseInfo: () => Challenge phase info
,
- ChallengeScopedErrorState: (props: {
- message?: string
- onRetry: () => void
- }) => (
-
-
- {props.message ?? 'Something went wrong while loading the challenge. Please try again.'}
-
- Retry
-
- ),
- ChallengeTimeline: () => Challenge timeline
,
- PageWrapper: (props: PropsWithChildren<{
- breadCrumb?: Array<{ label?: string; path?: string }>
- pageTitle?: string
- rightHeader?: ReactNode
- titleUrl?: string
- }>) => (
-
-
{props.pageTitle}
-
{props.titleUrl}
-
- {(props.breadCrumb ?? [])
- .map(item => `${item.label ?? ''}:${item.path ?? ''}`)
- .join('|')}
-
-
{props.rightHeader}
- {props.children}
-
- ),
- TableNoRecord: (props: { message?: string }) => {props.message}
,
- TableRegistration: () => Resources table
,
- Tabs: () => Tabs
,
- }
-})
-
-jest.mock('../../../lib/hooks', () => ({
- useFetchAiReviewConfig: jest.fn(),
- useFetchAiReviewDecisions: jest.fn(),
- useFetchAiWorkflowsRuns: jest.fn(),
- useFetchChallengeInfo: jest.fn(),
- useFetchChallengeResources: jest.fn(),
- useFetchChallengeSubmissions: jest.fn(),
- useFetchScreeningReview: jest.fn(),
- useFetchSubmissionInfo: jest.fn(),
- useRole: jest.fn(),
-}))
-
-jest.mock('../../../lib/services', () => ({
- updateChallengePhase: jest.fn(),
- updatePhaseChangeNotifications: jest.fn(),
-}))
-
-jest.mock('../../reviews/components/ReviewsSidebar', () => ({
- ReviewsSidebar: () => Reviews sidebar
,
-}))
-
-jest.mock('../../reviews/components/AiReviewViewer', () => ({
- AiReviewViewer: () => AI review viewer
,
-}))
-
-jest.mock('../../reviews/components/ReviewViewer', () => ({
- ReviewViewer: () => Review viewer
,
-}))
-
-jest.mock('../../../lib/components/SubmissionBarInfo', () => ({
- SubmissionBarInfo: () => Submission bar
,
-}))
-
-const mockedUseFetchAiReviewConfig = useFetchAiReviewConfig as jest.Mock
-const mockedUseFetchAiReviewDecisions = useFetchAiReviewDecisions as jest.Mock
-const mockedUseFetchAiWorkflowsRuns = useFetchAiWorkflowsRuns as jest.Mock
-const mockedUseFetchChallengeInfo = useFetchChallengeInfo as jest.Mock
-const mockedUseFetchChallengeResources = useFetchChallengeResources as jest.Mock
-const mockedUseFetchChallengeSubmissions = useFetchChallengeSubmissions as jest.Mock
-const mockedUseFetchScreeningReview = useFetchScreeningReview as jest.Mock
-const mockedUseFetchSubmissionInfo = useFetchSubmissionInfo as jest.Mock
-const mockedUseRole = useRole as jest.Mock
-
-const retryChallengeInfo = jest.fn()
-const retryChallengeResources = jest.fn()
-const retryChallengeSubmissions = jest.fn()
-
-const challengeInfo = {
- currentPhase: 'Registration',
- currentPhaseEndDate: '2026-01-01T00:00:00.000Z',
- id: 'challenge-1',
- name: 'Visible Challenge',
- phases: [],
- status: 'ACTIVE',
- submissions: [],
- track: {
- id: 'track-1',
- name: 'Development',
- },
- type: {
- id: 'type-1',
- name: 'Code',
- },
- typeId: 'type-1',
-}
-
-/**
- * Creates the 403 error shape returned by the mocked challenge-scoped fetches.
- *
- * @returns an Error object with a 403 status field.
- */
-function makeForbiddenError(): Error {
- return Object.assign(new Error('Forbidden'), { status: 403 })
-}
-
-/**
- * Renders a review-app route with the review app context provider.
- *
- * @param route concrete route used as the initial memory history entry.
- * @param element route element under test.
- * @param path route pattern registered in the memory router.
- */
-function renderWithReviewContext(route: string, element: JSX.Element, path: string): void {
- render(
-
-
-
-
-
-
- ,
- )
-}
-
-/**
- * Renders the direct challenge details route through ChallengeDetailContextProvider.
- */
-function renderChallengeDetailsRoute(): void {
- renderWithReviewContext(
- '/active-challenges/challenge-1/challenge-details',
-
-
- ,
- '/active-challenges/:challengeId/challenge-details',
- )
-}
-
-/**
- * Renders the nested reviews route through both challenge and reviews providers.
- */
-function renderReviewsRoute(): void {
- renderWithReviewContext(
- '/active-challenges/challenge-1/challenge-details/reviews/submission-1',
-
-
-
-
- ,
- '/active-challenges/:challengeId/challenge-details/reviews/:submissionId',
- )
-}
-
-describe('review challenge direct route errors', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- retryChallengeInfo.mockClear()
- retryChallengeResources.mockClear()
- retryChallengeSubmissions.mockClear()
- retryChallengeInfo.mockImplementation(() => Promise.resolve(undefined))
- retryChallengeResources.mockImplementation(() => Promise.resolve(undefined))
- retryChallengeSubmissions.mockImplementation(() => Promise.resolve(undefined))
-
- mockedUseFetchChallengeInfo.mockReturnValue({
- challengeInfo,
- error: undefined,
- isError: false,
- isLoading: false,
- retry: retryChallengeInfo,
- })
- mockedUseFetchChallengeResources.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- myResources: [{
- challengeId: 'challenge-1',
- created: '2026-01-01T00:00:00.000Z',
- createdBy: 'system',
- id: 'resource-1',
- memberHandle: 'reviewer',
- memberId: '1001',
- roleId: 'role-1',
- roleName: 'Reviewer',
- }],
- myRoles: ['Reviewer'],
- registrants: [],
- resourceMemberIdMapping: {},
- resources: [],
- retry: retryChallengeResources,
- reviewers: [],
- })
- mockedUseFetchChallengeSubmissions.mockReturnValue({
- challengeSubmissions: [],
- deletedLegacySubmissionIds: new Set(),
- deletedSubmissionIds: new Set(),
- error: undefined,
- isError: false,
- isLoading: false,
- retry: retryChallengeSubmissions,
- })
- mockedUseFetchAiReviewConfig.mockReturnValue({
- aiReviewConfig: undefined,
- isLoading: false,
- })
- mockedUseFetchAiReviewDecisions.mockReturnValue({
- decisions: [],
- isLoading: false,
- })
- mockedUseFetchScreeningReview.mockReturnValue({
- approvalMinimumPassingScore: 0,
- approvalReviews: [],
- checkpoint: [],
- checkpointReview: [],
- checkpointReviewMinimumPassingScore: 0,
- checkpointScreeningMinimumPassingScore: 0,
- isLoading: false,
- isLoadingReviews: false,
- mappingReviewAppeal: {},
- postMortemMinimumPassingScore: 0,
- postMortemReviews: [],
- review: [],
- reviewMinimumPassingScore: 0,
- reviewProgress: 0,
- screening: [],
- screeningMinimumPassingScore: 0,
- submitterReviews: [],
- })
- mockedUseRole.mockReturnValue({
- actionChallengeRole: 'Reviewer',
- })
- mockedUseFetchSubmissionInfo.mockReturnValue([undefined, false])
- mockedUseFetchAiWorkflowsRuns.mockReturnValue({
- isLoading: false,
- runs: [],
- })
- })
-
- it('renders a generic retryable error for a direct challenge-info 403', async () => {
- mockedUseFetchChallengeInfo.mockReturnValue({
- challengeInfo: undefined,
- error: makeForbiddenError(),
- isError: true,
- isLoading: false,
- retry: retryChallengeInfo,
- })
-
- renderChallengeDetailsRoute()
-
- await waitFor(() => {
- expect(screen.getByRole('alert').textContent)
- .toContain('Something went wrong while loading the challenge. Please try again.')
- })
-
- fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
-
- expect(retryChallengeInfo)
- .toHaveBeenCalled()
- expect(screen.queryByText(/permission to see this challenge/i))
- .toBeNull()
- })
-
- it('renders a generic retryable error for a direct resources 403 instead of permission denied', async () => {
- mockedUseFetchChallengeResources.mockReturnValue({
- error: makeForbiddenError(),
- isError: true,
- isLoading: false,
- myResources: [],
- myRoles: [],
- registrants: [],
- resourceMemberIdMapping: {},
- resources: [],
- retry: retryChallengeResources,
- reviewers: [],
- })
-
- renderChallengeDetailsRoute()
-
- await waitFor(() => {
- expect(screen.getByRole('alert').textContent)
- .toContain('Something went wrong while loading the challenge. Please try again.')
- })
-
- expect(screen.queryByText(/permission to see this challenge/i))
- .toBeNull()
- })
-
- it('renders the shared generic error on the nested reviews route without undefined challenge links', async () => {
- mockedUseFetchChallengeInfo.mockReturnValue({
- challengeInfo: undefined,
- error: makeForbiddenError(),
- isError: true,
- isLoading: false,
- retry: retryChallengeInfo,
- })
-
- renderReviewsRoute()
-
- await waitFor(() => {
- expect(screen.getByRole('alert').textContent)
- .toContain('Something went wrong while loading the challenge. Please try again.')
- })
-
- expect(screen.queryByText('Review viewer'))
- .toBeNull()
- expect(screen.getByTestId('title-url').textContent)
- .toBe('')
- expect(screen.getByTestId('breadcrumbs').textContent)
- .not.toContain('undefined')
- })
-})
diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx
index e9c31970c..5b3909323 100644
--- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx
+++ b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx
@@ -23,7 +23,6 @@ import {
ChallengeDetailsContent,
ChallengeLinks,
ChallengePhaseInfo,
- ChallengeScopedErrorState,
ChallengeTimeline,
ChallengeTimelineAction,
ChallengeTimelineRow,
@@ -281,8 +280,6 @@ export const ChallengeDetailsPage: FC = (props: Props) => {
myResources,
challengeSubmissions,
isLoadingChallengeSubmissions,
- hasChallengeScopedFetchError,
- retryChallengeScopedFetches,
}: ChallengeDetailContextModel = useContext(ChallengeDetailContext)
const { loginUserInfo }: ReviewAppContextModel = useContext(ReviewAppContext)
const { actionChallengeRole }: useRoleProps = useRole()
@@ -1793,14 +1790,10 @@ export const ChallengeDetailsPage: FC = (props: Props) => {
- {hasChallengeScopedFetchError ? (
-
- ) : isLoadingChallengeInfo ? (
+ {isLoadingChallengeInfo ? (
) : (!isLoadingAnything && hasChallengeInfo && !canViewChallenge) ? (
diff --git a/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx b/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx
index bd250b5ab..ebbf778b6 100644
--- a/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx
+++ b/src/apps/review/src/pages/reviews/ReviewsContext/ReviewsContextProvider.tsx
@@ -40,7 +40,7 @@ export const ReviewsContextProvider: FC
= props => {
() => workflowRuns.find(w => w.workflow.id === workflowId),
[workflowRuns, workflowId],
)
- const workflow = useMemo(() => workflowRun?.workflow, [workflowRun?.workflow])
+ const workflow = useMemo(() => workflowRun?.workflow, [workflowRuns, workflowId])
const scorecard = useMemo(() => workflow?.scorecard, [workflow])
const value = useMemo(
diff --git a/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx b/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx
index 243988172..6bb3ae350 100644
--- a/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx
+++ b/src/apps/review/src/pages/reviews/ReviewsViewer/ReviewsViewer.tsx
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
import { EnvironmentConfig } from '~/config'
-import { ChallengeScopedErrorState, PageWrapper } from '../../../lib'
+import { PageWrapper } from '../../../lib'
import { BreadCrumbData, ReviewsContextModel } from '../../../lib/models'
import { ReviewsSidebar } from '../components/ReviewsSidebar'
import { useReviewsContext } from '../ReviewsContext'
@@ -21,70 +21,49 @@ const ReviewsViewer: FC = () => {
workflowRun,
actionButtons,
submissionInfo,
- hasChallengeScopedFetchError,
- retryChallengeScopedFetches,
}: ReviewsContextModel = useReviewsContext()
const location = useLocation()
const containsPastChallenges = location.pathname.indexOf('/past-challenges/')
- const breadCrumb = useMemo(() => {
- const items: BreadCrumbData[] = [{
+ const breadCrumb = useMemo(() => [
+ {
index: 1,
label: 'Active Challenges',
path: `${rootRoute}/${activeReviewAssignmentsRouteId}/`,
- }]
-
- if (!hasChallengeScopedFetchError && challengeInfo) {
- items.push({
- fallback: './../../../../challenge-details',
- index: 2,
- label: challengeInfo.name,
- path: containsPastChallenges > -1
- ? `${rootRoute}/past-challenges/${challengeInfo.id}/challenge-details`
- : `${rootRoute}/active-challenges/${challengeInfo.id}/challenge-details`,
- })
- }
-
- items.push({
+ },
+ {
+ fallback: './../../../../challenge-details',
+ index: 2,
+ label: challengeInfo?.name,
+ path: containsPastChallenges > -1
+ ? `${rootRoute}/past-challenges/${challengeInfo?.id}/challenge-details`
+ : `${rootRoute}/active-challenges/${challengeInfo?.id}/challenge-details`,
+ },
+ {
index: 3,
label: `Review Scorecard - ${submissionId}`,
- })
-
- return items
- }, [
- challengeInfo,
- containsPastChallenges,
- hasChallengeScopedFetchError,
- submissionId,
- ])
+ },
+ ], [challengeInfo?.name, challengeInfo?.id, submissionId, containsPastChallenges])
return (
- {hasChallengeScopedFetchError ? (
-
- ) : (
- <>
-
-
-
-
-
-
- {!!workflowRun &&
}
- {!workflowRun &&
}
-
-
- >
- )}
+
+
+
+
+
+
+ {!!workflowRun &&
}
+ {!workflowRun &&
}
+
+
)
}
diff --git a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
index df0f254e9..c3ce57b3f 100644
--- a/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
+++ b/src/apps/review/src/pages/reviews/components/ReviewsSidebar/ReviewsSidebar.tsx
@@ -252,13 +252,6 @@ const ReviewsSidebar: FC = props => {
status='pending'
/>
-
- }
- label='Unlocked'
- status='human-override'
- />
-
{
jest.clearAllMocks()
})
- it('defaults the approver view to the On Hold (Admin) status filter and both allowed categories', async () => {
+ it('defaults the engagement approver view to the On Hold (Admin) status filter', async () => {
render(
,
)
@@ -232,13 +232,14 @@ describe('PaymentsListView', () => {
expect(mockedGetPayments)
.toHaveBeenLastCalledWith(10, 0, {
- categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'],
+ category: ['ENGAGEMENT_PAYMENT'],
status: ['ON_HOLD_ADMIN'],
})
expect(mockFilterBar)
.toHaveBeenCalled()
expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides)
.toEqual(expect.objectContaining({
+ category: 'ENGAGEMENT_PAYMENT',
status: 'ON_HOLD_ADMIN',
}))
})
@@ -265,7 +266,7 @@ describe('PaymentsListView', () => {
it('applies the default approver status after switching from admin view', async () => {
render(
,
)
@@ -274,18 +275,19 @@ describe('PaymentsListView', () => {
expect(mockedGetPayments)
.toHaveBeenLastCalledWith(10, 0, {})
- fireEvent.click(screen.getByRole('button', { name: 'Approver View' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Engagement Approver View' }))
await screen.findByText('No payments match your filters.')
expect(mockedGetPayments)
.toHaveBeenLastCalledWith(10, 0, {
- categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'],
+ category: ['ENGAGEMENT_PAYMENT'],
status: ['ON_HOLD_ADMIN'],
})
expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides)
.toEqual(expect.objectContaining({
+ category: 'ENGAGEMENT_PAYMENT',
status: 'ON_HOLD_ADMIN',
}))
})
@@ -312,7 +314,7 @@ describe('PaymentsListView', () => {
it('lets an explicit status filter override the default approver status', async () => {
render(
,
)
@@ -325,13 +327,14 @@ describe('PaymentsListView', () => {
await waitFor(() => {
expect(mockedGetPayments)
.toHaveBeenLastCalledWith(10, 0, {
- categories: ['TASK_PAYMENT', 'ENGAGEMENT_PAYMENT'],
+ category: ['ENGAGEMENT_PAYMENT'],
status: ['PAID'],
})
})
expect(mockFilterBar.mock.calls.at(-1)?.[0].selectedValueOverrides)
.toEqual(expect.objectContaining({
+ category: 'ENGAGEMENT_PAYMENT',
status: 'PAID',
}))
})
@@ -364,7 +367,7 @@ describe('PaymentsListView', () => {
}))
})
- it('lets approvers reject selected on hold admin payments with an audit note', async () => {
+ it('lets engagement approvers reject selected on hold admin payments with an audit note', async () => {
mockedGetPayments.mockResolvedValue(paymentsResponse as any)
mockedGetMemberHandle.mockResolvedValue(new Map([
[111, 'sathya22in'],
@@ -373,7 +376,7 @@ describe('PaymentsListView', () => {
render(
,
)
diff --git a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx
index 7a7430579..41b64aab6 100644
--- a/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx
+++ b/src/apps/wallet-admin/src/home/tabs/payments/PaymentsListView.tsx
@@ -19,13 +19,11 @@ import PaymentsTable from '../../../lib/components/payments-table/PaymentTable'
import styles from './Payments.module.scss'
-type PaymentRoleView = 'admin' | 'paymentApprover' | 'wiproTaasAdmin'
+type PaymentRoleView = 'admin' | 'engagementApprover' | 'wiproTaasAdmin'
type SelectedPaymentAction = 'approve' | 'reject'
-const taskPaymentCategory = 'TASK_PAYMENT'
const engagementPaymentCategory = 'ENGAGEMENT_PAYMENT'
const restrictedRoleDefaultStatus = 'ON_HOLD_ADMIN'
-const approverAllowedCategories = [taskPaymentCategory, engagementPaymentCategory]
const taasPaymentCategory = 'TAAS_PAYMENT'
const topgearPaymentCategory = 'TOPGEAR_PAYMENT'
const defaultPageSize = 10
@@ -174,8 +172,8 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
const isWiproTaasAdmin = hasRole('Wipro TaaS Admin')
const hasPaymentAdminRole = hasRole('Payment Admin')
const isPaymentAdmin = hasPaymentAdminRole || isWiproTaasAdmin
- const isPaymentApprover = hasRole('Payment Approver')
- const canToggleRoleView = isPaymentAdmin && isPaymentApprover
+ const isEngagementPaymentApprover = hasRole('Engagement Payment Approver')
+ const canToggleRoleView = isPaymentAdmin && (isEngagementPaymentApprover)
const [confirmFlow, setConfirmFlow] = React.useState(undefined)
const [isConfirmFormValid, setIsConfirmFormValid] = React.useState(false)
const [winnings, setWinnings] = React.useState>([])
@@ -183,110 +181,62 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
const selectedPaymentsCount = Object.keys(selectedPayments).length
const [isLoading, setIsLoading] = React.useState(false)
const [paymentRoleView, setPaymentRoleView] = React.useState(
- isPaymentAdmin ? 'admin' : 'paymentApprover',
+ isPaymentAdmin ? 'admin' : 'engagementApprover',
)
- const isApproverView = isPaymentApprover && (
- !isPaymentAdmin || paymentRoleView === 'paymentApprover'
+ const isEngagementApproverView = isEngagementPaymentApprover && (
+ !isPaymentAdmin || paymentRoleView === 'engagementApprover'
)
- const restrictedCategory = isWiproTaasAdmin && !hasPaymentAdminRole ? taasPaymentCategory : undefined
- const restrictedDefaultStatus = isApproverView ? restrictedRoleDefaultStatus : undefined
- const isRestrictedApproverView = isApproverView
+ const restrictedCategory = isEngagementApproverView
+ ? engagementPaymentCategory
+ : (isWiproTaasAdmin && !hasPaymentAdminRole ? taasPaymentCategory : undefined)
+ const restrictedDefaultStatus = isEngagementApproverView ? restrictedRoleDefaultStatus : undefined
+ const isRestrictedApproverView = isEngagementApproverView
const [filters, setFilters] = React.useState>({})
-
+ const hasSelectedStatusFilter = (filters.status?.length ?? 0) > 0
const appliedFilters = React.useMemo>(() => {
- // Strip 'all' sentinel values — never forward them to the API
- const activeFilters = Object.fromEntries(
- Object.entries(filters)
- .filter(([, v]) => v.length > 0 && v[0] !== 'all'),
- )
-
- if (restrictedCategory) {
- // WiproTaasAdmin scoped to a single category
- let statusFilter: Record = {}
- if (filters.status && filters.status[0] !== 'all') {
- statusFilter = { status: activeFilters.status }
- }
-
- return {
- ...activeFilters,
- category: [restrictedCategory],
- ...statusFilter,
- }
+ if (!restrictedCategory) {
+ return filters
}
- if (isApproverView) {
- // Payment Approver: restrict to allowed categories, default status ON_HOLD_ADMIN
- let statusFilter: Record = {}
- if (filters.status && filters.status[0] !== 'all') {
- statusFilter = { status: activeFilters.status }
- } else if (!filters.status && restrictedDefaultStatus) {
- statusFilter = { status: [restrictedDefaultStatus] }
- }
-
- let categoryFilter: Record = {}
- if (
- activeFilters.category
- && approverAllowedCategories.includes(activeFilters.category[0])
- ) {
- categoryFilter = { category: activeFilters.category }
- } else if (!filters.category || filters.category[0] === 'all') {
- categoryFilter = { categories: ([] as string[]).concat(approverAllowedCategories) }
- }
-
- const rest = { ...activeFilters }
- delete rest.category
-
- return {
- ...rest,
- ...categoryFilter,
- ...statusFilter,
- }
+ return {
+ ...filters,
+ category: [restrictedCategory],
+ ...(hasSelectedStatusFilter
+ ? { status: filters.status }
+ : (restrictedDefaultStatus ? { status: [restrictedDefaultStatus] } : {})),
}
-
- return activeFilters
- }, [filters, restrictedCategory, restrictedDefaultStatus, isApproverView])
-
+ }, [filters, hasSelectedStatusFilter, restrictedCategory, restrictedDefaultStatus])
const hasActiveFilters = React.useMemo(
() => Object.entries(appliedFilters)
- .some(([key, value]) => key !== 'category' && key !== 'categories' && value.length > 0),
+ .some(([key, value]) => key !== 'category' && value.length > 0),
[appliedFilters],
)
const selectedValueOverrides = React.useMemo>(() => {
- if (restrictedCategory) {
- const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined
-
- return {
- category: restrictedCategory,
- ...(statusOverride ? { status: statusOverride } : {}),
- }
+ if (!restrictedCategory) {
+ return {} as Record
}
- if (isApproverView) {
- const statusOverride = filters.status?.[0] !== 'all' ? filters.status?.[0] : undefined
+ const statusOverride = filters.status?.[0] ?? restrictedDefaultStatus
- return {
- ...(statusOverride ? { status: statusOverride } : {}),
- }
+ return {
+ category: restrictedCategory,
+ ...(statusOverride ? { status: statusOverride } : {}),
}
-
- return {} as Record
- }, [filters.status, restrictedCategory, isApproverView])
+ }, [filters.status, restrictedCategory, restrictedDefaultStatus])
const defaultDropdownValues = React.useMemo>(() => {
const defaults: Record = {}
if (!restrictedCategory) {
+ defaults.status = filters.status?.[0] ?? 'all'
defaults.category = filters.category?.[0] ?? 'all'
}
defaults.date = filters.date?.[0] ?? 'all'
- // Fall back to the restricted default if no filter is applied
- defaults.status = filters.status?.[0] ?? (restrictedDefaultStatus || 'all')
-
return defaults
- }, [filters.category, filters.date, filters.status, restrictedCategory, restrictedDefaultStatus])
+ }, [filters.category, filters.date, filters.status, restrictedCategory])
const [pagination, setPagination] = React.useState({
currentPage: 1,
pageSize: defaultPageSize,
@@ -586,14 +536,14 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
>
Admin View
- {isPaymentApprover && (
+ {isEngagementPaymentApprover && (
onRoleViewChange('paymentApprover')}
+ aria-pressed={isEngagementApproverView}
+ className={`${styles.roleViewButton} ${isEngagementApproverView ? styles.roleViewButtonActive : ''}`}
+ onClick={() => onRoleViewChange('engagementApprover')}
>
- Approver View
+ Engagement Approver View
)}
@@ -663,27 +613,7 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
],
type: 'dropdown',
},
- ...(isWiproTaasAdmin && !hasPaymentAdminRole ? [] : isApproverView ? [
- {
- key: 'category',
- label: 'Payment Type',
- options: [
- {
- label: 'All',
- value: 'all',
- },
- {
- label: 'Task Payments',
- value: taskPaymentCategory,
- },
- {
- label: 'Engagement Payments',
- value: engagementPaymentCategory,
- },
- ],
- type: 'dropdown',
- },
- ] as Filter[] : [
+ ...(isRestrictedApproverView || (isWiproTaasAdmin && !hasPaymentAdminRole) ? [] : [
{
key: 'category',
label: 'Type',
@@ -773,11 +703,20 @@ const PaymentsListView: FC = (props: PaymentsListViewProp
}
setPagination(newPagination)
+ /* setFilters({
+ ...filters,
+ [key]: value,
+ }) */
+ setFilters(prev => {
+ const newFilters = { ...prev }
+ if (value[0] === 'all') {
+ delete newFilters[key]
+ } else {
+ newFilters[key] = value
+ }
- setFilters(prev => ({
- ...prev,
- [key]: value, // store 'all' explicitly; appliedFilters strips it before the API call
- }))
+ return newFilters
+ })
setSelectedPayments({})
}}
onResetFilters={() => {
diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss
index c836fe226..0f9922925 100644
--- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss
+++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.module.scss
@@ -22,12 +22,6 @@
gap: 10px;
flex-wrap: wrap;
min-width: 0;
-
- [class*="InputSelect-module_textSelected"] {
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
}
.filterContainer {
@@ -58,15 +52,6 @@
flex-shrink: 0;
margin-left: 30px;
}
-
- .taskApproveBtns {
- display: flex;
- justify-content: flex-end;
- gap: 10px;
- width: 100%;
- margin-top: 10px;
- flex-wrap: wrap;
- }
}
@media (max-width: 768px) {
diff --git a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx
index c575a8d6a..23569ff66 100644
--- a/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx
+++ b/src/apps/wallet-admin/src/lib/components/filter-bar/FilterBar.tsx
@@ -151,20 +151,8 @@ const FilterBar: React.FC = (props: FilterBarProps) => {
size='lg'
/>
)}
- {
- selectedMembers.current = []
- setSelectedValue(new Map())
- props.onResetFilters?.()
- }}
- />
{selectionActions.length > 0 && (
-
+ <>
{selectionActions.map(action => (
= (props: FilterBarProps) => {
onClick={action.onClick}
/>
))}
-
+ >
)}
+ {
+ selectedMembers.current = []
+ setSelectedValue(new Map())
+ props.onResetFilters?.()
+ }}
+ />
)
}
diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.spec.tsx b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.spec.tsx
index 351ba2813..bf3806abf 100644
--- a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.spec.tsx
+++ b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.spec.tsx
@@ -182,45 +182,4 @@ describe('PaymentView', () => {
expect(workLogRemarksLink.getAttribute('target'))
.toBe('_blank')
})
-
- it('renders task details section for task payments', async () => {
- const taskPayment: Winning = {
- ...payment,
- description: 'Build a cool widget for the dashboard',
- externalId: 'challenge-uuid-1',
- type: 'task payment',
- }
-
- mockedFetchWinningPaymentDetails.mockResolvedValue({
- taskDetails: {
- paymentApproverHandle: 'approver-handle',
- paymentCreatorHandle: 'task-creator',
- projectId: '42',
- projectName: 'My Awesome Project',
- },
- })
-
- render( )
-
- await waitFor(() => {
- expect(mockedFetchWinningPaymentDetails)
- .toHaveBeenCalledWith(taskPayment)
- })
-
- expect(await screen.findByRole('heading', { name: 'Task Details' }))
- .toBeTruthy()
-
- expect(await screen.findByRole('heading', { name: 'Task Details' }))
- .toBeTruthy()
- expect(await screen.findByText('task-creator'))
- .toBeTruthy()
- expect(await screen.findByText('approver-handle'))
- .toBeTruthy()
-
- const projectLink = await screen.findByRole('link', { name: 'My Awesome Project' })
- expect(projectLink.getAttribute('href'))
- .toBe('https://challenges.example.com/projects/42/challenges/challenge-uuid-1/view')
- expect(projectLink.getAttribute('target'))
- .toBe('_blank')
- })
})
diff --git a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx
index a597eee66..02db5e10c 100644
--- a/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx
+++ b/src/apps/wallet-admin/src/lib/components/payment-view/PaymentView.tsx
@@ -19,7 +19,6 @@ import {
getMemberHandle,
} from '../../services/wallet'
import {
- buildWorkAppChallengeUrl,
buildWorkManagerAssignmentUrl,
buildWorkManagerProjectUrl,
formatOptionalDate,
@@ -43,7 +42,6 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
const [paymentDetailsError, setPaymentDetailsError] = React.useState()
const isEngagementPayment = props.payment.type.toLowerCase() === 'engagement payment'
- const isTaskPayment = props.payment.type.toLowerCase() === 'task payment'
const hasEngagementDetails = Boolean(paymentDetails?.engagementDetails)
const handleToggleView = (newView: 'audit' | 'details' | 'external_transaction'): void => {
@@ -51,7 +49,7 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
}
React.useEffect(() => {
- if (!isEngagementPayment && !isTaskPayment) {
+ if (!isEngagementPayment) {
setPaymentDetails(undefined)
setIsPaymentDetailsLoading(false)
setPaymentDetailsError(undefined)
@@ -72,7 +70,7 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
.catch(() => {
if (!ignore) {
setPaymentDetails(undefined)
- setPaymentDetailsError(isTaskPayment ? 'Unable to load task details.' : 'Unable to load engagement details.')
+ setPaymentDetailsError('Unable to load engagement details.')
}
})
.finally(() => {
@@ -84,7 +82,7 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
return () => {
ignore = true
}
- }, [isEngagementPayment, isTaskPayment, props.payment])
+ }, [isEngagementPayment, props.payment])
React.useEffect(() => {
if (view === 'audit') {
@@ -140,9 +138,7 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
const descriptionLink = isEngagementPayment
? buildWorkManagerAssignmentUrl(paymentDetails?.engagementDetails)
- : isTaskPayment
- ? buildWorkAppChallengeUrl(paymentDetails?.taskDetails?.projectId, props.payment.externalId)
- : `${TOPCODER_URL}/challenges/${props.payment.externalId}`
+ : `${TOPCODER_URL}/challenges/${props.payment.externalId}`
const projectLink = buildWorkManagerProjectUrl(paymentDetails?.engagementDetails)
return (
@@ -310,61 +306,6 @@ const PaymentView: React.FC = (props: PaymentViewProps) => {
)}
- {isTaskPayment && (
-
diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss
index 3d286cbf5..c14487696 100644
--- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss
+++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.module.scss
@@ -36,24 +36,12 @@
}
.itemHeader {
- align-items: flex-start;
+ align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
-.amountBlock {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.amount {
- color: #2a2a2a;
- font-size: 16px;
- font-weight: 700;
-}
-
.status {
background: #f3f5fb;
border-radius: 999px;
@@ -80,7 +68,7 @@
font-size: 12px;
}
-.metaRow {
+.paymentCreator {
color: #5b5b5b;
display: flex;
flex-wrap: wrap;
@@ -88,7 +76,7 @@
gap: 4px;
}
-.metaLabel {
+.paymentCreatorLabel {
font-weight: 600;
}
diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx
index 0338e3a0b..2b459612c 100644
--- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx
+++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.spec.tsx
@@ -12,10 +12,6 @@ jest.mock('../../hooks', () => ({
useFetchAssignmentPayments: (...args: unknown[]): unknown => mockUseFetchAssignmentPayments(...args),
}))
-jest.mock('../../constants', () => ({
- BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED: true,
-}))
-
jest.mock('~/libs/ui', () => ({
BaseModal: (props: {
buttons?: JSX.Element
@@ -48,7 +44,7 @@ describe('PaymentHistoryModal', () => {
mockUseFetchAssignmentPayments.mockReset()
})
- it('renders clickable remarks links, the payment creator handle, and fee details', async () => {
+ it('renders clickable remarks links and the payment creator handle', async () => {
mockUseFetchAssignmentPayments.mockReturnValue({
error: undefined,
isLoading: false,
@@ -62,13 +58,6 @@ describe('PaymentHistoryModal', () => {
},
createdAt: '2026-03-31T00:00:00.000Z',
createdByHandle: 'payment.manager',
- details: [
- {
- challengeFee: 18.6,
- grossAmount: 120,
- totalAmount: 120,
- },
- ],
id: 'payment-1',
title: 'Salesforce support',
},
@@ -96,9 +85,5 @@ describe('PaymentHistoryModal', () => {
.toBeTruthy()
expect(screen.getByText('payment.manager'))
.toBeTruthy()
- expect(screen.getByText('Fee:'))
- .toBeTruthy()
- expect(screen.getByText('$18.60'))
- .toBeTruthy()
})
})
diff --git a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx
index bb7b33318..b74d13f91 100644
--- a/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx
+++ b/src/apps/work/src/lib/components/PaymentHistoryModal/PaymentHistoryModal.tsx
@@ -5,16 +5,12 @@ import {
Button,
} from '~/libs/ui'
-import {
- BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED,
-} from '../../constants'
import {
useFetchAssignmentPayments,
} from '../../hooks'
import {
formatCurrency,
getPaymentAmount,
- getPaymentChallengeFee,
getPaymentCreatorLabel,
getPaymentHoursWorked,
getPaymentRemarks,
@@ -90,10 +86,6 @@ const PaymentHistoryModal: FC
= (
? (
{paymentsResult.payments.map((payment, index) => {
- const paymentAmount = getPaymentAmount(payment)
- const paymentChallengeFee = BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED
- ? getPaymentChallengeFee(payment)
- : undefined
const paymentStatus = getPaymentStatus(payment)
const paymentHoursWorked = getPaymentHoursWorked(payment)
const paymentRemarks = getPaymentRemarks(payment)
@@ -110,21 +102,7 @@ const PaymentHistoryModal: FC = (
className={styles.item}
>
-
-
- {formatCurrency(paymentAmount)}
-
- {paymentChallengeFee !== undefined
- ? (
-
-
- Fee:
-
- {formatCurrency(paymentChallengeFee)}
-
- )
- : undefined}
-
+
{formatCurrency(getPaymentAmount(payment))}
{showPaymentStatus
?
{paymentStatus}
: undefined}
@@ -147,8 +125,8 @@ const PaymentHistoryModal: FC
= (
)
: undefined}
-
-
+
+
Payment Creator:
{paymentCreator || '-'}
diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss
index e775b583c..4a4be4b8b 100644
--- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss
+++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.module.scss
@@ -1,9 +1,3 @@
-.noticeStack {
- align-items: flex-start;
- display: flex;
- flex-direction: column;
-}
-
.container {
display: inline-flex;
align-items: center;
@@ -55,44 +49,3 @@
.requestCopilotLink:hover {
color: #0f5e48;
}
-
-.budgetDisplay {
- border-radius: 4px;
- font-size: 13px;
- font-weight: 600;
- padding: 4px 8px;
-}
-
-.budgetHealthy {
- background: #d1fae5;
- color: #047857;
-}
-
-.budgetWarning {
- background: #fef3c7;
- color: #b45309;
-}
-
-.budgetCritical {
- background: #fee4e2;
- color: #b42318;
-}
-
-.infoButton {
- align-items: center;
- background: none;
- border: none;
- cursor: pointer;
- display: inline-flex;
- padding: 2px;
-}
-
-.infoIcon {
- color: #4a5568;
- height: 18px;
- width: 18px;
-}
-
-.infoButton:hover .infoIcon {
- color: #137d60;
-}
diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx
deleted file mode 100644
index 7e10429d1..000000000
--- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.spec.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import {
- fireEvent,
- render,
- screen,
-} from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-
-import { WorkAppContext } from '../../contexts/WorkAppContext'
-import type { WorkAppContextModel } from '../../models'
-import type { BillingAccountDetails } from '../../services'
-import {
- useFetchBillingAccountDetails,
- useFetchBillingAccounts,
- useFetchProjectBillingAccount,
-} from '../../hooks'
-
-import ProjectBillingAccountExpiredNotice from './ProjectBillingAccountExpiredNotice'
-
-jest.mock('../../hooks', () => ({
- useFetchBillingAccountDetails: jest.fn(),
- useFetchBillingAccounts: jest.fn(),
- useFetchProjectBillingAccount: jest.fn(),
-}))
-
-jest.mock('../BillingAccountLineItemsModal', () => ({
- BillingAccountLineItemsModal: (props: {
- billingAccountDetails: BillingAccountDetails
- }): JSX.Element => (
-
- Billing account details for
- {' '}
- {props.billingAccountDetails.id}
-
- ),
-}))
-
-jest.mock('../../constants', () => {
- const constants = {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: true,
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: true,
- }
-
- Object.assign(globalThis, { mockWorkConstants: constants })
-
- return constants
-})
-
-jest.mock('~/libs/ui', () => ({
- IconOutline: {
- InformationCircleIcon: (): JSX.Element =>
info ,
- },
-}), {
- virtual: true,
-})
-
-const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction<
- typeof useFetchBillingAccountDetails
->
-const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction
-const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.MockedFunction<
- typeof useFetchProjectBillingAccount
->
-
-interface MockWorkConstants {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: boolean
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: boolean
-}
-
-/**
- * Returns the mutable constants mock installed for this spec.
- *
- * @returns Work app billing feature flag state used by mocked constants.
- */
-function getMockWorkConstants(): MockWorkConstants {
- return (globalThis as unknown as {
- mockWorkConstants: MockWorkConstants
- }).mockWorkConstants
-}
-
-const billingAccountDetails: BillingAccountDetails = {
- budget: 1000,
- consumedAmounts: [],
- consumedBudget: 0,
- id: 80001063,
- lockedAmounts: [],
- lockedBudget: 0,
- name: 'Test Project Engagement BA',
- totalBudgetRemaining: -25,
-}
-
-const defaultContextValue: WorkAppContextModel = {
- isAdmin: false,
- isAnonymous: false,
- isCopilot: false,
- isManager: false,
- isReadOnly: false,
- loginUserInfo: undefined,
- userRoles: [],
-}
-
-function renderNotice(
- contextValue: WorkAppContextModel = defaultContextValue,
- displayMemberPaymentDetailsToCopilots: boolean = false,
-): void {
- render(
-
-
-
-
- ,
- )
-}
-
-describe('ProjectBillingAccountExpiredNotice', () => {
- beforeEach(() => {
- jest.clearAllMocks()
-
- getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = true
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
- mockedUseFetchBillingAccounts.mockReturnValue({
- billingAccounts: [],
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails,
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchProjectBillingAccount.mockReturnValue({
- billingAccount: {
- active: true,
- id: '80001063',
- name: 'Test Project Engagement BA',
- status: 'ACTIVE',
- totalBudgetRemaining: -25,
- },
- isLoading: false,
- })
- })
-
- it('hides billing account budget and line-item details while billing details are disabled', () => {
- getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = false
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = false
-
- renderNotice()
-
- expect(screen.getByText(/Billing account:/))
- .toBeTruthy()
- expect(screen.queryByText('$1,025 / $1,000 spent'))
- .toBeNull()
- expect(screen.queryByRole('button', {
- name: 'View billing account details',
- }))
- .toBeNull()
- expect(screen.queryByRole('dialog'))
- .toBeNull()
- })
-
- it('keeps billing account details and line items available when remaining funds are insufficient', () => {
- renderNotice()
-
- expect(screen.getByText(/Billing account:/))
- .toBeTruthy()
- expect(screen.getByText(/Test Project Engagement BA/))
- .toBeTruthy()
- expect(screen.getByText(/80001063/))
- .toBeTruthy()
- expect(screen.getByText('$1,025 / $1,000 spent'))
- .toBeTruthy()
- expect(screen.getByText(/The billing account for this project has insufficient remaining funds,/))
- .toBeTruthy()
- expect(screen.getByRole('link', { name: 'click here to update' }))
- .toBeTruthy()
- fireEvent.click(screen.getByRole('button', {
- name: 'View billing account details',
- }))
-
- expect(screen.getByRole('dialog')
- .textContent)
- .toContain('Billing account details for 80001063')
- })
-
- it('shows member payments remaining instead of spent and total budget for copilots', () => {
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails: {
- ...billingAccountDetails,
- budget: 1000,
- markup: 0.25,
- totalBudgetRemaining: 250,
- },
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchProjectBillingAccount.mockReturnValue({
- billingAccount: {
- active: true,
- id: '80001063',
- markup: 0.25,
- name: 'Test Project Engagement BA',
- status: 'ACTIVE',
- totalBudgetRemaining: 250,
- },
- isLoading: false,
- })
-
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
- renderNotice({
- ...defaultContextValue,
- isCopilot: true,
- userRoles: ['copilot'],
- }, true)
-
- expect(screen.getByText('Member Payments Remaining: $200.00'))
- .toBeTruthy()
- expect(screen.queryByText('$750 / $1,000 spent'))
- .toBeNull()
- })
-
- it('hides the inline member payment balance and billing account modal access for copilots '
- + 'when disabled', () => {
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails: {
- ...billingAccountDetails,
- budget: 1000,
- markup: 0.25,
- totalBudgetRemaining: 250,
- },
- error: undefined,
- isError: false,
- isLoading: false,
- })
-
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
- renderNotice({
- ...defaultContextValue,
- isCopilot: true,
- userRoles: ['copilot'],
- })
-
- expect(screen.queryByText('Member Payments Remaining: $200.00'))
- .toBeNull()
- expect(screen.queryByText('$750 / $1,000 spent'))
- .toBeNull()
- expect(screen.queryByRole('button', {
- name: 'View billing account details',
- }))
- .toBeNull()
- expect(screen.queryByRole('dialog'))
- .toBeNull()
- expect(mockedUseFetchBillingAccountDetails)
- .toHaveBeenCalledWith(undefined)
- })
-})
diff --git a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx
index 12f2818a2..2a824973d 100644
--- a/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx
+++ b/src/apps/work/src/lib/components/ProjectBillingAccountExpiredNotice/ProjectBillingAccountExpiredNotice.tsx
@@ -1,46 +1,21 @@
import {
FC,
- useCallback,
- useContext,
useMemo,
- useState,
} from 'react'
import { Link } from 'react-router-dom'
-import { IconOutline } from '~/libs/ui'
-
-import {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED,
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED,
-} from '../../constants'
import {
- WorkAppContext,
-} from '../../contexts/WorkAppContext'
-import {
- useFetchBillingAccountDetails,
useFetchBillingAccounts,
useFetchProjectBillingAccount,
} from '../../hooks'
-import type {
- UseFetchBillingAccountDetailsResult,
- UseFetchBillingAccountsResult,
- UseFetchProjectBillingAccountResult,
-} from '../../hooks'
-import type {
- BillingAccountDetails,
-} from '../../services'
-import type { WorkAppContextModel } from '../../models'
-import type {
- BillingAccountBudgetInfo,
- CopilotMemberPaymentsBudgetInfo,
-} from '../../utils/project-billing-account.utils'
import {
- getBillingAccountBudgetInfo,
- getCopilotMemberPaymentsBudgetInfo,
getProjectBillingAccountChallengeIssue,
getProjectBillingAccountNoticeMessage,
} from '../../utils/project-billing-account.utils'
-import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal'
+import type {
+ UseFetchBillingAccountsResult,
+ UseFetchProjectBillingAccountResult,
+} from '../../hooks'
import styles from './ProjectBillingAccountExpiredNotice.module.scss'
@@ -48,27 +23,7 @@ interface ProjectBillingAccountExpiredNoticeProps {
billingAccountId?: number | string
billingAccountName?: string
canManageProject: boolean
- displayMemberPaymentDetailsToCopilots?: boolean
- projectId: string
-}
-
-type BillingAccountIssue = ReturnType
-
-interface BillingAccountDetailsContentProps {
- billingAccountId: string
- billingAccountName: string | undefined
- budgetDisplayContent: JSX.Element | undefined
- budgetInfo: BillingAccountBudgetInfo | undefined
- onOpenModal: () => void
- showDetailsButton: boolean
-}
-
-interface RenderBillingAccountContentParams {
- billingAccountDetailsContent: JSX.Element | undefined
- billingAccountModal: JSX.Element | undefined
- canManageProject: boolean
projectId: string
- visibleBillingAccountIssue: BillingAccountIssue
}
function normalizeOptionalString(value: unknown): string | undefined {
@@ -82,294 +37,16 @@ function normalizeOptionalString(value: unknown): string | undefined {
return normalizedValue || undefined
}
-function formatCurrency(amount: number, includeCents: boolean = false): string {
- return new Intl.NumberFormat('en-US', {
- currency: 'USD',
- maximumFractionDigits: includeCents ? 2 : 0,
- minimumFractionDigits: includeCents ? 2 : 0,
- style: 'currency',
- })
- .format(amount)
-}
-
-/**
- * Identifies copilot-only users whose project payment details are controlled
- * by the project-level display flag.
- *
- * @param workAppContext Current work app user context.
- * @returns `true` when the user is a copilot without admin or manager access.
- */
-function isRestrictedCopilot(workAppContext: WorkAppContextModel): boolean {
- return workAppContext.isCopilot
- && !workAppContext.isAdmin
- && !workAppContext.isManager
-}
-
-/**
- * Resolves whether the current user may see inline project payment amounts.
- *
- * @param workAppContext Current work app user context.
- * @param displayMemberPaymentDetailsToCopilots Project-level copilot display flag.
- * @returns `true` when payment amounts may be shown in the notice summary.
- */
-function canShowProjectPaymentAmounts(
- workAppContext: WorkAppContextModel,
- displayMemberPaymentDetailsToCopilots: boolean | undefined,
-): boolean {
- return !isRestrictedCopilot(workAppContext)
- || displayMemberPaymentDetailsToCopilots === true
-}
-
-/**
- * Resolves whether the copilot-safe member-payment balance should be shown.
- *
- * @param workAppContext Current work app user context.
- * @param showPaymentAmounts Whether payment amounts are enabled for this project.
- * @returns `true` when the user should see member payments remaining.
- */
-function canShowMemberPaymentsRemaining(
- workAppContext: WorkAppContextModel,
- showPaymentAmounts: boolean,
-): boolean {
- return showPaymentAmounts && isRestrictedCopilot(workAppContext)
-}
-
-interface VisibleBudgetInfoParams {
- copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined
- showMemberPaymentsRemaining: boolean
- showPaymentAmounts: boolean
- standardBudgetInfo: BillingAccountBudgetInfo | undefined
-}
-
-/**
- * Selects the budget payload that is safe for the current user to see.
- *
- * @param params Standard and copilot budget variants with display flags.
- * @returns The visible budget payload, or `undefined` when hidden.
- */
-function getVisibleBudgetInfo(
- params: VisibleBudgetInfoParams,
-): BillingAccountBudgetInfo | undefined {
- if (!params.showPaymentAmounts) {
- return undefined
- }
-
- return params.showMemberPaymentsRemaining
- ? params.copilotBudgetInfo
- : params.standardBudgetInfo
-}
-
-function getBudgetStatusClass(
- budgetInfo: BillingAccountBudgetInfo | undefined,
-): string {
- return budgetInfo
- ? styles[`budget${budgetInfo.status.charAt(0)
- .toUpperCase()}${budgetInfo.status.slice(1)}`]
- : ''
-}
-
-function renderBudgetDisplayContent(
- budgetInfo: BillingAccountBudgetInfo | undefined,
- copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined,
- showMemberPaymentsRemaining: boolean,
-): JSX.Element | undefined {
- if (!budgetInfo) {
- return undefined
- }
-
- if (showMemberPaymentsRemaining && copilotBudgetInfo) {
- return (
- <>
- Member Payments Remaining:
- {' '}
- {formatCurrency(copilotBudgetInfo.memberPaymentsRemaining, true)}
- >
- )
- }
-
- return (
- <>
- {formatCurrency(budgetInfo.spent)}
- {' / '}
- {formatCurrency(budgetInfo.totalBudget)}
- {' spent'}
- >
- )
-}
-
-/**
- * Hides budget-derived billing account notices when budget display is disabled.
- *
- * @param billingAccountIssue The billing account issue resolved for the project.
- * @returns The issue to display, or `undefined` when budget display is disabled.
- */
-function getVisibleBillingAccountIssue(
- billingAccountIssue: BillingAccountIssue,
-): BillingAccountIssue {
- const isInsufficientFundsNoticeHidden = !BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED
- && billingAccountIssue === 'insufficient-funds'
-
- return isInsufficientFundsNoticeHidden
- ? undefined
- : billingAccountIssue
-}
-
-/**
- * Renders the visible billing account label plus optional budget and details controls.
- *
- * @param props Billing account label, optional budget data, and modal open handler.
- * @returns The billing account details row for project pages.
- */
-const BillingAccountDetailsContent: FC = (
- props: BillingAccountDetailsContentProps,
-) => {
- const budgetStatusClass = getBudgetStatusClass(props.budgetInfo)
-
- return (
-
-
- Billing account:
- {' '}
- {props.billingAccountName || 'Unknown'}
- {' '}
- /
- {' '}
- {props.billingAccountId}
-
- {props.budgetInfo
- ? (
-
- {props.budgetDisplayContent}
-
- )
- : undefined}
- {props.showDetailsButton
- ? (
-
-
-
- )
- : undefined}
-
- )
-}
-
-/**
- * Renders the gated billing-account line-item modal.
- *
- * @param billingAccountDetails Billing account detail payload, if loaded.
- * @param isModalOpen Whether the details modal has been requested.
- * @param onClose Close handler passed to the modal.
- * @param projectId Project id used to build project-scoped line-item links.
- * @param showMemberPaymentsRemaining Whether the modal should hide markup and show copilot-safe payment values.
- * @returns The line-item modal, or `undefined` when the feature is hidden.
- */
-function renderBillingAccountModal(
- billingAccountDetails: BillingAccountDetails | undefined,
- isModalOpen: boolean,
- onClose: () => void,
- projectId: string,
- showMemberPaymentsRemaining: boolean,
-): JSX.Element | undefined {
- if (!BILLING_ACCOUNT_DETAILS_MODAL_ENABLED || !isModalOpen || !billingAccountDetails) {
- return undefined
- }
-
- return (
-
- )
-}
-
-/**
- * Renders the project billing-account issue notice when one should remain visible.
- *
- * @param params Project billing display state and rendered child content.
- * @returns The notice stack, normal details content, or an empty fragment.
- */
-function renderBillingAccountContent(params: RenderBillingAccountContentParams): JSX.Element {
- if (params.visibleBillingAccountIssue) {
- const noticeMessage = getProjectBillingAccountNoticeMessage(params.visibleBillingAccountIssue)
- const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, `
-
- return (
-
- {params.billingAccountDetailsContent}
-
- {params.canManageProject
- ? (
- <>
- {managedNoticeMessage}
-
- click here to update
-
- >
- )
- : (
- {noticeMessage}
- )}
-
- {params.billingAccountModal}
-
- )
- }
-
- if (!params.billingAccountDetailsContent) {
- return <>>
- }
-
- return (
- <>
- {params.billingAccountDetailsContent}
- {params.billingAccountModal}
- >
- )
-}
-
export const ProjectBillingAccountExpiredNotice: FC = (
props: ProjectBillingAccountExpiredNoticeProps,
) => {
- const [isModalOpen, setIsModalOpen] = useState(false)
- const workAppContext: WorkAppContextModel = useContext(WorkAppContext)
- const showPaymentAmounts: boolean = canShowProjectPaymentAmounts(
- workAppContext,
- props.displayMemberPaymentDetailsToCopilots,
- )
- const showMemberPaymentsRemaining: boolean = canShowMemberPaymentsRemaining(
- workAppContext,
- showPaymentAmounts,
- )
- const showMemberPaymentsRemainingInModal: boolean = isRestrictedCopilot(workAppContext)
- const showDetailsButton: boolean = BILLING_ACCOUNT_DETAILS_MODAL_ENABLED && showPaymentAmounts
-
const projectBillingAccountResult: UseFetchProjectBillingAccountResult = useFetchProjectBillingAccount(
props.projectId,
)
const billingAccountsResult: UseFetchBillingAccountsResult = useFetchBillingAccounts()
const billingAccount = projectBillingAccountResult.billingAccount
const normalizedBillingAccountId = normalizeOptionalString(props.billingAccountId)
- || normalizeOptionalString(billingAccount?.id)
- const shouldFetchBillingAccountDetails = (BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED && showPaymentAmounts)
- || showMemberPaymentsRemaining
- || (showDetailsButton && isModalOpen)
- const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails(
- shouldFetchBillingAccountDetails
- ? normalizedBillingAccountId
- : undefined,
- )
-
- const billingAccountDetailsData = billingAccountDetailsResult.billingAccountDetails
const normalizedBillingAccountName = normalizeOptionalString(props.billingAccountName)
-
const billingAccountNameFromLookup: string | undefined = useMemo(
(): string | undefined => {
if (!normalizedBillingAccountId) {
@@ -387,73 +64,48 @@ export const ProjectBillingAccountExpiredNotice: FC {
- if (!BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED || !billingAccountDetailsData) {
- return undefined
- }
-
- return getBillingAccountBudgetInfo(billingAccountDetailsData)
- }, [billingAccountDetailsData])
- const copilotBudgetInfo = useMemo(() => (
- showMemberPaymentsRemaining
- ? getCopilotMemberPaymentsBudgetInfo(billingAccountDetailsData)
- : undefined
- ), [billingAccountDetailsData, showMemberPaymentsRemaining])
- const budgetInfo = getVisibleBudgetInfo({
- copilotBudgetInfo,
- showMemberPaymentsRemaining,
- showPaymentAmounts,
- standardBudgetInfo,
- })
- const budgetDisplayContent = renderBudgetDisplayContent(
- budgetInfo,
- copilotBudgetInfo,
- showMemberPaymentsRemaining,
- )
+ if (billingAccountIssue) {
+ const noticeMessage = getProjectBillingAccountNoticeMessage(billingAccountIssue)
+ const managedNoticeMessage = `${noticeMessage.slice(0, -1)}, `
- const handleOpenModal = useCallback((): void => {
- setIsModalOpen(true)
- }, [])
+ return (
+
+ {props.canManageProject
+ ? (
+ <>
+ {managedNoticeMessage}
+
+ click here to update
+
+ >
+ )
+ : (
+ {noticeMessage}
+ )}
+
+ )
+ }
- const handleCloseModal = useCallback((): void => {
- setIsModalOpen(false)
- }, [])
+ if (!normalizedBillingAccountId) {
+ return <>>
+ }
- const billingAccountDetailsContent = normalizedBillingAccountId
- ? (
-
- )
- : undefined
- const billingAccountModal = renderBillingAccountModal(
- billingAccountDetailsData,
- showDetailsButton && isModalOpen,
- handleCloseModal,
- props.projectId,
- showMemberPaymentsRemainingInModal,
+ return (
+
+
+ Billing account:
+ {' '}
+ {billingAccountName || 'Unknown'}
+ {' '}
+ /
+ {' '}
+ {normalizedBillingAccountId}
+
+
)
-
- return renderBillingAccountContent({
- billingAccountDetailsContent,
- billingAccountModal,
- canManageProject: props.canManageProject,
- projectId: props.projectId,
- visibleBillingAccountIssue,
- })
}
export default ProjectBillingAccountExpiredNotice
diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
index 237864a54..2ed8a974d 100644
--- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
+++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.module.scss
@@ -61,10 +61,6 @@
margin-top: 10px;
}
-.billingAccount {
- margin-top: 10px;
-}
-
.actionLink {
color: $link-blue-dark;
font-size: 13px;
diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
index 7afa091b3..33174eb19 100644
--- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
+++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
@@ -1,6 +1,5 @@
-import type {
+import {
FC,
- ReactNode,
} from 'react'
import { Link } from 'react-router-dom'
import classNames from 'classnames'
@@ -17,7 +16,6 @@ import { ProjectStatus } from '../ProjectStatus'
import styles from './ProjectCard.module.scss'
interface ProjectCardProps {
- billingAccountContent?: ReactNode
canEdit?: boolean
project: Project
selected?: boolean
@@ -51,13 +49,6 @@ export const ProjectCard: FC = (props: ProjectCardProps) => {
{lastActivity}
- {props.billingAccountContent
- ? (
-
- {props.billingAccountContent}
-
- )
- : undefined}
{props.canEdit
? (
diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx
deleted file mode 100644
index fcd189e1f..000000000
--- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-/* 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 '../../models'
-import { useFetchProject } from '../../hooks'
-import { checkProjectAccess } from '../../utils'
-
-import {
- PROJECT_ACCESS_DENIED_MESSAGE,
- ProjectRouteAccessGuard,
-} from './ProjectRouteAccessGuard'
-
-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 }) => (
- {props.label}
- ),
- LoadingSpinner: () => Loading Spinner
,
-}), {
- virtual: true,
-})
-jest.mock('../../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('../../hooks', () => ({
- useFetchProject: jest.fn(),
-}))
-jest.mock('../../utils', () => ({
- checkProjectAccess: jest.fn(),
-}))
-
-const mockedUseFetchProject = useFetchProject as jest.Mock
-const mockedCheckProjectAccess = checkProjectAccess as jest.Mock
-
-const defaultContextValue: WorkAppContextModel = {
- isAdmin: false,
- isAnonymous: false,
- isCopilot: false,
- isManager: true,
- isReadOnly: false,
- loginUserInfo: {
- email: 'manager@example.com',
- exp: 0,
- handle: 'manager-user',
- iat: 0,
- roles: ['Project Manager'],
- userId: 12345,
- } as WorkAppContextModel['loginUserInfo'],
- userRoles: ['Project Manager'],
-}
-
-function renderGuard(
- route: string,
- contextValue: WorkAppContextModel = defaultContextValue,
-): void {
- const MockWorkAppContext = mockWorkAppContext
-
- render(
-
-
-
-
- Protected Project Users
-
- )}
- />
-
-
- ,
- )
-}
-
-describe('ProjectRouteAccessGuard', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- members: [{
- userId: 12345,
- }],
- },
- })
- mockedCheckProjectAccess.mockReturnValue(true)
- })
-
- it('renders the protected route when project access is allowed', () => {
- renderGuard('/projects/200/users')
-
- expect(mockedCheckProjectAccess)
- .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 }))
- expect(screen.getByText('Protected Project Users'))
- .toBeTruthy()
- })
-
- it('renders the protected route when cached project access survives a revalidation error', () => {
- mockedUseFetchProject.mockReturnValue({
- error: new Error('Network unavailable'),
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- members: [{
- userId: 12345,
- }],
- },
- })
- mockedCheckProjectAccess.mockReturnValue(true)
-
- renderGuard('/projects/200/users')
-
- expect(mockedCheckProjectAccess)
- .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 }))
- expect(screen.getByText('Protected Project Users'))
- .toBeTruthy()
- expect(screen.queryByText(PROJECT_ACCESS_DENIED_MESSAGE))
- .toBeNull()
- })
-
- it('shows loading while project access is resolving', () => {
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: true,
- mutate: jest.fn(),
- project: undefined,
- })
-
- renderGuard('/projects/200/users')
-
- expect(screen.getByRole('heading', { level: 1, name: 'Users' }))
- .toBeTruthy()
- expect(screen.getByText('Loading Spinner'))
- .toBeTruthy()
- expect(screen.queryByText('Protected Project Users'))
- .toBeNull()
- expect(mockedCheckProjectAccess)
- .not.toHaveBeenCalled()
- })
-
- it('shows the project access denial message when project access is rejected', () => {
- mockedCheckProjectAccess.mockReturnValue(false)
-
- renderGuard('/projects/200/users')
-
- expect(screen.getByRole('heading', { level: 1, name: 'Users' }))
- .toBeTruthy()
- const supportLink = screen.getByRole('link', { name: 'support@topcoder.com' })
-
- expect(supportLink.getAttribute('href'))
- .toBe('mailto:support@topcoder.com')
- expect(supportLink.closest('p')?.textContent)
- .toBe(PROJECT_ACCESS_DENIED_MESSAGE)
- expect(screen.queryByText('Protected Project Users'))
- .toBeNull()
- })
-
- it('shows the project access denial message when the project fetch fails', () => {
- mockedUseFetchProject.mockReturnValue({
- error: new Error('Forbidden'),
- isLoading: false,
- mutate: jest.fn(),
- project: undefined,
- })
- mockedCheckProjectAccess.mockReturnValue(false)
-
- renderGuard('/projects/200/users')
-
- expect(mockedCheckProjectAccess)
- .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, undefined)
- const supportLink = screen.getByRole('link', { name: 'support@topcoder.com' })
-
- expect(supportLink.getAttribute('href'))
- .toBe('mailto:support@topcoder.com')
- expect(supportLink.closest('p')?.textContent)
- .toBe(PROJECT_ACCESS_DENIED_MESSAGE)
- expect(screen.queryByText('Protected Project Users'))
- .toBeNull()
- })
-})
diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx
deleted file mode 100644
index 21dc756f4..000000000
--- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import {
- FC,
- PropsWithChildren,
- useContext,
-} from 'react'
-import { useParams } from 'react-router-dom'
-
-import { PageWrapper } from '~/apps/review/src/lib'
-
-import { WorkAppContext } from '../../contexts'
-import { useFetchProject } from '../../hooks'
-import { WorkAppContextModel } from '../../models'
-import { checkProjectAccess } from '../../utils'
-import { ErrorMessage } from '../ErrorMessage'
-import { LoadingSpinner } from '../LoadingSpinner'
-
-export const PROJECT_ACCESS_DENIED_MESSAGE
- = 'You don’t have access to this project. Please contact support@topcoder.com.'
-
-interface ProjectRouteAccessGuardProps extends PropsWithChildren {
- pageTitle: string
-}
-
-/**
- * Blocks project-scoped Work routes until the current user has project access.
- *
- * @param props child route content and fallback page title used while access is loading or denied.
- * @returns child route content when the project exists and the caller is an admin or project member.
- * @remarks Used by project workspace routes so unauthorized users do not mount pages that fetch project child data.
- * Access decisions use cached project data when available, so SWR revalidation errors do not block authorized users.
- * @throws Does not throw; missing project access renders the standard project access denial message.
- */
-export const ProjectRouteAccessGuard: FC = (
- props: ProjectRouteAccessGuardProps,
-) => {
- const params: Readonly<{ projectId?: string }> = useParams<'projectId'>()
- const projectId = params.projectId?.trim()
-
- const workAppContext = useContext(WorkAppContext) as WorkAppContextModel
- const projectResult = useFetchProject(projectId || undefined)
-
- if (!projectId) {
- return <>{props.children}>
- }
-
- if (projectResult.isLoading) {
- return (
-
-
-
- )
- }
-
- const hasProjectAccess = checkProjectAccess(
- workAppContext.userRoles,
- workAppContext.loginUserInfo?.userId,
- projectResult.project,
- )
-
- if (!hasProjectAccess) {
- return (
-
-
-
- )
- }
-
- return <>{props.children}>
-}
-
-export default ProjectRouteAccessGuard
diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts
deleted file mode 100644
index c9831bfb9..000000000
--- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './ProjectRouteAccessGuard'
diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss
index 0eac072c4..2210caa3e 100644
--- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss
+++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.module.scss
@@ -33,61 +33,6 @@
gap: 12px;
}
-.billingAccountCell {
- align-items: center;
- display: inline-flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.billingAccountLabel {
- color: $black-80;
-}
-
-.budgetDisplay {
- background: #f2f4f7;
- border-radius: 4px;
- color: $black-80;
- font-size: 12px;
- font-weight: 600;
- padding: 4px 8px;
- white-space: nowrap;
-}
-
-.budgetHealthy {
- background: #d1fae5;
- color: #047857;
-}
-
-.budgetWarning {
- background: #fef3c7;
- color: #b45309;
-}
-
-.budgetCritical {
- background: #fee4e2;
- color: #b42318;
-}
-
-.infoButton {
- align-items: center;
- background: none;
- border: none;
- cursor: pointer;
- display: inline-flex;
- padding: 2px;
-}
-
-.infoIcon {
- color: #4a5568;
- height: 18px;
- width: 18px;
-}
-
-.infoButton:hover .infoIcon {
- color: #137d60;
-}
-
.listView {
display: none;
gap: 12px;
diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx
index 8870ba76f..929fd0135 100644
--- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx
+++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.spec.tsx
@@ -1,56 +1,26 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import type { ReactNode } from 'react'
import {
- fireEvent,
render,
screen,
} from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
-import { WorkAppContext } from '../../contexts/WorkAppContext'
-import type {
- Project,
- WorkAppContextModel,
-} from '../../models'
-import type { BillingAccountDetails } from '../../services'
-import {
- useFetchBillingAccountDetails,
- useFetchBillingAccounts,
-} from '../../hooks'
+import type { Project } from '../../models'
+import { useFetchBillingAccounts } from '../../hooks'
import { ProjectsTable } from './ProjectsTable'
jest.mock('../../hooks', () => ({
- useFetchBillingAccountDetails: jest.fn(),
useFetchBillingAccounts: jest.fn(),
}))
-jest.mock('../BillingAccountLineItemsModal', () => ({
- BillingAccountLineItemsModal: (props: {
- billingAccountDetails: BillingAccountDetails
- }): JSX.Element => (
-
- Billing account details for
- {' '}
- {props.billingAccountDetails.id}
-
- ),
+jest.mock('../../constants', () => ({
+ PROJECT_STATUS: {
+ DRAFT: 'draft',
+ },
}))
-jest.mock('../../constants', () => {
- const constants = {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: true,
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: true,
- PROJECT_STATUS: {
- DRAFT: 'draft',
- },
- }
-
- Object.assign(globalThis, { mockWorkConstants: constants })
-
- return constants
-})
-
jest.mock('../../utils', () => ({
buildProjectChallengesPath: (projectId: string | number) => (
`/projects/${encodeURIComponent(String(projectId))}/challenges`
@@ -59,9 +29,6 @@ jest.mock('../../utils', () => ({
}))
jest.mock('~/libs/ui', () => ({
- IconOutline: {
- InformationCircleIcon: (): JSX.Element => info ,
- },
LoadingSpinner: () => Loading
,
Table: (props: {
columns: Array<{
@@ -90,48 +57,8 @@ jest.mock('../ProjectStatus', () => ({
ProjectStatus: () => Active ,
}))
-const mockedUseFetchBillingAccountDetails = useFetchBillingAccountDetails as jest.MockedFunction<
- typeof useFetchBillingAccountDetails
->
const mockedUseFetchBillingAccounts = useFetchBillingAccounts as jest.MockedFunction
-interface MockWorkConstants {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED: boolean
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED: boolean
-}
-
-/**
- * Returns the mutable constants mock installed for this spec.
- *
- * @returns Work app billing feature flag state used by mocked constants.
- */
-function getMockWorkConstants(): MockWorkConstants {
- return (globalThis as unknown as {
- mockWorkConstants: MockWorkConstants
- }).mockWorkConstants
-}
-
-const billingAccountDetails: BillingAccountDetails = {
- budget: 1000,
- consumedAmounts: [],
- consumedBudget: 225,
- id: 80001063,
- lockedAmounts: [],
- lockedBudget: 125,
- name: 'Access BA',
- totalBudgetRemaining: 650,
-}
-
-const defaultContextValue: WorkAppContextModel = {
- isAdmin: false,
- isAnonymous: false,
- isCopilot: false,
- isManager: false,
- isReadOnly: false,
- loginUserInfo: undefined,
- userRoles: [],
-}
-
describe('ProjectsTable', () => {
const invitedProject: Project = {
id: 100440,
@@ -149,43 +76,25 @@ describe('ProjectsTable', () => {
beforeEach(() => {
jest.clearAllMocks()
- getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = true
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
mockedUseFetchBillingAccounts.mockReturnValue({
billingAccounts: [],
error: undefined,
isError: false,
isLoading: false,
})
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails: undefined,
- error: undefined,
- isError: false,
- isLoading: false,
- })
})
- function renderTable(
- projects: Project[],
- contextValue: WorkAppContextModel = defaultContextValue,
- ): void {
+ it('links the project name and open action to the challenges route', () => {
render(
-
-
-
-
- ,
+
+
+ ,
)
- }
-
- it('links the project name and open action to the challenges route', () => {
- renderTable([invitedProject])
expect(screen.getByRole('link', { name: 'SK project1' })
.getAttribute('href'))
@@ -194,183 +103,4 @@ describe('ProjectsTable', () => {
.getAttribute('href'))
.toBe('/projects/100440/challenges')
})
-
- it('hides billing account spent totals and line-item details while billing details are disabled', () => {
- getMockWorkConstants().BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = false
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = false
-
- mockedUseFetchBillingAccounts.mockReturnValue({
- billingAccounts: [
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 125,
- name: 'Access BA',
- totalBudgetRemaining: 650,
- },
- ],
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails,
- error: undefined,
- isError: false,
- isLoading: false,
- })
-
- renderTable([{
- ...invitedProject,
- billingAccountId: 80001063,
- }])
-
- expect(screen.getAllByText('Access BA / 80001063').length)
- .toBeGreaterThan(0)
- expect(screen.queryByText('$350 / $1,000 spent'))
- .toBeNull()
- expect(screen.queryByRole('button', {
- name: 'View billing account details',
- }))
- .toBeNull()
- expect(screen.queryByRole('dialog'))
- .toBeNull()
- })
-
- it('shows billing account spent totals and line-item details when enabled', () => {
- mockedUseFetchBillingAccounts.mockReturnValue({
- billingAccounts: [
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 125,
- name: 'Access BA',
- totalBudgetRemaining: 650,
- },
- ],
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails,
- error: undefined,
- isError: false,
- isLoading: false,
- })
-
- renderTable([{
- ...invitedProject,
- billingAccountId: 80001063,
- }])
-
- expect(screen.getAllByText('$350 / $1,000 spent').length)
- .toBeGreaterThan(0)
- fireEvent.click(screen.getAllByRole('button', {
- name: 'View billing account details',
- })[0])
-
- expect(screen.getByRole('dialog')
- .textContent)
- .toContain('Billing account details for 80001063')
- expect(mockedUseFetchBillingAccountDetails)
- .toHaveBeenCalledWith('80001063')
- })
-
- it('shows member payments remaining for copilot project rows', () => {
- mockedUseFetchBillingAccounts.mockReturnValue({
- billingAccounts: [
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 525,
- markup: 0.25,
- name: 'Access BA',
- totalBudgetRemaining: 250,
- },
- ],
- error: undefined,
- isError: false,
- isLoading: false,
- })
-
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
- renderTable(
- [{
- ...invitedProject,
- billingAccountId: 80001063,
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
- }],
- {
- ...defaultContextValue,
- isCopilot: true,
- userRoles: ['copilot'],
- },
- )
-
- expect(screen.getAllByText('Member Payments Remaining: $200.00').length)
- .toBeGreaterThan(0)
- expect(screen.queryByText('$750 / $1,000 spent'))
- .toBeNull()
- })
-
- it('hides inline payment amounts and billing details for copilot project rows '
- + 'when disabled', () => {
- mockedUseFetchBillingAccounts.mockReturnValue({
- billingAccounts: [
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 525,
- markup: 0.25,
- name: 'Access BA',
- totalBudgetRemaining: 250,
- },
- ],
- error: undefined,
- isError: false,
- isLoading: false,
- })
- mockedUseFetchBillingAccountDetails.mockReturnValue({
- billingAccountDetails,
- error: undefined,
- isError: false,
- isLoading: false,
- })
-
- getMockWorkConstants().BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
- renderTable(
- [{
- ...invitedProject,
- billingAccountId: 80001063,
- details: {
- displayMemberPaymentDetailsToCopilots: false,
- },
- }],
- {
- ...defaultContextValue,
- isCopilot: true,
- userRoles: ['copilot'],
- },
- )
-
- expect(screen.queryByText('Member Payments Remaining: $200.00'))
- .toBeNull()
- expect(screen.queryByText('$750 / $1,000 spent'))
- .toBeNull()
- expect(screen.queryByRole('button', {
- name: 'View billing account details',
- }))
- .toBeNull()
- expect(screen.queryByRole('dialog'))
- .toBeNull()
- })
})
diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx
index 19dfe8896..f4eddd831 100644
--- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx
+++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx
@@ -1,52 +1,27 @@
import {
FC,
useCallback,
- useContext,
useMemo,
- useState,
} from 'react'
import { Link } from 'react-router-dom'
import { Sort } from '~/apps/admin/src/platform/gamification-admin/src/game-lib'
import {
- IconOutline,
LoadingSpinner,
Table,
TableColumn,
} from '~/libs/ui'
-import {
- BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED,
- BILLING_ACCOUNT_DETAILS_MODAL_ENABLED,
- PROJECT_STATUS,
-} from '../../constants'
-import { WorkAppContext } from '../../contexts/WorkAppContext'
-import {
- useFetchBillingAccountDetails,
- useFetchBillingAccounts,
-} from '../../hooks'
-import type {
- UseFetchBillingAccountDetailsResult,
- UseFetchBillingAccountsResult,
-} from '../../hooks'
+import { PROJECT_STATUS } from '../../constants'
import {
Project,
ProjectStatusValue,
- WorkAppContextModel,
} from '../../models'
-import type { BillingAccount } from '../../services'
+import { useFetchBillingAccounts } from '../../hooks'
+import type { UseFetchBillingAccountsResult } from '../../hooks'
import {
buildProjectChallengesPath,
} from '../../utils'
-import type {
- BillingAccountBudgetInfo,
- CopilotMemberPaymentsBudgetInfo,
-} from '../../utils/project-billing-account.utils'
-import {
- getBillingAccountBudgetInfo,
- getCopilotMemberPaymentsBudgetInfo,
-} from '../../utils/project-billing-account.utils'
-import { BillingAccountLineItemsModal } from '../BillingAccountLineItemsModal'
import { ProjectCard } from '../ProjectCard'
import { ProjectStatus } from '../ProjectStatus'
@@ -114,123 +89,15 @@ function getProjectPath(project: Project): string {
return buildProjectChallengesPath(project.id)
}
-/**
- * Converts optional id or label values into trimmed display strings.
- *
- * @param value Raw value from a project or billing-account payload.
- * @returns A trimmed string, or `undefined` when the value is blank.
- */
-function normalizeOptionalString(value: unknown): string | undefined {
- if (value === undefined || value === null) {
- return undefined
- }
-
- const normalizedValue = String(value)
- .trim()
-
- return normalizedValue || undefined
-}
-
-/**
- * Formats budget amounts for compact project-list display.
- *
- * @param amount Dollar amount to format.
- * @param includeCents Whether to include cents in the formatted value.
- * @returns USD currency text.
- */
-function formatCurrency(amount: number, includeCents: boolean = false): string {
- return new Intl.NumberFormat('en-US', {
- currency: 'USD',
- maximumFractionDigits: includeCents ? 2 : 0,
- minimumFractionDigits: includeCents ? 2 : 0,
- style: 'currency',
- })
- .format(amount)
-}
-
-/**
- * Identifies copilot-only users whose project payment details are controlled
- * by the project-level display flag.
- *
- * @param workAppContext Current work app user context.
- * @returns `true` when the user is a copilot without admin or manager access.
- */
-function isRestrictedCopilot(workAppContext: WorkAppContextModel): boolean {
- return workAppContext.isCopilot
- && !workAppContext.isAdmin
- && !workAppContext.isManager
-}
-
-/**
- * Resolves whether the current user may see inline payment amounts for one project row.
- *
- * @param workAppContext Current work app user context.
- * @param project Project row being rendered.
- * @returns `true` when payment amounts may be shown in the row summary.
- */
-function canShowProjectPaymentAmounts(
- workAppContext: WorkAppContextModel,
- project: Project,
-): boolean {
- return !isRestrictedCopilot(workAppContext)
- || project.details?.displayMemberPaymentDetailsToCopilots === true
-}
-
-function getBudgetStatusClass(
- budgetInfo: BillingAccountBudgetInfo | undefined,
- showMemberPaymentsRemaining: boolean,
-): string {
- return showMemberPaymentsRemaining && budgetInfo
- ? styles[`budget${budgetInfo.status.charAt(0)
- .toUpperCase()}${budgetInfo.status.slice(1)}`]
- : ''
-}
-
-function renderBudgetDisplayContent(
- budgetInfo: BillingAccountBudgetInfo | undefined,
- copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined,
- showMemberPaymentsRemaining: boolean,
-): JSX.Element | undefined {
- if (!budgetInfo) {
- return undefined
- }
-
- if (showMemberPaymentsRemaining && copilotBudgetInfo) {
- return (
- <>
- Member Payments Remaining:
- {' '}
- {formatCurrency(copilotBudgetInfo.memberPaymentsRemaining, true)}
- >
- )
- }
-
- return (
- <>
- {formatCurrency(budgetInfo.spent)}
- {' / '}
- {formatCurrency(budgetInfo.totalBudget)}
- {' spent'}
- >
- )
-}
-
-/**
- * Builds the billing-account label for a project row.
- *
- * @param project Project summary from the projects API.
- * @param billingAccount Matching billing-account summary from the billing API.
- * @returns Name/id text, falling back to `-` when no account is available.
- */
function getBillingAccountDisplay(
project: Project,
- billingAccount: BillingAccount | undefined,
+ billingAccountNames: Map,
): string {
- const billingAccountId = normalizeOptionalString(project.billingAccountId)
- || normalizeOptionalString(billingAccount?.id)
- || ''
- const billingAccountName = normalizeOptionalString(project.billingAccountName)
- || normalizeOptionalString(billingAccount?.name)
+ const billingAccountId = project.billingAccountId !== undefined && project.billingAccountId !== null
+ ? String(project.billingAccountId)
+ .trim()
+ : ''
+ const billingAccountName = (project.billingAccountName || '').trim() || billingAccountNames.get(billingAccountId)
if (!billingAccountId && !billingAccountName) {
return '-'
@@ -243,230 +110,6 @@ function getBillingAccountDisplay(
return `${billingAccountName || 'Unknown'} / ${billingAccountId}`
}
-interface ProjectBillingAccountCellProps {
- billingAccount: BillingAccount | undefined
- project: Project
- showPaymentAmounts: boolean
- showMemberPaymentsRemaining: boolean
- showMemberPaymentsRemainingInModal: boolean
-}
-
-interface ProjectBillingBudgetDisplayState {
- budgetInfo: BillingAccountBudgetInfo | undefined
- copilotBudgetInfo: CopilotMemberPaymentsBudgetInfo | undefined
-}
-
-interface RenderProjectBillingAccountModalParams {
- billingAccountDetails: UseFetchBillingAccountDetailsResult['billingAccountDetails']
- isModalOpen: boolean
- onClose: () => void
- projectId: Project['id']
- showMemberPaymentsRemaining: boolean
-}
-
-/**
- * Resolves whether the billing-account details hook should fetch modal data.
- *
- * @param isModalOpen Whether the row details modal has been opened.
- * @param showDetailsButton Whether this user may open billing-account details.
- * @returns `true` when the modal feature is enabled and data should be fetched.
- */
-function canFetchProjectBillingAccountDetails(
- isModalOpen: boolean,
- showDetailsButton: boolean,
-): boolean {
- return BILLING_ACCOUNT_DETAILS_MODAL_ENABLED
- && showDetailsButton
- && isModalOpen
-}
-
-/**
- * Selects the visible budget state for one project billing-account row.
- *
- * @param billingAccount Billing-account summary attached to the project row.
- * @param showPaymentAmounts Whether the current user may see inline payment amounts.
- * @param showMemberPaymentsRemaining Whether the current user needs the copilot-safe member payment view.
- * @returns Budget data to render, with copilot budget data included when needed.
- */
-function getProjectBillingBudgetDisplayState(
- billingAccount: BillingAccount | undefined,
- showPaymentAmounts: boolean,
- showMemberPaymentsRemaining: boolean,
-): ProjectBillingBudgetDisplayState {
- if (showMemberPaymentsRemaining) {
- const copilotBudgetInfo = getCopilotMemberPaymentsBudgetInfo(billingAccount)
-
- return {
- budgetInfo: copilotBudgetInfo,
- copilotBudgetInfo,
- }
- }
-
- if (!BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED || !showPaymentAmounts) {
- return {
- budgetInfo: undefined,
- copilotBudgetInfo: undefined,
- }
- }
-
- return {
- budgetInfo: getBillingAccountBudgetInfo(billingAccount),
- copilotBudgetInfo: undefined,
- }
-}
-
-/**
- * Renders the optional billing-account budget badge for one project row.
- *
- * @param budgetState Budget data selected for the current user.
- * @param showMemberPaymentsRemaining Whether the badge should render copilot-safe copy.
- * @returns A budget badge element, or `undefined` when no budget should be shown.
- */
-function renderProjectBillingAccountBudget(
- budgetState: ProjectBillingBudgetDisplayState,
- showMemberPaymentsRemaining: boolean,
-): JSX.Element | undefined {
- if (!budgetState.budgetInfo) {
- return undefined
- }
-
- const budgetStatusClass = getBudgetStatusClass(
- budgetState.budgetInfo,
- showMemberPaymentsRemaining,
- )
- const budgetDisplayClass = budgetStatusClass
- ? `${styles.budgetDisplay} ${budgetStatusClass}`
- : styles.budgetDisplay
-
- return (
-
- {renderBudgetDisplayContent(
- budgetState.budgetInfo,
- budgetState.copilotBudgetInfo,
- showMemberPaymentsRemaining,
- )}
-
- )
-}
-
-/**
- * Renders the billing-account line-item details button when the feature is enabled.
- *
- * @param billingAccountId Normalized billing-account id for the current row.
- * @param onOpen Open handler for the row modal.
- * @param showDetailsButton Whether this user may open billing-account details.
- * @returns The details button, or `undefined` when unavailable.
- */
-function renderProjectBillingAccountDetailsButton(
- billingAccountId: string | undefined,
- onOpen: () => void,
- showDetailsButton: boolean,
-): JSX.Element | undefined {
- if (!BILLING_ACCOUNT_DETAILS_MODAL_ENABLED || !showDetailsButton || !billingAccountId) {
- return undefined
- }
-
- return (
-
-
-
- )
-}
-
-/**
- * Renders the row-level billing-account line items modal after data is loaded.
- *
- * @param params Modal visibility, project context, and loaded billing-account details.
- * @returns The modal element, or `undefined` while hidden or unloaded.
- */
-function renderProjectBillingAccountModal(
- params: RenderProjectBillingAccountModalParams,
-): JSX.Element | undefined {
- if (
- !BILLING_ACCOUNT_DETAILS_MODAL_ENABLED
- || !params.isModalOpen
- || !params.billingAccountDetails
- ) {
- return undefined
- }
-
- return (
-
- )
-}
-
-/**
- * Renders a project billing-account summary and lazily loads the line-item
- * modal only after the details button is opened.
- *
- * @param props Project row and matching billing-account summary from the list API.
- * @returns Billing-account label, with budget and line-item details shown only when enabled.
- */
-const ProjectBillingAccountCell: FC = (
- props: ProjectBillingAccountCellProps,
-) => {
- const [isModalOpen, setIsModalOpen] = useState(false)
- const normalizedBillingAccountId = normalizeOptionalString(props.project.billingAccountId)
- || normalizeOptionalString(props.billingAccount?.id)
- const showDetailsButton = props.showPaymentAmounts
- const billingAccountDetailsResult: UseFetchBillingAccountDetailsResult = useFetchBillingAccountDetails(
- canFetchProjectBillingAccountDetails(isModalOpen, showDetailsButton)
- ? normalizedBillingAccountId
- : undefined,
- )
- const budgetDisplayState = getProjectBillingBudgetDisplayState(
- props.billingAccount,
- props.showPaymentAmounts,
- props.showMemberPaymentsRemaining,
- )
- const billingAccountBudget = renderProjectBillingAccountBudget(
- budgetDisplayState,
- props.showMemberPaymentsRemaining,
- )
-
- const handleOpenModal = useCallback((): void => {
- setIsModalOpen(true)
- }, [])
-
- const handleCloseModal = useCallback((): void => {
- setIsModalOpen(false)
- }, [])
- const billingAccountDetailsButton = renderProjectBillingAccountDetailsButton(
- normalizedBillingAccountId,
- handleOpenModal,
- showDetailsButton,
- )
- const billingAccountModal = renderProjectBillingAccountModal({
- billingAccountDetails: billingAccountDetailsResult.billingAccountDetails,
- isModalOpen: showDetailsButton && isModalOpen,
- onClose: handleCloseModal,
- projectId: props.project.id,
- showMemberPaymentsRemaining: props.showMemberPaymentsRemainingInModal,
- })
-
- return (
-
-
- {getBillingAccountDisplay(props.project, props.billingAccount)}
-
- {billingAccountBudget}
- {billingAccountDetailsButton}
- {billingAccountModal}
-
- )
-}
-
export const ProjectsTable: FC = (props: ProjectsTableProps) => {
const canEditProject = props.canEditProject || NOOP_CAN_EDIT_PROJECT
const projects: Project[] = props.projects
@@ -474,15 +117,14 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
const onSort: (fieldName: string) => void = props.onSort
const sortBy: string = props.sortBy
const sortOrder: SortOrder = props.sortOrder
- const workAppContext: WorkAppContextModel = useContext(WorkAppContext)
const {
billingAccounts,
}: UseFetchBillingAccountsResult = useFetchBillingAccounts()
- const billingAccountsById = useMemo(
+ const billingAccountNames = useMemo(
() => new Map(
billingAccounts.map(account => ([
String(account.id),
- account,
+ account.name,
])),
),
[billingAccounts],
@@ -523,16 +165,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
{
isSortable: false,
label: 'Billing Account',
- renderer: (project: Project) => (
-
- ),
+ renderer: (project: Project) => <>{getBillingAccountDisplay(project, billingAccountNames)}>,
type: 'element',
},
{
@@ -561,7 +194,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
type: 'action',
},
],
- [billingAccountsById, canEditProject, workAppContext],
+ [billingAccountNames, canEditProject],
)
const forceSort = useMemo(
@@ -614,16 +247,6 @@ export const ProjectsTable: FC = (props: ProjectsTableProps)
{projects.map(project => (
- )}
canEdit={canEditProject(project)}
key={String(project.id)}
project={project}
diff --git a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss b/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss
deleted file mode 100644
index 77324a80a..000000000
--- a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.module.scss
+++ /dev/null
@@ -1,91 +0,0 @@
-@import '@libs/ui/styles/includes';
-
-.overlay {
- align-items: center;
- background: rgb(44 44 44 / 45%);
- bottom: 0;
- display: flex;
- justify-content: center;
- left: 0;
- padding: 20px;
- position: fixed;
- right: 0;
- top: 0;
- z-index: 1000;
-}
-
-.container {
- background: $tc-white;
- border-radius: 8px;
- max-width: 980px;
- width: 100%;
-}
-
-.header {
- border-bottom: 1px solid $black-10;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 18px 20px 12px;
-}
-
-.title {
- color: $black-80;
- font-size: 18px;
- margin: 0;
-}
-
-.submissionId {
- color: $black-60;
- font-family: monospace;
- font-size: 12px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.body {
- max-height: 76vh;
- overflow: hidden;
- padding: 16px 20px 20px;
-}
-
-.loadingWrap {
- display: flex;
- justify-content: center;
- min-height: 160px;
-}
-
-.message {
- color: $black-60;
- margin: 0;
- text-align: center;
-}
-
-.logsTextarea {
- background: #101820;
- border: 1px solid $black-20;
- border-radius: 6px;
- color: #f7f8fa;
- display: block;
- font-family: monospace;
- font-size: 12px;
- line-height: 1.5;
- min-height: 420px;
- outline: none;
- padding: 12px;
- resize: vertical;
- white-space: pre;
- width: 100%;
-}
-
-@media (max-width: 768px) {
- .body {
- max-height: 72vh;
- padding: 12px 14px 16px;
- }
-
- .logsTextarea {
- min-height: 320px;
- }
-}
diff --git a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx b/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx
deleted file mode 100644
index 67784537d..000000000
--- a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/SubmissionRunnerLogsModal.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import {
- FC,
- MouseEvent,
- useCallback,
- useMemo,
-} from 'react'
-
-import { LoadingSpinner } from '~/libs/ui'
-
-import { useFetchSubmissionRunnerLogs } from '../../hooks'
-import {
- MarathonMatchRunnerLogEvent,
- MarathonMatchRunnerLogs,
-} from '../../models'
-
-import styles from './SubmissionRunnerLogsModal.module.scss'
-
-export interface SubmissionRunnerLogsModalProps {
- onClose: () => void
- submissionId: string
-}
-
-/**
- * Formats a CloudWatch event timestamp for display beside a log message.
- * @param timestamp CloudWatch event timestamp in milliseconds.
- * @returns ISO timestamp text, or an empty string when the timestamp is missing or invalid.
- * Used by `formatRunnerLogsText` to preserve event ordering with readable dates.
- */
-function formatEventTimestamp(timestamp: number | undefined): string {
- if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) {
- return ''
- }
-
- return new Date(timestamp)
- .toISOString()
-}
-
-/**
- * Formats marathon match runner log events into a single textarea value.
- * @param runnerLogs Runner-log API response containing CloudWatch events and warnings.
- * @returns Monospace log text for display in the modal.
- * Used by `SubmissionRunnerLogsModal` after log data has loaded.
- */
-function formatRunnerLogsText(
- runnerLogs: MarathonMatchRunnerLogs | undefined,
-): string {
- if (!runnerLogs) {
- return ''
- }
-
- if (runnerLogs.warning) {
- return runnerLogs.warning
- }
-
- if (!runnerLogs.events.length) {
- return 'No runner logs found.'
- }
-
- return runnerLogs.events
- .map((event: MarathonMatchRunnerLogEvent) => {
- const timestamp = formatEventTimestamp(event.timestamp)
- const message = event.message || ''
-
- return timestamp
- ? `[${timestamp}] ${message}`
- : message
- })
- .join('\n')
-}
-
-/**
- * Renders a modal containing ECS runner logs for a marathon match submission.
- * @param props Submission id to fetch plus a close handler for the overlay.
- * @returns A modal dialog with loading, error, or read-only log text states.
- * @throws Does not throw; API failures are shown inside the modal body.
- * Used by `SubmissionsSection` when a manager or copilot opens the runner-log action.
- */
-export const SubmissionRunnerLogsModal: FC
= (
- props: SubmissionRunnerLogsModalProps,
-) => {
- const runnerLogsResult = useFetchSubmissionRunnerLogs(props.submissionId)
- const logsText = useMemo(
- () => formatRunnerLogsText(runnerLogsResult.runnerLogs),
- [runnerLogsResult.runnerLogs],
- )
-
- const handleContainerClick = useCallback((event: MouseEvent): void => {
- event.stopPropagation()
- }, [])
-
- return (
-
-
-
- Runner Logs
-
- {props.submissionId}
-
-
-
-
- {runnerLogsResult.isLoading
- ? (
-
-
-
- )
- : undefined}
-
- {!runnerLogsResult.isLoading && runnerLogsResult.isError
- ? (
-
- Unable to load runner logs.
-
- )
- : undefined}
-
- {!runnerLogsResult.isLoading && !runnerLogsResult.isError
- ? (
-
- )
- : undefined}
-
-
-
- )
-}
-
-export default SubmissionRunnerLogsModal
diff --git a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/index.ts b/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/index.ts
deleted file mode 100644
index 13f3ef913..000000000
--- a/src/apps/work/src/lib/components/SubmissionRunnerLogsModal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './SubmissionRunnerLogsModal'
diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss
index deffba997..fbb797c1b 100644
--- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss
+++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.module.scss
@@ -66,7 +66,7 @@
align-items: center;
background: transparent;
border: 0;
- color: #137d60;
+ color: #0d61bf;
cursor: pointer;
display: inline-flex;
height: 28px;
@@ -89,7 +89,7 @@
&:hover,
&:focus-visible {
- color: #0d664f;
+ color: #074f9c;
}
}
diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx
index df538da3d..c264c8dbb 100644
--- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx
+++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx
@@ -32,11 +32,6 @@ jest.mock('../../assets/icons/IconSquareDownload.svg', () => ({
}), {
virtual: true,
})
-jest.mock('../../assets/icons/IconRunnerLogs.svg', () => ({
- ReactComponent: () => ,
-}), {
- virtual: true,
-})
jest.mock('./SubmissionsTable.module.scss', () => new Proxy({}, {
get: (_target, property) => String(property),
}))
@@ -147,61 +142,4 @@ describe('SubmissionsTable', () => {
expect(screen.getByRole('button', { name: 'Download submission artifacts' }))
.toBeTruthy()
})
-
- it('renders and triggers the runner logs action when enabled', () => {
- const onOpenRunnerLogs = jest.fn()
-
- render(
- ,
- )
-
- screen.getByRole('button', { name: 'View runner logs' })
- .click()
-
- expect(onOpenRunnerLogs)
- .toHaveBeenCalledWith('submission-1')
- })
-
- it('hides the runner logs action when disabled', () => {
- render(
- ,
- )
-
- expect(screen.queryByRole('button', { name: 'View runner logs' }))
- .toBeNull()
- })
})
diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx
index 46b65da1f..2d89b244f 100644
--- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx
+++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx
@@ -8,7 +8,6 @@ import { LoadingSpinner } from '~/libs/ui'
import { COMMUNITY_APP_URL, REVIEW_APP_URL } from '../../constants'
import { ReactComponent as IconDownloadArtifacts } from '../../assets/icons/IconDownloadArtifacts.svg'
-import { ReactComponent as IconRunnerLogs } from '../../assets/icons/IconRunnerLogs.svg'
import { ReactComponent as IconSquareDownload } from '../../assets/icons/IconSquareDownload.svg'
import { Submission } from '../../models'
import {
@@ -38,13 +37,11 @@ interface ColumnConfig {
interface SubmissionsTableProps {
canDownloadSubmissions: boolean
- canViewRunnerLogs?: boolean
challengeId: string
isLoading?: boolean
isLoadingMembers?: boolean
onDownloadSubmission: (submissionId: string) => void
onOpenArtifacts: (submissionId: string) => void
- onOpenRunnerLogs?: (submissionId: string) => void
onSort: (fieldName: SubmissionSortBy) => void
sortBy: SubmissionSortBy
sortOrder: SortOrder
@@ -174,15 +171,6 @@ export const SubmissionsTable: FC = (
props.onOpenArtifacts(submissionId)
}
- function handleRunnerLogsClick(event: MouseEvent): void {
- const submissionId = event.currentTarget.dataset.submissionId
- if (!submissionId || !props.onOpenRunnerLogs) {
- return
- }
-
- props.onOpenRunnerLogs(submissionId)
- }
-
return (
@@ -319,21 +307,6 @@ export const SubmissionsTable: FC = (
- {props.canViewRunnerLogs
- ? (
-
-
-
- )
- : undefined}
-
diff --git a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx
deleted file mode 100644
index 171f2bda8..000000000
--- a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.spec.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import { FC } from 'react'
-import {
- render,
- waitFor,
-} from '@testing-library/react'
-import {
- FormProvider,
- useForm,
-} from 'react-hook-form'
-
-import { fetchGroups } from '../../../services'
-
-import { FormGroupsSelect } from './FormGroupsSelect'
-
-let latestAsyncSelectProps: Record | undefined
-
-const fetchGroupsMock = fetchGroups as jest.Mock
-
-jest.mock('react-select/async', () => ({
- __esModule: true,
- default: (props: Record) => {
- latestAsyncSelectProps = props
-
- return false
- },
-}))
-
-jest.mock('react-select/async-creatable', () => ({
- __esModule: true,
- default: (props: Record) => {
- latestAsyncSelectProps = props
-
- return false
- },
-}))
-
-jest.mock('../../../services', () => ({
- createGroup: jest.fn(),
- fetchGroups: jest.fn(),
-}))
-
-interface TestFormValues {
- groups: string[]
-}
-
-const TestHarness: FC = () => {
- const formMethods = useForm({
- defaultValues: {
- groups: ['db53f15b-2d61-4d9e-8263-8cfc3f98337e'],
- },
- })
-
- return (
-
-
-
- )
-}
-
-describe('FormGroupsSelect', () => {
- beforeEach(() => {
- latestAsyncSelectProps = undefined
- jest.clearAllMocks()
- })
-
- it('hydrates saved group ids from the accessible groups list before falling back to raw ids', async () => {
- fetchGroupsMock.mockResolvedValue([
- {
- id: 'db53f15b-2d61-4d9e-8263-8cfc3f98337e',
- name: 'Hide Challenges',
- },
- ])
-
- render(
- ,
- )
-
- await waitFor(() => {
- expect(fetchGroupsMock)
- .toHaveBeenCalledWith()
- })
-
- await waitFor(() => {
- expect(latestAsyncSelectProps?.value)
- .toEqual([
- {
- label: 'Hide Challenges',
- value: 'db53f15b-2d61-4d9e-8263-8cfc3f98337e',
- },
- ])
- })
- })
-})
diff --git a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx
index 1454c1a2f..1bddbe35c 100644
--- a/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx
+++ b/src/apps/work/src/lib/components/form/FormGroupsSelect/FormGroupsSelect.tsx
@@ -23,6 +23,7 @@ import {
} from '../../../models'
import {
createGroup,
+ fetchGroupById,
fetchGroups,
} from '../../../services'
import { FormFieldWrapper } from '../FormFieldWrapper'
@@ -177,37 +178,26 @@ export const FormGroupsSelect: FC = (props: FormGroupsSel
let isMounted = true
- fetchGroups()
- .then(accessibleGroups => {
- if (!isMounted) {
- return
- }
+ Promise.all(missingGroupIds.map(async groupId => {
+ try {
+ const group = await fetchGroupById(groupId)
- const accessibleGroupsById = new Map(
- accessibleGroups.map(group => [group.id, toOption(group)]),
- )
- const resolvedOptions = missingGroupIds.map(groupId => (
- accessibleGroupsById.get(groupId) || {
- label: groupId,
- value: groupId,
- }
- ))
-
- setOptionCache(previousOptions => mergeOptions(previousOptions, resolvedOptions))
- })
- .catch(() => {
+ return toOption(group)
+ } catch {
+ return {
+ label: groupId,
+ value: groupId,
+ }
+ }
+ }))
+ .then(resolvedOptions => {
if (!isMounted) {
return
}
- setOptionCache(previousOptions => mergeOptions(
- previousOptions,
- missingGroupIds.map(groupId => ({
- label: groupId,
- value: groupId,
- })),
- ))
+ setOptionCache(previousOptions => mergeOptions(previousOptions, resolvedOptions))
})
+ .catch(() => undefined)
return () => {
isMounted = false
diff --git a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss
index 986652af1..68a162210 100644
--- a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss
+++ b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss
@@ -1,15 +1,7 @@
.editor {
- height: 420px;
min-height: 280px;
- overflow: hidden;
- resize: vertical;
:global(.EasyMDEContainer) {
min-height: 280px;
}
}
-
-.editor .markdownEditor {
- height: 100%;
- min-height: 280px;
-}
diff --git a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.tsx b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.tsx
index d419a0ecd..4e8273ff9 100644
--- a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.tsx
+++ b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.tsx
@@ -24,7 +24,6 @@ export interface FormMarkdownEditorProps {
name: string
onBlur?: () => void
placeholder?: string
- readOnly?: boolean
required?: boolean
uploadCategory?: string
}
@@ -38,7 +37,6 @@ export const FormMarkdownEditor: FC = (props: FormMarkd
const name = props.name
const onBlur = props.onBlur
const placeholder = props.placeholder
- const readOnly = props.readOnly
const required = props.required
const uploadCategory = props.uploadCategory
@@ -86,7 +84,6 @@ export const FormMarkdownEditor: FC = (props: FormMarkd
= (props: FormMarkd
onBlur={handleBlur}
onChange={field.onChange}
placeholder={placeholder}
- readOnly={readOnly}
showBorder
uploadAttachment={handleUploadAttachment}
uploadCategory={uploadCategory}
diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts
index 519666342..d79cc006d 100644
--- a/src/apps/work/src/lib/components/index.ts
+++ b/src/apps/work/src/lib/components/index.ts
@@ -1,4 +1,3 @@
-export * from './BillingAccountLineItemsModal'
export * from './ChallengesFilter'
export * from './ChallengesTable'
export * from './EngagementCard'
@@ -22,7 +21,6 @@ export * from './Pagination'
export * from './ProjectCard'
export * from './ProjectBillingAccountExpiredNotice'
export * from './ProjectListTabs'
-export * from './ProjectRouteAccessGuard'
export * from './ProjectStatus'
export * from './PaymentFormModal'
export * from './PaymentHistoryModal'
@@ -32,7 +30,6 @@ export * from './ResourceAddModal'
export * from './ResourcesTable'
export * from './TerminateAssignmentModal'
export * from './SubmissionHistoryModal'
-export * from './SubmissionRunnerLogsModal'
export * from './SubmissionsTable'
export * from './TaasProjectCard'
export * from './TaasProjectsFilter'
diff --git a/src/apps/work/src/lib/constants.ts b/src/apps/work/src/lib/constants.ts
index f89755041..42837dddb 100644
--- a/src/apps/work/src/lib/constants.ts
+++ b/src/apps/work/src/lib/constants.ts
@@ -2,12 +2,6 @@ import { EnvironmentConfig } from '~/config'
export const WORK_APP_BODY_CLASS = 'work-app'
-export const BILLING_ACCOUNT_BUDGET_DISPLAY_ENABLED = true
-
-export const BILLING_ACCOUNT_DETAILS_MODAL_ENABLED = true
-
-export const BILLING_ACCOUNT_MEMBER_PAYMENT_DETAILS_ENABLED = true
-
const DEFAULT_CREATE_FORUM_TYPE_IDS = [
'927abff4-7af9-4145-8ba1-577c16e64e2e',
'dc876fa4-ef2d-4eee-b701-b555fcc6544c',
@@ -32,12 +26,6 @@ export const CHALLENGE_STATUS = {
NEW: 'NEW',
} as const
-export const CHALLENGE_APPROVAL_STATUS = {
- APPROVED: 'APPROVED',
- PENDING_APPROVAL: 'PENDING_APPROVAL',
- REJECTED: 'REJECTED',
-} as const
-
export const PAGE_SIZE = 10
export const PAGINATION_PER_PAGE_OPTIONS: ReadonlyArray = [5, 10, 20, 25, 50]
@@ -234,11 +222,6 @@ export const SUBMISSIONS_API_URL = process.env.REACT_APP_SUBMISSIONS_API_URL
|| process.env.SUBMISSIONS_API_URL
|| `${EnvironmentConfig.API.V6}/submissions`
-export const MARATHON_MATCH_API_URL = process.env.REACT_APP_MARATHON_MATCH_API_URL
- || process.env.MARATHON_MATCH_API_URL
- || EnvironmentConfig.MARATHON_MATCH_API
- || `${EnvironmentConfig.API.V6}/marathon-match`
-
export const COMMUNITY_APP_URL = process.env.REACT_APP_COMMUNITY_APP_URL
|| process.env.COMMUNITY_APP_URL
|| EnvironmentConfig.COMMUNITY_APP_URL
diff --git a/src/apps/work/src/lib/hooks/index.ts b/src/apps/work/src/lib/hooks/index.ts
index 6a2617a0b..53ac1fe47 100644
--- a/src/apps/work/src/lib/hooks/index.ts
+++ b/src/apps/work/src/lib/hooks/index.ts
@@ -13,7 +13,6 @@ export * from './useFetchGroups'
export * from './useFetchChallengeTracks'
export * from './useFetchChallengeTypes'
export * from './useFetchBillingAccounts'
-export * from './useFetchBillingAccountDetails'
export * from './useFetchEngagement'
export * from './useFetchEngagements'
export * from './useFetchProject'
@@ -27,7 +26,6 @@ export * from './useFetchResources'
export * from './useFetchResourceRoles'
export * from './useFetchReviews'
export * from './useFetchSubmissionArtifacts'
-export * from './useFetchSubmissionRunnerLogs'
export * from './useFetchSubmissions'
export * from './useFetchSubmissionVersions'
export * from './useFetchTaasProject'
diff --git a/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts b/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts
deleted file mode 100644
index 2adf6c007..000000000
--- a/src/apps/work/src/lib/hooks/useFetchBillingAccountDetails.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import useSWR, { SWRResponse } from 'swr'
-
-import {
- BillingAccountDetails,
- fetchBillingAccountById,
-} from '../services'
-
-export interface UseFetchBillingAccountDetailsResult {
- billingAccountDetails: BillingAccountDetails | undefined
- error: Error | undefined
- isError: boolean
- isLoading: boolean
-}
-
-function normalizeId(billingAccountId: string | number | undefined): string {
- if (billingAccountId === undefined || billingAccountId === null) {
- return ''
- }
-
- return String(billingAccountId)
- .trim()
-}
-
-/**
- * Fetches detailed billing account information including locked and consumed external entries.
- *
- * @param billingAccountId The billing account identifier to fetch.
- * @returns Billing account details with budget totals and typed external-entry line item payloads.
- */
-export function useFetchBillingAccountDetails(
- billingAccountId: string | number | undefined,
-): UseFetchBillingAccountDetailsResult {
- const normalizedId = normalizeId(billingAccountId)
-
- const swrKey = normalizedId
- ? ['work/billing-account-details', normalizedId]
- : undefined
-
- const {
- data,
- error,
- }: SWRResponse
- = useSWR(
- swrKey,
- () => fetchBillingAccountById(normalizedId),
- {
- errorRetryCount: 2,
- shouldRetryOnError: true,
- },
- )
-
- return {
- billingAccountDetails: data,
- error,
- isError: !!error,
- isLoading: !!normalizedId && !data && !error,
- }
-}
diff --git a/src/apps/work/src/lib/hooks/useFetchChallenge.spec.tsx b/src/apps/work/src/lib/hooks/useFetchChallenge.spec.tsx
index c5be34a90..8434df7f7 100644
--- a/src/apps/work/src/lib/hooks/useFetchChallenge.spec.tsx
+++ b/src/apps/work/src/lib/hooks/useFetchChallenge.spec.tsx
@@ -1,7 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import { PropsWithChildren } from 'react'
import {
- fireEvent,
render,
screen,
waitFor,
@@ -37,24 +36,10 @@ function createWrapper(cache: Map) {
}
const TestComponent = (): JSX.Element => {
- const {
- challenge,
- error,
- mutate,
- }: UseFetchChallengeResult = useFetchChallenge('challenge-1')
-
- function handleRefresh(): void {
- mutate()
- .catch(() => undefined)
- }
+ const { challenge }: UseFetchChallengeResult = useFetchChallenge('challenge-1')
return (
- <>
-
- Refresh
-
- {error?.message || challenge?.name || 'Loading'}
- >
+ {challenge?.name || 'Loading'}
)
}
@@ -100,34 +85,4 @@ describe('useFetchChallenge', () => {
.toHaveBeenCalledTimes(2)
})
})
-
- it('does not expose cached challenge details after a fresh fetch errors', async () => {
- const cache = new Map()
- const wrapper = createWrapper(cache)
-
- mockedFetchChallenge
- .mockResolvedValueOnce({
- id: 'challenge-1',
- name: 'Restricted challenge',
- } as never)
- .mockRejectedValueOnce(new Error('Forbidden'))
-
- render( , {
- wrapper,
- })
-
- await waitFor(() => {
- expect(screen.getByText('Restricted challenge'))
- .toBeTruthy()
- })
-
- fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
-
- await waitFor(() => {
- expect(screen.queryByText('Restricted challenge'))
- .toBeNull()
- })
- expect(screen.getByText('Forbidden'))
- .toBeTruthy()
- })
})
diff --git a/src/apps/work/src/lib/hooks/useFetchChallenge.ts b/src/apps/work/src/lib/hooks/useFetchChallenge.ts
index 113f9a657..c7f782812 100644
--- a/src/apps/work/src/lib/hooks/useFetchChallenge.ts
+++ b/src/apps/work/src/lib/hooks/useFetchChallenge.ts
@@ -40,9 +40,7 @@ export function useFetchChallenge(challengeId?: string): UseFetchChallengeResult
)
return {
- challenge: error
- ? undefined
- : data,
+ challenge: data,
error,
isError: !!error,
isLoading: !!challengeId && !data && !error,
diff --git a/src/apps/work/src/lib/hooks/useFetchChallenges.spec.tsx b/src/apps/work/src/lib/hooks/useFetchChallenges.spec.tsx
index 4589d63ff..51d8a225e 100644
--- a/src/apps/work/src/lib/hooks/useFetchChallenges.spec.tsx
+++ b/src/apps/work/src/lib/hooks/useFetchChallenges.spec.tsx
@@ -1,7 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import { PropsWithChildren } from 'react'
import {
- fireEvent,
render,
screen,
waitFor,
@@ -51,33 +50,6 @@ const TestComponent = (props: TestComponentProps): JSX.Element => {
return {metadata.page}
}
-const AppendResultsTestComponent = (): JSX.Element => {
- const {
- challenges,
- mutate,
- }: UseFetchChallengesResult = useFetchChallenges({
- appendResults: true,
- page: 1,
- perPage: 25,
- })
- const challengeNames: string = challenges.map(challenge => challenge.name)
- .join(', ')
-
- function handleRefresh(): void {
- mutate()
- .catch(() => undefined)
- }
-
- return (
- <>
-
- Refresh
-
- {challengeNames || 'No challenges'}
- >
- )
-}
-
describe('useFetchChallenges', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -135,89 +107,4 @@ describe('useFetchChallenges', () => {
expect(mockedFetchChallenges)
.not.toHaveBeenCalled()
})
-
- it('removes omitted challenges from appended results after a same-page refresh', async () => {
- const cache = new Map()
- const wrapper = createWrapper(cache)
-
- mockedFetchChallenges
- .mockResolvedValueOnce({
- data: [
- { id: 'visible-challenge', name: 'Visible Challenge' },
- { id: 'restricted-challenge', name: 'Restricted Challenge' },
- ],
- metadata: {
- page: 1,
- perPage: 25,
- total: 2,
- totalPages: 1,
- },
- } as never)
- .mockResolvedValueOnce({
- data: [
- { id: 'visible-challenge', name: 'Visible Challenge' },
- ],
- metadata: {
- page: 1,
- perPage: 25,
- total: 1,
- totalPages: 1,
- },
- } as never)
-
- render( , {
- wrapper,
- })
-
- await waitFor(() => {
- expect(screen.getByText(/Restricted Challenge/))
- .toBeTruthy()
- })
-
- fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
-
- await waitFor(() => {
- expect(screen.queryByText(/Restricted Challenge/))
- .toBeNull()
- })
- expect(screen.getByText('Visible Challenge'))
- .toBeTruthy()
- })
-
- it('does not return cached challenges when a fresh fetch errors', async () => {
- const cache = new Map()
- const wrapper = createWrapper(cache)
-
- mockedFetchChallenges
- .mockResolvedValueOnce({
- data: [
- { id: 'restricted-challenge', name: 'Restricted Challenge' },
- ],
- metadata: {
- page: 1,
- perPage: 25,
- total: 1,
- totalPages: 1,
- },
- } as never)
- .mockRejectedValueOnce(new Error('Forbidden'))
-
- render( , {
- wrapper,
- })
-
- await waitFor(() => {
- expect(screen.getByText('Restricted Challenge'))
- .toBeTruthy()
- })
-
- fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
-
- await waitFor(() => {
- expect(screen.queryByText('Restricted Challenge'))
- .toBeNull()
- })
- expect(screen.getByText('No challenges'))
- .toBeTruthy()
- })
})
diff --git a/src/apps/work/src/lib/hooks/useFetchChallenges.ts b/src/apps/work/src/lib/hooks/useFetchChallenges.ts
index 3b2bb1b65..b182585f6 100644
--- a/src/apps/work/src/lib/hooks/useFetchChallenges.ts
+++ b/src/apps/work/src/lib/hooks/useFetchChallenges.ts
@@ -66,7 +66,6 @@ export function useFetchChallenges(
appendKey: string
page: number
} | undefined>(undefined)
- const aggregatedPagesRef = useRef>(new Map())
const requestParams = useMemo(
() => ({
@@ -170,30 +169,38 @@ export function useFetchChallenges(
const hasSameQuery = !!previousRequest && previousRequest.appendKey === appendKey
const isSequentialPage = hasSameQuery && page === previousRequest.page + 1
const isSamePageRefresh = hasSameQuery && page === previousRequest.page
- const shouldResetAggregatedPages = !previousRequest
- || !hasSameQuery
- || page <= 1
- || (!isSequentialPage && !isSamePageRefresh)
- if (shouldResetAggregatedPages) {
- aggregatedPagesRef.current = new Map()
- }
-
- aggregatedPagesRef.current.set(page, data.data || [])
- const seenChallengeIds = new Set()
- const nextAggregatedChallenges = Array.from(aggregatedPagesRef.current.entries())
- .sort(([pageA], [pageB]) => pageA - pageB)
- .flatMap(([, challengesPage]) => challengesPage)
- .filter(challenge => {
- if (seenChallengeIds.has(challenge.id)) {
- return false
+ if (!previousRequest || !hasSameQuery || page <= 1) {
+ setAggregatedChallenges(data.data || [])
+ } else if (isSequentialPage) {
+ setAggregatedChallenges(current => {
+ const existingIds = new Set(current.map(challenge => challenge.id))
+ const nextPageChallenges = (data.data || [])
+ .filter(challenge => !existingIds.has(challenge.id))
+
+ return [
+ ...current,
+ ...nextPageChallenges,
+ ]
+ })
+ } else if (isSamePageRefresh) {
+ setAggregatedChallenges(current => {
+ const existingIds = new Set(current.map(challenge => challenge.id))
+ const refreshedChallenges = (data.data || [])
+ .filter(challenge => !existingIds.has(challenge.id))
+
+ if (!refreshedChallenges.length) {
+ return current
}
- seenChallengeIds.add(challenge.id)
- return true
+ return [
+ ...current,
+ ...refreshedChallenges,
+ ]
})
-
- setAggregatedChallenges(nextAggregatedChallenges)
+ } else {
+ setAggregatedChallenges(data.data || [])
+ }
setAggregatedMetadata(data.metadata || {
page,
@@ -208,22 +215,18 @@ export function useFetchChallenges(
}
}, [appendKey, appendResults, data, page, perPage])
- let challenges: Challenge[] = []
- let metadata: PaginationModel = {
- page,
- perPage,
- total: 0,
- totalPages: 0,
- }
+ const challenges = appendResults
+ ? aggregatedChallenges
+ : (data?.data || [])
- if (!error) {
- challenges = appendResults
- ? aggregatedChallenges
- : (data?.data || [])
- metadata = appendResults
- ? aggregatedMetadata
- : (data?.metadata || metadata)
- }
+ const metadata = appendResults
+ ? aggregatedMetadata
+ : (data?.metadata || {
+ page,
+ perPage,
+ total: 0,
+ totalPages: 0,
+ })
return {
challenges,
diff --git a/src/apps/work/src/lib/hooks/useFetchSubmissionRunnerLogs.ts b/src/apps/work/src/lib/hooks/useFetchSubmissionRunnerLogs.ts
deleted file mode 100644
index 86482e710..000000000
--- a/src/apps/work/src/lib/hooks/useFetchSubmissionRunnerLogs.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import useSWR, { SWRResponse } from 'swr'
-
-import { MarathonMatchRunnerLogs } from '../models'
-import { fetchSubmissionRunnerLogs } from '../services'
-
-export interface UseFetchSubmissionRunnerLogsResult {
- error: Error | undefined
- isError: boolean
- isLoading: boolean
- runnerLogs: MarathonMatchRunnerLogs | undefined
-}
-
-/**
- * Fetches marathon match ECS runner logs for a submission while a modal is open.
- * @param submissionId Submission identifier to load logs for.
- * @returns Loading, error, and runner-log response state for the caller.
- * @throws Does not throw; request failures are exposed through `error` and `isError`.
- * Used by `SubmissionRunnerLogsModal` to retrieve CloudWatch events on demand.
- */
-export function useFetchSubmissionRunnerLogs(
- submissionId?: string,
-): UseFetchSubmissionRunnerLogsResult {
- const swrKey = submissionId
- ? [
- 'submission-runner-logs',
- submissionId,
- ]
- : undefined
-
- const {
- data,
- error,
- }: SWRResponse
- = useSWR(
- swrKey,
- () => fetchSubmissionRunnerLogs(submissionId as string),
- {
- errorRetryCount: 2,
- shouldRetryOnError: true,
- },
- )
-
- return {
- error,
- isError: !!error,
- isLoading: !!submissionId && !data && !error,
- runnerLogs: data,
- }
-}
-
-export default useFetchSubmissionRunnerLogs
diff --git a/src/apps/work/src/lib/models/AiReview.model.ts b/src/apps/work/src/lib/models/AiReview.model.ts
index c4842fd31..d784c170f 100644
--- a/src/apps/work/src/lib/models/AiReview.model.ts
+++ b/src/apps/work/src/lib/models/AiReview.model.ts
@@ -37,7 +37,6 @@ export interface AiReviewTemplate {
challengeTrack: string
challengeType: string
createdAt?: string | Date
- disabled?: boolean
description: string
formula?: Record
id: string
diff --git a/src/apps/work/src/lib/models/Challenge.model.ts b/src/apps/work/src/lib/models/Challenge.model.ts
index 5f1737d44..48551c4da 100644
--- a/src/apps/work/src/lib/models/Challenge.model.ts
+++ b/src/apps/work/src/lib/models/Challenge.model.ts
@@ -65,9 +65,6 @@ export interface ChallengeTerm {
export interface Challenge {
id: string
- approvalApprovedBy?: string
- approvalRejectionReason?: string
- approvalStatus?: string
assignedMemberId?: string
attachments?: Attachment[]
billing?: {
diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts
index bf7f0258c..d1bd7a742 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 =
export type ApplicationStatus = 'REJECTED' | 'SELECTED' | 'SUBMITTED' | 'UNDER_REVIEW'
-export type AssignmentStatus = 'ACTIVE' | 'ASSIGNED' | 'COMPLETED' | 'OFFER_REJECTED' | 'SELECTED' | 'TERMINATED'
+export type AssignmentStatus = 'ACTIVE' | 'ASSIGNED' | 'COMPLETED' | 'TERMINATED'
export interface Assignment {
agreementRate: string
@@ -110,7 +110,6 @@ export interface AssignmentPayment {
description?: string
details?: Array<{
amount?: number
- challengeFee?: number | string
grossAmount?: number
hoursWorked?: number | string
totalAmount?: number
diff --git a/src/apps/work/src/lib/models/MarathonMatch.model.ts b/src/apps/work/src/lib/models/MarathonMatch.model.ts
index 2d0c67339..b7419b747 100644
--- a/src/apps/work/src/lib/models/MarathonMatch.model.ts
+++ b/src/apps/work/src/lib/models/MarathonMatch.model.ts
@@ -87,74 +87,6 @@ export interface MarathonMatchTester extends MarathonMatchTesterSummary {
sourceCode: string
}
-/**
- * CloudWatch event returned for a marathon match ECS runner log stream.
- * Used by the submission runner logs modal to render task output.
- */
-export interface MarathonMatchRunnerLogEvent {
- ingestionTime?: number
- message?: string
- timestamp?: number
-}
-
-/**
- * Persisted submission-to-runner task mapping returned with runner logs.
- * Used to identify which ECS task and CloudWatch stream produced the output.
- */
-export interface MarathonMatchRunnerLogMapping {
- id: string
- submissionId: string
- challengeId: string
- taskArn: string
- taskId: string
- cluster: string
- containerName: string
- taskDefinition: string
- phaseConfigType?: MarathonMatchConfigType
- logGroup?: string
- logStreamPrefix?: string
- logStreamName?: string
- cloudWatchLogsConsoleUrl?: string
- createdAt: string
- updatedAt: string
-}
-
-/**
- * Runner log response returned by GET /submissions/:submissionId/runner-logs.
- * Used by Work Manager to display marathon match ECS runner output.
- */
-export interface MarathonMatchRunnerLogs {
- submissionId: string
- selectedTaskArn: string
- selectedMapping: MarathonMatchRunnerLogMapping
- mappings: MarathonMatchRunnerLogMapping[]
- events: MarathonMatchRunnerLogEvent[]
- nextForwardToken?: string
- nextBackwardToken?: string
- warning?: string
-}
-
-/**
- * Per-submission dispatch result from a marathon match score rerun.
- * Used by the scorer section to summarize which latest submissions were queued.
- */
-export interface MarathonMatchRerunResult {
- submissionId: string
- taskArn?: string
- taskId?: string
- error?: string
-}
-
-/**
- * Response returned after requesting a rerun of latest marathon match submissions.
- * Used by the scorer section to show operator feedback after rerun dispatch.
- */
-export interface MarathonMatchRerunResponse {
- challengeId: string
- submissionsQueued: number
- results: MarathonMatchRerunResult[]
-}
-
/**
* Payload for creating a new marathon match scorer configuration.
* Used by POST /challenge/:challengeId requests.
diff --git a/src/apps/work/src/lib/models/Project.model.ts b/src/apps/work/src/lib/models/Project.model.ts
index 2ed1d07e5..5c044535b 100644
--- a/src/apps/work/src/lib/models/Project.model.ts
+++ b/src/apps/work/src/lib/models/Project.model.ts
@@ -8,13 +8,6 @@ import { TaasJob } from './TaasJob.model'
export type ProjectStatus = typeof PROJECT_STATUSES[number]['value']
-export interface ProjectDetails extends Record {
- displayMemberPaymentDetailsToCopilots?: boolean
- taasDefinition?: {
- taasJobs?: TaasJob[]
- }
-}
-
export interface Project {
id: number | string
name: string
@@ -32,7 +25,11 @@ export interface Project {
members?: ProjectMember[]
invites?: ProjectInvite[]
isInvited?: boolean
- details?: ProjectDetails
+ details?: {
+ taasDefinition?: {
+ taasJobs?: TaasJob[]
+ }
+ }
}
export interface ProjectType {
@@ -46,7 +43,6 @@ export interface CreateProjectPayload {
description: string
type: string
billingAccountId?: number | string
- details?: ProjectDetails
terms?: string[]
groups?: string[]
}
@@ -54,8 +50,7 @@ export interface CreateProjectPayload {
export interface UpdateProjectPayload {
name: string
description: string
- billingAccountId?: null | number | string
- details?: ProjectDetails
+ billingAccountId?: number | string
status?: ProjectStatus
cancelReason?: string
terms?: string[]
diff --git a/src/apps/work/src/lib/models/Reviewer.model.ts b/src/apps/work/src/lib/models/Reviewer.model.ts
index 9c2cbb799..fe195c291 100644
--- a/src/apps/work/src/lib/models/Reviewer.model.ts
+++ b/src/apps/work/src/lib/models/Reviewer.model.ts
@@ -30,7 +30,6 @@ export interface Scorecard {
}
export interface Workflow {
- disabled?: boolean
id: string
name: string
scorecardId?: string
diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts
index 0f6ae477c..27470e454 100644
--- a/src/apps/work/src/lib/models/index.ts
+++ b/src/apps/work/src/lib/models/index.ts
@@ -14,7 +14,6 @@ export type {
ProjectPhase,
ProjectPhaseProduct,
Project,
- ProjectDetails,
ProjectFilters,
ProjectStatus as ProjectStatusValue,
ProjectType,
diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
index 84a6430a5..eb78539c4 100644
--- a/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
+++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.spec.ts
@@ -189,70 +189,6 @@ describe('challenge-editor schema fun challenge prize validation', () => {
.resolves
.toBeTruthy()
})
-
- it('allows equal lower placement prizes when funChallenge is false', async () => {
- await expect(
- challengeBasicInfoSchema.validate({
- ...baseBasicInfo,
- funChallenge: false,
- prizeSets: [
- {
- prizes: [
- {
- type: 'USD',
- value: 100,
- },
- {
- type: 'USD',
- value: 50,
- },
- {
- type: 'USD',
- value: 20,
- },
- {
- type: 'USD',
- value: 20,
- },
- ],
- type: PRIZE_SET_TYPES.PLACEMENT,
- },
- ],
- }),
- )
- .resolves
- .toBeTruthy()
- })
-
- it('rejects lower placement prizes that increase when funChallenge is false', async () => {
- await expect(
- challengeBasicInfoSchema.validate({
- ...baseBasicInfo,
- funChallenge: false,
- prizeSets: [
- {
- prizes: [
- {
- type: 'USD',
- value: 100,
- },
- {
- type: 'USD',
- value: 50,
- },
- {
- type: 'USD',
- value: 60,
- },
- ],
- type: PRIZE_SET_TYPES.PLACEMENT,
- },
- ],
- }),
- )
- .rejects
- .toThrow('Placement prizes must stay the same or decrease for lower placements')
- })
})
describe('challenge-editor schema reviewer slot assignment validation', () => {
diff --git a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
index 72403d8c0..4a41efdd5 100644
--- a/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
+++ b/src/apps/work/src/lib/schemas/challenge-editor.schema.ts
@@ -59,7 +59,7 @@ const prizeSetSchema = yup.object({
.default([])
.test(
'descending-prizes',
- 'Placement prizes must stay the same or decrease for lower placements',
+ 'Placement prizes must be in descending order',
function validateDescendingPrizes(prizes: unknown): boolean {
const prizeSetType = this.parent?.type
@@ -78,7 +78,7 @@ const prizeSetSchema = yup.object({
if (
previousValue > 0
&& currentValue > 0
- && currentValue > previousValue
+ && currentValue >= previousValue
) {
return false
}
diff --git a/src/apps/work/src/lib/schemas/project-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/project-editor.schema.spec.ts
deleted file mode 100644
index 5d5ef55a6..000000000
--- a/src/apps/work/src/lib/schemas/project-editor.schema.spec.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { PROJECT_STATUS } from '../constants'
-
-import {
- createProjectEditorSchema,
- ProjectEditorSchemaData,
-} from './project-editor.schema'
-
-jest.mock('~/config', () => ({
- EnvironmentConfig: {
- ADMIN: {
- DIRECT_URL: 'https://direct.topcoder-dev.com',
- REVIEW_UI_URL: 'https://review.topcoder-dev.com',
- },
- API: {
- V5: 'https://api.topcoder-dev.com/v5',
- V6: 'https://api.topcoder-dev.com/v6',
- },
- CHALLENGE_API_URL: 'https://api.topcoder-dev.com/v5/challenges',
- CHALLENGE_API_VERSION: 'v5',
- COMMUNITY_APP_URL: 'https://topcoder-dev.com',
- DIRECT_PROJECT_URL: 'https://direct.topcoder-dev.com',
- ENGAGEMENTS_URL: 'https://work.topcoder-dev.com',
- REVIEW_APP_URL: 'https://review.topcoder-dev.com',
- TC_DOMAIN: 'topcoder-dev.com',
- TC_FINANCE_API: 'https://finance.topcoder-dev.com',
- TOPCODER_URL: 'https://topcoder-dev.com',
- },
-}), {
- virtual: true,
-})
-
-describe('createProjectEditorSchema', () => {
- it('allows project creation without a billing account', async () => {
- const schema = createProjectEditorSchema(false, true)
- const values: ProjectEditorSchemaData = {
- billingAccountId: '',
- description: 'Create work before billing is assigned',
- groups: [],
- name: 'Project without billing',
- status: PROJECT_STATUS.DRAFT,
- terms: '',
- type: 'generic',
- }
-
- await expect(schema.validate(values, {
- abortEarly: false,
- })).resolves.toMatchObject({
- billingAccountId: '',
- name: 'Project without billing',
- })
- })
-})
diff --git a/src/apps/work/src/lib/schemas/project-editor.schema.ts b/src/apps/work/src/lib/schemas/project-editor.schema.ts
index dcf9e3a88..fcbbd116e 100644
--- a/src/apps/work/src/lib/schemas/project-editor.schema.ts
+++ b/src/apps/work/src/lib/schemas/project-editor.schema.ts
@@ -6,7 +6,6 @@ export interface ProjectEditorSchemaData {
billingAccountId?: string
name: string
description: string
- displayMemberPaymentDetailsToCopilots?: boolean
type?: string
status?: string
cancelReason?: string
@@ -22,7 +21,7 @@ export function createProjectEditorSchema(
.object({
billingAccountId: yup
.string()
- .optional(),
+ .required('Billing account is required'),
cancelReason: yup
.string()
.when('status', {
@@ -33,9 +32,6 @@ export function createProjectEditorSchema(
description: yup
.string()
.required('Description is required'),
- displayMemberPaymentDetailsToCopilots: yup
- .boolean()
- .optional(),
groups: yup
.array()
.of(yup.string()
diff --git a/src/apps/work/src/lib/services/ai-review-templates.service.ts b/src/apps/work/src/lib/services/ai-review-templates.service.ts
index b92cfa18d..cf2f40cb5 100644
--- a/src/apps/work/src/lib/services/ai-review-templates.service.ts
+++ b/src/apps/work/src/lib/services/ai-review-templates.service.ts
@@ -132,7 +132,6 @@ function normalizeTemplate(
challengeType,
createdAt: normalizeText(typedTemplate.createdAt),
description: normalizeText(typedTemplate.description) || '',
- disabled: normalizeBoolean(typedTemplate.disabled) === true,
formula: typeof typedTemplate.formula === 'object' && typedTemplate.formula
? typedTemplate.formula as Record
: undefined,
diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts
index dbf00f65d..52ce18538 100644
--- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts
+++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts
@@ -1,13 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import { xhrGetAsync } from '~/libs/core'
-import {
- BillingAccountDetails,
- combineBillingAccountLineItems,
- fetchBillingAccounts,
- fetchBillingAccountById,
- searchBillingAccounts,
-} from './billing-accounts.service'
+import { searchBillingAccounts } from './billing-accounts.service'
jest.mock('~/config', () => ({
EnvironmentConfig: {
@@ -31,45 +25,6 @@ jest.mock('~/libs/core', () => ({
virtual: true,
})
-const NULL_EXTERNAL_NAME = JSON.parse('null') as null
-
-describe('fetchBillingAccounts', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('requests a large lookup page for project billing summaries', async () => {
- const mockedGetAsync = xhrGetAsync as jest.Mock
-
- mockedGetAsync.mockResolvedValue({
- data: [
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 125,
- name: 'Platform Dev - One',
- },
- ],
- })
-
- const result = await fetchBillingAccounts()
-
- expect(result)
- .toEqual([
- {
- budget: 1000,
- consumedBudget: 225,
- id: 80001063,
- lockedBudget: 125,
- name: 'Platform Dev - One',
- },
- ])
- expect(mockedGetAsync)
- .toHaveBeenCalledWith('https://example.com/v6/billing-accounts?perPage=1000')
- })
-})
-
describe('searchBillingAccounts', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -107,238 +62,3 @@ describe('searchBillingAccounts', () => {
)
})
})
-
-describe('fetchBillingAccountById', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('fetches billing account details with typed external-entry line items', async () => {
- const mockedGetAsync = xhrGetAsync as jest.Mock
- const billingAccountDetails = {
- budget: 5000,
- consumedAmounts: [
- {
- amount: 100,
- date: '2026-02-11T00:00:00.000Z',
- externalId: 'engagement-200',
- externalName: 'Engagement Two Hundred',
- externalType: 'ENGAGEMENT',
- },
- ],
- consumedBudget: 100,
- id: 80001063,
- lockedAmounts: [
- {
- amount: '250.50',
- challengeId: 'legacy-challenge-100',
- date: '2026-02-10T00:00:00.000Z',
- externalId: 'challenge-100',
- externalName: 'Challenge One Hundred',
- externalType: 'CHALLENGE',
- },
- ],
- lockedBudget: 250.50,
- name: 'Platform Dev - One',
- totalBudgetRemaining: 4649.50,
- } as BillingAccountDetails
-
- mockedGetAsync.mockResolvedValue(billingAccountDetails)
-
- const result = await fetchBillingAccountById(' 80001063 ')
-
- expect(result)
- .toEqual(billingAccountDetails)
- expect(result.lockedAmounts[0])
- .toMatchObject({
- amount: '250.50',
- challengeId: 'legacy-challenge-100',
- date: '2026-02-10T00:00:00.000Z',
- externalId: 'challenge-100',
- externalName: 'Challenge One Hundred',
- externalType: 'CHALLENGE',
- })
- expect(result.consumedAmounts[0])
- .toMatchObject({
- amount: 100,
- date: '2026-02-11T00:00:00.000Z',
- externalId: 'engagement-200',
- externalName: 'Engagement Two Hundred',
- externalType: 'ENGAGEMENT',
- })
- expect(mockedGetAsync)
- .toHaveBeenCalledWith('https://example.com/v6/billing-accounts/80001063')
- })
-})
-
-describe('combineBillingAccountLineItems', () => {
- it('normalizes typed external entries into status-aware UI rows', () => {
- const billingAccountDetails = {
- budget: 2000,
- consumedAmounts: [
- {
- amount: '75',
- date: '2026-02-12T00:00:00.000Z',
- engagementId: 'engagement-300',
- externalId: 'assignment-300',
- externalName: 'Engagement Assignment',
- externalType: 'ENGAGEMENT',
- },
- {
- amount: '75',
- date: '2026-02-12T00:00:00.000Z',
- externalId: 'assignment-300',
- externalName: 'Engagement Assignment',
- externalType: 'ENGAGEMENT',
- },
- ],
- consumedBudget: 150,
- id: 80001063,
- lockedAmounts: [
- {
- amount: '125.25',
- challengeId: 'legacy-challenge-100',
- createdAt: '2025-01-01T00:00:00.000Z',
- date: '2026-02-10T00:00:00.000Z',
- externalId: 'challenge-100',
- externalName: 'Challenge One Hundred',
- externalType: 'CHALLENGE',
- },
- {
- amount: 50,
- challengeId: 'legacy-challenge-should-not-drive-type',
- date: '2026-02-11T00:00:00.000Z',
- externalId: 'engagement-legacy',
- externalName: 'Legacy Engagement',
- externalType: 'ENGAGEMENT',
- },
- ],
- lockedBudget: 175.25,
- name: 'Platform Dev - One',
- totalBudgetRemaining: 1674.75,
- } as unknown as BillingAccountDetails
-
- const result = combineBillingAccountLineItems(billingAccountDetails)
-
- expect(result)
- .toHaveLength(4)
- expect(result[0])
- .toMatchObject({
- amount: 125.25,
- date: '2026-02-10T00:00:00.000Z',
- externalId: 'challenge-100',
- externalName: 'Challenge One Hundred',
- externalType: 'CHALLENGE',
- status: 'locked',
- })
- expect(result[1])
- .toMatchObject({
- amount: 50,
- externalId: 'engagement-legacy',
- externalName: 'Legacy Engagement',
- externalType: 'ENGAGEMENT',
- status: 'locked',
- })
- expect(result[1].externalId)
- .not
- .toBe('legacy-challenge-should-not-drive-type')
-
- const consumedRows = result.filter(item => item.status === 'consumed')
-
- expect(consumedRows)
- .toHaveLength(2)
- expect(consumedRows[0])
- .toMatchObject({
- date: '2026-02-12T00:00:00.000Z',
- engagementId: 'engagement-300',
- externalId: 'assignment-300',
- externalType: 'ENGAGEMENT',
- status: 'consumed',
- })
- expect(consumedRows[1])
- .toMatchObject({
- date: '2026-02-12T00:00:00.000Z',
- externalId: 'assignment-300',
- externalType: 'ENGAGEMENT',
- status: 'consumed',
- })
- expect(consumedRows[0].id)
- .not
- .toBe(consumedRows[1].id)
- })
-
- it('preserves legacy challenge ids without normalizing them into canonical external ids', () => {
- const billingAccountDetails = {
- budget: 2000,
- consumedAmounts: [],
- consumedBudget: 0,
- id: 80001063,
- lockedAmounts: [
- {
- amount: '125.25',
- challengeId: 'legacy-challenge-100',
- date: '2026-02-10T00:00:00.000Z',
- externalName: 'Legacy Challenge One Hundred',
- externalType: 'CHALLENGE',
- },
- ],
- lockedBudget: 125.25,
- name: 'Platform Dev - One',
- totalBudgetRemaining: 1874.75,
- } as BillingAccountDetails
-
- const result = combineBillingAccountLineItems(billingAccountDetails)
-
- expect(result)
- .toHaveLength(1)
- expect(result[0])
- .toMatchObject({
- amount: 125.25,
- challengeId: 'legacy-challenge-100',
- date: '2026-02-10T00:00:00.000Z',
- externalName: 'Legacy Challenge One Hundred',
- externalType: 'CHALLENGE',
- status: 'locked',
- })
- expect(Object.prototype.hasOwnProperty.call(result[0], 'externalId'))
- .toBe(false)
- expect(result[0].externalId)
- .toBeUndefined()
- })
-
- it('normalizes null external names from canonical or legacy ids', () => {
- const billingAccountDetails = {
- budget: 2000,
- consumedAmounts: [
- {
- amount: 75,
- date: '2026-02-12T00:00:00.000Z',
- externalId: 'assignment-300',
- externalName: NULL_EXTERNAL_NAME,
- externalType: 'ENGAGEMENT',
- },
- ],
- consumedBudget: 75,
- id: 80001063,
- lockedAmounts: [
- {
- amount: 125,
- challengeId: 'legacy-challenge-100',
- date: '2026-02-10T00:00:00.000Z',
- externalName: NULL_EXTERNAL_NAME,
- externalType: 'CHALLENGE',
- },
- ],
- lockedBudget: 125,
- name: 'Platform Dev - One',
- totalBudgetRemaining: 1800,
- } as BillingAccountDetails
-
- const result = combineBillingAccountLineItems(billingAccountDetails)
-
- expect(result[0].externalName)
- .toBe('legacy-challenge-100')
- expect(result[1].externalName)
- .toBe('assignment-300')
- })
-})
diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts
index 0d0923e90..b9d6e237b 100644
--- a/src/apps/work/src/lib/services/billing-accounts.service.ts
+++ b/src/apps/work/src/lib/services/billing-accounts.service.ts
@@ -1,15 +1,9 @@
import { EnvironmentConfig } from '~/config'
import { xhrGetAsync } from '~/libs/core'
-const BILLING_ACCOUNTS_LOOKUP_PAGE_SIZE = 1000
-
export interface BillingAccount {
active?: boolean
- budget?: number | string
- consumedBudget?: number | string
- lockedBudget?: number | string
- markup?: number | string
- memberPaymentsRemaining?: number | string
+ markup?: number
endDate?: string
id: number | string
name: string
@@ -19,45 +13,6 @@ export interface BillingAccount {
[key: string]: unknown
}
-export type BillingAccountLineItemStatus = 'locked' | 'consumed'
-export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT'
-
-export interface BillingAccountBudgetEntry {
- amount: number | string
- challengeId?: string
- date: string
- engagementId?: string
- externalId?: string
- externalName: string | null
- externalType: BillingAccountExternalType
- memberPaymentAmount?: number | string
-}
-
-export type BillingAccountLockedAmount = BillingAccountBudgetEntry
-export type BillingAccountConsumedAmount = BillingAccountBudgetEntry
-
-export interface BillingAccountLineItem {
- id: string
- amount: number
- challengeId?: string
- date: string
- engagementId?: string
- externalId?: string
- externalName?: string | null
- externalType: BillingAccountExternalType
- memberPaymentAmount?: number
- status: BillingAccountLineItemStatus
-}
-
-export interface BillingAccountDetails extends BillingAccount {
- budget: number
- lockedBudget: number
- consumedBudget: number
- totalBudgetRemaining: number
- lockedAmounts: BillingAccountLockedAmount[]
- consumedAmounts: BillingAccountConsumedAmount[]
-}
-
interface BillingAccountsResponse {
data?: BillingAccount[]
page?: number
@@ -138,91 +93,14 @@ function createSearchQuery(params: SearchBillingAccountsParams): string {
}
/**
- * Creates a deterministic UI-only row key from the source bucket and stable row context.
- *
- * @param status The source budget bucket for the row.
- * @param item The raw external budget entry returned by the billing account API.
- * @param index The entry index within its source bucket, used to keep repeated rows unique.
- * @returns A row key suitable for React rendering.
- */
-function createLineItemKey(
- status: BillingAccountLineItemStatus,
- item: BillingAccountBudgetEntry,
- index: number,
-): string {
- return [
- status,
- item.externalType,
- item.externalId || item.challengeId || 'unknown',
- item.date || 'unknown-date',
- item.amount,
- index,
- ]
- .map(value => encodeURIComponent(String(value)))
- .join('-')
-}
-
-/**
- * Converts an API budget entry into a UI line item without aliasing legacy challenge ids.
- *
- * @param status The budget bucket the API entry came from.
- * @param item The raw external budget entry returned by the billing account API.
- * @param index The entry index within its source bucket, used in the generated row key.
- * @returns A normalized line item with numeric amount, original date, display
- * fallback for nullable external names, optional canonical external id,
- * optional engagement id, optional legacy challenge id, optional copilot-safe
- * member payment amount, and a deterministic UI row key.
- */
-function createLineItem(
- status: BillingAccountLineItemStatus,
- item: BillingAccountBudgetEntry,
- index: number,
-): BillingAccountLineItem {
- const normalizedExternalName = item.externalName
- || item.externalId
- || item.challengeId
- const lineItem: BillingAccountLineItem = {
- amount: Number(item.amount),
- date: item.date,
- externalType: item.externalType,
- id: createLineItemKey(status, item, index),
- status,
- }
-
- if (normalizedExternalName) {
- lineItem.externalName = normalizedExternalName
- }
-
- if (item.challengeId) {
- lineItem.challengeId = item.challengeId
- }
-
- if (item.engagementId) {
- lineItem.engagementId = item.engagementId
- }
-
- if (item.externalId) {
- lineItem.externalId = item.externalId
- }
-
- const memberPaymentAmount = Number(item.memberPaymentAmount)
-
- if (Number.isFinite(memberPaymentAmount)) {
- lineItem.memberPaymentAmount = memberPaymentAmount
- }
-
- return lineItem
-}
-
-/**
- * Fetches billing accounts using a large lookup page for project-list joins.
+ * Fetches billing accounts using default API pagination.
*
* Returns only accounts with both `id` and `name`, sorted by name.
*/
export async function fetchBillingAccounts(): Promise {
try {
const response = await xhrGetAsync(
- `${EnvironmentConfig.API.V6}/billing-accounts?perPage=${BILLING_ACCOUNTS_LOOKUP_PAGE_SIZE}`,
+ `${EnvironmentConfig.API.V6}/billing-accounts`,
)
return normalizeBillingAccounts(extractBillingAccounts(response))
@@ -255,16 +133,10 @@ export async function searchBillingAccounts(
/**
* Fetches a single billing account by its identifier.
- *
- * The detail payload includes budget totals plus locked and consumed external
- * entries with `amount`, optional copilot-safe `memberPaymentAmount`, `date`,
- * optional canonical `externalId`, optional `engagementId`, `externalType`,
- * and nullable `externalName`. Top-level `id` and `name` remain available
- * for lookup labels.
*/
export async function fetchBillingAccountById(
billingAccountId: string,
-): Promise {
+): Promise {
const normalizedBillingAccountId = billingAccountId.trim()
if (!normalizedBillingAccountId) {
@@ -272,32 +144,10 @@ export async function fetchBillingAccountById(
}
try {
- return await xhrGetAsync(
+ return await xhrGetAsync(
`${EnvironmentConfig.API.V6}/billing-accounts/${encodeURIComponent(normalizedBillingAccountId)}`,
)
} catch (error) {
throw normalizeError(error, 'Failed to fetch billing account details')
}
}
-
-/**
- * Combines locked and consumed external budget entries into UI line items.
- *
- * @param details Billing account details containing locked and consumed entry arrays.
- * @returns Normalized line items with numeric amounts, optional engagement
- * ids, optional member payment amounts, API dates, external metadata,
- * status, and UI row keys.
- */
-export function combineBillingAccountLineItems(
- details: BillingAccountDetails,
-): BillingAccountLineItem[] {
- const lockedItems: BillingAccountLineItem[] = (details.lockedAmounts || []).map(
- (item, index) => createLineItem('locked', item, index),
- )
-
- const consumedItems: BillingAccountLineItem[] = (details.consumedAmounts || []).map(
- (item, index) => createLineItem('consumed', item, index),
- )
-
- return [...lockedItems, ...consumedItems]
-}
diff --git a/src/apps/work/src/lib/services/challenges.service.ts b/src/apps/work/src/lib/services/challenges.service.ts
index 56ab56ad1..2ffb70c0d 100644
--- a/src/apps/work/src/lib/services/challenges.service.ts
+++ b/src/apps/work/src/lib/services/challenges.service.ts
@@ -404,7 +404,6 @@ function normalizeWorkflow(workflow: Partial): Workflow | undefined {
}
return {
- disabled: (workflow as Record).disabled === true,
id,
name,
scorecardId: workflow.scorecardId !== undefined && workflow.scorecardId !== null
diff --git a/src/apps/work/src/lib/services/groups.service.spec.ts b/src/apps/work/src/lib/services/groups.service.spec.ts
deleted file mode 100644
index c4925ffe0..000000000
--- a/src/apps/work/src/lib/services/groups.service.spec.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import { xhrGetPaginatedAsync } from '~/libs/core'
-
-import { fetchGroups } from './groups.service'
-
-jest.mock('~/libs/core', () => ({
- xhrCreateInstance: jest.fn(() => ({
- defaults: {
- headers: {
- common: {},
- },
- },
- })),
- xhrDeleteAsync: jest.fn(),
- xhrGetAsync: jest.fn(),
- xhrGetPaginatedAsync: jest.fn(),
- xhrPatchAsync: jest.fn(),
- xhrPostAsync: jest.fn(),
- xhrPutAsync: jest.fn(),
-}), {
- virtual: true,
-})
-jest.mock('../constants', () => ({
- GROUPS_API_URL: 'https://example.com/groups',
-}))
-
-describe('fetchGroups', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('merges all paginated accessible group results when hydrating saved group ids', async () => {
- const mockedGetPaginated = xhrGetPaginatedAsync as jest.Mock
-
- mockedGetPaginated
- .mockResolvedValueOnce({
- data: [
- {
- id: 'group-1',
- name: ' Hide Challenges ',
- },
- ],
- page: 1,
- perPage: 1000,
- total: 2,
- totalPages: 2,
- })
- .mockResolvedValueOnce({
- data: [
- {
- id: 'group-2',
- name: 'QA - Public',
- },
- ],
- page: 2,
- perPage: 1000,
- total: 2,
- totalPages: 2,
- })
-
- await expect(fetchGroups({
- name: 'Hide',
- }))
- .resolves
- .toEqual([
- expect.objectContaining({
- id: 'group-1',
- name: 'Hide Challenges',
- }),
- expect.objectContaining({
- id: 'group-2',
- name: 'QA - Public',
- }),
- ])
-
- expect(mockedGetPaginated)
- .toHaveBeenNthCalledWith(
- 1,
- 'https://example.com/groups?page=1&perPage=1000&name=Hide',
- )
- expect(mockedGetPaginated)
- .toHaveBeenNthCalledWith(
- 2,
- 'https://example.com/groups?page=2&perPage=1000&name=Hide',
- )
- })
-})
diff --git a/src/apps/work/src/lib/services/groups.service.ts b/src/apps/work/src/lib/services/groups.service.ts
index 35202fb77..2a09f376a 100644
--- a/src/apps/work/src/lib/services/groups.service.ts
+++ b/src/apps/work/src/lib/services/groups.service.ts
@@ -193,50 +193,24 @@ function normalizeGroupMember(value: unknown): GroupMember | undefined {
}
export async function fetchGroups(filters?: { name?: string }): Promise {
- try {
- const buildGroupsUrl = (page: number): string => {
- const query = new URLSearchParams({
- page: String(page),
- perPage: String(GROUPS_PER_PAGE),
- })
- const groupNameFilter = filters?.name?.trim()
-
- if (groupNameFilter) {
- query.set('name', groupNameFilter)
- }
-
- return `${GROUPS_API_URL}?${query.toString()}`
- }
-
- const firstPageResponse = await xhrGetPaginatedAsync(
- buildGroupsUrl(1),
- )
- const firstPageGroups = (firstPageResponse.data || [])
- .map(group => normalizeGroup(group))
- .filter((group): group is Group => !!group)
-
- if ((firstPageResponse.totalPages || 1) <= 1) {
- return firstPageGroups
- }
+ const query = new URLSearchParams({
+ page: '1',
+ perPage: String(GROUPS_PER_PAGE),
+ })
- const extraPageNumbers = Array.from({
- length: firstPageResponse.totalPages - 1,
- }, (_, index) => index + 2)
+ const groupNameFilter = filters?.name?.trim()
+ if (groupNameFilter) {
+ query.set('name', groupNameFilter)
+ }
- const extraPageResponses = await Promise.all(extraPageNumbers
- .map(pageNumber => xhrGetPaginatedAsync(
- buildGroupsUrl(pageNumber),
- )))
+ try {
+ const response = await xhrGetPaginatedAsync(
+ `${GROUPS_API_URL}?${query.toString()}`,
+ )
- const extraPageGroups = extraPageResponses
- .flatMap(response => response.data || [])
+ return (response.data || [])
.map(group => normalizeGroup(group))
.filter((group): group is Group => !!group)
-
- return [
- ...firstPageGroups,
- ...extraPageGroups,
- ]
} catch (error) {
throw normalizeError(error, 'Failed to fetch groups')
}
diff --git a/src/apps/work/src/lib/services/marathon-match.service.ts b/src/apps/work/src/lib/services/marathon-match.service.ts
index a2b8a1f93..6bf4a5f9d 100644
--- a/src/apps/work/src/lib/services/marathon-match.service.ts
+++ b/src/apps/work/src/lib/services/marathon-match.service.ts
@@ -6,8 +6,8 @@ import {
xhrPostAsync,
xhrPutAsync,
} from '~/libs/core'
+import { EnvironmentConfig } from '~/config'
-import { MARATHON_MATCH_API_URL } from '../constants'
import {
CreateMarathonMatchConfigInput,
CreateTesterInput,
@@ -16,15 +16,14 @@ import {
MarathonMatchConfig,
MarathonMatchDefaults,
MarathonMatchPhaseConfig,
- MarathonMatchRerunResponse,
- MarathonMatchRerunResult,
- MarathonMatchRunnerLogs,
MarathonMatchScoreDirection,
MarathonMatchTester,
MarathonMatchTesterSummary,
UpdateMarathonMatchConfigInput,
} from '../models'
+const MARATHON_MATCH_API_URL = EnvironmentConfig.MARATHON_MATCH_API
+ || `${EnvironmentConfig.API.V6}/marathon-match`
const TESTERS_PER_PAGE = 100
interface TesterListMetadata {
@@ -405,68 +404,6 @@ function normalizeTesterCollection(response: unknown): MarathonMatchTesterSummar
.filter((tester): tester is MarathonMatchTesterSummary => !!tester)
}
-/**
- * Normalizes a raw per-submission rerun result into the UI rerun-result model.
- * @param result Raw result object from POST /challenge/:challengeId/rerun.
- * @returns A normalized rerun result when a submission id is present; otherwise `undefined`.
- * Used by `normalizeMarathonMatchRerunResponse` to discard malformed result rows.
- */
-function normalizeMarathonMatchRerunResult(
- result: unknown,
-): MarathonMatchRerunResult | undefined {
- if (typeof result !== 'object' || !result) {
- return undefined
- }
-
- const typedResult = result as Record
- const submissionId = normalizeText(typedResult.submissionId)
-
- if (!submissionId) {
- return undefined
- }
-
- return {
- error: normalizeText(typedResult.error),
- submissionId,
- taskArn: normalizeText(typedResult.taskArn),
- taskId: normalizeText(typedResult.taskId),
- }
-}
-
-/**
- * Normalizes a raw marathon match rerun response.
- * @param response Raw response from POST /challenge/:challengeId/rerun.
- * @returns The normalized rerun response when required fields are present; otherwise `undefined`.
- * Used by `rerunMarathonMatchScores` before resolving the API call.
- */
-function normalizeMarathonMatchRerunResponse(
- response: unknown,
-): MarathonMatchRerunResponse | undefined {
- if (typeof response !== 'object' || !response) {
- return undefined
- }
-
- const typedResponse = response as Record
- const challengeId = normalizeText(typedResponse.challengeId)
- const submissionsQueued = normalizeNumber(typedResponse.submissionsQueued)
-
- if (!challengeId || submissionsQueued === undefined) {
- return undefined
- }
-
- const rawResults = Array.isArray(typedResponse.results)
- ? typedResponse.results
- : []
-
- return {
- challengeId,
- results: rawResults
- .map(normalizeMarathonMatchRerunResult)
- .filter((result): result is MarathonMatchRerunResult => !!result),
- submissionsQueued,
- }
-}
-
/**
* Extracts tester-list pagination metadata from the response payload.
* Used by `fetchTesters` to keep paging resilient when header metadata is absent.
@@ -636,33 +573,6 @@ export async function updateMarathonMatchConfig(
}
}
-/**
- * Requests a rerun of the latest submissions for a marathon match challenge.
- * @param challengeId Challenge identifier used in the rerun route path.
- * @returns Dispatch summary including queued submissions and per-submission launch results.
- * @throws Error When the API request fails or returns an invalid rerun response.
- * Used by `MarathonMatchScorerSection` when an operator clicks `Rerun scores`.
- */
-export async function rerunMarathonMatchScores(
- challengeId: string,
-): Promise {
- try {
- const response = await xhrPostAsync, unknown>(
- `${MARATHON_MATCH_API_URL}/challenge/${encodeURIComponent(challengeId.trim())}/rerun`,
- {},
- )
- const normalizedResponse = normalizeMarathonMatchRerunResponse(response)
-
- if (!normalizedResponse) {
- throw new Error('Marathon match rerun response was invalid')
- }
-
- return normalizedResponse
- } catch (error) {
- throw normalizeError(error, 'Failed to rerun marathon match scores')
- }
-}
-
/**
* Lists available testers for scorer configuration.
* @param params Optional tester-name filter and pagination controls.
@@ -821,31 +731,6 @@ export async function createTesterVersion(
}
}
-/**
- * Loads ECS runner logs for a marathon match submission.
- * @param submissionId Submission identifier used in the runner-log route path.
- * @returns Mapping metadata and CloudWatch log events for the latest runner task.
- * @throws Error When the API request fails.
- * Used by `SubmissionRunnerLogsModal` when a copilot or manager opens runner output.
- */
-export async function fetchSubmissionRunnerLogs(
- submissionId: string,
-): Promise {
- const normalizedSubmissionId = submissionId.trim()
-
- if (!normalizedSubmissionId) {
- throw new Error('Submission id is required')
- }
-
- try {
- return await xhrGetAsync(
- `${MARATHON_MATCH_API_URL}/submissions/${encodeURIComponent(normalizedSubmissionId)}/runner-logs`,
- )
- } catch (error) {
- throw normalizeError(error, 'Failed to fetch submission runner logs')
- }
-}
-
export {
normalizeMarathonMatchConfig,
normalizeTester,
diff --git a/src/apps/work/src/lib/services/payments.service.ts b/src/apps/work/src/lib/services/payments.service.ts
index b77695e55..dc06bf103 100644
--- a/src/apps/work/src/lib/services/payments.service.ts
+++ b/src/apps/work/src/lib/services/payments.service.ts
@@ -18,6 +18,7 @@ const DEFAULT_ENGAGEMENT_PAYMENT_STATUS = 'ON_HOLD_ADMIN'
interface PaymentDetailsPayload {
billingAccount: string
+ challengeFee: number
currency: string
grossAmount: number
installmentNumber: number
@@ -172,6 +173,7 @@ export async function createMemberPayment(
details: [
{
billingAccount: String(billingAccountId),
+ challengeFee: 0,
currency: 'USD',
grossAmount: numericAmount,
installmentNumber: 1,
diff --git a/src/apps/work/src/lib/services/projects.service.spec.ts b/src/apps/work/src/lib/services/projects.service.spec.ts
index 39cbf6b56..d4ffb9c1f 100644
--- a/src/apps/work/src/lib/services/projects.service.spec.ts
+++ b/src/apps/work/src/lib/services/projects.service.spec.ts
@@ -258,9 +258,6 @@ describe('fetchProjectsList', () => {
mockedGetPaginatedAsync.mockResolvedValue({
data: [
{
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
id: 200,
invites: [
{
@@ -285,9 +282,6 @@ describe('fetchProjectsList', () => {
expect(result.projects)
.toEqual([
expect.objectContaining({
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
id: 200,
invites: [
expect.objectContaining({
@@ -350,9 +344,6 @@ describe('fetchProjectById', () => {
const mockedGetAsync = xhrGetAsync as jest.Mock
mockedGetAsync.mockResolvedValue({
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
id: 200,
invites: [
{
@@ -370,9 +361,6 @@ describe('fetchProjectById', () => {
expect(result)
.toEqual(expect.objectContaining({
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
id: '200',
invites: [
expect.objectContaining({
diff --git a/src/apps/work/src/lib/services/projects.service.ts b/src/apps/work/src/lib/services/projects.service.ts
index e039a019d..3b045b500 100644
--- a/src/apps/work/src/lib/services/projects.service.ts
+++ b/src/apps/work/src/lib/services/projects.service.ts
@@ -23,7 +23,6 @@ import {
Project,
ProjectAttachment,
ProjectAttachmentPayload,
- ProjectDetails,
ProjectInvite,
ProjectMember,
ProjectPhase,
@@ -45,7 +44,6 @@ export type ProjectSummary = Pick): Project {
description: typeof project.description === 'string'
? project.description
: undefined,
- details: normalizeProjectDetails(project.details),
groups: normalizeProjectTermsOrGroups(project.groups),
id,
invites,
@@ -550,7 +531,6 @@ function normalizeProjectSummary(project: ProjectSummary): ProjectSummary {
return {
...project,
- details: normalizeProjectDetails(project.details),
invites,
isInvited: normalizeOptionalBoolean(project.isInvited),
members,
@@ -790,7 +770,6 @@ export async function fetchProjectBillingAccount(
endDate?: unknown
id?: unknown
markup?: unknown
- memberPaymentsRemaining?: unknown
name?: unknown
startDate?: unknown
status?: unknown
@@ -814,8 +793,6 @@ export async function fetchProjectBillingAccount(
id: billingAccountId,
markup: normalizeOptionalNumber(billingAccount?.markup)
?? normalizeOptionalNumber(billingAccountDetails?.markup),
- memberPaymentsRemaining: normalizeOptionalNumber(billingAccount?.memberPaymentsRemaining)
- ?? normalizeOptionalNumber(billingAccountDetails?.memberPaymentsRemaining),
name: normalizeOptionalString(billingAccount?.name)
|| normalizeOptionalString(billingAccountDetails?.name),
startDate: normalizeOptionalString(billingAccount?.startDate)
@@ -830,7 +807,6 @@ export async function fetchProjectBillingAccount(
normalizedBillingAccount.active === undefined
&& !normalizedBillingAccount.id
&& normalizedBillingAccount.markup === undefined
- && normalizedBillingAccount.memberPaymentsRemaining === undefined
&& !normalizedBillingAccount.name
&& !normalizedBillingAccount.endDate
&& !normalizedBillingAccount.startDate
diff --git a/src/apps/work/src/lib/services/reviewers.service.ts b/src/apps/work/src/lib/services/reviewers.service.ts
index c1cc3f6f5..cbdac908f 100644
--- a/src/apps/work/src/lib/services/reviewers.service.ts
+++ b/src/apps/work/src/lib/services/reviewers.service.ts
@@ -89,7 +89,6 @@ function normalizeWorkflow(workflow: Partial): Workflow | undefined {
}
return {
- disabled: (workflow as Record).disabled === true,
id,
name,
scorecardId: workflow.scorecardId !== undefined && workflow.scorecardId !== null
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 adf3b451d..2c6e2dde6 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
@@ -140,21 +140,6 @@ describe('challenge-editor utils wiproAllowed mapping', () => {
})
})
-describe('challenge-editor utils creator mapping', () => {
- it('keeps the challenge creator handle in form data', () => {
- const result = transformChallengeToFormData({
- createdBy: ' challenge.creator ',
- description: 'Public specification',
- name: 'Creator Challenge',
- trackId: 'track-id',
- typeId: 'type-id',
- })
-
- expect(result.createdBy)
- .toBe('challenge.creator')
- })
-})
-
describe('challenge-editor utils billing markup mapping', () => {
it('keeps billing markup when converting challenge to 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 473c8a622..a3cc5415d 100644
--- a/src/apps/work/src/lib/utils/challenge-editor.utils.ts
+++ b/src/apps/work/src/lib/utils/challenge-editor.utils.ts
@@ -1,4 +1,3 @@
-/* eslint-disable complexity */
import {
DESIGN_WORK_TYPES,
PHASE_DURATION_MAX_HOURS,
@@ -9,7 +8,6 @@ import {
SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS,
} from '../constants/challenge-editor.constants'
import {
- CHALLENGE_APPROVAL_STATUS,
CHALLENGE_STATUS,
} from '../constants'
import {
@@ -1004,8 +1002,6 @@ export function transformChallengeToFormData(
const isTask = normalizeOptionalBoolean(challenge?.task?.isTask) || false
const status = normalizeOptionalString(challenge?.status)
?.toUpperCase()
- const approvalStatus = normalizeOptionalString(challenge?.approvalStatus)
- ?.toUpperCase()
const billing = normalizeBillingInfo(challenge?.billing)
const normalizedPrizeSets = normalizePrizeSets(challenge?.prizeSets)
const prizeSetsForForm = challenge?.id && status !== CHALLENGE_STATUS.NEW
@@ -1013,16 +1009,11 @@ export function transformChallengeToFormData(
: ensurePlacementPrizeSet(normalizedPrizeSets)
return {
- approvalApprovedBy: normalizeStringValue(challenge?.approvalApprovedBy) || undefined,
- approvalRejectionReason: normalizeStringValue(challenge?.approvalRejectionReason) || undefined,
- approvalStatus: approvalStatus
- || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL,
assignedMemberId: getChallengeAssignedMemberSelectorValue(challenge),
attachments: normalizeAttachments(challenge?.attachments),
billing,
challengeFee: normalizeOptionalNumber(challenge?.challengeFee),
copilot: getChallengeCopilotSelectorValue(challenge),
- createdBy: normalizeOptionalString(challenge?.createdBy),
description,
discussionForum: normalizeOptionalBoolean(challenge?.discussionForum),
funChallenge: normalizeOptionalBoolean(challenge?.funChallenge) || false,
@@ -1082,8 +1073,6 @@ export function transformFormDataToChallenge(
const reviewType = normalizeReviewType(formData.legacy?.reviewType) || REVIEW_TYPES.INTERNAL
const status = normalizeOptionalString(formData.status)
?.toUpperCase()
- const approvalStatus = normalizeOptionalString(formData.approvalStatus)
- ?.toUpperCase()
const billing = normalizeBillingInfo(formData.billing)
const prizeSets = formData.funChallenge === true
? []
@@ -1094,11 +1083,6 @@ export function transformFormDataToChallenge(
)
const challenge: Partial = {
- approvalApprovedBy: normalizeOptionalString(formData.approvalApprovedBy)
- || undefined,
- approvalRejectionReason: normalizeOptionalString(formData.approvalRejectionReason)
- || undefined,
- approvalStatus: approvalStatus || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL,
assignedMemberId: normalizeMemberSelectorValue(formData.assignedMemberId),
billing,
challengeFee: normalizeOptionalNumber(formData.challengeFee),
diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts
index 1512259be..544ad95ad 100644
--- a/src/apps/work/src/lib/utils/challenge.utils.ts
+++ b/src/apps/work/src/lib/utils/challenge.utils.ts
@@ -122,42 +122,6 @@ function getChallengeTypeName(type: string | ChallengeTypeRef | undefined): stri
return type.name
}
-/**
- * Normalizes challenge type labels for equality checks.
- * @param value Challenge type name, abbreviation, or tag value.
- * @returns Lowercase alphanumeric text with separators removed.
- * Used by `isMarathonMatchChallenge` to compare inconsistent API payload shapes.
- */
-function normalizeChallengeTypeToken(value: unknown): string {
- return typeof value === 'string'
- ? value.replace(/[^a-zA-Z0-9]/g, '')
- .toLowerCase()
- : ''
-}
-
-/**
- * Returns whether the challenge is a Marathon Match.
- * @param challenge Challenge payload from the challenge API.
- * @returns `true` when the type or tags identify Marathon Match.
- * Used by the submissions view to expose marathon-only runner log actions.
- */
-export function isMarathonMatchChallenge(challenge: Pick): boolean {
- const typeName = getChallengeTypeName(challenge.type)
- const typeAbbreviation = typeof challenge.type === 'object'
- ? challenge.type?.abbreviation
- : undefined
- const typeTokens = [
- typeName,
- typeAbbreviation,
- ...(Array.isArray(challenge.tags)
- ? challenge.tags
- : []),
- ].map(normalizeChallengeTypeToken)
-
- return typeTokens.includes('marathonmatch')
- || typeTokens.includes('mm')
-}
-
export function getStatusText(status?: string, selfService: boolean = false): string {
const normalizedStatus = normalizeStatus(status)
diff --git a/src/apps/work/src/lib/utils/engagement.utils.spec.ts b/src/apps/work/src/lib/utils/engagement.utils.spec.ts
index 2859ce0d1..1e5c51375 100644
--- a/src/apps/work/src/lib/utils/engagement.utils.spec.ts
+++ b/src/apps/work/src/lib/utils/engagement.utils.spec.ts
@@ -5,9 +5,7 @@ import {
import {
formatEngagementStatus,
fromEngagementStatusApi,
- getCountableEngagementAssignments,
getEngagementStatusPillVariant,
- normalizeEngagement,
toEngagementStatusApi,
} from './engagement.utils'
@@ -52,36 +50,4 @@ describe('engagement.utils status mappings', () => {
expect(getEngagementStatusPillVariant('Pending Assignment'))
.toBe('yellow')
})
-
- it('preserves assignment history while deriving assigned handles from active rows', () => {
- const normalized = normalizeEngagement({
- assignedMemberHandles: ['stale_member'],
- assignments: [
- {
- id: 'assignment-active',
- memberHandle: 'active_member',
- status: 'ASSIGNED',
- },
- {
- id: 'assignment-completed',
- memberHandle: 'completed_member',
- status: 'COMPLETED',
- },
- {
- id: 'assignment-terminated',
- memberHandle: 'terminated_member',
- status: 'TERMINATED',
- },
- ],
- id: 'engagement-1',
- } as any)
-
- expect(normalized.assignments.map(assignment => assignment.memberHandle))
- .toEqual(['active_member', 'completed_member', 'terminated_member'])
- expect(normalized.assignedMemberHandles)
- .toEqual(['active_member'])
- expect(getCountableEngagementAssignments(normalized.assignments)
- .map(assignment => assignment.memberHandle))
- .toEqual(['active_member'])
- })
})
diff --git a/src/apps/work/src/lib/utils/engagement.utils.ts b/src/apps/work/src/lib/utils/engagement.utils.ts
index df54c53f9..acc2dc20d 100644
--- a/src/apps/work/src/lib/utils/engagement.utils.ts
+++ b/src/apps/work/src/lib/utils/engagement.utils.ts
@@ -394,15 +394,11 @@ export function normalizeEngagement(data: Partial = {}): Engagement
const skills = normalizeEngagementSkills(data)
- const assignedMemberHandles = assignments.length > 0
- ? getCountableEngagementAssignments(assignments)
- .map(assignment => normalizeString(assignment.memberHandle))
+ const assignedMemberHandles = Array.isArray(data.assignedMemberHandles)
+ ? data.assignedMemberHandles
+ .map(value => normalizeString(value))
.filter(Boolean)
- : (Array.isArray(data.assignedMemberHandles)
- ? data.assignedMemberHandles
- .map(value => normalizeString(value))
- .filter(Boolean)
- : [])
+ : []
const countries = Array.isArray(data.countries)
? data.countries
diff --git a/src/apps/work/src/lib/utils/payment.utils.spec.ts b/src/apps/work/src/lib/utils/payment.utils.spec.ts
deleted file mode 100644
index c01154ce8..000000000
--- a/src/apps/work/src/lib/utils/payment.utils.spec.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import type {
- AssignmentPayment,
-} from '../models'
-import {
- calculatePaymentChallengeFee,
- getPaymentChallengeFee,
-} from './payment.utils'
-
-describe('payment.utils', () => {
- it('calculates payment fees from decimal or whole-number markup values', () => {
- expect(calculatePaymentChallengeFee(480, 0.15))
- .toBe(72)
- expect(calculatePaymentChallengeFee(480, 15))
- .toBe(72)
- })
-
- it('reads the persisted payment challenge fee when finance returns it explicitly', () => {
- const payment: AssignmentPayment = {
- details: [
- {
- challengeFee: 72,
- grossAmount: 480,
- totalAmount: 480,
- },
- ],
- }
-
- expect(getPaymentChallengeFee(payment))
- .toBe(72)
- })
-
- it('falls back to the total-versus-gross delta for older payment payloads', () => {
- const payment: AssignmentPayment = {
- details: [
- {
- grossAmount: 480,
- totalAmount: 552,
- },
- ],
- }
-
- expect(getPaymentChallengeFee(payment))
- .toBe(72)
- })
-})
diff --git a/src/apps/work/src/lib/utils/payment.utils.ts b/src/apps/work/src/lib/utils/payment.utils.ts
index 34c0fbf7e..a41eb8f89 100644
--- a/src/apps/work/src/lib/utils/payment.utils.ts
+++ b/src/apps/work/src/lib/utils/payment.utils.ts
@@ -30,37 +30,6 @@ function toOptionalString(value: unknown): string | undefined {
return normalized || undefined
}
-type AssignmentPaymentDetail = NonNullable[number]
-
-function getFirstPaymentDetail(
- payment: AssignmentPayment,
-): AssignmentPaymentDetail | undefined {
- return Array.isArray(payment.details) && payment.details.length > 0
- ? payment.details[0]
- : undefined
-}
-
-/**
- * Normalizes billing markup into a decimal multiplier for payment fee math.
- *
- * Stored markup can arrive as either a decimal fraction like `0.15` or a
- * whole percentage like `15`. Missing or invalid inputs return `undefined`.
- *
- * @param billingMarkup raw billing markup from project billing-account data.
- * @returns normalized decimal markup, or `undefined` when unavailable.
- */
-function normalizeBillingMarkup(billingMarkup: unknown): number | undefined {
- const parsedMarkup = toNumber(billingMarkup)
-
- if (parsedMarkup === undefined) {
- return undefined
- }
-
- return parsedMarkup > 1
- ? parsedMarkup / 100
- : parsedMarkup
-}
-
export function formatCurrency(value: unknown): string {
const parsed = toNumber(value)
@@ -76,63 +45,17 @@ export function getPaymentAmount(payment: AssignmentPayment): number | undefined
return toNumber(payment.amount)
}
- const firstDetail = getFirstPaymentDetail(payment)
-
- if (firstDetail) {
- const totalAmount = toNumber(firstDetail.totalAmount)
-
- if (totalAmount !== undefined) {
- return totalAmount
- }
-
- const grossAmount = toNumber(firstDetail.grossAmount)
+ if (Array.isArray(payment.details) && payment.details.length > 0) {
+ const firstDetail = payment.details[0]
- if (grossAmount !== undefined) {
- return grossAmount
- }
-
- return toNumber(firstDetail.amount)
+ return toNumber(firstDetail.totalAmount)
+ || toNumber(firstDetail.grossAmount)
+ || toNumber(firstDetail.amount)
}
return undefined
}
-/**
- * Resolves the persisted challenge fee associated with a payment.
- *
- * Engagement payments store the manager-entered payment amount separately from
- * the billing-account fee. When finance returns the fee explicitly, this
- * helper uses that field. For older payloads it falls back to a positive
- * `totalAmount - grossAmount` delta when present.
- *
- * @param payment payment record returned by the finance API.
- * @returns challenge fee rounded to two decimals, or `undefined` when no fee
- * is available on the payment.
- */
-export function getPaymentChallengeFee(
- payment: AssignmentPayment,
-): number | undefined {
- const firstDetail = getFirstPaymentDetail(payment)
- const persistedChallengeFee = toNumber(firstDetail?.challengeFee)
-
- if (persistedChallengeFee !== undefined && persistedChallengeFee >= 0) {
- return Number(persistedChallengeFee.toFixed(2))
- }
-
- const totalAmount = toNumber(firstDetail?.totalAmount)
- const grossAmount = toNumber(firstDetail?.grossAmount)
-
- if (
- totalAmount === undefined
- || grossAmount === undefined
- || totalAmount <= grossAmount
- ) {
- return undefined
- }
-
- return Number((totalAmount - grossAmount).toFixed(2))
-}
-
export function getPaymentStatus(payment: AssignmentPayment): string {
if (!payment.status) {
return 'Unknown'
@@ -245,33 +168,6 @@ export function calculatePaymentAmount(
return Number((parsedHoursWorked * parsedRatePerHour).toFixed(2))
}
-/**
- * Calculates the billing-account fee preview for an engagement payment.
- *
- * @param amount manager-entered payment amount before fee.
- * @param billingMarkup raw billing-account markup from the project billing
- * details. Accepts decimal or whole-percentage values.
- * @returns calculated fee rounded to two decimals, or `undefined` when the
- * inputs are incomplete or invalid.
- */
-export function calculatePaymentChallengeFee(
- amount: unknown,
- billingMarkup: unknown,
-): number | undefined {
- const parsedAmount = toNumber(amount)
- const normalizedMarkup = normalizeBillingMarkup(billingMarkup)
-
- if (
- parsedAmount === undefined
- || parsedAmount < 0
- || normalizedMarkup === undefined
- ) {
- return undefined
- }
-
- return Number((parsedAmount * normalizedMarkup).toFixed(2))
-}
-
export function getPaymentRemarks(payment: AssignmentPayment): string {
return toOptionalString(payment.attributes?.remarks) || ''
}
diff --git a/src/apps/work/src/lib/utils/permissions.utils.spec.ts b/src/apps/work/src/lib/utils/permissions.utils.spec.ts
index b6c41a8fc..9c546440d 100644
--- a/src/apps/work/src/lib/utils/permissions.utils.spec.ts
+++ b/src/apps/work/src/lib/utils/permissions.utils.spec.ts
@@ -5,10 +5,8 @@ import type { Project } from '../models'
import {
canCreateEngagement,
canViewAllEngagements,
- checkCanEditProjectDetails,
checkCanManageProject,
checkIsUserInvitedToProject,
- checkProjectAccess,
checkProjectMembership,
getProjectMemberRole,
} from './permissions.utils'
@@ -42,10 +40,6 @@ describe('permissions.utils project management helpers', () => {
role: 'customer',
userId: 456,
},
- {
- role: 'copilot',
- userId: 789,
- },
],
name: 'Managed project',
status: 'active',
@@ -82,20 +76,6 @@ describe('permissions.utils project management helpers', () => {
.toBe(false)
})
- it('requires full access membership for project details edits', () => {
- expect(checkCanEditProjectDetails(['Talent Manager'], '123', managedProject))
- .toBe(true)
- expect(checkCanEditProjectDetails(['Talent Manager'], '789', managedProject))
- .toBe(false)
- expect(checkCanEditProjectDetails(['Project Manager'], '456', managedProject))
- .toBe(false)
- })
-
- it('allows admins to edit project details without membership', () => {
- expect(checkCanEditProjectDetails(['administrator'], '999', managedProject))
- .toBe(true)
- })
-
it('limits engagement creation to admins and talent managers', () => {
expect(canCreateEngagement(['copilot']))
.toBe(false)
@@ -127,17 +107,6 @@ describe('permissions.utils project management helpers', () => {
.toBe('manager')
})
- it('allows project workspace access for admins and project members only', () => {
- expect(checkProjectAccess(['administrator'], '999', managedProject))
- .toBe(true)
- expect(checkProjectAccess(['Project Manager'], '123', managedProject))
- .toBe(true)
- expect(checkProjectAccess(['Project Manager'], '999', managedProject))
- .toBe(false)
- expect(checkProjectAccess(['Project Manager'], '123', undefined))
- .toBe(false)
- })
-
it('matches invited users by normalized user id or email', () => {
mockedDecodeToken.mockReturnValue({
email: 'tm@example.com',
diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts
index a5f33c175..1bd0bda42 100644
--- a/src/apps/work/src/lib/utils/permissions.utils.ts
+++ b/src/apps/work/src/lib/utils/permissions.utils.ts
@@ -159,18 +159,6 @@ export function canDownloadSubmissions(userRoles: string[]): boolean {
return hasDownloadSubmissionsRole(userRoles)
}
-/**
- * Returns whether the supplied roles can view marathon match runner logs.
- * @param userRoles caller roles from the decoded auth token or app context.
- * @returns `true` for admins, project managers, and copilots; otherwise `false`.
- * Used by `SubmissionsSection` to show ECS runner output only to operators.
- */
-export function canViewMarathonMatchRunnerLogs(userRoles: string[]): boolean {
- return hasAdminRole(userRoles)
- || hasManagerRole(userRoles)
- || hasCopilotRole(userRoles)
-}
-
export function canCreateTaasProject(userRoles: string[]): boolean {
return hasAdminRole(userRoles) || hasCopilotRole(userRoles)
}
@@ -279,30 +267,6 @@ export function checkProjectMembership(
return !!getProjectMemberByUserId(project, userId)
}
-/**
- * Returns whether the caller can open project-scoped workspace pages.
- *
- * Admins can access every project. Other work-app users must be listed in the
- * project's membership payload before project details or child records can be
- * displayed.
- *
- * @param userRoles caller roles from the decoded auth token or app context.
- * @param userId logged-in user identifier used for project membership checks.
- * @param project project whose access should be evaluated.
- * @returns `true` when the caller may view the project workspace; otherwise `false`.
- */
-export function checkProjectAccess(
- userRoles: string[],
- userId: number | string | undefined,
- project: Project | undefined,
-): boolean {
- if (!project) {
- return false
- }
-
- return hasAdminRole(userRoles) || checkProjectMembership(project, userId)
-}
-
/**
* Returns whether the caller can manage project ownership and billing flows.
*
@@ -338,36 +302,6 @@ export function checkCanManageProject(
|| normalizedRole === PROJECT_ROLES.MANAGER
}
-/**
- * Returns whether the caller can edit an existing project's core details.
- *
- * Admins always qualify. Non-admin callers must hold a manager-tier user role
- * and the project's Full Access membership. Copilot membership is excluded so
- * copilot users can keep other write access without changing project details.
- *
- * @param userRoles caller roles from the decoded auth token or app context.
- * @param userId logged-in user identifier used for project membership checks.
- * @param project project context for the edit check.
- * @returns `true` when the caller can edit project details.
- */
-export function checkCanEditProjectDetails(
- userRoles: string[],
- userId: number | string | undefined,
- project: Project | undefined,
-): boolean {
- if (hasAdminRole(userRoles)) {
- return true
- }
-
- if (!project || !hasManagerRole(userRoles)) {
- return false
- }
-
- const normalizedRole = normalizeValue(getProjectMemberByUserId(project, userId)?.role)
-
- return normalizedRole === PROJECT_ROLES.MANAGER
-}
-
export function checkAdminOrPmOrTaskManager(
token: string,
project?: Project,
diff --git a/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts b/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts
index 481a80ca8..81e557ed3 100644
--- a/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts
+++ b/src/apps/work/src/lib/utils/project-billing-account.utils.spec.ts
@@ -1,27 +1,12 @@
import type { ProjectBillingAccount } from '../services'
import {
- calculateMemberPaymentAmount,
- calculateMemberPaymentsRemaining,
- getBillingAccountBudgetInfo,
- getCopilotMemberPaymentsBudgetInfo,
getProjectBillingAccountChallengeErrorMessage,
getProjectBillingAccountChallengeIssue,
getProjectBillingAccountNoticeMessage,
} from './project-billing-account.utils'
describe('project-billing-account challenge gating helpers', () => {
- it('treats missing billing accounts as blocked only when required', () => {
- expect(getProjectBillingAccountChallengeIssue(undefined))
- .toBeUndefined()
- expect(getProjectBillingAccountChallengeIssue(undefined, true))
- .toBe('missing')
- expect(getProjectBillingAccountNoticeMessage('missing'))
- .toBe('This project does not have a billing account.')
- expect(getProjectBillingAccountChallengeErrorMessage('missing'))
- .toBe('Cannot launch challenges because this project does not have a billing account.')
- })
-
it('treats inactive billing accounts as blocked for challenges', () => {
const billingAccount: ProjectBillingAccount = {
active: false,
@@ -78,54 +63,4 @@ describe('project-billing-account challenge gating helpers', () => {
expect(getProjectBillingAccountChallengeIssue(billingAccount))
.toBeUndefined()
})
-
- it('calculates standard billing budget info from locked and consumed totals', () => {
- expect(getBillingAccountBudgetInfo({
- budget: 1000,
- consumedBudget: 225,
- lockedBudget: 125,
- totalBudgetRemaining: 650,
- }))
- .toEqual({
- spent: 350,
- status: 'healthy',
- totalBudget: 1000,
- totalBudgetRemaining: 650,
- })
- })
-
- it('calculates copilot member payments remaining without exposing markup', () => {
- expect(calculateMemberPaymentAmount(125.25, 0.25))
- .toBe(100.20)
- expect(calculateMemberPaymentsRemaining(250, 0.25))
- .toBe(200)
- expect(getCopilotMemberPaymentsBudgetInfo({
- budget: 1000,
- consumedBudget: 500,
- lockedBudget: 250,
- memberPaymentsRemaining: 200,
- totalBudgetRemaining: 250,
- }))
- .toEqual({
- memberPaymentsRemaining: 200,
- spent: 750,
- status: 'warning',
- totalBudget: 1000,
- totalBudgetRemaining: 250,
- })
- expect(getCopilotMemberPaymentsBudgetInfo({
- budget: 1000,
- consumedBudget: 500,
- lockedBudget: 250,
- markup: 0.25,
- totalBudgetRemaining: 250,
- }))
- .toEqual({
- memberPaymentsRemaining: 200,
- spent: 750,
- status: 'warning',
- totalBudget: 1000,
- totalBudgetRemaining: 250,
- })
- })
})
diff --git a/src/apps/work/src/lib/utils/project-billing-account.utils.ts b/src/apps/work/src/lib/utils/project-billing-account.utils.ts
index 832372483..5bab7f3a2 100644
--- a/src/apps/work/src/lib/utils/project-billing-account.utils.ts
+++ b/src/apps/work/src/lib/utils/project-billing-account.utils.ts
@@ -1,27 +1,6 @@
import type { ProjectBillingAccount } from '../services'
-type ProjectBillingAccountChallengeIssue = 'expired' | 'inactive' | 'insufficient-funds' | 'missing'
-export type BillingAccountBudgetStatus = 'healthy' | 'warning' | 'critical'
-
-export interface BillingAccountBudgetSource {
- budget?: number | string
- consumedBudget?: number | string
- lockedBudget?: number | string
- markup?: number | string
- memberPaymentsRemaining?: number | string
- totalBudgetRemaining?: number | string
-}
-
-export interface BillingAccountBudgetInfo {
- spent: number
- status: BillingAccountBudgetStatus
- totalBudget: number
- totalBudgetRemaining: number
-}
-
-export interface CopilotMemberPaymentsBudgetInfo extends BillingAccountBudgetInfo {
- memberPaymentsRemaining: number
-}
+type ProjectBillingAccountChallengeIssue = 'expired' | 'inactive' | 'insufficient-funds'
/**
* Normalizes an optional billing-account string value for challenge gating checks.
@@ -60,175 +39,6 @@ function normalizeOptionalNumber(value: unknown): number | undefined {
: undefined
}
-/**
- * Normalizes billing markup into the decimal value used by billing-account math.
- *
- * @param value Raw markup value from the billing-account API.
- * @returns A non-negative decimal markup, or `undefined` when unavailable.
- * @remarks Whole percentage values such as `15` are normalized to `0.15`.
- */
-function normalizeBillingMarkup(value: unknown): number | undefined {
- const normalizedValue = normalizeOptionalNumber(value)
-
- if (normalizedValue === undefined || normalizedValue < 0) {
- return undefined
- }
-
- return normalizedValue > 1
- ? normalizedValue / 100
- : normalizedValue
-}
-
-/**
- * Resolves the display color state for billing-account remaining budget.
- *
- * @param remaining Remaining billing-account budget.
- * @param total Total billing-account budget.
- * @returns Healthy, warning, or critical status for the remaining percentage.
- * @remarks Used by project billing badges, including the copilot-safe member
- * payments remaining display.
- */
-export function getBillingAccountBudgetStatus(
- remaining: number,
- total: number,
-): BillingAccountBudgetStatus {
- if (total <= 0) {
- return 'healthy'
- }
-
- const percentage = (remaining / total) * 100
-
- if (percentage < 10) {
- return 'critical'
- }
-
- if (percentage < 30) {
- return 'warning'
- }
-
- return 'healthy'
-}
-
-/**
- * Calculates a copilot-safe member payment amount from a billing-account amount.
- *
- * @param billingAccountAmount Billing-account amount with markup already accounted for.
- * @param markup Hidden billing-account markup multiplier.
- * @returns Member payment amount, or `undefined` when required values are unavailable.
- * @remarks Product requires copilot-safe amounts to be calculated as
- * `billing account amount / (1 + markup)` so copilots can see the underlying
- * payment value without seeing the markup itself. A zero markup means no fee
- * has been added, so the full billing-account amount is available for member
- * payments.
- */
-export function calculateMemberPaymentAmount(
- billingAccountAmount: unknown,
- markup: unknown,
-): number | undefined {
- const amount = normalizeOptionalNumber(billingAccountAmount)
- const normalizedMarkup = normalizeBillingMarkup(markup)
-
- if (amount === undefined || normalizedMarkup === undefined) {
- return undefined
- }
-
- if (normalizedMarkup === 0) {
- return Number(amount.toFixed(2))
- }
-
- return Number((amount / (1 + normalizedMarkup)).toFixed(2))
-}
-
-/**
- * Calculates the copilot-safe member payment capacity for a billing account.
- *
- * @param totalBudgetRemaining Remaining billing-account budget.
- * @param markup Hidden billing-account markup multiplier.
- * @returns Member payments remaining, or `undefined` when required values are unavailable.
- * @remarks Used by project billing displays so copilots can see remaining
- * member payment capacity without seeing markup or total budget values.
- */
-export function calculateMemberPaymentsRemaining(
- totalBudgetRemaining: unknown,
- markup: unknown,
-): number | undefined {
- return calculateMemberPaymentAmount(totalBudgetRemaining, markup)
-}
-
-/**
- * Resolves standard billing-account spent, total, and remaining values.
- *
- * @param billingAccount Billing-account data returned by project or billing APIs.
- * @returns Budget info for manager/admin displays, or `undefined` when budget
- * data is incomplete.
- * @remarks Remaining budget is preferred for the spent value so project-page
- * notices keep their previous display behavior. Locked and consumed totals are
- * used only when remaining budget is unavailable.
- */
-export function getBillingAccountBudgetInfo(
- billingAccount: BillingAccountBudgetSource | undefined,
-): BillingAccountBudgetInfo | undefined {
- const totalBudget = normalizeOptionalNumber(billingAccount?.budget)
-
- if (totalBudget === undefined) {
- return undefined
- }
-
- const lockedBudget = normalizeOptionalNumber(billingAccount?.lockedBudget)
- const consumedBudget = normalizeOptionalNumber(billingAccount?.consumedBudget)
- const totalBudgetRemaining = normalizeOptionalNumber(billingAccount?.totalBudgetRemaining)
- let spent: number | undefined
- let remaining: number | undefined
-
- if (totalBudgetRemaining !== undefined) {
- spent = totalBudget - totalBudgetRemaining
- remaining = totalBudgetRemaining
- } else if (lockedBudget !== undefined || consumedBudget !== undefined) {
- spent = (lockedBudget || 0) + (consumedBudget || 0)
- remaining = totalBudget - spent
- }
-
- if (spent === undefined || remaining === undefined) {
- return undefined
- }
-
- return {
- spent: Math.max(spent, 0),
- status: getBillingAccountBudgetStatus(remaining, totalBudget),
- totalBudget,
- totalBudgetRemaining: remaining,
- }
-}
-
-/**
- * Resolves the copilot-safe member payments remaining view for a billing account.
- *
- * @param billingAccount Billing-account data returned by project or billing APIs.
- * @returns Member payment capacity plus budget status, or `undefined` when
- * budget or member-payment data is incomplete.
- * @remarks Used only for copilot displays so manager/admin users continue to
- * see the standard locked/consumed and total budget values.
- */
-export function getCopilotMemberPaymentsBudgetInfo(
- billingAccount: BillingAccountBudgetSource | undefined,
-): CopilotMemberPaymentsBudgetInfo | undefined {
- const budgetInfo = getBillingAccountBudgetInfo(billingAccount)
- const memberPaymentsRemaining = normalizeOptionalNumber(billingAccount?.memberPaymentsRemaining)
- ?? calculateMemberPaymentsRemaining(
- billingAccount?.totalBudgetRemaining,
- billingAccount?.markup,
- )
-
- if (!budgetInfo || memberPaymentsRemaining === undefined) {
- return undefined
- }
-
- return {
- ...budgetInfo,
- memberPaymentsRemaining,
- }
-}
-
/**
* Resolves the effective billing-account active flag from either the boolean `active`
* field or the textual `status` field returned by upstream APIs.
@@ -301,19 +111,13 @@ function isBillingAccountExpired(
* Resolves whether a project billing account should block challenge launch actions.
*
* @param billingAccount Project billing-account payload resolved in the work app.
- * @param requiresBillingAccount Whether a missing billing account should block the action.
* @returns The blocking reason, or `undefined` when the billing account can be used.
* @remarks Used by the challenge editor and project billing notices so the work app
* matches the legacy launch restriction for inactive, expired, and depleted billing accounts.
*/
export function getProjectBillingAccountChallengeIssue(
billingAccount: ProjectBillingAccount | undefined,
- requiresBillingAccount: boolean = false,
): ProjectBillingAccountChallengeIssue | undefined {
- if (!billingAccount && requiresBillingAccount) {
- return 'missing'
- }
-
const active = resolveBillingAccountActive(billingAccount)
if (active === false) {
@@ -344,8 +148,6 @@ export function getProjectBillingAccountNoticeMessage(
issue: ProjectBillingAccountChallengeIssue,
): string {
switch (issue) {
- case 'missing':
- return 'This project does not have a billing account.'
case 'inactive':
return 'The billing account for this project is inactive.'
case 'expired':
@@ -369,8 +171,6 @@ export function getProjectBillingAccountChallengeErrorMessage(
issue: ProjectBillingAccountChallengeIssue,
): string {
switch (issue) {
- case 'missing':
- return 'Cannot launch challenges because this project does not have a billing account.'
case 'inactive':
return 'Cannot launch challenges because the project billing account is inactive.'
case 'expired':
diff --git a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx
index 4a53baa5c..3cd56360b 100644
--- a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx
+++ b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx
@@ -4,7 +4,6 @@ import type { Context, PropsWithChildren, ReactNode } from 'react'
import {
render,
screen,
- within,
} from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
@@ -14,10 +13,6 @@ import {
useFetchProjectAttachments,
useFetchProjectMembers,
} from '../../../lib/hooks'
-import {
- checkCanEditProjectDetails,
- checkCanManageProject,
-} from '../../../lib/utils'
import { ProjectAssetsPage } from './ProjectAssetsPage'
@@ -121,15 +116,12 @@ jest.mock('../../../lib/services', () => ({
updateProjectAttachment: jest.fn(),
}))
jest.mock('../../../lib/utils', () => ({
- checkCanEditProjectDetails: jest.fn(() => false),
checkCanManageProject: jest.fn(() => false),
}))
const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjectAttachments = useFetchProjectAttachments as jest.Mock
const mockedUseFetchProjectMembers = useFetchProjectMembers as jest.Mock
-const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
-const mockedCheckCanManageProject = checkCanManageProject as jest.Mock
const defaultContextValue: WorkAppContextModel = {
isAdmin: true,
@@ -168,8 +160,6 @@ function renderPage(
describe('ProjectAssetsPage', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockedCheckCanEditProjectDetails.mockReturnValue(false)
- mockedCheckCanManageProject.mockReturnValue(false)
mockedUseFetchProject.mockReturnValue({
error: undefined,
@@ -211,62 +201,4 @@ describe('ProjectAssetsPage', () => {
expect(screen.getByText('Assets Library'))
.toBeTruthy()
})
-
- it('hides project edit action when a copilot can manage but cannot edit project details', () => {
- mockedCheckCanManageProject.mockReturnValue(true)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- members: [
- {
- role: 'copilot',
- userId: 12345,
- },
- ],
- name: 'Copilot Project',
- },
- })
-
- renderPage('/projects/200/assets', {
- ...defaultContextValue,
- isAdmin: false,
- isCopilot: true,
- loginUserInfo: {
- email: 'copilot@example.com',
- exp: 0,
- handle: 'copilot-user',
- iat: 0,
- roles: ['copilot'],
- userId: 12345,
- } as WorkAppContextModel['loginUserInfo'],
- userRoles: ['copilot'],
- })
-
- expect(within(screen.getByTestId('page-title-action'))
- .queryByRole('link', { name: 'Edit project' }))
- .toBeNull()
- })
-
- it('shows project edit action when project detail editing is allowed', () => {
- mockedCheckCanEditProjectDetails.mockReturnValue(true)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- name: 'Payment Testing',
- },
- })
-
- renderPage('/projects/200/assets')
-
- expect(within(screen.getByTestId('page-title-action'))
- .getByRole('link', { name: 'Edit project' })
- .getAttribute('href'))
- .toBe('/projects/200/edit')
- })
})
diff --git a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx
index 4f486ac03..f458b285d 100644
--- a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx
+++ b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx
@@ -58,10 +58,7 @@ import {
removeProjectAttachment,
updateProjectAttachment,
} from '../../../lib/services'
-import {
- checkCanEditProjectDetails,
- checkCanManageProject,
-} from '../../../lib/utils'
+import { checkCanManageProject } from '../../../lib/utils'
import styles from './ProjectAssetsPage.module.scss'
@@ -327,12 +324,6 @@ export const ProjectAssetsPage: FC = () => {
workAppContext.loginUserInfo?.userId,
projectResult.project,
)
- const canEditProjectDetails = !!projectResult.project
- && checkCanEditProjectDetails(
- workAppContext.userRoles,
- workAppContext.loginUserInfo?.userId,
- projectResult.project,
- )
const [activeTab, setActiveTab] = useState('files')
const [isOpeningPicker, setIsOpeningPicker] = useState(false)
@@ -748,9 +739,6 @@ export const ProjectAssetsPage: FC = () => {
billingAccountId={projectResult.project?.billingAccountId}
billingAccountName={projectResult.project?.billingAccountName}
canManageProject={canManageProject}
- displayMemberPaymentDetailsToCopilots={
- projectResult.project?.details?.displayMemberPaymentDetailsToCopilots
- }
projectId={projectId}
/>
)
@@ -758,7 +746,7 @@ export const ProjectAssetsPage: FC = () => {
const titleAction = projectId
? (
- {canEditProjectDetails
+ {canManageProject
? (
{
})
jest.mock('../../../lib/hooks', () => ({
useFetchChallenge: jest.fn(),
- useFetchProject: jest.fn(),
useFetchResourceRoles: jest.fn(() => ({
resourceRoles: [],
})),
@@ -174,7 +168,6 @@ jest.mock('../../../lib/services', () => ({
patchChallenge: jest.fn(),
}))
jest.mock('../../../lib/utils', () => ({
- checkProjectAccess: jest.fn(() => true),
extractErrorMessage: jest.fn(() => 'Error'),
getStatusText: jest.fn((status?: string) => status || ''),
isChallengeCompletedOrCancelled: jest.fn(),
@@ -247,26 +240,17 @@ jest.mock('./ChallengeEditorPage.utils', () => ({
}))
const mockedUseFetchChallenge = useFetchChallenge as jest.Mock
-const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchResourceRoles = useFetchResourceRoles as jest.Mock
const mockedUseFetchResources = useFetchResources as jest.Mock
const mockedDeleteChallenge = deleteChallenge as jest.Mock
-const mockedCheckProjectAccess = checkProjectAccess as jest.Mock
const mockedIsChallengeCompletedOrCancelled = isChallengeCompletedOrCancelled as jest.Mock
const mockedGetAssignedTaskMember = getAssignedTaskMember as jest.Mock
const mockedShouldShowCompleteTaskAction = shouldShowCompleteTaskAction as jest.Mock
-/**
- * Builds the routed challenge editor test tree.
- *
- * @param route concrete route used as the initial memory history entry.
- * @param path route pattern registered for the page under test.
- * @returns the challenge editor element wrapped with router and work context providers.
- */
-function renderPageElement(route: string, path: string): JSX.Element {
+function renderPage(route: string, path: string): void {
const MockWorkAppContext = mockWorkAppContext
- return (
+ render(
} />
-
+ ,
)
}
-/**
- * Renders the challenge editor page in a memory router for route-level assertions.
- *
- * @param route concrete route used as the initial memory history entry.
- * @param path route pattern registered for the page under test.
- * @returns the React Testing Library render result.
- */
-function renderPage(route: string, path: string): ReturnType
{
- return render(renderPageElement(route, path))
-}
-
describe('ChallengeEditorPage', () => {
beforeEach(() => {
jest.clearAllMocks()
@@ -309,26 +282,12 @@ describe('ChallengeEditorPage', () => {
id: '456',
name: 'Edit test',
prizeSets: [],
- projectId: '123',
status: 'DRAFT',
},
error: undefined,
isLoading: false,
mutate: jest.fn(),
})
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: '123',
- members: [{
- userId: 12345,
- }],
- name: 'Allowed Project',
- status: 'active',
- },
- })
- mockedCheckProjectAccess.mockReturnValue(true)
mockedUseFetchResourceRoles.mockReturnValue({
resourceRoles: [],
})
@@ -437,76 +396,6 @@ describe('ChallengeEditorPage', () => {
.toBe('lg')
})
- it('blocks project-scoped challenge views when project access is denied', async () => {
- mockedCheckProjectAccess.mockReturnValue(false)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: '123',
- members: [{
- userId: 99999,
- }],
- name: 'Restricted Project',
- status: 'active',
- },
- })
-
- renderPage(
- '/projects/123/challenges/456/view',
- '/projects/:projectId/challenges/:challengeId/view',
- )
-
- await waitFor(() => {
- expect(screen.getByText('You don’t have access to this project. Please contact support@topcoder.com.'))
- .toBeTruthy()
- })
-
- expect(mockedUseFetchChallenge)
- .toHaveBeenCalledWith(undefined)
- expect(screen.queryByText('Challenge View Form'))
- .toBeNull()
- expect(screen.queryByRole('heading', { name: 'View Edit test' }))
- .toBeNull()
- expect(screen.queryByRole('button', { name: 'Edit' }))
- .toBeNull()
- })
-
- it('blocks unscoped challenge views after resolving the challenge project', async () => {
- mockedCheckProjectAccess.mockReturnValue(false)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: '123',
- members: [{
- userId: 99999,
- }],
- name: 'Restricted Project',
- status: 'active',
- },
- })
-
- renderPage(
- '/challenges/456/view',
- '/challenges/:challengeId/view',
- )
-
- await waitFor(() => {
- expect(screen.getByText('You don’t have access to this project. Please contact support@topcoder.com.'))
- .toBeTruthy()
- })
-
- expect(mockedUseFetchChallenge)
- .toHaveBeenCalledWith('456')
- expect(mockedUseFetchProject)
- .toHaveBeenCalledWith('123')
- expect(screen.queryByText('Challenge View Form'))
- .toBeNull()
- expect(screen.queryByRole('heading', { name: 'View Edit test' }))
- .toBeNull()
- })
-
it('treats trailing-slash view routes as read-only mode', async () => {
renderPage(
'/projects/123/challenges/456/view/',
@@ -792,69 +681,4 @@ describe('ChallengeEditorPage', () => {
.toHaveBeenCalledWith('789')
})
})
-
- it('clears stale header state after a fresh challenge fetch 403', async () => {
- const mutate = jest.fn()
- let challengeResult: UseFetchChallengeResult = {
- challenge: {
- discussions: [{
- url: 'https://example.com/forum/challenges/456',
- }],
- id: '456',
- name: 'Edit test',
- prizeSets: [],
- status: 'DRAFT',
- },
- error: undefined,
- isError: false,
- isLoading: false,
- mutate,
- }
- mockedUseFetchChallenge.mockImplementation(() => challengeResult)
-
- const route = '/projects/123/challenges/456/edit'
- const path = '/projects/:projectId/challenges/:challengeId/edit'
- const renderResult = renderPage(route, path)
-
- await waitFor(() => {
- expect(screen.getByText('Challenge Editor Form'))
- .toBeTruthy()
- })
-
- expect(within(screen.getByTestId('title-action'))
- .getByText('DRAFT'))
- .toBeTruthy()
- expect(within(screen.getByTestId('right-header'))
- .getByRole('link', { name: 'Challenge' }))
- .toBeTruthy()
- expect(within(screen.getByTestId('right-header'))
- .getByRole('button', { name: 'Launch' }))
- .toBeTruthy()
-
- challengeResult = {
- challenge: undefined,
- error: Object.assign(new Error('Forbidden'), { status: 403 }),
- isError: true,
- isLoading: false,
- mutate,
- }
- renderResult.rerender(renderPageElement(route, path))
-
- await waitFor(() => {
- expect(screen.getByText('Forbidden'))
- .toBeTruthy()
- })
- expect(within(screen.getByTestId('title-action'))
- .queryByText('DRAFT'))
- .toBeNull()
- expect(within(screen.getByTestId('right-header'))
- .queryByRole('link', { name: 'Challenge' }))
- .toBeNull()
- expect(within(screen.getByTestId('right-header'))
- .queryByRole('button', { name: 'Launch' }))
- .toBeNull()
- expect(within(screen.getByTestId('right-header'))
- .queryByRole('button', { name: 'Cancel' }))
- .toBeNull()
- })
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx
index 4e18f5851..e728c4830 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx
@@ -34,20 +34,15 @@ import {
import { WorkAppContext } from '../../../lib/contexts'
import {
useFetchChallenge,
- useFetchProject,
useFetchResourceRoles,
useFetchResources,
} from '../../../lib/hooks'
-import type {
- UseFetchChallengeResult,
- UseFetchProjectResult,
-} from '../../../lib/hooks'
+import type { UseFetchChallengeResult } from '../../../lib/hooks'
import {
deleteChallenge,
patchChallenge,
} from '../../../lib/services'
import {
- checkProjectAccess,
extractErrorMessage,
getStatusText,
isChallengeCompletedOrCancelled,
@@ -90,7 +85,6 @@ const CANCEL_CHALLENGE_STATUSES: string[] = [
const NO_TASK_ASSIGNEE_MESSAGE = 'Task is not assigned yet'
const MARK_COMPLETE_TOOLTIP_MESSAGE
= 'This will close the task and generate a payment for the assignee and copilot.'
-const PROJECT_ACCESS_DENIED_MESSAGE = 'You don’t have access to this project. Please contact support@topcoder.com.'
interface EditorTabsProps {
activeTab: EditorTab
@@ -128,8 +122,6 @@ interface ChallengeEditorBodyProps {
canLaunchChallenge: boolean
challengeId?: string
challengeResult: UseFetchChallengeResult
- isProjectAccessDenied: boolean
- isProjectAccessLoading: boolean
isExistingChallenge: boolean
isLaunchDisabled: boolean
isReadOnly: boolean
@@ -148,75 +140,7 @@ interface ChallengeEditorBodyProps {
interface ChallengeQuickLinksProps {
challenge: UseFetchChallengeResult['challenge']
-}
-
-interface ChallengeProjectAccessParams {
- hasChallengeProjectMismatch: boolean
- isProjectLoading: boolean
- project?: UseFetchProjectResult['project']
- projectError?: Error
- projectId?: string
- userId?: number | string
- userRoles: string[]
-}
-
-interface ChallengeProjectAccessState {
- isDenied: boolean
- isLoading: boolean
-}
-
-/**
- * Normalizes a project identifier from route, challenge, or created challenge data.
- *
- * @param projectId project id value that may be a number, string, or absent.
- * @returns trimmed project id text when present; otherwise `undefined`.
- */
-function normalizeProjectId(projectId: number | string | undefined): string | undefined {
- if (projectId === undefined || projectId === null) {
- return undefined
- }
-
- const normalizedProjectId = String(projectId)
- .trim()
-
- return normalizedProjectId || undefined
-}
-
-/**
- * Resolves whether challenge editor content can render for a project-owned challenge.
- *
- * @param params project lookup, route/challenge ownership, and caller identity state.
- * @returns loading and denied flags used to hide challenge details until access is confirmed.
- */
-function resolveChallengeProjectAccess(
- params: ChallengeProjectAccessParams,
-): ChallengeProjectAccessState {
- if (!params.projectId) {
- return {
- isDenied: params.hasChallengeProjectMismatch,
- isLoading: false,
- }
- }
-
- if (params.isProjectLoading) {
- return {
- isDenied: false,
- isLoading: true,
- }
- }
-
- const hasProjectAccess = checkProjectAccess(
- params.userRoles,
- params.userId,
- params.project,
- )
-
- return {
- isDenied: params.hasChallengeProjectMismatch
- || !!params.projectError
- || !hasProjectAccess,
- isLoading: false,
- }
+ challengeId?: string
}
function getErrorMessage(error: Error | undefined): string {
@@ -286,7 +210,6 @@ function shouldShowDeleteAction(
function useResolvedChallengeStatus(
challengeId: string | undefined,
fetchedChallengeStatus: string | undefined,
- shouldResetChallengeStatus: boolean,
): [
string | undefined,
(status?: string) => void,
@@ -299,15 +222,6 @@ function useResolvedChallengeStatus(
setChallengeStatus(undefined)
}, [challengeId])
- useEffect(() => {
- if (!shouldResetChallengeStatus) {
- return
- }
-
- lastFetchedChallengeStatusRef.current = undefined
- setChallengeStatus(undefined)
- }, [shouldResetChallengeStatus])
-
useEffect(() => {
if (
!fetchedChallengeStatus
@@ -787,11 +701,7 @@ function renderLaunchModal(params: RenderLaunchModalParams): JSX.Element | undef
confirmText={params.isLaunching
? 'Launching...'
: 'Launch'}
- message={
- `Are you ready to launch challenge ${params.challengeName}?
-
-Prizes and copilot fees are locked after launch. Contact the Project Manager for any updates post-launch.`
- }
+ message={`Are you ready to launch challenge ${params.challengeName}?`}
onCancel={params.onLaunchCancel}
onConfirm={params.onLaunchConfirmClick}
title='Launch Challenge'
@@ -942,14 +852,6 @@ const ChallengeEditorContent: FC = (
const ChallengeEditorBody: FC = (
props: ChallengeEditorBodyProps,
) => {
- if (props.isProjectAccessLoading) {
- return
- }
-
- if (props.isProjectAccessDenied) {
- return
- }
-
if (props.challengeResult.isLoading) {
return
}
@@ -998,7 +900,7 @@ const ChallengeEditorBody: FC = (
function renderChallengeQuickLinks(
props: ChallengeQuickLinksProps,
): JSX.Element | undefined {
- const resolvedChallengeId = props.challenge?.id
+ const resolvedChallengeId = props.challenge?.id || props.challengeId
if (!resolvedChallengeId) {
return undefined
@@ -1052,7 +954,7 @@ export const ChallengeEditorPage: FC = () => {
const params: Readonly<{ challengeId?: string; projectId?: string }>
= useParams<'challengeId' | 'projectId'>()
const challengeId = params.challengeId
- const routeProjectId = normalizeProjectId(params.projectId)
+ const routeProjectId = params.projectId
const isExistingChallenge = !!challengeId
const isViewMode = isChallengeEditorViewPath(location.pathname)
@@ -1065,34 +967,13 @@ export const ChallengeEditorPage: FC = () => {
const [createdChallenge, setCreatedChallenge] = useState()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showLaunchModal, setShowLaunchModal] = useState(false)
- const workAppContext = useContext(WorkAppContext)
- const routeProjectResult: UseFetchProjectResult = useFetchProject(routeProjectId)
- const canFetchProjectRouteChallenge = !routeProjectId
- || (
- !routeProjectResult.isLoading
- && !routeProjectResult.error
- && checkProjectAccess(
- workAppContext.userRoles,
- workAppContext.loginUserInfo?.userId,
- routeProjectResult.project,
- )
- )
- const challengeResult: UseFetchChallengeResult = useFetchChallenge(
- canFetchProjectRouteChallenge
- ? challengeId
- : undefined,
- )
+ const challengeResult: UseFetchChallengeResult = useFetchChallenge(challengeId)
const [
challengeStatus,
handleChallengeStatusChange,
] = useResolvedChallengeStatus(
challengeId,
challengeResult.challenge?.status,
- isExistingChallenge
- && (
- challengeResult.isError
- || (!challengeResult.isLoading && !challengeResult.challenge)
- ),
)
const handleRetry = useCallback((): void => {
challengeResult.mutate()
@@ -1118,13 +999,12 @@ export const ChallengeEditorPage: FC = () => {
handleChallengeStatusChange(challenge.status)
}, [handleChallengeStatusChange])
- const challengeProjectId = normalizeProjectId(challengeResult.challenge?.projectId)
- const createdChallengeProjectId = normalizeProjectId(createdChallenge?.projectId)
- const challengeProjectResult: UseFetchProjectResult = useFetchProject(
- routeProjectId
- ? undefined
- : challengeProjectId,
- )
+ const challengeProjectId = challengeResult.challenge?.projectId
+ ? String(challengeResult.challenge.projectId)
+ : undefined
+ const createdChallengeProjectId = createdChallenge?.projectId
+ ? String(createdChallenge.projectId)
+ : undefined
const projectId = routeProjectId || challengeProjectId || createdChallengeProjectId
const persistedChallengeId = challengeId || createdChallenge?.id
const isCreatedChallenge = !isExistingChallenge && !!createdChallenge?.id
@@ -1157,22 +1037,18 @@ export const ChallengeEditorPage: FC = () => {
}
}, [challengeId])
- const hasSuccessfulCurrentChallengeFetch = !isExistingChallenge
- || (
- !challengeResult.isError
- && !challengeResult.isLoading
- && !!challengeResult.challenge
- )
- const currentChallenge = hasSuccessfulCurrentChallengeFetch
- ? challengeResult.challenge
- : undefined
+ const pageTitle = getChallengeEditorPageTitle(
+ challengeId,
+ isViewMode,
+ challengeResult.challenge?.name,
+ )
const effectiveChallengeStatus = challengeStatus
|| createdChallenge?.status
- || currentChallenge?.status
- const headerChallenge = currentChallenge
+ || challengeResult.challenge?.status
+ const headerChallenge = challengeResult.challenge
? {
- ...currentChallenge,
- status: effectiveChallengeStatus || currentChallenge.status,
+ ...challengeResult.challenge,
+ status: effectiveChallengeStatus || challengeResult.challenge.status,
}
: undefined
const canLaunchChallenge = shouldShowLaunchAction(
@@ -1298,51 +1174,21 @@ export const ChallengeEditorPage: FC = () => {
navigate(editChallengePath)
}, [editChallengePath, navigate])
- const projectAccessResult = routeProjectId
- ? routeProjectResult
- : challengeProjectResult
- const hasChallengeProjectMismatch = !!routeProjectId
- && !!challengeProjectId
- && routeProjectId !== challengeProjectId
- const projectAccessState = resolveChallengeProjectAccess({
- hasChallengeProjectMismatch,
- isProjectLoading: projectAccessResult.isLoading,
- project: projectAccessResult.project,
- projectError: projectAccessResult.error,
- projectId,
- userId: workAppContext.loginUserInfo?.userId,
- userRoles: workAppContext.userRoles,
- })
- const canRenderChallengeDetails = !projectAccessState.isDenied && !projectAccessState.isLoading
- const pageTitle = getChallengeEditorPageTitle(
- challengeId,
- isViewMode,
- canRenderChallengeDetails
- ? challengeResult.challenge?.name
- : undefined,
- )
const challengeQuickLinks = renderChallengeQuickLinks({
- challenge: canRenderChallengeDetails
- ? currentChallenge
- : undefined,
+ challenge: challengeResult.challenge,
+ challengeId,
})
- const canEditChallenge = hasSuccessfulCurrentChallengeFetch
- && canRenderChallengeDetails
- && isViewMode
+ const canEditChallenge = isViewMode
&& !!editChallengePath
&& !isChallengeCompletedOrCancelled(effectiveChallengeStatus)
const rightHeader = renderHeaderAction({
- canCancelChallenge: canRenderChallengeDetails && canCancelChallenge,
- canCompleteTask: canRenderChallengeDetails && canCompleteTask,
- canDeleteChallenge: canRenderChallengeDetails && canDeleteChallenge,
+ canCancelChallenge,
+ canCompleteTask,
+ canDeleteChallenge,
canEditChallenge,
- canLaunchChallenge: canRenderChallengeDetails && canLaunchChallenge,
- challenge: canRenderChallengeDetails
- ? headerChallenge
- : undefined,
- challengeId: canRenderChallengeDetails
- ? persistedChallengeId
- : undefined,
+ canLaunchChallenge,
+ challenge: headerChallenge,
+ challengeId: persistedChallengeId,
challengeName: launchChallengeName,
challengeQuickLinks,
isDeleting,
@@ -1354,7 +1200,7 @@ export const ChallengeEditorPage: FC = () => {
onLaunchOpen: handleLaunchOpen,
})
const deleteModal = renderDeleteModal({
- canDeleteChallenge: canRenderChallengeDetails && canDeleteChallenge,
+ canDeleteChallenge,
challengeName: deleteChallengeName,
isDeleting,
onDeleteCancel: handleDeleteCancel,
@@ -1362,7 +1208,7 @@ export const ChallengeEditorPage: FC = () => {
showDeleteModal,
})
const launchModal = renderLaunchModal({
- canLaunchChallenge: canRenderChallengeDetails && canLaunchChallenge,
+ canLaunchChallenge,
challengeName: launchChallengeName,
isLaunching,
onLaunchCancel: handleLaunchCancel,
@@ -1370,22 +1216,18 @@ export const ChallengeEditorPage: FC = () => {
showLaunchModal,
})
const titleAction = renderTitleAction(
- canRenderChallengeDetails
- && ((isExistingChallenge && hasSuccessfulCurrentChallengeFetch) || isCreatedChallenge),
+ isExistingChallenge || isCreatedChallenge,
effectiveChallengeStatus,
)
const launchButtonLabel = isLaunching
? 'Launching...'
: 'Launch'
const isLaunchDisabled = isLaunching || isSavingChallenge
- const backUrl = canRenderChallengeDetails
- ? challengesListPath
- : getChallengesListPath()
return (
<>
{
canLaunchChallenge={canLaunchChallenge}
challengeId={challengeId}
challengeResult={challengeResult}
- isProjectAccessDenied={projectAccessState.isDenied}
- isProjectAccessLoading={projectAccessState.isLoading}
isExistingChallenge={isExistingChallenge}
isLaunchDisabled={isLaunchDisabled}
isReadOnly={isViewMode}
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
index 24527c587..88c9876a3 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
@@ -5,8 +5,6 @@
- `ChallengeEditorPage.tsx`: route-level page for create, edit, and read-only view challenge modes.
Existing challenges keep the same `Details`, `Resources`, and `Submissions` tabs in both edit and
view routes; view mode only makes the details tab read-only and suppresses edit-only form controls.
- Marathon Match submissions show an operator-only runner-log action that opens the ECS runner output
- returned by the marathon-match API.
The read-only `Review` section also renders a configuration summary before the human/AI tabs so AI
gating details remain visible even though the surrounding form fieldset is disabled.
- `ChallengeEditorPage.tsx` also renders challenge quick links in the right header action group for
@@ -17,13 +15,11 @@
footer actions. Manual saves from an existing `/edit` route, including trailing-slash variants,
navigate back to the matching `/view` route after the update succeeds. When challenge detail
revalidation returns a fresher snapshot for the same challenge id, the form rehydrates from that
- updated payload while still avoiding resets over in-progress edits, then reapplies that snapshot
- once the form becomes clean again even if the refreshed payload did not bump the challenge's
- `updated` timestamp. Local post-create draft state remains visible until a fetched challenge
- payload is available, so the create route can expand to the full editor immediately after the
- initial draft is created.
+ updated payload while still avoiding resets over in-progress edits. Local post-create draft state
+ remains visible until a fetched challenge payload is available, so the create route can expand to
+ the full editor immediately after the initial draft is created.
- `components/*Field.tsx`: field-level components for each challenge section.
-- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, summary, and post-save reset now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts and newly saved drafts reopen with the saved reviewer assignments intact. Initial persisted-resource hydration also keeps running while the form is still in its mount-time normalization window, so internal dirty flags from compatibility fields do not block restored copilot or reviewer assignments after a full refresh. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
+- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. Repeated human-review rows that share the same resource role now consume persisted challenge-resource assignments in row order so every assigned reviewer still appears once in the summary, and mixed legacy resource layouts continue into the generic `Reviewer` fallback pool when a phase-specific role runs out of persisted assignments. The editor hydration, editable tab, and summary now tolerate persisted resource rows that only expose role names, member handles, or member ids instead of the full modern payload shape, so refreshed drafts reopen with the saved reviewer assignments intact. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. On narrow screens the review-flow diagram switches to a compact portrait branch: submission stays full width, the `AI Gate` and `Locked` states sit side by side as narrower cards, the `< threshold` connector sits between those two cards, and the human-review path continues only from the gate column. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
- `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them.
## Validation Rules
@@ -50,8 +46,6 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
- Autosave is implemented via `useAutosave`.
- Delay defaults to `AUTOSAVE_DELAY_MS` (10s).
- Autosave runs when form is dirty and valid, except in read-only view mode.
-- 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.
@@ -66,20 +60,19 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha
- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection.
- `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls.
- `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages.
-- `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change.
+- `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change.
- `ChallengeDescriptionField`: public markdown spec editor.
- `ChallengePrivateDescriptionField`: optional private markdown spec editor.
- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately.
- `ChallengeTagsField`: multi creatable tag picker excluding special challenge tags.
- `ChallengeSkillsField`: async multi skills picker with billing-account-based required behavior.
-- `ChallengePrizesField`: placement-prize editor with an inline USD/POINTS selector that uses the challenge editor's green selected state, keeps the `Challenge Prizes` header on one line, and stays right-aligned above the fixed-width prize inputs. Each row always shows a numbered `Prize X` label, multi-prize setups allow tied lower placements while still rejecting prize increases for lower places, older payloads that omit the placement set are hydrated on demand, and only removable rows render the delete action so the first prize stays aligned with the selector.
+- `ChallengePrizesField`: placement-prize editor with an inline USD/POINTS selector that uses the challenge editor's green selected state, keeps the `Challenge Prizes` header on one line, and stays right-aligned above the fixed-width prize inputs. Each row always shows a numbered `Prize X` label, descending-value validation still applies to multi-prize setups, older payloads that omit the placement set are hydrated on demand, and only removable rows render the delete action so the first prize stays aligned with the selector.
- `AssignedMemberField`: task-only assignee selector backed by member ids; persisted through the challenge `Submitter` resource assignment and restored from resources when task payloads omit the legacy field.
-- `CopilotField`: clearable dropdown populated with copilot handles from the current project; persisted through the challenge `Copilot` resource assignment and restored from resources when draft payloads omit the legacy field. Persisted selections are matched case-insensitively so refreshes still show the saved copilot even when the resource payload and project-member option list disagree on handle casing, and member-id-only copilot resources are normalized back to handles during refresh hydration. Save-time form resets also reload the persisted copilot resource before the editor reopens the saved draft, so sparse challenge responses do not blank the field. When a refreshed draft still carries a legacy member-id-only copilot resource, the next save deletes that stale resource before writing the canonical handle-based assignment so the challenge does not keep duplicate copilot rows. The initial `New` draft-creation step also saves any selected copilot assignment before the editor resets from fetched challenge data, so the basic-information selection survives the transition into the full draft form. A copilot is required whenever the copilot fee is greater than 0, and that rule is enforced by form validation before save or launch actions run.
+- `CopilotField`: clearable dropdown populated with copilot handles from the current project; persisted through the challenge `Copilot` resource assignment and restored from resources when draft payloads omit the legacy field. Persisted selections are matched case-insensitively so refreshes still show the saved copilot even when the resource payload and project-member option list disagree on handle casing, and member-id-only copilot resources are normalized back to handles during refresh hydration. When a refreshed draft still carries a legacy member-id-only copilot resource, the next save deletes that stale resource before writing the canonical handle-based assignment so the challenge does not keep duplicate copilot rows. The initial `New` draft-creation step also saves any selected copilot assignment before the editor resets from fetched challenge data, so the basic-information selection survives the transition into the full draft form. A copilot is required whenever the copilot fee is greater than 0, and that rule is enforced by form validation before save or launch actions run.
- `CopilotFeeField`: optional copilot payment input that updates only the underlying copilot prize set, preserving placement prize edits and removing the copilot prize set when cleared so empty fees do not leave hidden validation errors.
- `ChallengeFeeField`: derived summary value that uses the challenge billing markup together with the current prize and reviewer estimates so draft saves do not fall back to a stale `challengeFee` snapshot. It uses the same reviewer-cost estimate shown in `Review cost` and always renders two decimal places. For point-based challenges, the derived fee only uses the USD-denominated billable total so point prizes do not inflate the dollar billing summary. When the challenge payload does not yet include billing, or challenge-api returns the draft's billing markup as `0` for the same project billing account, the editor hydrates billing-account id and markup from the parent project billing account so draft pages still show the correct fee.
- `ChallengeTotalField`: derived billing summary that always renders a dollar total and adds the current challenge fee on top of the billable subtotal from placement prizes, copilot fee, and estimated review cost. For point-based challenges it matches legacy work-manager behavior by counting only the USD-denominated copilot payment and its derived fee, excluding point prizes from the monetary total.
-- `Billing Account Id`: read-only `Prizes & Billing` summary value that shows the challenge billing-account id, falling back to the parent project billing account when the saved challenge payload has not populated billing yet.
-- `Payment Creator`: read-only `Prizes & Billing` summary value that shows the challenge creator handle. If the challenge `createdBy` value is a numeric user id, the editor resolves it through the member profile API so later viewers see the original creator handle instead of the raw id.
+- `Billing Account Id`: read-only advanced-option display that shows the challenge billing-account id, falling back to the parent project billing account when the saved challenge payload has not populated billing yet.
- `ReviewTypeField`: task-only reviewer controls; enforces internal review type, allows searching any community reviewer handle, and persists the selection through the challenge `Iterative Reviewer` resource assignment.
- `Wipro Allowed` checkbox: advanced-option toggle that maps to the challenge `wiproAllowed` API flag. Only the checkbox control toggles the value so nearby text clicks do not change it accidentally.
- Saved selector values may come from legacy challenge fields or challenge resources. The editor restores task `assignedMemberId`, `copilot`, and task `reviewer` from matching resource assignments first, falls back to role-name matches when resource rows are missing role ids, and then falls back to legacy challenge payload shapes.
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.spec.tsx
deleted file mode 100644
index b125dd159..000000000
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.spec.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import { render, screen } from '@testing-library/react'
-import '@testing-library/jest-dom'
-
-import { ChallengeDescriptionField } from './ChallengeDescriptionField'
-
-jest.mock('../../../../lib/components/form', () => ({
- FormMarkdownEditor: (props: {
- label: string
- name: string
- readOnly?: boolean
- required?: boolean
- }) => (
-
- {props.label}
-
- ),
-}))
-
-describe('ChallengeDescriptionField', () => {
- it('renders the public specification editor with the template link', () => {
- render( )
-
- expect(screen.getByTestId('description'))
- .toHaveTextContent('Public Specification')
- expect(screen.getByTestId('description'))
- .toHaveAttribute('data-required', 'true')
- expect(screen.getByRole('link', {
- name: 'here',
- }))
- .toHaveAttribute('href', 'https://github.com/topcoder-platform-templates/specification-templates')
- })
-
- it('passes read-only mode to the public specification editor', () => {
- render( )
-
- expect(screen.getByTestId('description'))
- .toHaveAttribute('data-read-only', 'true')
- })
-})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.tsx
index c409d5de9..2faa5e232 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeDescriptionField.tsx
@@ -6,13 +6,7 @@ import styles from './ChallengeDescriptionField.module.scss'
const specificationTemplateLink = 'https://github.com/topcoder-platform-templates/specification-templates'
-export interface ChallengeDescriptionFieldProps {
- readOnly?: boolean
-}
-
-export const ChallengeDescriptionField: FC = (
- props: ChallengeDescriptionFieldProps,
-) => (
+export const ChallengeDescriptionField: FC = () => (
Access specification templates
@@ -29,7 +23,6 @@ export const ChallengeDescriptionField: FC = (
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 5322a2e17..ebebf184f 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
@@ -17,15 +17,6 @@
&[disabled] {
pointer-events: none;
-
- :global(.EasyMDEContainer) {
- pointer-events: auto;
- }
-
- :global(.EasyMDEContainer .editor-statusbar),
- :global(.EasyMDEContainer .editor-toolbar) {
- pointer-events: none;
- }
}
}
@@ -63,28 +54,28 @@
gap: 24px;
}
-.billingSummary {
+.readOnlyField {
display: flex;
- flex-wrap: wrap;
- gap: 12px 28px;
+ flex-direction: column;
+ gap: 8px;
}
-.billingSummaryItem {
- align-items: baseline;
+.readOnlyFieldLabel {
color: $black-80;
- display: inline-flex;
font-size: 14px;
- gap: 4px;
- white-space: nowrap;
+ font-weight: 700;
}
-.billingSummaryLabel {
- color: $black-60;
- font-weight: 500;
+.readOnlyFieldValue {
+ color: $black-100;
+ font-size: 14px;
+ line-height: 1.5;
}
-.billingSummaryValue {
- font-weight: 700;
+.billingSummary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 28px;
}
.prizesBillingGrid {
@@ -113,56 +104,6 @@
width: 100%;
}
-.approvalSection {
- border: 1px solid $black-20;
- border-radius: 8px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- max-width: 100%;
- padding: 14px;
-}
-
-.approvalStatusRow {
- align-items: center;
- display: flex;
- gap: 8px;
-}
-
-.approvalStatusLabel {
- color: $black-80;
- font-size: 14px;
- font-weight: 700;
-}
-
-.approvalStatusValue {
- color: $black-100;
- font-size: 14px;
- font-weight: 600;
-}
-
-.approvalReason {
- color: $black-80;
- font-size: 13px;
- line-height: 1.4;
-}
-
-.rejectionReasonInput {
- border: 1px solid $black-20;
- border-radius: 6px;
- font-family: inherit;
- font-size: 13px;
- min-height: 72px;
- padding: 8px 10px;
- resize: vertical;
-}
-
-.approvalActions {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
-}
-
@media (min-width: 1024px) {
.submissionSettingsGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
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 48b417bf3..fd1ef31c5 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
@@ -22,28 +22,20 @@ import {
useFetchResources,
useFetchTimelineTemplates,
} from '../../../../lib/hooks'
-import {
- WorkAppContext,
-} from '../../../../lib/contexts/WorkAppContext'
import type {
Challenge,
ChallengeEditorFormData,
- WorkAppContextModel,
} from '../../../../lib/models'
import {
createResource,
createChallenge,
deleteResource,
- fetchAiReviewConfigByChallenge,
- fetchAiReviewTemplates,
fetchChallenge,
fetchProfile,
fetchProjectBillingAccount,
- fetchWorkflows,
patchChallenge,
fetchResourceRoles,
fetchResources,
- searchProfilesByUserIds,
} from '../../../../lib/services'
import {
showErrorToast,
@@ -56,10 +48,6 @@ import {
} from './ChallengeEditorForm'
import { TermsField } from './TermsField'
-let mockShouldAutoDirtyDuringInitialHydration = false
-let mockShouldAutoDirtyAfterChallengeIdHydrates = false
-let mockMaximumSubmissionsDeferDirtyValues: boolean[] = []
-
jest.mock('../../../../lib/components/form', () => ({
FormCheckboxField: () => <>>,
}))
@@ -76,16 +64,12 @@ jest.mock('../../../../lib/services', () => ({
createChallenge: jest.fn(),
createResource: jest.fn(),
deleteResource: jest.fn(),
- fetchAiReviewConfigByChallenge: jest.fn(),
- fetchAiReviewTemplates: jest.fn(),
fetchChallenge: jest.fn(),
fetchProfile: jest.fn(),
fetchProjectBillingAccount: jest.fn(),
fetchResourceRoles: jest.fn(),
fetchResources: jest.fn(),
- fetchWorkflows: jest.fn(),
patchChallenge: jest.fn(),
- searchProfilesByUserIds: jest.fn(),
}))
jest.mock('../../../../lib/utils', () => ({
formatLastSaved: () => '',
@@ -101,7 +85,6 @@ jest.mock('../../../../lib/utils', () => ({
copilot: typeof challenge?.copilot === 'string'
? challenge.copilot
: undefined,
- createdBy: challenge?.createdBy,
description: challenge?.description || '',
discussionForum: challenge?.discussionForum,
funChallenge: challenge?.funChallenge === true,
@@ -256,33 +239,7 @@ jest.mock('./AttachmentsField', () => {
}
})
jest.mock('./ChallengeDescriptionField', () => ({
- ChallengeDescriptionField: function ChallengeDescriptionField(props: {
- readOnly?: boolean
- }) {
- const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form')
- const controller = reactHookForm.useController({
- control: reactHookForm.useFormContext().control,
- name: 'description',
- })
-
- return (
-
-
- Public Specification
-
-
-
- )
- },
+ ChallengeDescriptionField: () => <>>,
}))
jest.mock('./ChallengeScheduleSection', () => ({
ChallengeScheduleSection: function ChallengeScheduleSection(props: {
@@ -294,56 +251,15 @@ jest.mock('./ChallengeScheduleSection', () => ({
control: formContext.control,
name: 'phases',
}) as Array<{
- duration?: number
- phaseId?: string
scheduledEndDate?: string
- scheduledStartDate?: string
}> | undefined
- const handleSetDirtyPhaseEnd = (): void => {
- const currentPhases = formContext.getValues('phases') as typeof phases
-
- formContext.setValue('phases', (currentPhases || []).map((phase, index) => (
- index === 0
- ? {
- ...phase,
- duration: 1440,
- phaseId: phase?.phaseId || 'submission-phase-id',
- scheduledEndDate: '2026-04-18T04:58:51.000Z',
- scheduledStartDate: phase?.scheduledStartDate || '2026-04-11T04:58:51.000Z',
- }
- : phase
- )), {
- shouldDirty: true,
- shouldValidate: true,
- })
- }
-
- const handleMarkFormClean = (): void => {
- formContext.reset(formContext.getValues())
- }
return (
- <>
-
-
- Mock Dirty Phase End
-
-
- Mock Clean Form
-
- >
+
)
},
}))
@@ -357,11 +273,7 @@ jest.mock('./ChallengeFeeField', () => ({
markup?: number
} | undefined
- return (
-
- {String(billing?.markup ?? '')}
-
- )
+ return {String(billing?.markup ?? '')}
},
}))
jest.mock('./ChallengeNameField', () => {
@@ -389,14 +301,7 @@ jest.mock('./ChallengeNameField', () => {
}
})
jest.mock('./ChallengePrivateDescriptionField', () => ({
- ChallengePrivateDescriptionField: (props: {
- readOnly?: boolean
- }) => (
-
- ),
+ ChallengePrivateDescriptionField: () => <>>,
}))
jest.mock('./ChallengePrizesField', () => ({
ChallengePrizesField: () => <>>,
@@ -408,19 +313,7 @@ jest.mock('./ChallengeTagsField', () => ({
ChallengeTagsField: () => <>>,
}))
jest.mock('./ChallengeTotalField', () => ({
- ChallengeTotalField: (props: {
- includeChallengeFee?: boolean
- label?: string
- }) => (
-
- {props.label || 'Estimated challenge total:'}
-
- ),
+ ChallengeTotalField: () => <>>,
}))
jest.mock('./ChallengeTrackField', () => ({
ChallengeTrackField: function ChallengeTrackField() {
@@ -545,20 +438,16 @@ jest.mock('./GroupsField', () => ({
jest.mock('./MaximumSubmissionsField', () => ({
MaximumSubmissionsField: (props: {
deferDirty?: boolean
- }) => {
- mockMaximumSubmissionsDeferDirtyValues.push(props.deferDirty === true)
-
- return (
-
- Maximum Submissions Field
-
- )
- },
+ }) => (
+
+ Maximum Submissions Field
+
+ ),
}))
jest.mock('./MarathonMatchScorerSection', () => ({
MarathonMatchScorerSection: () => <>>,
@@ -595,40 +484,7 @@ jest.mock('./RoundTypeField', () => ({
RoundTypeField: () => <>>,
}))
jest.mock('./StockArtsField', () => ({
- StockArtsField: function StockArtsField() {
- const React: typeof import('react') = jest.requireActual('react')
- const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form')
- const formContext = reactHookForm.useFormContext()
- const hasAutoDirtiedRef = React.useRef(false)
- const challengeId = reactHookForm.useWatch({
- control: formContext.control,
- name: 'id',
- })
-
- React.useEffect(() => {
- if (!mockShouldAutoDirtyDuringInitialHydration || hasAutoDirtiedRef.current) {
- return
- }
-
- if (mockShouldAutoDirtyAfterChallengeIdHydrates && !challengeId) {
- return
- }
-
- hasAutoDirtiedRef.current = true
- formContext.setValue('metadata', [{
- name: 'autoDirty',
- value: 'true',
- }], {
- shouldDirty: true,
- shouldValidate: false,
- })
- }, [
- challengeId,
- formContext,
- ])
-
- return <>Stock Arts Field>
- },
+ StockArtsField: () => <>Stock Arts Field>,
}))
jest.mock('./SubmissionVisibilityField', () => ({
SubmissionVisibilityField: () => <>Submission Visibility Field>,
@@ -647,16 +503,12 @@ const mockedUseFetchTimelineTemplates = useFetchTimelineTemplates as jest.Mock
const mockedCreateResource = createResource as jest.Mock
const mockedCreateChallenge = createChallenge as jest.Mock
const mockedDeleteResource = deleteResource as jest.Mock
-const mockedFetchAiReviewConfigByChallenge = fetchAiReviewConfigByChallenge as jest.Mock
-const mockedFetchAiReviewTemplates = fetchAiReviewTemplates as jest.Mock
const mockedFetchChallenge = fetchChallenge as jest.Mock
-const mockedFetchWorkflows = fetchWorkflows as jest.Mock
const mockedFetchProfile = fetchProfile as jest.Mock
const mockedFetchProjectBillingAccountService = fetchProjectBillingAccount as jest.Mock
const mockedPatchChallenge = patchChallenge as jest.Mock
const mockedFetchResourceRolesService = fetchResourceRoles as jest.Mock
const mockedFetchResourcesService = fetchResources as jest.Mock
-const mockedSearchProfilesByUserIds = searchProfilesByUserIds as jest.Mock
const mockedShowErrorToast = showErrorToast as jest.Mock
const mockedShowSuccessToast = showSuccessToast as jest.Mock
const mockedTermsField = TermsField as jest.MockedFunction
@@ -668,19 +520,6 @@ const LocationDisplay = (): JSX.Element => {
}
describe('ChallengeEditorForm', () => {
- const copilotContextValue: WorkAppContextModel = {
- isAdmin: false,
- isAnonymous: false,
- isCopilot: true,
- isManager: false,
- isReadOnly: false,
- loginUserInfo: {
- roles: ['copilot'],
- token: 'token',
- userId: 123,
- } as WorkAppContextModel['loginUserInfo'],
- userRoles: ['copilot'],
- }
const draftChallenge = {
id: '12345',
name: 'Draft challenge',
@@ -773,23 +612,16 @@ describe('ChallengeEditorForm', () => {
mockedUseFetchTimelineTemplates.mockReturnValue({
timelineTemplates: [],
})
- mockedFetchAiReviewConfigByChallenge.mockResolvedValue(undefined)
- mockedFetchAiReviewTemplates.mockResolvedValue([])
- mockedFetchWorkflows.mockResolvedValue([])
mockedFetchProjectBillingAccountService.mockResolvedValue({
billingAccount: undefined,
})
mockedFetchProfile.mockResolvedValue(undefined)
mockedFetchResourceRolesService.mockResolvedValue([])
mockedFetchResourcesService.mockResolvedValue([])
- mockedSearchProfilesByUserIds.mockResolvedValue([])
mockedCreateResource.mockResolvedValue(undefined)
})
afterEach(() => {
- mockShouldAutoDirtyDuringInitialHydration = false
- mockShouldAutoDirtyAfterChallengeIdHydrates = false
- mockMaximumSubmissionsDeferDirtyValues = []
jest.clearAllMocks()
})
@@ -851,48 +683,27 @@ describe('ChallengeEditorForm', () => {
.toBeNull()
})
- it('renders billing metadata inside prizes and billing when project billing is available', async () => {
+ it('renders the billing account id inside advanced options when project billing is available', () => {
mockedUseFetchProjectBillingAccount.mockReturnValue({
billingAccount: {
id: '80001063',
},
isLoading: false,
})
- mockedSearchProfilesByUserIds.mockResolvedValue([{
- handle: 'challenge.creator',
- userId: '123456',
- }])
render(
-
+
,
)
const advancedOptionsSection = screen.getByRole('heading', { name: 'Advanced Options' })
.closest('section')
- const prizesBillingSection = screen.getByRole('heading', { name: 'Prizes & Billing' })
- .closest('section')
- expect(prizesBillingSection)
+ expect(advancedOptionsSection)
.toHaveTextContent('Billing Account Id')
- expect(prizesBillingSection)
- .toHaveTextContent('80001063')
- expect(prizesBillingSection)
- .toHaveTextContent('Payment Creator')
- await waitFor(() => {
- expect(prizesBillingSection)
- .toHaveTextContent('challenge.creator')
- })
- expect(prizesBillingSection)
- .not.toHaveTextContent('123456')
expect(advancedOptionsSection)
- .not.toHaveTextContent('Billing Account Id')
+ .toHaveTextContent('80001063')
})
it('hides the editable timeline section for task challenges in edit mode', () => {
@@ -1078,10 +889,6 @@ describe('ChallengeEditorForm', () => {
expect(screen.getByTestId('challenge-schedule-section')
.closest('fieldset[disabled]'))
.toBeNull()
- expect(screen.getByTestId('challenge-description-field'))
- .toHaveAttribute('data-read-only', 'true')
- expect(screen.getByTestId('challenge-private-description-field'))
- .toHaveAttribute('data-read-only', 'true')
expect(screen.queryByRole('button', { name: 'Cancel' }))
.toBeNull()
expect(screen.queryByRole('button', { name: 'Save Challenge' }))
@@ -1172,26 +979,6 @@ describe('ChallengeEditorForm', () => {
})
})
- it('uses the copilot-safe challenge billing summary for copilot-only users', () => {
- render(
-
-
-
-
- ,
- )
-
- expect(screen.queryByTestId('challenge-fee-field'))
- .toBeNull()
- expect(screen.getByTestId('challenge-total-field'))
- .toHaveAttribute('data-include-challenge-fee', 'false')
- expect(screen.getByTestId('challenge-total-field'))
- .toHaveTextContent('Estimated challenge cost:')
- })
-
it('requires an assigned member before launching a task challenge', () => {
expect(getTaskLaunchValidationError({
currentStatus: 'DRAFT',
@@ -1629,14 +1416,6 @@ describe('ChallengeEditorForm', () => {
it('returns to view mode after launching from an edit route', async () => {
let launchAction: (() => Promise) | undefined
- mockedUseFetchProjectBillingAccount.mockReturnValue({
- billingAccount: {
- active: true,
- id: '80001063',
- totalBudgetRemaining: 500,
- },
- isLoading: false,
- })
mockedPatchChallenge.mockResolvedValue({
...validDraftChallenge,
status: 'ACTIVE',
@@ -1783,118 +1562,6 @@ describe('ChallengeEditorForm', () => {
})
})
- it('defers compatibility normalization while asynchronously loaded assignments hydrate', async () => {
- let resolveFetchedResources: ((value: unknown[]) => void) | undefined
- let resolveFetchedResourceRoles: ((value: unknown[]) => void) | undefined
-
- mockShouldAutoDirtyDuringInitialHydration = true
- mockShouldAutoDirtyAfterChallengeIdHydrates = true
- mockedUseFetchChallengeTracks.mockReturnValue({
- isLoading: false,
- tracks: [{
- id: 'design-track',
- name: 'Design',
- track: 'DESIGN',
- }],
- })
- mockedUseFetchChallengeTypes.mockReturnValue({
- challengeTypes: [{
- abbreviation: 'CH',
- id: 'design-challenge',
- name: 'Challenge',
- }],
- isLoading: false,
- })
- mockedFetchResourcesService.mockImplementation(
- () => new Promise(resolve => {
- resolveFetchedResources = resolve as (value: unknown[]) => void
- }),
- )
- mockedFetchResourceRolesService.mockImplementation(
- () => new Promise(resolve => {
- resolveFetchedResourceRoles = resolve as (value: unknown[]) => void
- }),
- )
-
- const renderResult: ReturnType = render(
-
-
- ,
- )
-
- expect(screen.queryByTestId('maximum-submissions-field'))
- .toBeNull()
-
- renderResult.rerender(
-
-
- ,
- )
-
- expect(mockMaximumSubmissionsDeferDirtyValues[0])
- .toBe(true)
- expect(screen.getByTestId('maximum-submissions-field'))
- .toHaveAttribute('data-defer-dirty', 'true')
-
- await act(async () => {
- resolveFetchedResourceRoles?.([
- {
- id: 'copilot-role-id',
- name: 'Copilot',
- },
- {
- id: 'reviewer-role-id',
- name: 'Reviewer',
- },
- ])
- resolveFetchedResources?.([
- {
- challengeId: '12345',
- memberHandle: 'saved-copilot',
- roleId: 'copilot-role-id',
- },
- {
- challengeId: '12345',
- memberId: 'manual-reviewer-member-id',
- role: 'Reviewer',
- roleId: 'reviewer-role-id',
- },
- ])
- })
-
- await waitFor(() => {
- expect(screen.getByLabelText('Copilot Field'))
- .toHaveValue('saved-copilot')
- })
- expect(screen.getByTestId('reviewers-field')
- .getAttribute('data-reviewers'))
- .toContain('"memberId":"manual-reviewer-member-id"')
- expect(screen.getByTestId('maximum-submissions-field'))
- .toHaveAttribute('data-defer-dirty', 'false')
- })
-
it('rehydrates the copilot field from member-id-only copilot resources on refresh', async () => {
mockedFetchResourceRolesService.mockResolvedValue([{
id: 'copilot-role-id',
@@ -1918,79 +1585,6 @@ describe('ChallengeEditorForm', () => {
})
})
- it('rehydrates persisted assignments during initial hydration when mount-time dirty state exists', async () => {
- let resolveFetchedResources: ((value: unknown[]) => void) | undefined
- let resolveFetchedResourceRoles: ((value: unknown[]) => void) | undefined
-
- mockShouldAutoDirtyDuringInitialHydration = true
- mockedFetchResourceRolesService.mockImplementation(
- () => new Promise(resolve => {
- resolveFetchedResourceRoles = resolve as (value: unknown[]) => void
- }),
- )
- mockedFetchResourcesService.mockImplementation(
- () => new Promise(resolve => {
- resolveFetchedResources = resolve as (value: unknown[]) => void
- }),
- )
-
- render(
-
-
- ,
- )
-
- await act(async () => {
- resolveFetchedResourceRoles?.([
- {
- id: 'copilot-role-id',
- name: 'Copilot',
- },
- {
- id: 'reviewer-role-id',
- name: 'Reviewer',
- },
- ])
- resolveFetchedResources?.([
- {
- challengeId: '12345',
- memberHandle: 'saved-copilot',
- roleId: 'copilot-role-id',
- },
- {
- challengeId: '12345',
- memberId: 'manual-reviewer-member-id',
- role: 'Reviewer',
- roleId: 'reviewer-role-id',
- },
- ])
- })
-
- await waitFor(() => {
- expect(screen.getByLabelText('Copilot Field'))
- .toHaveValue('saved-copilot')
- })
- expect(screen.getByTestId('reviewers-field')
- .getAttribute('data-reviewers'))
- .toContain('"memberId":"manual-reviewer-member-id"')
- })
-
it('rehydrates handle-only reviewer resources before the refreshed form settles', async () => {
mockedFetchResourceRolesService.mockResolvedValue([{
id: 'reviewer-role-id',
@@ -2062,15 +1656,6 @@ describe('ChallengeEditorForm', () => {
roleId: 'copilot-role-id',
}],
})
- mockedFetchResourceRolesService.mockResolvedValue([{
- id: 'copilot-role-id',
- name: 'Copilot',
- }])
- mockedFetchResourcesService.mockResolvedValue([{
- challengeId: '12345',
- memberId: '40158994',
- roleId: 'copilot-role-id',
- }])
mockedPatchChallenge.mockResolvedValue({
...validDraftChallenge,
copilot: 'resolved-copilot',
@@ -2096,237 +1681,20 @@ describe('ChallengeEditorForm', () => {
expect(mockedPatchChallenge)
.toHaveBeenCalledTimes(1)
})
- await waitFor(() => {
- expect(mockedDeleteResource)
- .toHaveBeenCalledWith({
- challengeId: '12345',
- memberId: '40158994',
- roleId: 'copilot-role-id',
- })
- })
- await waitFor(() => {
- expect(mockedCreateResource)
- .toHaveBeenCalledWith({
- challengeId: '12345',
- memberHandle: 'resolved-copilot',
- roleId: 'copilot-role-id',
- })
- })
- expect(mockedDeleteResource.mock.invocationCallOrder[0])
- .toBeLessThan(mockedCreateResource.mock.invocationCallOrder[0])
- })
-
- it('creates a copilot resource from the selected dropdown value even when cached resources are stale', async () => {
- const user = userEvent.setup()
-
- mockedUseFetchResourceRoles.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- resourceRoles: [{
- id: 'copilot-role-id',
- name: 'Copilot',
- }],
- })
- mockedUseFetchResources.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- mutate: jest.fn(),
- resources: [{
+ expect(mockedDeleteResource)
+ .toHaveBeenCalledWith({
challengeId: '12345',
- memberHandle: 'selected-copilot',
+ memberId: '40158994',
roleId: 'copilot-role-id',
- }],
- })
- mockedFetchResourceRolesService.mockResolvedValue([{
- id: 'copilot-role-id',
- name: 'Copilot',
- }])
- mockedFetchResourcesService.mockResolvedValue([])
- mockedPatchChallenge.mockResolvedValue({
- ...validDraftChallenge,
- copilot: 'selected-copilot',
- })
-
- render(
-
-
- ,
- )
-
- await waitFor(() => {
- expect(screen.getByLabelText('Copilot Field'))
- .toHaveValue('selected-copilot')
- })
- await user.type(screen.getByLabelText('Challenge Name'), ' updated')
- await user.click(screen.getByRole('button', { name: 'Save Challenge' }))
-
- await waitFor(() => {
- expect(mockedPatchChallenge)
- .toHaveBeenCalledWith('12345', expect.objectContaining({
- copilot: 'selected-copilot',
- }))
- })
- await waitFor(() => {
- expect(mockedCreateResource)
- .toHaveBeenCalledWith({
- challengeId: '12345',
- memberHandle: 'selected-copilot',
- roleId: 'copilot-role-id',
- })
- })
- expect(mockedDeleteResource)
- .not.toHaveBeenCalled()
- })
-
- it('rehydrates persisted reviewer assignments from fresh resources after saving a draft', async () => {
- const user = userEvent.setup()
-
- mockedUseFetchResourceRoles.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- resourceRoles: [],
- })
- mockedUseFetchResources.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- mutate: jest.fn(),
- resources: [],
- })
- mockedFetchResourceRolesService.mockResolvedValue([{
- id: 'reviewer-role-id',
- name: 'Reviewer',
- }])
- mockedFetchResourcesService.mockResolvedValue([{
- challengeId: '12345',
- memberId: 'manual-reviewer-member-id',
- role: 'Reviewer',
- roleId: 'reviewer-role-id',
- }])
- mockedPatchChallenge.mockResolvedValue({
- ...validDraftChallenge,
- phases: [{
- duration: 60,
- name: 'Review',
- phaseId: 'review-phase-id',
- }],
- reviewers: [{
- isMemberReview: true,
- memberReviewerCount: 1,
- phaseId: 'review-phase-id',
- scorecardId: 'review-scorecard-id',
- shouldOpenOpportunity: false,
- }],
- })
-
- render(
-
-
- ,
- )
-
- expect(screen.getByTestId('reviewers-field')
- .getAttribute('data-reviewers'))
- .toContain('"memberId":"manual-reviewer-member-id"')
-
- mockedFetchResourceRolesService.mockClear()
- mockedFetchResourcesService.mockClear()
-
- await user.type(screen.getByLabelText('Challenge Name'), ' updated')
- await user.click(screen.getByRole('button', { name: 'Save Challenge' }))
-
- await waitFor(() => {
- expect(mockedPatchChallenge)
- .toHaveBeenCalledTimes(1)
- expect(screen.getByLabelText('Challenge Name'))
- .toHaveValue(validDraftChallenge.name)
- })
- expect(mockedFetchResourceRolesService)
- .toHaveBeenCalledTimes(2)
- expect(mockedFetchResourcesService)
- .toHaveBeenCalledTimes(2)
- expect(mockedFetchResourcesService)
- .toHaveBeenNthCalledWith(1, '12345')
- expect(mockedFetchResourcesService)
- .toHaveBeenNthCalledWith(2, '12345')
- expect(screen.getByTestId('reviewers-field')
- .getAttribute('data-reviewers'))
- .toContain('"memberId":"manual-reviewer-member-id"')
- })
-
- it('keeps public specification edits made while autosave is in flight', async () => {
- const user = userEvent.setup()
- let resolvePatch: ((challenge: Challenge) => void) | undefined
-
- mockedPatchChallenge.mockImplementation(
- () => new Promise(resolve => {
- resolvePatch = resolve as (challenge: Challenge) => void
- }),
- )
-
- render(
-
-
- ,
- )
-
- const specificationInput = screen.getByLabelText('Public Specification') as HTMLTextAreaElement
- const autosavedDescription = `${validDraftChallenge.description} Autosaved markdown`
- const currentDescription = `${autosavedDescription} plus unsaved text`
-
- await user.type(specificationInput, ' Autosaved markdown')
-
- const autosaveParams = mockedUseAutosave.mock
- .calls[mockedUseAutosave.mock.calls.length - 1][0] as {
- formValues: ChallengeEditorFormData
- onSave: (formData: ChallengeEditorFormData) => Promise
- }
- let autosavePromise: Promise = Promise.resolve()
-
- await act(async () => {
- autosavePromise = autosaveParams.onSave({
- ...autosaveParams.formValues,
- description: autosavedDescription,
})
- await Promise.resolve()
- })
-
- await user.type(specificationInput, ' plus unsaved text')
-
- await act(async () => {
- resolvePatch?.({
- ...validDraftChallenge,
- description: 'Rendered specification from challenge-api',
+ expect(mockedCreateResource)
+ .toHaveBeenCalledWith({
+ challengeId: '12345',
+ memberHandle: 'resolved-copilot',
+ roleId: 'copilot-role-id',
})
- await autosavePromise
- })
-
- expect(mockedPatchChallenge)
- .toHaveBeenCalledWith('12345', expect.objectContaining({
- description: autosavedDescription,
- }))
- expect(specificationInput)
- .toHaveValue(currentDescription)
+ expect(mockedDeleteResource.mock.invocationCallOrder[0])
+ .toBeLessThan(mockedCreateResource.mock.invocationCallOrder[0])
})
it('keeps the review section after submission settings in read-only mode', () => {
@@ -2756,195 +2124,6 @@ describe('ChallengeEditorForm', () => {
.not.toHaveBeenCalledWith('Failed to save challenge')
})
- it('blocks launch with a clear reason when the project has no billing account', async () => {
- let launchAction: (() => Promise) | undefined
- let launchError: Error | undefined
-
- mockedUseFetchChallengeTracks.mockReturnValue({
- isLoading: false,
- tracks: [{
- id: 'design-track',
- name: 'Design',
- track: 'DESIGN',
- }],
- })
- mockedUseFetchChallengeTypes.mockReturnValue({
- challengeTypes: [{
- abbreviation: 'F2F',
- id: 'design-first2finish',
- isTask: false,
- name: 'First2Finish',
- }],
- isLoading: false,
- })
- mockedUseFetchResourceRoles.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- resourceRoles: [{
- id: 'iterative-reviewer-role-id',
- name: 'Iterative Reviewer',
- }],
- })
- mockedUseFetchResources.mockReturnValue({
- error: undefined,
- isError: false,
- isLoading: false,
- mutate: jest.fn(),
- resources: [{
- challengeId: '12345',
- memberHandle: 'taasiintake300',
- memberId: 'manual-reviewer-member-id',
- role: 'Iterative Reviewer',
- roleId: 'iterative-reviewer-role-id',
- }],
- })
-
- render(
-
- {
- launchAction = action
- }}
- />
- ,
- )
-
- await waitFor(() => {
- expect(launchAction)
- .toEqual(expect.any(Function))
- })
-
- await act(async () => {
- try {
- await (launchAction as () => Promise)()
- } catch (error) {
- launchError = error as Error
- }
- })
-
- expect(launchError)
- .toEqual(expect.objectContaining({
- message: 'Cannot launch challenges because this project does not have a billing account.',
- }))
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(
- 'Cannot launch challenges because this project does not have a billing account.',
- )
- expect(mockedShowErrorToast)
- .not.toHaveBeenCalledWith('Failed to save challenge')
- })
-
- it('blocks launching when an assigned AI workflow has been disabled', async () => {
- let launchAction: (() => Promise) | undefined
- let launchError: Error | undefined
-
- mockedFetchWorkflows.mockResolvedValue([{
- disabled: true,
- id: 'workflow-disabled',
- name: 'Disabled workflow',
- }])
-
- render(
-
- {
- launchAction = action
- }}
- />
- ,
- )
-
- await waitFor(() => {
- expect(launchAction)
- .toEqual(expect.any(Function))
- })
-
- await act(async () => {
- try {
- await (launchAction as () => Promise)()
- } catch (error) {
- launchError = error as Error
- }
- })
-
- expect(launchError?.message)
- .toContain('One or more saved AI workflows were disabled.')
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.'))
- })
-
- it('blocks launching when the saved AI template has been disabled', async () => {
- let launchAction: (() => Promise) | undefined
- let launchError: Error | undefined
-
- mockedFetchAiReviewConfigByChallenge.mockResolvedValue({
- challengeId: '12345',
- id: 'config-1',
- minPassingThreshold: 75,
- mode: 'AI_GATING',
- templateId: 'template-disabled',
- workflows: [],
- })
- mockedFetchAiReviewTemplates.mockResolvedValue([{
- autoFinalize: false,
- challengeTrack: 'DESIGN',
- challengeType: 'First2Finish',
- description: 'Disabled template',
- disabled: true,
- id: 'template-disabled',
- minPassingThreshold: 75,
- mode: 'AI_GATING',
- title: 'Disabled template',
- workflows: [],
- }])
-
- render(
-
- {
- launchAction = action
- }}
- />
- ,
- )
-
- await waitFor(() => {
- expect(launchAction)
- .toEqual(expect.any(Function))
- })
-
- await act(async () => {
- try {
- await (launchAction as () => Promise)()
- } catch (error) {
- launchError = error as Error
- }
- })
-
- expect(launchError?.message)
- .toContain('The saved AI review template was disabled.')
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(expect.stringContaining('The saved AI review template was disabled.'))
- })
-
it('does not render the attachments section while editing a draft', () => {
render(
@@ -2984,129 +2163,6 @@ describe('ChallengeEditorForm', () => {
})
})
- it('blocks saving when an assigned AI workflow has been disabled', async () => {
- const user = userEvent.setup()
-
- mockedFetchWorkflows.mockResolvedValue([{
- disabled: true,
- id: 'workflow-disabled',
- name: 'Disabled workflow',
- }])
-
- render(
-
-
- ,
- )
-
- await user.type(screen.getByLabelText('Challenge Name'), ' updated')
- await user.click(screen.getByRole('button', { name: 'Save Challenge' }))
-
- await waitFor(() => {
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- })
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.'))
- expect(mockedShowErrorToast)
- .not.toHaveBeenCalledWith('Failed to save challenge')
- })
-
- it('blocks saving when disabled workflow exists only in persisted AI config', async () => {
- const user = userEvent.setup()
-
- mockedFetchAiReviewConfigByChallenge.mockResolvedValue({
- challengeId: '12345',
- id: 'config-1',
- minPassingThreshold: 75,
- mode: 'AI_HUMAN',
- workflows: [{
- id: 'config-workflow-1',
- isGating: false,
- weightPercent: 100,
- workflowId: 'workflow-disabled',
- }],
- })
- mockedFetchWorkflows.mockResolvedValue([{
- disabled: true,
- id: 'workflow-disabled',
- name: 'Disabled workflow',
- }])
-
- render(
-
-
- ,
- )
-
- await user.type(screen.getByLabelText('Challenge Name'), ' updated')
- await user.click(screen.getByRole('button', { name: 'Save Challenge' }))
-
- await waitFor(() => {
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- })
- expect(mockedFetchAiReviewConfigByChallenge)
- .toHaveBeenCalledWith('12345')
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(expect.stringContaining('One or more saved AI workflows were disabled.'))
- })
-
- it('blocks saving when the saved AI template has been disabled', async () => {
- const user = userEvent.setup()
-
- mockedFetchAiReviewConfigByChallenge.mockResolvedValue({
- challengeId: '12345',
- id: 'config-1',
- minPassingThreshold: 75,
- mode: 'AI_GATING',
- templateId: 'template-disabled',
- workflows: [],
- })
- mockedFetchAiReviewTemplates.mockResolvedValue([{
- autoFinalize: false,
- challengeTrack: 'DESIGN',
- challengeType: 'First2Finish',
- description: 'Disabled template',
- disabled: true,
- id: 'template-disabled',
- minPassingThreshold: 75,
- mode: 'AI_GATING',
- title: 'Disabled template',
- workflows: [],
- }])
-
- render(
-
-
- ,
- )
-
- await user.type(screen.getByLabelText('Challenge Name'), ' updated')
- await user.click(screen.getByRole('button', { name: 'Save Challenge' }))
-
- await waitFor(() => {
- expect(mockedPatchChallenge)
- .not.toHaveBeenCalled()
- })
- expect(mockedShowErrorToast)
- .toHaveBeenCalledWith(expect.stringContaining('The saved AI review template was disabled.'))
- })
-
it('refreshes phase data when the fetched challenge updates for the same id', async () => {
const initialChallenge = {
...validDraftChallenge,
@@ -3149,54 +2205,6 @@ describe('ChallengeEditorForm', () => {
})
})
- it('reapplies a same-id challenge refresh after the form becomes clean again', async () => {
- const user = userEvent.setup()
- const initialChallenge = {
- ...validDraftChallenge,
- phases: [{
- duration: 1440,
- name: 'Submission',
- phaseId: 'submission-phase-id',
- scheduledEndDate: '2026-04-17T04:58:51.000Z',
- scheduledStartDate: '2026-04-11T04:58:51.000Z',
- }],
- } as Challenge
- const refreshedChallenge = {
- ...initialChallenge,
- phases: [{
- ...initialChallenge.phases?.[0],
- scheduledEndDate: '2026-04-19T04:58:51.000Z',
- }],
- } as Challenge
-
- const renderResult = render(
-
-
- ,
- )
-
- await user.click(screen.getByTestId('mock-dirty-phase-end'))
-
- expect(screen.getByTestId('challenge-schedule-section'))
- .toHaveAttribute('data-first-phase-end', '2026-04-18T04:58:51.000Z')
-
- renderResult.rerender(
-
-
- ,
- )
-
- expect(screen.getByTestId('challenge-schedule-section'))
- .toHaveAttribute('data-first-phase-end', '2026-04-18T04:58:51.000Z')
-
- await user.click(screen.getByTestId('mock-clean-form'))
-
- await waitFor(() => {
- expect(screen.getByTestId('challenge-schedule-section'))
- .toHaveAttribute('data-first-phase-end', '2026-04-19T04:58:51.000Z')
- })
- })
-
it('returns to view mode after saving a new draft from the create route', async () => {
const user = userEvent.setup()
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx
index 2d6b0d9b6..a9094ce7b 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx
@@ -1,8 +1,6 @@
import {
- ChangeEvent,
FC,
useCallback,
- useContext,
useEffect,
useMemo,
useRef,
@@ -20,7 +18,6 @@ import { Button } from '~/libs/ui'
import { FormCheckboxField } from '../../../../lib/components/form'
import {
- CHALLENGE_APPROVAL_STATUS,
CHALLENGE_STATUS,
CHALLENGE_TRACKS,
CREATE_FORUM_TYPE_IDS,
@@ -31,9 +28,6 @@ import {
PRIZE_SET_TYPES,
ROUND_TYPES,
} from '../../../../lib/constants/challenge-editor.constants'
-import {
- WorkAppContext,
-} from '../../../../lib/contexts/WorkAppContext'
import {
useAutosave,
useFetchChallengeTracks,
@@ -59,16 +53,12 @@ import {
createChallenge,
createResource,
deleteResource,
- fetchAiReviewConfigByChallenge,
- fetchAiReviewTemplates,
fetchChallenge,
fetchProfile,
fetchProjectBillingAccount,
fetchResourceRoles,
fetchResources,
- fetchWorkflows,
patchChallenge,
- searchProfilesByUserIds,
} from '../../../../lib/services'
import {
formatLastSaved,
@@ -224,11 +214,6 @@ interface SaveStatusMetadata {
payloadStatus?: string
}
-interface ResolvedPaymentCreator {
- handle: string
- source: string
-}
-
interface ResolvePostSaveNavigationPathParams {
isEditMode?: boolean
isSaveAsDraft: boolean
@@ -250,8 +235,6 @@ interface SingleAssignmentConfig {
interface SyncSingleAssignmentResourceParams extends Omit {
challengeId: string
nextValue?: string
- resourceRolesOverride?: ResourceRole[]
- resourcesOverride?: Resource[]
}
interface PersistCreatedChallengeCopilotParams {
@@ -269,14 +252,6 @@ const SAVE_VALIDATION_ERROR_MESSAGE = 'Please fix validation errors before savin
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.'
-const APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE
- = 'Challenge launch is blocked until budget approval is Approved.'
-const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE
- = 'One or more saved AI workflows were disabled. '
- + 'Update the AI workflow configuration before saving or launching this challenge.'
-const DISABLED_AI_TEMPLATE_FOR_CHALLENGE_ACTION_MESSAGE
- = 'The saved AI review template was disabled. '
- + 'Update the AI template selection before saving or launching this challenge.'
const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH'
const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE'
const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F'
@@ -436,33 +411,6 @@ function normalizeTextValue(value: unknown): string {
return value.trim()
}
-/**
- * Normalizes optional display tokens from API payloads and auth context.
- *
- * @param value Raw value that may be a string or numeric user id.
- * @returns Trimmed string value, or an empty string when no value is available.
- * @throws Does not throw.
- */
-function normalizeDisplayToken(value: unknown): string {
- if (value === undefined || value === null) {
- return ''
- }
-
- return String(value)
- .trim()
-}
-
-/**
- * Detects creator values that need member-profile resolution before display.
- *
- * @param value Normalized challenge `createdBy` value.
- * @returns `true` when the value looks like a Topcoder numeric user id.
- * @throws Does not throw.
- */
-function isUserIdToken(value: string): boolean {
- return /^\d+$/.test(value)
-}
-
function hasSameNormalizedValue(valueA: unknown, valueB: unknown): boolean {
return normalizeTextValue(valueA)
.toLowerCase() === normalizeTextValue(valueB)
@@ -1166,75 +1114,6 @@ function getReviewerValidationError(
return getMissingRequiredPhaseCoverageError(reviewers, requiredPhases)
}
-async function getDisabledAiWorkflowForActionError(
- formData: ChallengeEditorFormData,
- challengeId: string | undefined,
- challengeTrack?: string,
- challengeType?: string,
-): Promise {
- const selectedAiWorkflowIds = (Array.isArray(formData.reviewers)
- ? formData.reviewers
- : [])
- .map(reviewer => normalizeTextValue(reviewer?.aiWorkflowId))
- .filter(Boolean)
- const normalizedChallengeId = normalizeTextValue(challengeId)
- const persistedAiConfig = normalizedChallengeId
- ? await fetchAiReviewConfigByChallenge(normalizedChallengeId)
- .catch(() => undefined)
- : undefined
- const persistedWorkflowIds = (persistedAiConfig?.workflows || [])
- .map(workflow => normalizeTextValue(workflow.workflowId))
- .filter(Boolean)
- const configuredAiWorkflowIds = Array.from(new Set([
- ...selectedAiWorkflowIds,
- ...persistedWorkflowIds,
- ]))
- const selectedTemplateId = normalizeTextValue(persistedAiConfig?.templateId)
-
- if (selectedTemplateId) {
- const templates = await fetchAiReviewTemplates({
- challengeTrack,
- challengeType,
- })
- let selectedTemplate = templates.find(template => (
- normalizeTextValue(template.id) === selectedTemplateId
- ))
-
- if (!selectedTemplate && (challengeTrack || challengeType)) {
- const allTemplates = await fetchAiReviewTemplates()
-
- selectedTemplate = allTemplates.find(template => (
- normalizeTextValue(template.id) === selectedTemplateId
- ))
- }
-
- if (selectedTemplate?.disabled === true) {
- return DISABLED_AI_TEMPLATE_FOR_CHALLENGE_ACTION_MESSAGE
- }
- }
-
- if (!configuredAiWorkflowIds.length) {
- return undefined
- }
-
- const workflows = await fetchWorkflows()
- const workflowMapById = new Map(
- workflows.map(workflow => [
- normalizeTextValue(workflow.id),
- workflow,
- ] as const),
- )
- const hasDisabledWorkflow = configuredAiWorkflowIds.some(workflowId => {
- const matchedWorkflow = workflowMapById.get(workflowId)
-
- return matchedWorkflow?.disabled === true
- })
-
- return hasDisabledWorkflow
- ? DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE
- : undefined
-}
-
function getStatusText(
saveStatus: 'error' | 'idle' | 'saved' | 'saving',
): string {
@@ -1381,18 +1260,6 @@ function getSaveSuccessMessage(
: 'Challenge saved successfully'
}
-function getApprovalStatusText(approvalStatus: string | undefined): string {
- if (approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) {
- return 'Approved'
- }
-
- if (approvalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED) {
- return 'Rejected'
- }
-
- return 'Pending Approval'
-}
-
interface TaskLaunchValidationParams {
assignedMemberId?: unknown
currentStatus?: unknown
@@ -1490,7 +1357,6 @@ export const ChallengeEditorForm: FC = (
) => {
const location = useLocation()
const navigate = useNavigate()
- const workAppContext = useContext(WorkAppContext)
const isEditMode = props.isEditMode
const isReadOnly = props.isReadOnly === true
const onChallengeCreated = props.onChallengeCreated
@@ -1500,7 +1366,6 @@ export const ChallengeEditorForm: FC = (
const onSavingChange = props.onSavingChange
const formElementRef = useRef(null)
const challengeRef = useRef(props.challenge)
- const pendingChallengeRefreshRef = useRef()
const defaultedDiscussionForumTypeIdRef = useRef()
const fallbackProjectId = useMemo(
() => normalizeProjectId(props.projectId) || normalizeProjectId(props.challenge?.projectId),
@@ -1530,31 +1395,12 @@ export const ChallengeEditorForm: FC = (
const [isInitialResourceHydrationPending, setIsInitialResourceHydrationPending] = useState(
!!props.challenge?.id,
)
- const isInitialResourceHydrationPendingRef = useRef(!!props.challenge?.id)
- /**
- * Keeps React state and the async hydration guard in sync.
- *
- * Challenge details can arrive after the first empty render during a full browser refresh. The
- * resource-hydration promise may resolve before React flushes the state update, so the ref must
- * be updated synchronously anywhere the pending flag changes.
- *
- * @param isPending whether the initial resource-backed assignment hydration is still running.
- * @returns nothing.
- * @throws Does not throw.
- */
- const setInitialResourceHydrationPending = useCallback((isPending: boolean): void => {
- isInitialResourceHydrationPendingRef.current = isPending
- setIsInitialResourceHydrationPending(isPending)
- }, [])
const [lastSaved, setLastSaved] = useState()
const [saveError, setSaveError] = useState()
const [saveValidationError, setSaveValidationError] = useState()
const [saveStatus, setSaveStatus] = useState<'error' | 'idle' | 'saved' | 'saving'>('idle')
const [scorerHasUnsavedChanges, setScorerHasUnsavedChanges] = useState(false)
const [scorerHasError, setScorerHasError] = useState(false)
- const [isUpdatingApproval, setIsUpdatingApproval] = useState(false)
- const [rejectionReasonInput, setRejectionReasonInput] = useState('')
- const [resolvedPaymentCreator, setResolvedPaymentCreator] = useState()
const formMethods = useForm({
defaultValues: applyProjectBillingToChallengeFormData(
@@ -1753,23 +1599,6 @@ export const ChallengeEditorForm: FC = (
values.status,
],
)
- const normalizedApprovalStatus = useMemo(
- () => normalizeStatus(values.approvalStatus)
- || normalizeStatus(props.challenge?.approvalStatus)
- || CHALLENGE_APPROVAL_STATUS.PENDING_APPROVAL,
- [
- props.challenge?.approvalStatus,
- values.approvalStatus,
- ],
- )
- const canApproveChallengeBudget = workAppContext.isAdmin || workAppContext.isManager
- const arePrizeFieldsLockedForRole = normalizedChallengeStatus === CHALLENGE_STATUS.ACTIVE
- && !canApproveChallengeBudget
- const arePrizeFieldsDisabled = isReadOnly || arePrizeFieldsLockedForRole
- const canRenderApprovalActions = !isReadOnly
- && canApproveChallengeBudget
- && !!currentChallengeId
- && normalizedChallengeStatus !== CHALLENGE_STATUS.ACTIVE
const isChallengeCreated = !!currentChallengeId
const isFunChallengeSelected = values.funChallenge === true
const showFunChallengeField = isMarathonMatchChallengeSelected
@@ -1788,20 +1617,6 @@ export const ChallengeEditorForm: FC = (
)
const isScorerBlockingChallengeActions = showMarathonMatchScorerSection
&& (scorerHasUnsavedChanges || scorerHasError)
-
- useEffect(() => {
- const nextReason = typeof values.approvalRejectionReason === 'string'
- ? values.approvalRejectionReason
- : ''
-
- setRejectionReasonInput(nextReason)
- }, [values.approvalRejectionReason])
-
- const shouldDeferInitialResourceDirtyNormalization = isInitialResourceHydrationPending
- || (!!props.challenge?.id && props.challenge.id !== currentChallengeId)
- const shouldUseCopilotBillingSummary = workAppContext.isCopilot
- && !workAppContext.isAdmin
- && !workAppContext.isManager
const getPersistedAssignmentValueByFields = useCallback((
fallbackValue: string | undefined,
roleNames: readonly string[],
@@ -1846,31 +1661,14 @@ export const ChallengeEditorForm: FC = (
fallbackValue: string | undefined,
resourcesOverride?: typeof challengeResources,
resourceRolesOverride?: typeof resourceRoles,
- ): string | undefined => {
- const resourceAssignment = resolvePersistedResourceAssignment({
- resourceRoles: resourceRolesOverride || resourceRoles,
- resources: resourcesOverride || challengeResources,
- roleNames: COPILOT_RESOURCE_ROLE_NAMES,
- valueFields: getSingleAssignmentResourceValueFields(COPILOT_ASSIGNMENT_CONFIG),
- })
- const normalizedFallbackValue = normalizeTextValue(fallbackValue)
-
- if (!resourceAssignment) {
- return normalizedFallbackValue || undefined
- }
-
- if (
- resourceAssignment.valueField === 'memberId'
- && normalizedFallbackValue
- && !hasSameNormalizedValue(resourceAssignment.value, normalizedFallbackValue)
- ) {
- return normalizedFallbackValue
- }
-
- return resourceAssignment.value
- }, [
- challengeResources,
- resourceRoles,
+ ): string | undefined => getPersistedAssignmentValueByFields(
+ fallbackValue,
+ COPILOT_RESOURCE_ROLE_NAMES,
+ getSingleAssignmentResourceValueFields(COPILOT_ASSIGNMENT_CONFIG),
+ resourcesOverride,
+ resourceRolesOverride,
+ ), [
+ getPersistedAssignmentValueByFields,
])
const isTaskSingleAssignmentChallenge = useCallback((
formData: ChallengeEditorFormData,
@@ -1969,10 +1767,8 @@ export const ChallengeEditorForm: FC = (
const syncSingleAssignmentResource = useCallback(async (
params: SyncSingleAssignmentResourceParams,
): Promise => {
- const resolvedResourceRoles = params.resourceRolesOverride
- || await loadSingleAssignmentResourceRoles()
- const resolvedResources = params.resourcesOverride
- || await loadSingleAssignmentResources(params.challengeId)
+ const resolvedResourceRoles = await loadSingleAssignmentResourceRoles()
+ const resolvedResources = await loadSingleAssignmentResources(params.challengeId)
const currentAssignment = resolvePersistedResourceAssignment({
resourceRoles: resolvedResourceRoles,
resources: resolvedResources,
@@ -2040,29 +1836,10 @@ export const ChallengeEditorForm: FC = (
loadSingleAssignmentResourceRoles,
loadSingleAssignmentResources,
])
- /**
- * Synchronizes single-member assignments against the latest persisted challenge resources.
- *
- * The edit flow keeps a SWR cache of resources for the Resources tab, but challenge saves
- * should compare against the freshest backend state so a newly selected copilot still creates
- * the required `Copilot` resource even when the local cache is stale.
- *
- * @param challengeId saved challenge identifier whose assignments should be synchronized.
- * @param formData current form snapshot containing the selected assignment values.
- * @returns Resolves after all changed single-member assignments are saved and the local
- * resource cache is revalidated.
- */
const syncDraftSingleAssignments = useCallback(async (
challengeId: string,
formData: ChallengeEditorFormData,
): Promise => {
- const [
- persistedResources,
- persistedResourceRoles,
- ] = await Promise.all([
- fetchResources(challengeId),
- loadSingleAssignmentResourceRoles(),
- ])
const resourceSyncOperations = getSingleAssignmentConfigs(
isTaskSingleAssignmentChallenge(formData),
)
@@ -2072,8 +1849,6 @@ export const ChallengeEditorForm: FC = (
undefined,
config.roleNames,
getSingleAssignmentResourceValueFields(config),
- persistedResources,
- persistedResourceRoles,
)
return hasSameNormalizedValue(nextValue, persistedValue)
@@ -2081,8 +1856,6 @@ export const ChallengeEditorForm: FC = (
: syncSingleAssignmentResource({
challengeId,
nextValue,
- resourceRolesOverride: persistedResourceRoles,
- resourcesOverride: persistedResources,
resourceValueFields: config.resourceValueFields,
roleNames: config.roleNames,
valueField: config.valueField,
@@ -2099,46 +1872,9 @@ export const ChallengeEditorForm: FC = (
}, [
getPersistedAssignmentValueByFields,
isTaskSingleAssignmentChallenge,
- loadSingleAssignmentResourceRoles,
mutateChallengeResources,
syncSingleAssignmentResource,
])
- /**
- * Reapplies resource-backed assignments after a save response resets the form.
- *
- * Challenge patch responses may omit persisted copilot and manual-reviewer member selections
- * even though those resources were saved successfully. Reloading resources before the post-save
- * reset keeps the editor aligned with the persisted draft state.
- *
- * @param challengeId saved challenge identifier whose persisted resources should be reloaded.
- * @param formData form-state snapshot derived from the saved challenge payload.
- * @returns the same form data with persisted resource assignments restored.
- */
- const hydratePersistedSavedFormData = useCallback(async (
- challengeId: string,
- formData: ChallengeEditorFormData,
- ): Promise => {
- const [
- persistedResources,
- persistedResourceRoles,
- ] = await Promise.all([
- fetchResources(challengeId),
- loadSingleAssignmentResourceRoles(),
- ])
-
- return hydratePersistedManualReviewerAssignments(
- applyPersistedSingleAssignments(
- formData,
- persistedResources,
- persistedResourceRoles,
- ),
- persistedResources,
- persistedResourceRoles,
- )
- }, [
- applyPersistedSingleAssignments,
- loadSingleAssignmentResourceRoles,
- ])
const handleScorerConfigChange = useCallback(
(hasUnsavedChanges: boolean, hasError: boolean): void => {
@@ -2147,11 +1883,60 @@ export const ChallengeEditorForm: FC = (
},
[],
)
- const hydrateChallengeSnapshot = useCallback((
- challenge?: Challenge,
- ): (() => void) => {
+
+ useEffect(() => {
+ if (!onSavingChange) {
+ return undefined
+ }
+
+ onSavingChange(isSaving)
+
+ return () => {
+ onSavingChange(false)
+ }
+ }, [
+ isSaving,
+ onSavingChange,
+ ])
+
+ useEffect(() => {
+ challengeRef.current = props.challenge
+ }, [props.challenge])
+
+ useEffect(() => {
+ currentChallengeIdRef.current = currentChallengeId
+ }, [currentChallengeId])
+
+ useEffect(() => {
+ projectBillingAccountRef.current = projectBillingAccount
+ }, [projectBillingAccount])
+
+ useEffect(() => {
+ resourceRolesRef.current = resourceRoles
+ }, [resourceRoles])
+
+ useEffect(() => {
+ isFormDirtyRef.current = formState.isDirty
+ }, [formState.isDirty])
+
+ useEffect(() => {
+ applyPersistedSingleAssignmentsRef.current = applyPersistedSingleAssignments
+ }, [applyPersistedSingleAssignments])
+
+ useEffect(() => {
let isActive = true
+ const challenge = challengeRef.current
const challengeId = challenge?.id
+ const isRefreshingCurrentChallenge = !!challengeId
+ && challengeId === currentChallengeIdRef.current
+ && isFormDirtyRef.current
+
+ if (isRefreshingCurrentChallenge) {
+ return () => {
+ isActive = false
+ }
+ }
+
const baseFormData = applyProjectBillingToChallengeFormData(
transformChallengeToFormData(challenge),
projectBillingAccountRef.current,
@@ -2159,13 +1944,12 @@ export const ChallengeEditorForm: FC = (
setCurrentChallengeId(challengeId)
defaultedDiscussionForumTypeIdRef.current = undefined
- setInitialResourceHydrationPending(!!challengeId)
+ setIsInitialResourceHydrationPending(!!challengeId)
reset(baseFormData)
if (!challengeId) {
- setInitialResourceHydrationPending(false)
-
+ setIsInitialResourceHydrationPending(false)
return () => {
isActive = false
}
@@ -2183,10 +1967,7 @@ export const ChallengeEditorForm: FC = (
fetchedResources,
fetchedResourceRoles,
]) => {
- if (
- !isActive
- || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current)
- ) {
+ if (!isActive || isFormDirtyRef.current) {
return
}
@@ -2200,10 +1981,7 @@ export const ChallengeEditorForm: FC = (
fetchedResourceRoles,
)
- if (
- !isActive
- || (isFormDirtyRef.current && !isInitialResourceHydrationPendingRef.current)
- ) {
+ if (!isActive || isFormDirtyRef.current) {
return
}
@@ -2214,7 +1992,7 @@ export const ChallengeEditorForm: FC = (
})
.finally(() => {
if (isActive) {
- setInitialResourceHydrationPending(false)
+ setIsInitialResourceHydrationPending(false)
}
})
@@ -2222,96 +2000,15 @@ export const ChallengeEditorForm: FC = (
isActive = false
}
}, [
- reset,
- setInitialResourceHydrationPending,
- ])
-
- useEffect(() => {
- if (!onSavingChange) {
- return undefined
- }
-
- onSavingChange(isSaving)
-
- return () => {
- onSavingChange(false)
- }
- }, [
- isSaving,
- onSavingChange,
- ])
-
- useEffect(() => {
- challengeRef.current = props.challenge
- }, [props.challenge])
-
- useEffect(() => {
- currentChallengeIdRef.current = currentChallengeId
- }, [currentChallengeId])
-
- useEffect(() => {
- projectBillingAccountRef.current = projectBillingAccount
- }, [projectBillingAccount])
-
- useEffect(() => {
- resourceRolesRef.current = resourceRoles
- }, [resourceRoles])
-
- useEffect(() => {
- isFormDirtyRef.current = formState.isDirty
- }, [formState.isDirty])
-
- useEffect(() => {
- applyPersistedSingleAssignmentsRef.current = applyPersistedSingleAssignments
- }, [applyPersistedSingleAssignments])
-
- useEffect(() => {
- const challenge = challengeRef.current
- const challengeId = challenge?.id
- const isRefreshingCurrentChallenge = !!challengeId
- && challengeId === currentChallengeIdRef.current
- && isFormDirtyRef.current
- && !isInitialResourceHydrationPendingRef.current
-
- if (isRefreshingCurrentChallenge) {
- pendingChallengeRefreshRef.current = challenge
-
- return undefined
- }
-
- pendingChallengeRefreshRef.current = undefined
-
- return hydrateChallengeSnapshot(challenge)
- }, [
- hydrateChallengeSnapshot,
- props.challenge,
props.challenge?.id,
props.challenge?.updated,
- ])
-
- useEffect(() => {
- if (formState.isDirty) {
- return undefined
- }
-
- const pendingChallengeRefresh = pendingChallengeRefreshRef.current
-
- if (!pendingChallengeRefresh) {
- return undefined
- }
-
- pendingChallengeRefreshRef.current = undefined
-
- return hydrateChallengeSnapshot(pendingChallengeRefresh)
- }, [
- formState.isDirty,
- hydrateChallengeSnapshot,
+ reset,
])
useEffect(() => {
if (
!currentChallengeId
- || (formState.isDirty && !isInitialResourceHydrationPending)
+ || formState.isDirty
|| challengeResourcesResult.isLoading
|| resourceRolesResult.isLoading
) {
@@ -2371,7 +2068,6 @@ export const ChallengeEditorForm: FC = (
currentChallengeId,
formState.isDirty,
getValues,
- isInitialResourceHydrationPending,
isTaskSingleAssignmentChallenge,
resourceRoles,
resourceRolesResult.isLoading,
@@ -2779,42 +2475,6 @@ export const ChallengeEditorForm: FC = (
throw createHandledLaunchBlockError(taskLaunchValidationError)
}
- if (
- isChallengeBeingActivated
- && normalizeStatus(formData.approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED
- ) {
- setSaveStatus('idle')
- setSaveValidationError(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE)
-
- if (!options.isAutosave) {
- showErrorToast(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE)
- }
-
- throw createHandledLaunchBlockError(APPROVAL_REQUIRED_FOR_LAUNCH_MESSAGE)
- }
-
- const disabledAiWorkflowError = await getDisabledAiWorkflowForActionError(
- formData,
- currentChallengeId,
- selectedChallengeTrack?.track || selectedChallengeTrack?.name,
- selectedChallengeType?.name,
- )
-
- if (disabledAiWorkflowError) {
- setSaveStatus('idle')
- setError('reviewers', {
- message: disabledAiWorkflowError,
- type: 'manual',
- })
- setSaveValidationError(disabledAiWorkflowError)
-
- if (!options.isAutosave) {
- showErrorToast(disabledAiWorkflowError)
- }
-
- throw createHandledLaunchBlockError(disabledAiWorkflowError)
- }
-
if (!options.isAutosave) {
setIsSaving(true)
setSaveStatus('saving')
@@ -2823,10 +2483,7 @@ export const ChallengeEditorForm: FC = (
try {
const resolvedProjectBillingAccount = await resolveProjectBillingAccount()
const projectBillingAccountIssue = isChallengeBeingActivated
- ? getProjectBillingAccountChallengeIssue(
- resolvedProjectBillingAccount,
- !!fallbackProjectId,
- )
+ ? getProjectBillingAccountChallengeIssue(resolvedProjectBillingAccount)
: undefined
const projectBillingAccountErrorMessage = projectBillingAccountIssue
? getProjectBillingAccountChallengeErrorMessage(projectBillingAccountIssue)
@@ -2862,8 +2519,7 @@ export const ChallengeEditorForm: FC = (
)
const nextValues = applySingleAssignmentFieldValues(
- await hydratePersistedSavedFormData(
- currentChallengeId,
+ applyPersistedSingleAssignments(
{
...persistedFormData,
attachments: Array.isArray(persistedFormData.attachments)
@@ -2880,15 +2536,7 @@ export const ChallengeEditorForm: FC = (
setLastSaved(savedAt)
setSaveStatus('saved')
- // Autosave should advance the saved baseline without replacing inputs users may still be editing.
- reset(
- options.isAutosave
- ? formDataWithProjectBilling
- : nextValues,
- options.isAutosave
- ? { keepValues: true }
- : undefined,
- )
+ reset(nextValues)
onChallengeStatusChange?.(normalizeStatus(nextValues.status))
if (!options.isAutosave) {
@@ -2931,18 +2579,16 @@ export const ChallengeEditorForm: FC = (
}
},
[
+ applyPersistedSingleAssignments,
clearErrors,
currentChallengeId,
fallbackProjectId,
- hydratePersistedSavedFormData,
isEditMode,
isTaskSingleAssignmentChallenge,
navigate,
onChallengeStatusChange,
reset,
resolveProjectBillingAccount,
- selectedChallengeTrack,
- selectedChallengeType,
setError,
syncDraftSingleAssignments,
usesManualReviewers,
@@ -2950,67 +2596,6 @@ export const ChallengeEditorForm: FC = (
],
)
- const updateApprovalStatus = useCallback(async (
- nextApprovalStatus: string,
- rejectionReason?: string,
- ): Promise => {
- if (!currentChallengeId || isUpdatingApproval) {
- return
- }
-
- if (nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED && !normalizeTextValue(rejectionReason)) {
- showErrorToast('Rejection reason is required.')
- return
- }
-
- setIsUpdatingApproval(true)
-
- try {
- const payload = {
- approvalRejectionReason: nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED
- ? normalizeTextValue(rejectionReason)
- : undefined,
- approvalStatus: nextApprovalStatus,
- }
- const savedChallenge = await patchChallenge(currentChallengeId, payload)
- const mergedFormData = {
- ...getValues(),
- ...transformChallengeToFormData(savedChallenge),
- }
-
- reset(mergedFormData)
- setSaveValidationError(undefined)
- showSuccessToast(nextApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED
- ? 'Challenge budget approved.'
- : 'Challenge budget rejected.')
- } catch (error) {
- const errorMessage = error instanceof Error
- ? error.message
- : 'Failed to update approval status'
- showErrorToast(errorMessage)
- } finally {
- setIsUpdatingApproval(false)
- }
- }, [
- currentChallengeId,
- getValues,
- isUpdatingApproval,
- reset,
- ])
-
- const handleApproveChallengeBudget = useCallback((): void => {
- updateApprovalStatus(CHALLENGE_APPROVAL_STATUS.APPROVED)
- .catch(() => undefined)
- }, [updateApprovalStatus])
-
- const handleRejectChallengeBudget = useCallback((): void => {
- updateApprovalStatus(CHALLENGE_APPROVAL_STATUS.REJECTED, rejectionReasonInput)
- .catch(() => undefined)
- }, [
- rejectionReasonInput,
- updateApprovalStatus,
- ])
-
const launchChallenge = useCallback(async (): Promise => {
if (isScorerBlockingChallengeActions) {
showErrorToast('Save a valid scorer configuration before launching the challenge')
@@ -3116,17 +2701,9 @@ export const ChallengeEditorForm: FC = (
}
clearErrors('reviewers')
- try {
- await saveChallenge(formData, {
- redirectToViewOnSuccess: true,
- })
- } catch (error) {
- if (isHandledLaunchBlockError(error)) {
- return
- }
-
- throw error
- }
+ await saveChallenge(formData, {
+ redirectToViewOnSuccess: true,
+ })
},
[
clearErrors,
@@ -3170,106 +2747,6 @@ export const ChallengeEditorForm: FC = (
values.billing?.billingAccountId,
],
)
- const rawPaymentCreator = useMemo(
- (): string => normalizeDisplayToken(values.createdBy)
- || normalizeDisplayToken(props.challenge?.createdBy),
- [
- props.challenge?.createdBy,
- values.createdBy,
- ],
- )
- const isPaymentCreatorUserId = useMemo(
- (): boolean => isUserIdToken(rawPaymentCreator),
- [rawPaymentCreator],
- )
- const loginUserHandle = normalizeDisplayToken(workAppContext.loginUserInfo?.handle)
- const loginUserId = normalizeDisplayToken(workAppContext.loginUserInfo?.userId)
- const displayedPaymentCreator = useMemo(
- (): string => {
- if (resolvedPaymentCreator?.source === rawPaymentCreator) {
- return resolvedPaymentCreator.handle
- }
-
- if (!rawPaymentCreator) {
- return '-'
- }
-
- if (isPaymentCreatorUserId) {
- return loginUserId === rawPaymentCreator && loginUserHandle
- ? loginUserHandle
- : '-'
- }
-
- return rawPaymentCreator
- },
- [
- isPaymentCreatorUserId,
- loginUserHandle,
- loginUserId,
- rawPaymentCreator,
- resolvedPaymentCreator?.handle,
- resolvedPaymentCreator?.source,
- ],
- )
-
- useEffect(() => {
- setResolvedPaymentCreator(undefined)
-
- if (!rawPaymentCreator) {
- return undefined
- }
-
- if (!isPaymentCreatorUserId) {
- setResolvedPaymentCreator({
- handle: rawPaymentCreator,
- source: rawPaymentCreator,
- })
- return undefined
- }
-
- if (loginUserId === rawPaymentCreator && loginUserHandle) {
- setResolvedPaymentCreator({
- handle: loginUserHandle,
- source: rawPaymentCreator,
- })
- return undefined
- }
-
- let isActive = true
-
- searchProfilesByUserIds([rawPaymentCreator])
- .then(profiles => {
- if (!isActive) {
- return
- }
-
- const creatorProfile = profiles.find(profile => (
- normalizeDisplayToken(profile.userId) === rawPaymentCreator
- ))
- const creatorHandle = normalizeDisplayToken(creatorProfile?.handle)
-
- setResolvedPaymentCreator(creatorHandle
- ? {
- handle: creatorHandle,
- source: rawPaymentCreator,
- }
- : undefined)
- })
- .catch(() => {
- if (isActive) {
- setResolvedPaymentCreator(undefined)
- }
- })
-
- return () => {
- isActive = false
- }
- }, [
- isPaymentCreatorUserId,
- loginUserHandle,
- loginUserId,
- rawPaymentCreator,
- ])
const reviewSection = usesManualReviewers
? (
@@ -3408,8 +2885,8 @@ export const ChallengeEditorForm: FC = (
@@ -3433,116 +2910,26 @@ export const ChallengeEditorForm: FC = (
resolvedChallengeTypeAbbreviation
}
challengeTypeName={resolvedChallengeTypeName}
- disabled={arePrizeFieldsDisabled}
+ disabled={isReadOnly}
name='prizeSets'
/>
{showCheckpointPrizes
? (
)
: undefined}
-
+
- {shouldUseCopilotBillingSummary
- ? undefined
- :
}
-
-
-
- Billing Account Id:
-
-
- {displayedBillingAccountId}
-
-
-
-
- Payment Creator:
-
-
- {displayedPaymentCreator}
-
-
-
-
-
-
- Approval status:
-
-
- {getApprovalStatusText(normalizedApprovalStatus)}
-
-
- {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.REJECTED
- && normalizeTextValue(values.approvalRejectionReason)
- ? (
-
- {`Reason: ${values.approvalRejectionReason}`}
-
- )
- : undefined}
- {normalizedApprovalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED
- && normalizeTextValue(values.approvalApprovedBy)
- ? (
-
- {`Approved by ${values.approvalApprovedBy}`}
-
- )
- : undefined}
- {canRenderApprovalActions
- ? (
- <>
-
@@ -3605,6 +2992,12 @@ export const ChallengeEditorForm: FC = (
label='Wipro Allowed'
name='wiproAllowed'
/>
+
+ Billing Account Id
+
+ {displayedBillingAccountId}
+
+
@@ -3617,7 +3010,7 @@ export const ChallengeEditorForm: FC = (
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.spec.tsx
index 7b2ad2970..6a2c951a6 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.spec.tsx
@@ -15,12 +15,8 @@ jest.mock('../../../../lib/components/form', () => ({
FormMarkdownEditor: (props: {
label: string
name: string
- readOnly?: boolean
}) => (
-
+
{props.label}
),
@@ -44,7 +40,6 @@ jest.mock('~/libs/ui', () => ({
interface TestHarnessProps {
privateDescription?: string
- readOnly?: boolean
}
const TestHarness = (props: TestHarnessProps): JSX.Element => {
@@ -62,7 +57,7 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => {
return (
-
+
)
}
@@ -101,13 +96,4 @@ describe('ChallengePrivateDescriptionField', () => {
expect(screen.getByTestId('privateDescription'))
.toHaveTextContent('Private Specification')
})
-
- it('passes read-only mode to the private specification editor', () => {
- render(
-
,
- )
-
- expect(screen.getByTestId('privateDescription'))
- .toHaveAttribute('data-read-only', 'true')
- })
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.tsx
index 4bd3a41d5..53bc1888c 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrivateDescriptionField.tsx
@@ -14,13 +14,7 @@ import styles from './ChallengePrivateDescriptionField.module.scss'
const specificationTemplateLink = 'https://github.com/topcoder-platform-templates/specification-templates'
-export interface ChallengePrivateDescriptionFieldProps {
- readOnly?: boolean
-}
-
-export const ChallengePrivateDescriptionField: FC
= (
- props: ChallengePrivateDescriptionFieldProps,
-) => {
+export const ChallengePrivateDescriptionField: FC = () => {
const formContext = useFormContext()
const privateDescription = formContext.watch('privateDescription') as string | undefined
const [isVisible, setIsVisible] = useState(!!privateDescription)
@@ -71,7 +65,6 @@ export const ChallengePrivateDescriptionField: FC
)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
index a2fc43bc5..bd6c3e623 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.spec.tsx
@@ -203,68 +203,4 @@ describe('ChallengePrizesField', () => {
expect(secondPrizeRow.className)
.toContain(styles.prizeRowWithRemove)
})
-
- it('allows equal lower placement prizes without showing an ordering error', () => {
- render(
- ,
- )
-
- expect(screen.queryByText('Each subsequent prize must be less than or equal to the one above it.'))
- .toBeNull()
- })
-
- it('shows an ordering error when a lower placement prize increases', () => {
- render(
- ,
- )
-
- expect(screen.getByText('Each subsequent prize must be less than or equal to the one above it.'))
- .toBeTruthy()
- })
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
index ff88e3eb6..cbe0102a6 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengePrizesField/ChallengePrizesField.tsx
@@ -205,7 +205,7 @@ export const ChallengePrizesField: FC = (
placementSetIndex,
])
- const nonIncreasingOrderError = useMemo(() => {
+ const descendingError = useMemo(() => {
if (!Array.isArray(placementPrizes) || placementPrizes.length < 2) {
return undefined
}
@@ -217,9 +217,9 @@ export const ChallengePrizesField: FC = (
if (
previousPrize > 0
&& currentPrize > 0
- && currentPrize > previousPrize
+ && currentPrize >= previousPrize
) {
- return 'Each subsequent prize must be less than or equal to the one above it.'
+ return 'Each subsequent prize must be less than the one above it.'
}
}
@@ -328,7 +328,7 @@ export const ChallengePrizesField: FC = (
? fieldState.error.message
: undefined
const showPrizeRowLabels = fields.length > 0
- const errorMessage = fieldError || nonIncreasingOrderError
+ const errorMessage = fieldError || descendingError
const prizeTypeFieldName = `${props.name}-type`
const fieldLabelId = `${props.name}-label`
const usdOptionId = `${prizeTypeFieldName}-usd`
@@ -422,7 +422,7 @@ export const ChallengePrizesField: FC = (
= props => {
return (
-
+
)
}
@@ -139,49 +134,4 @@ describe('ChallengeTotalField', () => {
expect(screen.getByText('$2,041.80'))
.toBeTruthy()
})
-
- it('can omit challenge fee and use a copilot-safe cost label', () => {
- render(
- ,
- )
-
- expect(screen.getByText('Estimated challenge cost:'))
- .toBeTruthy()
- expect(screen.getByText('$135.00'))
- .toBeTruthy()
- })
})
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.tsx
index 34ddf8ce3..15b2e4e6e 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTotalField/ChallengeTotalField.tsx
@@ -21,20 +21,7 @@ import {
import styles from './ChallengeTotalField.module.scss'
-interface ChallengeTotalFieldProps {
- /**
- * Whether the displayed total should include the derived billing challenge fee.
- */
- includeChallengeFee?: boolean
- /**
- * Summary label displayed beside the formatted total.
- */
- label?: string
-}
-
-export const ChallengeTotalField: FC = (
- props: ChallengeTotalFieldProps,
-) => {
+export const ChallengeTotalField: FC = () => {
const formContext: UseFormReturn = useFormContext()
const control = formContext.control
const watchedBilling = useWatch({
@@ -87,15 +74,10 @@ export const ChallengeTotalField: FC = (
: 0
)
- return challengeTotal + (
- props.includeChallengeFee === false
- ? 0
- : challengeFee
- )
+ return challengeTotal + challengeFee
}, [
calculatedChallengeFee,
challengeTotal,
- props.includeChallengeFee,
watchedChallengeFee,
])
const formattedValue = useMemo(
@@ -105,7 +87,7 @@ export const ChallengeTotalField: FC = (
return (
- {props.label || 'Estimated challenge total:'}
+ Estimated challenge total:
{formattedValue}
)
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 c00a904d0..e8abe1cf7 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
@@ -110,16 +110,14 @@
.inlineActions,
.footerActions,
-.modalActions,
-.rerunActions {
+.modalActions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.inlineActions,
-.footerActions,
-.rerunActions {
+.footerActions {
align-items: flex-end;
justify-content: flex-end;
}
@@ -228,24 +226,6 @@
text-transform: uppercase;
}
-.rerunActions {
- border-top: 1px solid $black-10;
- justify-content: space-between;
- padding-top: 16px;
-}
-
-.rerunActions h4 {
- color: $black-80;
- margin: 0;
-}
-
-.rerunActions p {
- color: $black-60;
- font-size: 14px;
- line-height: 1.5;
- margin: 4px 0 0;
-}
-
.validationCard {
border-radius: 8px;
padding: 14px;
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 eeed64227..047d69261 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
@@ -27,12 +27,10 @@ import {
fetchMarathonMatchDefaults,
fetchTester,
fetchTesters,
- rerunMarathonMatchScores,
updateMarathonMatchConfig,
} from '../../../../../lib/services'
import {
showErrorToast,
- showInfoToast,
showSuccessToast,
} from '../../../../../lib/utils'
@@ -666,10 +664,8 @@ export const MarathonMatchScorerSection: FC = (
})
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
- const [isRerunning, setIsRerunning] = useState(false)
const [loadError, setLoadError] = useState()
const [saveError, setSaveError] = useState()
- const [rerunError, setRerunError] = useState()
const [testerLoadError, setTesterLoadError] = useState()
const [showNewTesterModal, setShowNewTesterModal] = useState(false)
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
@@ -1063,7 +1059,6 @@ export const MarathonMatchScorerSection: FC = (
setIsLoading(true)
setLoadError(undefined)
setSaveError(undefined)
- setRerunError(undefined)
setTesterLoadError(undefined)
setSelectedTester(undefined)
@@ -1255,55 +1250,6 @@ export const MarathonMatchScorerSection: FC = (
? maxTesterVersionByName[currentVersionTarget.name]
: undefined
const isReadyToSave = !!draft && !isSaving && !hasBlockingError && hasUnsavedChanges
- const canRerunScores = !!config
- && !hasUnsavedChanges
- && !hasBlockingError
- && !isRerunning
-
- const handleRerunScores = useCallback(
- async (): Promise => {
- if (!canRerunScores) {
- return
- }
-
- setIsRerunning(true)
- setRerunError(undefined)
-
- try {
- const rerunResponse = await rerunMarathonMatchScores(challengeId)
- const failedCount = rerunResponse.results
- .filter(result => !!result.error)
- .length
- const launchedCount = rerunResponse.submissionsQueued - failedCount
-
- if (rerunResponse.submissionsQueued === 0) {
- showInfoToast('No latest submissions found to rerun')
- } else if (failedCount > 0) {
- showErrorToast(
- `Rerun queued ${launchedCount}/${rerunResponse.submissionsQueued} submissions`,
- )
- } else {
- showSuccessToast(`Rerun queued ${rerunResponse.submissionsQueued} submissions`)
- }
- } catch (error) {
- const errorMessage = getErrorMessage(error, 'Failed to rerun marathon match scores')
-
- setRerunError(errorMessage)
- showErrorToast(errorMessage)
- } finally {
- setIsRerunning(false)
- }
- },
- [
- canRerunScores,
- challengeId,
- ],
- )
-
- const handleRerunScoresClick = useCallback((): void => {
- handleRerunScores()
- .catch(() => undefined)
- }, [handleRerunScores])
if (loadError && !draft) {
return {loadError}
@@ -1412,10 +1358,6 @@ export const MarathonMatchScorerSection: FC = (
? {testerLoadError}
: undefined}
- {rerunError
- ? {rerunError}
- : undefined}
-
{selectedTester
? (
@@ -1441,26 +1383,6 @@ export const MarathonMatchScorerSection: FC
= (
: testers.length === 0
? No scorers found yet. Create one to continue.
: undefined}
-
- {config
- ? (
-
-
-
Score Operations
-
Queue the latest submissions for scoring with the saved scorer config.
-
-
-
- )
- : undefined}
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
index 832985e17..3f4de24ca 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
@@ -541,22 +541,6 @@ export const AiReviewTab: FC = (
() => templates.find(template => template.id === configuration.templateId),
[configuration.templateId, templates],
)
- const activeTemplates = useMemo(
- () => templates.filter(template => template.disabled !== true),
- [templates],
- )
- const activeWorkflows = useMemo(
- () => availableWorkflows.filter(workflow => workflow.disabled !== true),
- [availableWorkflows],
- )
- const activeWorkflowIdSet = useMemo(
- () => new Set(activeWorkflows.map(workflow => normalizeReviewerText(workflow.id))),
- [activeWorkflows],
- )
- const activeTemplateIdSet = useMemo(
- () => new Set(activeTemplates.map(template => normalizeReviewerText(template.id))),
- [activeTemplates],
- )
const normalizedConfiguration = useMemo(
(): SaveAiReviewConfigInput | undefined => (
normalizedChallengeId
@@ -570,56 +554,10 @@ export const AiReviewTab: FC = (
[configuration, configurationMode, normalizedChallengeId],
)
const validationErrors = useMemo(
- () => {
- if (!normalizedConfiguration) {
- return []
- }
-
- const errors = validateAiReviewConfiguration(normalizedConfiguration)
-
- const selectedTemplateId = normalizeReviewerText(configuration.templateId)
- if (
- configurationMode === 'template'
- && selectedTemplateId
- && !templatesLoading
- && templates.length > 0
- && !activeTemplateIdSet.has(selectedTemplateId)
- ) {
- errors.push('Selected AI review template is deactivated. Please select an active template.')
- }
-
- if (
- configurationMode === 'manual'
- && !isWorkflowsLoading
- && availableWorkflows.length > 0
- ) {
- const hasDeactivatedWorkflow = (configuration.workflows || [])
- .map(workflow => normalizeReviewerText(workflow.workflowId))
- .filter(Boolean)
- .some(workflowId => !activeWorkflowIdSet.has(workflowId))
-
- if (hasDeactivatedWorkflow) {
- errors.push(
- 'One or more selected AI workflows are deactivated. '
- + 'Please select active workflows only.',
- )
- }
- }
-
- return errors
- },
- [
- activeTemplateIdSet,
- activeWorkflowIdSet,
- configuration.templateId,
- configuration.workflows,
- configurationMode,
- availableWorkflows,
- isWorkflowsLoading,
- normalizedConfiguration,
- templates,
- templatesLoading,
- ],
+ () => (normalizedConfiguration
+ ? validateAiReviewConfiguration(normalizedConfiguration)
+ : []),
+ [normalizedConfiguration],
)
const hasPersistedConfigForCurrentChallenge = useMemo(
() => (
@@ -716,7 +654,7 @@ export const AiReviewTab: FC = (
const handleTemplateSelect = useCallback(
(templateId: string): void => {
- const selected = activeTemplates.find(template => template.id === templateId)
+ const selected = templates.find(template => template.id === templateId)
if (!selected) {
setConfiguration(previousConfiguration => ({
...previousConfiguration,
@@ -734,7 +672,7 @@ export const AiReviewTab: FC = (
workflows: selected.workflows.map(toDraftWorkflow),
})
},
- [activeTemplates],
+ [templates],
)
const performModeSwitch = useCallback(async (targetMode: ConfigurationMode): Promise => {
@@ -1185,7 +1123,7 @@ export const AiReviewTab: FC = (
value={configuration.templateId || ''}
>
Select template
- {activeTemplates.map(template => (
+ {templates.map(template => (
{template.title}
@@ -1253,7 +1191,7 @@ export const AiReviewTab: FC = (
{(configuration.workflows || []).map((workflow, index) => (
= (
const [memberCache, setMemberCache] = useState({})
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(PAGE_SIZE)
- const [selectedRunnerLogsSubmissionId, setSelectedRunnerLogsSubmissionId]
- = useState('')
const [selectedSubmissionId, setSelectedSubmissionId] = useState('')
const [showArtifactsModal, setShowArtifactsModal] = useState(false)
- const [showRunnerLogsModal, setShowRunnerLogsModal] = useState(false)
const [sortBy, setSortBy] = useState('createdAt')
const [sortOrder, setSortOrder] = useState('desc')
@@ -359,8 +353,6 @@ export const SubmissionsSection: FC = (
const workAppContext = useContext(WorkAppContext)
const canDownload = canDownloadSubmissions(workAppContext.userRoles)
- const canViewRunnerLogs = isMarathonMatchChallenge(props.challenge)
- && canViewMarathonMatchRunnerLogs(workAppContext.userRoles)
const submissionsResult = useFetchSubmissions(
props.challengeId,
@@ -529,16 +521,6 @@ export const SubmissionsSection: FC = (
setShowArtifactsModal(false)
}, [])
- const handleOpenRunnerLogs = useCallback((submissionId: string): void => {
- setSelectedRunnerLogsSubmissionId(submissionId)
- setShowRunnerLogsModal(true)
- }, [])
-
- const handleCloseRunnerLogs = useCallback((): void => {
- setSelectedRunnerLogsSubmissionId('')
- setShowRunnerLogsModal(false)
- }, [])
-
const handleSort = useCallback((fieldName: SubmissionSortBy): void => {
setSortOrder(currentSortOrder => {
if (sortBy === fieldName) {
@@ -658,13 +640,11 @@ export const SubmissionsSection: FC = (
= (
Round 1 (Checkpoint) Submissions
= (
/>
)
: undefined}
-
- {showRunnerLogsModal && selectedRunnerLogsSubmissionId
- ? (
-
- )
- : undefined}
)
}
diff --git a/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.spec.tsx
deleted file mode 100644
index 2815e5973..000000000
--- a/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.spec.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import {
- render,
- screen,
-} from '@testing-library/react'
-import {
- MemoryRouter,
- Route,
- Routes,
-} from 'react-router-dom'
-
-import {
- useFetchChallenge,
-} from '../../../lib/hooks'
-
-import { ChallengeRouteRedirectPage } from './ChallengeRouteRedirectPage'
-
-jest.mock('../../../lib/components', () => ({
- ErrorMessage: (props: { message: string; onRetry?: () => void }) => (
-
- {props.message}
- {props.onRetry
- ? (
-
- Retry
-
- )
- : undefined}
-
- ),
- LoadingSpinner: () => Loading
,
-}))
-
-jest.mock('../../../lib/hooks', () => ({
- useFetchChallenge: jest.fn(),
-}))
-
-const mockedUseFetchChallenge = useFetchChallenge as jest.Mock
-
-describe('ChallengeRouteRedirectPage', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- })
-
- it('shows the existing generic error panel instead of redirecting after a challenge fetch error', () => {
- mockedUseFetchChallenge.mockReturnValue({
- challenge: undefined,
- error: new Error('Forbidden'),
- isError: true,
- isLoading: false,
- mutate: jest.fn(),
- })
-
- render(
-
-
- } />
- Redirected } />
-
- ,
- )
-
- expect(screen.getByText('Forbidden'))
- .toBeTruthy()
- expect(screen.queryByText('Redirected'))
- .toBeNull()
- })
-})
diff --git a/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.tsx b/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.tsx
index 4dc49b403..01d14b692 100644
--- a/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeRouteRedirectPage/ChallengeRouteRedirectPage.tsx
@@ -1,13 +1,7 @@
-import {
- FC,
- useCallback,
-} from 'react'
+import { FC } from 'react'
import { Navigate, useParams } from 'react-router-dom'
-import {
- ErrorMessage,
- LoadingSpinner,
-} from '../../../lib/components'
+import { LoadingSpinner } from '../../../lib/components'
import {
useFetchChallenge,
UseFetchChallengeResult,
@@ -54,26 +48,12 @@ function buildProjectChallengeViewPath(projectId: string, challengeId: string):
return `/projects/${encodeURIComponent(projectId)}/challenges/${encodeURIComponent(challengeId)}/view`
}
-/**
- * Resolves the generic challenge-load error text for the redirect helper.
- *
- * @param error challenge fetch error returned by the challenge hook.
- * @returns an error message suitable for the existing work app error panel.
- */
-function getChallengeErrorMessage(error: Error | undefined): string {
- return error?.message || 'Something went wrong while loading the challenge.'
-}
-
export const ChallengeRouteRedirectPage: FC = () => {
const routeParams: Readonly<{
challengeId?: string
}> = useParams<'challengeId'>()
const challengeId = normalizeRouteParam(routeParams.challengeId)
const challengeResult: UseFetchChallengeResult = useFetchChallenge(challengeId)
- const handleRetry = useCallback((): void => {
- challengeResult.mutate()
- .catch(() => undefined)
- }, [challengeResult])
if (!challengeId) {
return
@@ -83,15 +63,6 @@ export const ChallengeRouteRedirectPage: FC = () => {
return
}
- if (challengeResult.isError) {
- return (
-
- )
- }
-
const projectId = normalizeProjectId(challengeResult.challenge?.projectId)
const redirectPath = projectId
? buildProjectChallengeViewPath(projectId, challengeId)
diff --git a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx
index c9d62c2e8..a0ecc5c4a 100644
--- a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx
@@ -20,8 +20,6 @@ import {
import {
buildProjectLandingPath,
canCreateEngagement,
- checkCanEditProjectDetails,
- checkProjectAccess,
getAuthAccessToken,
} from '../../../lib/utils'
@@ -90,7 +88,6 @@ jest.mock('../../../lib/components', () => ({
),
ChallengesTable: () => Challenges Table
,
- ErrorMessage: (props: { message: string }) => {props.message}
,
Pagination: (props: { onPerPageChange: (perPage: number) => void }) => {
function handlePerPageChange(): void {
props.onPerPageChange(25)
@@ -132,25 +129,17 @@ jest.mock('../../../lib/hooks', () => ({
jest.mock('../../../lib/utils', () => ({
buildProjectLandingPath: jest.fn((project: { id?: string | number }) => `/projects/${project.id}/challenges`),
canCreateEngagement: jest.fn(() => false),
- checkCanEditProjectDetails: jest.fn(() => false),
checkCanManageProject: jest.fn(() => false),
- checkProjectAccess: jest.fn((
- _userRoles: string[],
- _userId: number | string | undefined,
- project?: { id?: string | number },
- ) => !!project),
getAuthAccessToken: jest.fn(() => 'token'),
getStatusText: jest.fn((status?: string) => status || ''),
}))
const mockedBuildProjectLandingPath = buildProjectLandingPath as jest.Mock
-const mockedCheckProjectAccess = checkProjectAccess as jest.Mock
const mockedUseFetchChallenges = useFetchChallenges as jest.Mock
const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock
const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjects = useFetchProjects as jest.Mock
const mockedCanCreateEngagement = canCreateEngagement as jest.Mock
-const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
const mockedGetAuthAccessToken = getAuthAccessToken as jest.Mock
const defaultContextValue: WorkAppContextModel = {
@@ -196,13 +185,6 @@ describe('ChallengesListPage', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockedCanCreateEngagement.mockReturnValue(false)
- mockedCheckCanEditProjectDetails.mockReturnValue(false)
- mockedCheckProjectAccess.mockImplementation((
- _userRoles: string[],
- _userId: number | string | undefined,
- project?: { id?: string | number },
- ) => !!project)
mockedUseFetchChallenges.mockReturnValue({
challenges: [],
error: undefined,
@@ -269,21 +251,6 @@ describe('ChallengesListPage', () => {
})
it('does not scope project challenge queries by member id', () => {
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: 200,
- members: [
- {
- userId: 12345,
- },
- ],
- name: 'Authorized Project',
- status: 'active',
- },
- })
-
renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
const fetchParams = mockedUseFetchChallenges.mock.calls[0][0]
@@ -292,133 +259,6 @@ describe('ChallengesListPage', () => {
.toBe('200')
expect(fetchParams.memberId)
.toBeUndefined()
- expect(fetchParams.enabled)
- .toBe(true)
- })
-
- it('hides project edit action when a copilot can manage but cannot edit project details', () => {
- const project = {
- id: 200,
- members: [
- {
- role: 'copilot',
- userId: 12345,
- },
- ],
- name: 'Authorized Project',
- status: 'active',
- }
-
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project,
- })
-
- renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
-
- expect(mockedCheckCanEditProjectDetails)
- .toHaveBeenCalledWith(['copilot'], 12345, project)
- expect(within(screen.getByTestId('page-title-action'))
- .queryByRole('link', { name: 'Edit project' }))
- .toBeNull()
- })
-
- it('shows project edit action when project detail editing is allowed', () => {
- mockedCheckCanEditProjectDetails.mockReturnValue(true)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: 200,
- members: [
- {
- role: 'manager',
- userId: 12345,
- },
- ],
- name: 'Authorized Project',
- status: 'active',
- },
- })
-
- renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
-
- expect(within(screen.getByTestId('page-title-action'))
- .getByRole('link', { name: 'Edit project' })
- .getAttribute('href'))
- .toBe('/projects/200/edit')
- })
-
- it('waits for project access before fetching project challenges', () => {
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: true,
- project: undefined,
- })
-
- renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
-
- expect(mockedUseFetchChallenges)
- .toHaveBeenCalledWith(expect.objectContaining({
- enabled: false,
- projectId: '200',
- }))
- expect(screen.getByText('Loading'))
- .toBeTruthy()
- })
-
- it('blocks project challenge details when the project cannot be loaded', () => {
- mockedUseFetchProject.mockReturnValue({
- error: new Error('Forbidden'),
- isLoading: false,
- project: undefined,
- })
- mockedCheckProjectAccess.mockReturnValue(false)
-
- renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
-
- expect(mockedUseFetchChallenges)
- .toHaveBeenCalledWith(expect.objectContaining({
- enabled: false,
- projectId: '200',
- }))
- expect(screen.getByText('You don’t have access to this project. Please contact support@topcoder.com.'))
- .toBeTruthy()
- expect(screen.queryByText('Challenges Table'))
- .toBeNull()
- })
-
- it('blocks project challenge details when the caller is not a project member', () => {
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- project: {
- id: 200,
- members: [
- {
- userId: 99999,
- },
- ],
- name: 'Restricted Project',
- status: 'active',
- },
- })
- mockedCheckProjectAccess.mockReturnValue(false)
-
- renderPage('/projects/200/challenges', '/projects/:projectId/challenges')
-
- expect(mockedUseFetchChallenges)
- .toHaveBeenCalledWith(expect.objectContaining({
- enabled: false,
- projectId: '200',
- }))
- expect(screen.getByText('You don’t have access to this project. Please contact support@topcoder.com.'))
- .toBeTruthy()
- expect(screen.queryByText('Restricted Project'))
- .toBeNull()
- expect(screen.queryByText('Challenges Table'))
- .toBeNull()
})
it('redirects invited users from the challenges route to the invitation modal route', async () => {
diff --git a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.tsx b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.tsx
index c480172ff..72fd5fd4f 100644
--- a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.tsx
@@ -33,7 +33,6 @@ import {
import {
ChallengesFilter,
ChallengesTable,
- ErrorMessage,
Pagination,
ProjectBillingAccountExpiredNotice,
ProjectListTabs,
@@ -60,16 +59,13 @@ import {
import {
buildProjectLandingPath,
canCreateEngagement,
- checkCanEditProjectDetails,
checkCanManageProject,
- checkProjectAccess,
getAuthAccessToken,
getStatusText,
} from '../../../lib/utils'
import styles from './ChallengesListPage.module.scss'
-const PROJECT_ACCESS_DENIED_MESSAGE = 'You don’t have access to this project. Please contact support@topcoder.com.'
const DEFAULT_FILTERS: ChallengeFilters = {
endDateEnd: undefined,
endDateStart: undefined,
@@ -188,7 +184,7 @@ function renderContextualActions(params: RenderContextualActionsParams): JSX.Ele
}
interface RenderProjectTitleActionParams {
- canEditProjectDetails: boolean
+ canManageProject: boolean
projectId: string | undefined
projectStatus: ProjectStatusValue | undefined
}
@@ -203,7 +199,7 @@ function renderProjectTitleAction(params: RenderProjectTitleActionParams): JSX.E
{params.projectStatus
?
: undefined}
- {params.canEditProjectDetails
+ {params.canManageProject
? (
)
@@ -374,22 +344,6 @@ interface DashboardMemberScopeState {
scopedMemberId?: number
}
-interface ProjectRouteAccessParams {
- isProjectLoading: boolean
- project?: Project
- projectError?: Error
- projectId?: string
- shouldRedirectToProjectLanding: boolean
- userId?: number | string
- userRoles: string[]
-}
-
-interface ProjectRouteAccessState {
- canFetchProjectChallenges: boolean
- isDenied: boolean
- isLoading: boolean
-}
-
interface ResolveDashboardMemberScopeParams {
isPrivilegedUser: boolean
selectedProjectId?: number | string
@@ -423,35 +377,6 @@ function resolveDashboardMemberScope(
}
}
-/**
- * Resolves project-route access state before project-scoped child records load.
- *
- * @param params current route, project loading, redirect, and caller identity state.
- * @returns whether child challenge records can load and whether to show loading or denial UI.
- * @remarks Used by direct project challenge URLs so unauthorized callers do not
- * receive challenge listings before project membership is verified.
- */
-function resolveProjectRouteAccess(
- params: ProjectRouteAccessParams,
-): ProjectRouteAccessState {
- if (!params.projectId) {
- return {
- canFetchProjectChallenges: true,
- isDenied: false,
- isLoading: false,
- }
- }
-
- const hasProjectAccess = checkProjectAccess(params.userRoles, params.userId, params.project)
- const isLoading = params.isProjectLoading || params.shouldRedirectToProjectLanding
-
- return {
- canFetchProjectChallenges: hasProjectAccess && !params.projectError,
- isDenied: !isLoading && (!!params.projectError || !hasProjectAccess),
- isLoading,
- }
-}
-
/**
* Returns the canonical challenges route for a project-scoped challenges page.
*
@@ -524,32 +449,6 @@ export const ChallengesListPage: FC = () => {
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(DEFAULT_SORT_ORDER)
const isPrivilegedUser = isAdmin || isManager
const selectedProjectId = projectIdFromRoute || filters.projectId
- const projectResult: UseFetchProjectResult = useFetchProject(projectIdFromRoute)
- const accessToken = useMemo(
- () => getAuthAccessToken(loginUserInfo),
- [loginUserInfo],
- )
- const currentProjectChallengesPath = useMemo(
- () => getProjectChallengesPath(projectIdFromRoute),
- [projectIdFromRoute],
- )
- const projectLandingPath = useMemo(
- () => getProjectLandingPath(projectIdFromRoute, projectResult.project, accessToken),
- [accessToken, projectIdFromRoute, projectResult.project],
- )
- const shouldRedirectToProjectLanding = useMemo(
- () => shouldRedirectToProjectLandingPath(currentProjectChallengesPath, projectLandingPath),
- [currentProjectChallengesPath, projectLandingPath],
- )
- const projectRouteAccess = resolveProjectRouteAccess({
- isProjectLoading: projectResult.isLoading,
- project: projectResult.project,
- projectError: projectResult.error,
- projectId: projectIdFromRoute,
- shouldRedirectToProjectLanding,
- userId: loginUserInfo?.userId,
- userRoles,
- })
const {
isWaitingForMemberScope,
scopedMemberId,
@@ -561,7 +460,7 @@ export const ChallengesListPage: FC = () => {
const fetchParams: UseFetchChallengesParams = {
...filters,
- enabled: !isWaitingForMemberScope && projectRouteAccess.canFetchProjectChallenges,
+ enabled: !isWaitingForMemberScope,
memberId: scopedMemberId,
page,
perPage,
@@ -572,6 +471,7 @@ export const ChallengesListPage: FC = () => {
const challengesResult: UseFetchChallengesResult = useFetchChallenges(fetchParams)
const challengeTypesResult: UseFetchChallengeTypesResult = useFetchChallengeTypes()
+ const projectResult: UseFetchProjectResult = useFetchProject(projectIdFromRoute)
const projectsResult: UseFetchProjectsResult = useFetchProjects({
memberOnly: !isPrivilegedUser,
})
@@ -687,6 +587,22 @@ export const ChallengesListPage: FC = () => {
.sort((projectA, projectB) => projectA.label.localeCompare(projectB.label)),
[projectsResult.projects],
)
+ const accessToken = useMemo(
+ () => getAuthAccessToken(loginUserInfo),
+ [loginUserInfo],
+ )
+ const currentProjectChallengesPath = useMemo(
+ () => getProjectChallengesPath(projectIdFromRoute),
+ [projectIdFromRoute],
+ )
+ const projectLandingPath = useMemo(
+ () => getProjectLandingPath(projectIdFromRoute, projectResult.project, accessToken),
+ [accessToken, projectIdFromRoute, projectResult.project],
+ )
+ const shouldRedirectToProjectLanding = useMemo(
+ () => shouldRedirectToProjectLandingPath(currentProjectChallengesPath, projectLandingPath),
+ [currentProjectChallengesPath, projectLandingPath],
+ )
useEffect(() => {
setFilters(currentFilters => ({
@@ -711,11 +627,6 @@ export const ChallengesListPage: FC = () => {
: 'Challenges'
const canManageProject = !!projectResult.project
&& checkCanManageProject(userRoles, loginUserInfo?.userId, projectResult.project)
- const canEditProjectDetails = canRenderProjectDetailsEditAction(
- userRoles,
- loginUserInfo?.userId,
- projectResult.project,
- )
const isProjectActive = String(projectResult.project?.status || '')
.trim()
.toLowerCase() === PROJECT_STATUS.ACTIVE
@@ -740,26 +651,15 @@ export const ChallengesListPage: FC = () => {
})
const titleAction = renderProjectTitleAction({
- canEditProjectDetails,
+ canManageProject,
projectId: projectIdFromRoute,
projectStatus: projectResult.project?.status,
})
- if (projectRouteAccess.isLoading) {
+ if (shouldRedirectToProjectLanding) {
return
}
- if (projectRouteAccess.isDenied) {
- return (
-
-
-
- )
- }
-
return (
{
billingAccountId: projectResult.project?.billingAccountId,
billingAccountName: projectResult.project?.billingAccountName,
canManageProject,
- displayMemberPaymentDetailsToCopilots:
- projectResult.project?.details?.displayMemberPaymentDetailsToCopilots,
projectId: projectIdFromRoute,
})}
{projectIdFromRoute
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
deleted file mode 100644
index 62f9adf9d..000000000
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.spec.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import { render } from '@testing-library/react'
-
-import AssignmentDetailsModal from './AssignmentDetailsModal'
-
-const mockStartDateTimeInput = jest.fn((props: { label: string }): JSX.Element => (
- {props.label}
-))
-
-jest.mock('~/libs/ui', () => ({
- BaseModal: (props: {
- buttons?: JSX.Element
- children: JSX.Element
- open: boolean
- }): JSX.Element => (
- props.open ? (
-
- {props.children}
- {props.buttons}
-
- ) : <>>
- ),
- Button: (props: {
- label: string
- onClick?: () => void
- }): JSX.Element => (
-
- {props.label}
-
- ),
-}), {
- virtual: true,
-})
-
-jest.mock('../../../../lib/components/form', () => ({
- StartDateTimeInput: (props: { label: string }): JSX.Element => mockStartDateTimeInput(props),
-}))
-
-jest.mock('../../../../lib/utils', () => ({
- calculateAssignmentRatePerWeek: jest.fn(() => ''),
- deserializeTentativeAssignmentDate: jest.fn(() => undefined),
- sanitizePositiveNumericInput: jest.fn((value: string) => value),
- serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()),
- toPositiveInteger: jest.fn(() => 1),
- toPositiveNumber: jest.fn(() => 1),
- toPositiveNumberWithMaxDecimalPlaces: jest.fn(() => 1),
-}))
-
-describe('AssignmentDetailsModal', () => {
- beforeEach(() => {
- mockStartDateTimeInput.mockClear()
- })
-
- it('allows past engagement start dates in the assignment form', () => {
- render(
- ,
- )
-
- const startDateTimeInputProps = mockStartDateTimeInput
- .mock.calls[mockStartDateTimeInput.mock.calls.length - 1][0] as {
- label: string
- minDate?: Date | null
- }
-
- expect(startDateTimeInputProps.label)
- .toBe('Engagement 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 709109a02..17c5f1636 100644
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/AssignmentDetailsModal.tsx
@@ -67,6 +67,7 @@ export const AssignmentDetailsModal: FC = (
props.initialValue?.standardHoursPerWeek || '',
)
+ const minStartDate = useMemo(() => new Date(), [])
const timezone = useMemo(
() => Intl.DateTimeFormat()
.resolvedOptions()
@@ -185,6 +186,7 @@ export const AssignmentDetailsModal: FC = (
{
setStartDate(value || undefined)
setErrors(previous => ({
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 b346e998f..68b9559cc 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
@@ -132,14 +132,6 @@ jest.mock('../../../../lib/utils', () => ({
? 'On Hold'
: status
),
- getCountableEngagementAssignments: (assignments: Array<{ status?: string }> = []) => (
- assignments.filter(assignment => !['COMPLETED', 'OFFER_REJECTED', 'TERMINATED'].includes(
- String(assignment.status || '')
- .trim()
- .replace(/[\s-]+/g, '_')
- .toUpperCase(),
- ))
- ),
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
}))
@@ -500,125 +492,6 @@ describe('EngagementEditorForm', () => {
})
})
- it('preserves existing assignments while keeping terminal history out of the edit payload', async () => {
- const user = userEvent.setup()
- const activeAssignment = {
- agreementRate: '800',
- durationMonths: 3,
- endDate: '',
- engagementId: 'engagement-history',
- id: 'assignment-active',
- memberHandle: 'active_member',
- memberId: '111',
- otherRemarks: 'active notes',
- ratePerHour: '20',
- standardHoursPerWeek: 40,
- startDate: '2026-05-01T00:00:00.000Z',
- status: 'ASSIGNED',
- termsAccepted: true,
- }
- const terminatedAssignment = {
- ...activeAssignment,
- agreementRate: '600',
- endDate: '2026-04-01T00:00:00.000Z',
- id: 'assignment-terminated',
- memberHandle: 'terminated_member',
- memberId: '222',
- status: 'TERMINATED',
- terminationReason: 'Finished early',
- }
-
- mockedUpdateEngagement.mockResolvedValue({
- anticipatedStart: 'Immediate',
- assignedMemberHandles: ['active_member'],
- assignments: [activeAssignment, terminatedAssignment],
- compensationRange: '',
- countries: ['US'],
- createdAt: '',
- description: 'History engagement description',
- durationWeeks: 4,
- id: 'engagement-history',
- isPrivate: true,
- projectId: '123',
- requiredMemberCount: 2,
- role: 'SOFTWARE_DEVELOPER',
- skills: [
- {
- id: 'skill-1',
- name: 'React',
- },
- ],
- status: 'Open',
- timezones: ['America/New_York'],
- title: 'History engagement',
- updatedAt: '',
- workload: 'FULL_TIME',
- } as any)
-
- render(
-
-
- ,
- )
-
- const requiredMembersField = screen.getByLabelText('Required Members')
-
- await user.clear(requiredMembersField)
- await user.type(requiredMembersField, '0')
- await user.click(screen.getByRole('button', { name: 'Save Engagement' }))
-
- await waitFor(() => {
- expect(mockedUpdateEngagement)
- .toHaveBeenCalled()
- })
-
- const payload = mockedUpdateEngagement.mock.calls[0][1] as {
- assignedMemberHandles?: string[]
- assignmentDetails?: Array<{ memberHandle: string }>
- requiredMemberCount?: number
- }
-
- expect(payload.requiredMemberCount)
- .toBe(1)
- expect(payload.assignedMemberHandles)
- .toEqual(['active_member'])
- expect(payload.assignmentDetails)
- .toHaveLength(1)
- expect(payload.assignmentDetails?.[0])
- .toEqual(expect.objectContaining({
- memberHandle: 'active_member',
- }))
- })
-
it('redirects to the saved parent project engagements list after creating an engagement', async () => {
const user = userEvent.setup()
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 6c9ad5f06..352a23a3a 100644
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementEditorForm.tsx
@@ -49,7 +49,6 @@ import {
} from '../../../../lib/services'
import {
formatEngagementStatus,
- getCountableEngagementAssignments,
showErrorToast,
showSuccessToast,
} from '../../../../lib/utils'
@@ -105,10 +104,6 @@ interface SaveEngagementOptions {
isAutosave?: boolean
}
-interface AssignmentSerializationOptions {
- lockedAssignmentDetails?: AssignmentDetailsFormValue[]
-}
-
type EngagementAssignment = Engagement['assignments'][number]
type SerializedAssignmentDetailsPayload = {
agreementRate: string
@@ -141,71 +136,41 @@ function normalizeProjectId(projectId: number | string | undefined): string {
*
* @param requiredMemberCount raw form value for the private member count.
* @param assignedMemberHandles form values for the selected member handles.
- * @param lockedAssignedMemberHandles persisted member handles that must remain visible.
* @returns trimmed handles for the currently active private-assignment slots.
*/
function getVisibleAssignedMemberHandles(
requiredMemberCount: number | string | undefined,
assignedMemberHandles: string[],
- lockedAssignedMemberHandles: string[] = [],
): string[] {
const parsedRequiredMemberCount = Number(requiredMemberCount)
- const assignmentLimit = Math.max(
- Number.isInteger(parsedRequiredMemberCount) && parsedRequiredMemberCount > 0
- ? parsedRequiredMemberCount
- : assignedMemberHandles.length,
- lockedAssignedMemberHandles.length,
- )
+ const assignmentLimit = Number.isInteger(parsedRequiredMemberCount) && parsedRequiredMemberCount > 0
+ ? parsedRequiredMemberCount
+ : assignedMemberHandles.length
- return Array.from({ length: assignmentLimit }, (_, index) => (
- lockedAssignedMemberHandles[index] || assignedMemberHandles[index] || ''
- ))
- .map(memberHandle => String(memberHandle)
+ return assignedMemberHandles
+ .slice(0, assignmentLimit)
+ .map(memberHandle => String(memberHandle || '')
.trim())
}
-/**
- * Extracts locked member handles from persisted assignment details.
- *
- * @param lockedAssignmentDetails existing assignment detail rows that should
- * remain owned by the assignments list.
- * @returns member handles that cannot be edited from the engagement form.
- */
-function getLockedAssignedMemberHandles(
- lockedAssignmentDetails: AssignmentDetailsFormValue[] = [],
-): string[] {
- return lockedAssignmentDetails
- .map(assignmentDetail => String(assignmentDetail.memberHandle || '')
- .trim())
- .filter(Boolean)
-}
-
/**
* Serializes private-assignment details only when they still match the current
* member handle selected for each visible slot.
*
* @param values engagement editor form values.
- * @param lockedAssignmentDetails persisted assignment details that must remain
- * unchanged while editing the engagement.
* @returns serialized assignment details aligned to the active member handles.
*/
function serializeAssignmentDetails(
values: EngagementEditorFormData,
- lockedAssignmentDetails: AssignmentDetailsFormValue[] = [],
): SerializedAssignmentDetailsPayload[] {
- const lockedAssignedMemberHandles = getLockedAssignedMemberHandles(lockedAssignmentDetails)
const visibleAssignedMemberHandles = getVisibleAssignedMemberHandles(
values.requiredMemberCount,
values.assignedMemberHandles,
- lockedAssignedMemberHandles,
)
const serializedAssignmentDetails: Array
= visibleAssignedMemberHandles
.map((memberHandle, index) => {
- const lockedDetail = lockedAssignmentDetails[index]
- const detail = lockedDetail?.memberHandle
- ? lockedDetail
- : values.assignmentDetails[index]
+ const detail = values.assignmentDetails[index]
const detailMemberHandle = String(detail?.memberHandle || '')
.trim()
@@ -259,16 +224,6 @@ function toAssignmentDetailsValue(assignment: EngagementAssignment): AssignmentD
}
}
-/**
- * Builds private-assignment form defaults from active assignment slots only.
- *
- * Historical completed or terminated assignments remain on the engagement
- * response, but editing an engagement should only submit currently countable
- * assignments so closed history rows are not modified.
- *
- * @param engagement engagement being edited, if one exists.
- * @returns member handles and details for active private-assignment slots.
- */
function getAssignmentDefaults(engagement: Engagement | undefined): {
assignedMemberHandles: string[]
assignmentDetails: AssignmentDetailsFormValue[]
@@ -276,8 +231,7 @@ function getAssignmentDefaults(engagement: Engagement | undefined): {
const assignments = engagement?.assignments
if (Array.isArray(assignments) && assignments.length > 0) {
- const assignmentDetails = getCountableEngagementAssignments(assignments)
- .map(toAssignmentDetailsValue)
+ const assignmentDetails = assignments.map(toAssignmentDetailsValue)
return {
assignedMemberHandles: assignmentDetails.map(assignment => assignment.memberHandle),
@@ -291,26 +245,6 @@ function getAssignmentDefaults(engagement: Engagement | undefined): {
}
}
-/**
- * Builds read-only assignment defaults for existing engagement assignments.
- *
- * @param engagement engagement being edited, if one exists.
- * @returns assignment details for active assignments that should no longer be
- * editable from the engagement editor.
- */
-function getLockedAssignmentDetails(
- engagement: Engagement | undefined,
-): AssignmentDetailsFormValue[] {
- const assignments = engagement?.assignments
-
- if (!Array.isArray(assignments) || assignments.length < 1) {
- return []
- }
-
- return getCountableEngagementAssignments(assignments)
- .map(toAssignmentDetailsValue)
-}
-
/**
* Resolves the form's parent project id from the engagement payload first,
* falling back to the route-scoped project id for new engagements.
@@ -449,48 +383,10 @@ function getEngagementsPath(projectId: number | string | undefined): string {
return `${rootRoute}/projects/${normalizeProjectId(projectId)}/engagements`
}
-/**
- * Resolves a required-member count while preserving already-assigned slots.
- *
- * @param rawRequiredMemberCount form value for the required member count.
- * @param minimumMemberCount minimum count needed to keep locked assignments visible.
- * @returns normalized member count, or `undefined` when the form value is blank.
- */
-function getPayloadRequiredMemberCount(
- rawRequiredMemberCount: number | string | undefined,
- minimumMemberCount: number,
-): number | undefined {
- if (rawRequiredMemberCount === '' || rawRequiredMemberCount === undefined) {
- return minimumMemberCount > 0
- ? minimumMemberCount
- : undefined
- }
-
- const requiredMemberCount = Number(rawRequiredMemberCount)
-
- if (!Number.isFinite(requiredMemberCount)) {
- return undefined
- }
-
- return Math.max(requiredMemberCount, minimumMemberCount)
-}
-
-/**
- * Converts engagement editor form state into the API payload.
- *
- * @param values engagement editor form values.
- * @param options serialization options for preserving locked assignment slots.
- * @returns partial engagement payload ready for create or update.
- */
-function toPayload(
- values: EngagementEditorFormData,
- options: AssignmentSerializationOptions = {},
-): Partial & {
+function toPayload(values: EngagementEditorFormData): Partial & {
assignmentDetails?: SerializedAssignmentDetailsPayload[]
} {
const rawRequiredMemberCount = values.requiredMemberCount
- const lockedAssignmentDetails = options.lockedAssignmentDetails || []
- const lockedAssignedMemberHandles = getLockedAssignedMemberHandles(lockedAssignmentDetails)
const payload: Partial & {
assignmentDetails?: SerializedAssignmentDetailsPayload[]
} = {
@@ -509,22 +405,18 @@ function toPayload(
workload: values.workload,
}
- const requiredMemberCount = getPayloadRequiredMemberCount(
- rawRequiredMemberCount,
- values.isPrivate
- ? lockedAssignedMemberHandles.length
- : 0,
- )
+ if (rawRequiredMemberCount !== '' && rawRequiredMemberCount !== undefined) {
+ const requiredMemberCount = Number(rawRequiredMemberCount)
- if (requiredMemberCount !== undefined) {
- payload.requiredMemberCount = requiredMemberCount
+ if (Number.isFinite(requiredMemberCount)) {
+ payload.requiredMemberCount = requiredMemberCount
+ }
}
if (values.isPrivate) {
const assignedMemberHandles = getVisibleAssignedMemberHandles(
values.requiredMemberCount,
values.assignedMemberHandles,
- lockedAssignedMemberHandles,
)
.filter(Boolean)
@@ -532,7 +424,7 @@ function toPayload(
payload.assignedMemberHandles = assignedMemberHandles
}
- const assignmentDetails = serializeAssignmentDetails(values, lockedAssignmentDetails)
+ const assignmentDetails = serializeAssignmentDetails(values)
if (assignmentDetails.length > 0) {
payload.assignmentDetails = assignmentDetails
@@ -556,16 +448,6 @@ export const EngagementEditorForm: FC = (
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState()
- const lockedAssignmentDetails = useMemo(
- () => (props.isEditMode
- ? getLockedAssignmentDetails(props.engagement)
- : []),
- [props.engagement, props.isEditMode],
- )
- const lockedAssignedMemberHandles = useMemo(
- () => getLockedAssignedMemberHandles(lockedAssignmentDetails),
- [lockedAssignmentDetails],
- )
const roleOptions = useMemo(() => createRoleOptions(), [])
const workloadOptions = useMemo(() => createWorkloadOptions(), [])
const currentProjectOption = useMemo(
@@ -628,9 +510,7 @@ export const EngagementEditorForm: FC = (
setSaveError(undefined)
try {
- const payload = toPayload(nextValues, {
- lockedAssignmentDetails,
- })
+ const payload = toPayload(nextValues)
let savedEngagement: Engagement
@@ -673,14 +553,7 @@ export const EngagementEditorForm: FC = (
}
}
},
- [
- currentEngagementId,
- lockedAssignmentDetails,
- navigate,
- props.isEditMode,
- props.projectId,
- reset,
- ],
+ [currentEngagementId, navigate, props.isEditMode, props.projectId, reset],
)
const loadParentProjectOptions = useCallback(
@@ -870,13 +743,7 @@ export const EngagementEditorForm: FC = (
-
+
{saveError
diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx
deleted file mode 100644
index 0c886c1de..000000000
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.spec.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import {
- render,
-} from '@testing-library/react'
-import moment from 'moment-timezone'
-
-import { EngagementLocationFields } from './EngagementLocationFields'
-
-const mockRecordedFields = new Map
()
-
-jest.mock('../../../../lib/components/form', () => ({
- FormSelectField: function FormSelectField(props: any) {
- mockRecordedFields.set(props.name, props)
-
- return {props.label}
- },
-}))
-
-describe('EngagementLocationFields', () => {
- beforeEach(() => {
- mockRecordedFields.clear()
- jest.spyOn(moment.tz, 'countries')
- .mockReturnValue(['DE', 'US'])
- jest.spyOn(moment.tz, 'names')
- .mockReturnValue(['Europe/Berlin'])
- })
-
- afterEach(() => {
- jest.restoreAllMocks()
- })
-
- it('prepends Any to the timezone and country option lists', () => {
- render( )
-
- const timezoneField = mockRecordedFields.get('timezones')
- const countryField = mockRecordedFields.get('countries')
-
- expect(timezoneField.options[0])
- .toEqual({
- label: 'Any',
- value: 'Any',
- })
- expect(countryField.options[0])
- .toEqual({
- label: 'Any',
- value: 'Any',
- })
- })
-
- it('stores Any as the only selected value when present', () => {
- render( )
-
- const timezoneField = mockRecordedFields.get('timezones')
- const countryField = mockRecordedFields.get('countries')
-
- expect(timezoneField.toFieldValue([
- {
- label: 'Any',
- value: 'Any',
- },
- {
- label: '(UTC+01:00) Europe/Berlin',
- value: 'Europe/Berlin',
- },
- ]))
- .toEqual(['Any'])
- expect(countryField.toFieldValue([
- {
- label: 'Germany',
- value: 'DE',
- },
- {
- label: 'Any',
- value: 'Any',
- },
- ]))
- .toEqual(['Any'])
- expect(countryField.toFieldValue([
- {
- label: 'Germany',
- value: 'DE',
- },
- ]))
- .toEqual(['DE'])
- })
-})
diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx
index 8472d3ab0..e6661972b 100644
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementLocationFields.tsx
@@ -1,6 +1,5 @@
import {
FC,
- useCallback,
useMemo,
} from 'react'
import moment from 'moment-timezone'
@@ -14,11 +13,6 @@ interface EngagementLocationFieldsProps {
disabled?: boolean
}
-const ANY_OPTION: FormSelectOption = {
- label: 'Any',
- value: 'Any',
-}
-
function formatTimezoneLabel(timezone: string): string {
const now = new Date()
@@ -65,14 +59,11 @@ function formatTimezoneLabel(timezone: string): string {
}
function getTimezoneOptions(): FormSelectOption[] {
- return [
- ANY_OPTION,
- ...moment.tz.names()
- .map(timezone => ({
- label: formatTimezoneLabel(timezone),
- value: timezone,
- })),
- ]
+ return moment.tz.names()
+ .map(timezone => ({
+ label: formatTimezoneLabel(timezone),
+ value: timezone,
+ }))
}
function getCountryOptions(): FormSelectOption[] {
@@ -86,10 +77,7 @@ function getCountryOptions(): FormSelectOption[] {
value: countryCode,
}))
- return [
- ANY_OPTION,
- ...options.sort((optionA, optionB) => optionA.label.localeCompare(optionB.label)),
- ]
+ return options.sort((optionA, optionB) => optionA.label.localeCompare(optionB.label))
}
export const EngagementLocationFields: FC = (
@@ -97,23 +85,6 @@ export const EngagementLocationFields: FC = (
) => {
const timezoneOptions = useMemo(() => getTimezoneOptions(), [])
const countryOptions = useMemo(() => getCountryOptions(), [])
- /**
- * Preserves the legacy "Any" sentinel as a mutually exclusive selection.
- *
- * @param selected Select value emitted by the form field.
- * @returns Normalized form values for react-hook-form state.
- */
- const toAnyOnlyFieldValue = useCallback((selected: unknown): string[] => {
- const selectedOptions = Array.isArray(selected)
- ? selected
- : []
-
- if (selectedOptions.some(option => option.value === ANY_OPTION.value)) {
- return [ANY_OPTION.value]
- }
-
- return selectedOptions.map(option => option.value)
- }, [])
return (
<>
@@ -125,7 +96,6 @@ export const EngagementLocationFields: FC = (
options={timezoneOptions}
placeholder='Select timezones'
required
- toFieldValue={toAnyOnlyFieldValue}
/>
= (
options={countryOptions}
placeholder='Select countries'
required
- toFieldValue={toAnyOnlyFieldValue}
/>
>
)
diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss
index 556da1e86..6ff94c4ef 100644
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss
+++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.module.scss
@@ -35,27 +35,6 @@
gap: 8px;
}
-.readOnlyAssignment {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.readOnlyLabel {
- color: #2a2a2a;
- font-size: 12px;
- font-weight: 700;
-}
-
-.readOnlyValue {
- background: #f5f7fa;
- border: 1px solid #d8dee8;
- border-radius: 6px;
- color: #2a2a2a;
- min-height: 44px;
- padding: 12px;
-}
-
.actionButton {
align-self: flex-start;
background: none;
@@ -98,13 +77,6 @@
text-decoration: underline;
}
-.assignmentLink {
- align-self: flex-start;
- color: #2a62d5;
- font-size: 12px;
- text-decoration: underline;
-}
-
.errorText {
color: #db524f;
font-size: 12px;
diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx
deleted file mode 100644
index b2239fa33..000000000
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */
-import type {
- FC,
- PropsWithChildren,
-} from 'react'
-import {
- render,
- screen,
-} from '@testing-library/react'
-import {
- FormProvider,
- useForm,
-} from 'react-hook-form'
-import { MemoryRouter } from 'react-router-dom'
-
-import {
- EngagementPrivateSection,
-} from './EngagementPrivateSection'
-
-jest.mock('../../../../lib/components/form', () => {
- const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form')
-
- return {
- FormCheckboxField: function FormCheckboxField(props: {
- disabled?: boolean
- label: string
- name: string
- }) {
- const controller = reactHookForm.useController({
- control: reactHookForm.useFormContext().control,
- name: props.name,
- })
-
- return (
-
- {props.label}
- controller.field.onChange(event.target.checked)}
- type='checkbox'
- />
-
- )
- },
- FormUserAutocomplete: function FormUserAutocomplete(props: {
- label: string
- name: string
- onValueChange?: (value: string) => void
- }) {
- const controller = reactHookForm.useController({
- control: reactHookForm.useFormContext().control,
- name: props.name,
- })
-
- return (
-
- {props.label}
- {
- controller.field.onChange(event.target.value)
- props.onValueChange?.(event.target.value)
- }}
- type='text'
- value={controller.field.value || ''}
- />
-
- )
- },
- }
-})
-
-jest.mock('../../../../lib/utils', () => ({
- formatAssignmentCurrency: (value?: string): string => (value ? `$${value}` : ''),
- getAssignmentStandardHoursPerWeek: (detail: { standardHoursPerWeek?: string }): string => (
- detail.standardHoursPerWeek || ''
- ),
-}))
-
-jest.mock('../../../../lib/utils/payment.utils', () => ({
- formatCurrency: (value?: string): string => (value ? `$${value}` : ''),
-}))
-
-jest.mock('./AssignmentDetailsModal', () => ({
- AssignmentDetailsModal: (): JSX.Element => <>>,
-}))
-
-interface TestFormValues {
- assignedMemberHandles: string[]
- assignmentDetails: Array<{
- agreementRate: string
- durationMonths: string
- memberHandle: string
- otherRemarks?: string
- ratePerHour: string
- standardHoursPerWeek: string
- startDate: string
- }>
- isPrivate: boolean
- requiredMemberCount: number | string
-}
-
-const defaultAssignmentDetails = {
- agreementRate: '800',
- durationMonths: '3',
- memberHandle: 'assigned_member',
- otherRemarks: 'active notes',
- ratePerHour: '20',
- standardHoursPerWeek: '40',
- startDate: '2026-05-01T00:00:00.000Z',
-}
-
-function renderPrivateSection(
- defaultValues: TestFormValues,
- props: {
- assignmentManagementPath?: string
- lockedAssignedMemberHandles?: string[]
- } = {},
-): void {
- const FormWrapper: FC = (wrapperProps: PropsWithChildren) => {
- const methods = useForm({
- defaultValues,
- })
-
- return (
-
-
- {wrapperProps.children}
-
-
- )
- }
-
- render(
-
-
- ,
- )
-}
-
-describe('EngagementPrivateSection', () => {
- it('renders existing assigned members as read-only assignment rows', () => {
- renderPrivateSection({
- assignedMemberHandles: ['assigned_member'],
- assignmentDetails: [defaultAssignmentDetails],
- isPrivate: true,
- requiredMemberCount: 1,
- }, {
- assignmentManagementPath: '/projects/123/engagements/engagement-1/assignments',
- lockedAssignedMemberHandles: ['assigned_member'],
- })
-
- expect(screen.getByText('assigned_member'))
- .not
- .toBeNull()
- expect(screen.queryByLabelText('Assign to Member'))
- .toBeNull()
- expect(screen.queryByRole('button', { name: 'Edit' }))
- .toBeNull()
- expect(screen.queryByRole('button', { name: 'Add Details' }))
- .toBeNull()
- expect((screen.getByLabelText('Private engagement') as HTMLInputElement).disabled)
- .toBe(true)
- expect(screen.getByRole('link', { name: 'Assignments' })
- .getAttribute('href'))
- .toBe('/projects/123/engagements/engagement-1/assignments')
- })
-
- it('keeps empty member slots editable before a member is assigned', () => {
- renderPrivateSection({
- assignedMemberHandles: [''],
- assignmentDetails: [],
- isPrivate: true,
- requiredMemberCount: 1,
- })
-
- expect(screen.getByLabelText('Assign to Member'))
- .not
- .toBeNull()
- expect((screen.getByRole('button', { name: 'Add Details' }) as HTMLButtonElement).disabled)
- .toBe(true)
- expect((screen.getByLabelText('Private engagement') as HTMLInputElement).disabled)
- .toBe(false)
- })
-})
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 e11fa900f..fc3ab9ff5 100644
--- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx
@@ -10,7 +10,6 @@ import {
import {
useFormContext,
} from 'react-hook-form'
-import { Link } from 'react-router-dom'
import {
FormCheckboxField,
@@ -35,11 +34,6 @@ interface EngagementPrivateSectionForm {
requiredMemberCount?: number | string
}
-interface EngagementPrivateSectionProps {
- assignmentManagementPath?: string
- lockedAssignedMemberHandles?: string[]
-}
-
function toNumber(value: unknown): number {
const parsed = Number(value)
@@ -88,22 +82,6 @@ function getAssignmentLabel(index: number, count: number): string {
: 'Assign to Member'
}
-/**
- * Normalizes persisted assignment handles before comparing them against form slots.
- *
- * @param handles member handles from existing active assignment rows.
- * @returns trimmed member handles, preserving slot order.
- */
-function normalizeLockedAssignedMemberHandles(
- handles: string[] | undefined,
-): string[] {
- return Array.isArray(handles)
- ? handles.map(handle => String(handle || '')
- .trim())
- .filter(Boolean)
- : []
-}
-
/**
* Creates an empty assignment-details value for form slots that no longer map
* to the currently selected member handle.
@@ -122,21 +100,13 @@ function createEmptyAssignmentDetails(): AssignmentDetailsFormValue {
}
}
-export const EngagementPrivateSection: FC = (
- props: EngagementPrivateSectionProps,
-) => {
+export const EngagementPrivateSection: FC = () => {
const formContext = useFormContext()
const [activeAssignmentIndex, setActiveAssignmentIndex] = useState()
const isPrivate = formContext.watch('isPrivate') === true
const requiredMemberCount = toNumber(formContext.watch('requiredMemberCount'))
- const lockedAssignedMemberHandles = useMemo(
- () => normalizeLockedAssignedMemberHandles(props.lockedAssignedMemberHandles),
- [props.lockedAssignedMemberHandles],
- )
- const lockedAssignmentCount = lockedAssignedMemberHandles.length
- const hasLockedAssignments = lockedAssignmentCount > 0
const assignedMemberHandles = formContext.watch('assignedMemberHandles') || []
const assignmentDetails = formContext.watch('assignmentDetails') || []
@@ -148,11 +118,8 @@ export const EngagementPrivateSection: FC = (
)?.message
const assignmentIndices = useMemo(
- () => Array.from(
- { length: Math.max(requiredMemberCount, lockedAssignmentCount) },
- (_, index) => index,
- ),
- [lockedAssignmentCount, requiredMemberCount],
+ () => Array.from({ length: requiredMemberCount }, (_, index) => index),
+ [requiredMemberCount],
)
useEffect(() => {
@@ -160,19 +127,10 @@ export const EngagementPrivateSection: FC = (
return
}
- if (
- requiredMemberCount < 1
- || activeAssignmentIndex >= Math.max(requiredMemberCount, lockedAssignmentCount)
- || lockedAssignedMemberHandles[activeAssignmentIndex]
- ) {
+ if (requiredMemberCount < 1 || activeAssignmentIndex >= requiredMemberCount) {
setActiveAssignmentIndex(undefined)
}
- }, [
- activeAssignmentIndex,
- lockedAssignedMemberHandles,
- lockedAssignmentCount,
- requiredMemberCount,
- ])
+ }, [activeAssignmentIndex, requiredMemberCount])
const activeMemberHandle = activeAssignmentIndex !== undefined
? assignedMemberHandles[activeAssignmentIndex]
@@ -195,7 +153,6 @@ export const EngagementPrivateSection: FC = (
Private
@@ -203,14 +160,12 @@ export const EngagementPrivateSection: FC = (
{isPrivate
? (
<>
- {assignmentIndices.length > 0
+ {requiredMemberCount > 0
? (
<>
{assignmentIndices.map(index => {
- const lockedMemberHandle = lockedAssignedMemberHandles[index]
- const isLockedAssignment = !!lockedMemberHandle
- const memberHandle = lockedMemberHandle || assignedMemberHandles[index]
+ const memberHandle = assignedMemberHandles[index]
const nextAssignmentDetail = assignmentDetails[index]
const assignmentDetail = (
nextAssignmentDetail
@@ -223,45 +178,32 @@ export const EngagementPrivateSection: FC
= (
return (
- {isLockedAssignment
- ? (
-
-
- {getAssignmentLabel(index, assignmentIndices.length)}
-
-
- {lockedMemberHandle}
-
-
- )
- : (
-
{
- if (value === memberHandle) {
- return
- }
-
- const nextAssignmentDetails = [...assignmentDetails]
- nextAssignmentDetails[index] = createEmptyAssignmentDetails()
-
- formContext.setValue('assignmentDetails', nextAssignmentDetails, {
- shouldDirty: true,
- shouldValidate: true,
- })
-
- if (!value) {
- return
- }
-
- setActiveAssignmentIndex(index)
- }}
- placeholder='Search user handle'
- required
- valueField='handle'
- />
- )}
+ {
+ if (value === memberHandle) {
+ return
+ }
+
+ const nextAssignmentDetails = [...assignmentDetails]
+ nextAssignmentDetails[index] = createEmptyAssignmentDetails()
+
+ formContext.setValue('assignmentDetails', nextAssignmentDetails, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+
+ if (!value) {
+ return
+ }
+
+ setActiveAssignmentIndex(index)
+ }}
+ placeholder='Search user handle'
+ required
+ valueField='handle'
+ />
@@ -297,48 +239,30 @@ export const EngagementPrivateSection: FC = (
{' '}
{formatCurrency(assignmentDetail.agreementRate)}
- {!isLockedAssignment
- ? (
- setActiveAssignmentIndex(index)}
- type='button'
- >
- Edit
-
- )
- : undefined}
+ setActiveAssignmentIndex(index)}
+ type='button'
+ >
+ Edit
+
)
: (
- isLockedAssignment
- ? undefined
- : (
- <>
-
setActiveAssignmentIndex(index)}
- type='button'
- >
- Add Details
-
-
- No details added
-
- >
- )
+ <>
+
setActiveAssignmentIndex(index)}
+ type='button'
+ >
+ Add Details
+
+
+ No details added
+
+ >
)}
- {isLockedAssignment && props.assignmentManagementPath
- ? (
-
- Assignments
-
- )
- : undefined}
)
diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss
index 4361ed177..fe3a6314a 100644
--- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss
+++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.module.scss
@@ -100,11 +100,6 @@
margin-bottom: 4px;
}
-.required {
- color: #db3030;
- margin-left: 2px;
-}
-
.value {
color: #2a2a2a;
font-size: 14px;
diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx
index 9953beb9e..6bc914a78 100644
--- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.spec.tsx
@@ -21,11 +21,7 @@ import type {
import {
useFetchEngagement,
useFetchProject,
- useFetchProjectBillingAccount,
} from '../../../lib/hooks'
-import {
- partiallyUpdateEngagement,
-} from '../../../lib/services'
import {
EditAssignmentModal,
@@ -96,7 +92,6 @@ jest.mock('../../../lib/components/form', () => ({
jest.mock('../../../lib/hooks', () => ({
useFetchEngagement: jest.fn(),
useFetchProject: jest.fn(),
- useFetchProjectBillingAccount: jest.fn(),
}))
jest.mock('../../../lib/services', () => ({
@@ -106,7 +101,7 @@ jest.mock('../../../lib/services', () => ({
}))
jest.mock('../../../lib/utils', () => ({
- calculateAssignmentRatePerWeek: (ratePerHour?: string, standardHoursPerWeek?: string) => {
+ calculateAssignmentRatePerWeek: jest.fn((ratePerHour?: string, standardHoursPerWeek?: string) => {
const rate = Number(ratePerHour || 0)
const hours = Number(standardHoursPerWeek || 0)
@@ -114,46 +109,38 @@ jest.mock('../../../lib/utils', () => ({
? (rate * hours)
.toFixed(2)
: ''
- },
- deserializeTentativeAssignmentDate: (value?: string) => (
+ }),
+ deserializeTentativeAssignmentDate: jest.fn((value?: string) => (
value
? new Date(value)
: undefined
- ),
- getCountableEngagementAssignments: (assignments: Array<{ status?: string }> = []) => (
- assignments.filter(assignment => !['COMPLETED', 'OFFER_REJECTED', 'TERMINATED'].includes(
- String(assignment.status || '')
- .trim()
- .replace(/[\s-]+/g, '_')
- .toUpperCase(),
- ))
- ),
- normalizeAssignmentStatus: (status: string) => status,
- sanitizePositiveNumericInput: (value: string) => value,
- serializeTentativeAssignmentDate: (value: Date) => value.toISOString(),
+ )),
+ normalizeAssignmentStatus: jest.fn((status: string) => status),
+ sanitizePositiveNumericInput: jest.fn((value: string) => value),
+ serializeTentativeAssignmentDate: jest.fn((value: Date) => value.toISOString()),
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
- toPositiveInteger: (value: string) => {
+ toPositiveInteger: jest.fn((value: string) => {
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed > 0
? parsed
: undefined
- },
- toPositiveNumber: (value: string) => {
+ }),
+ toPositiveNumber: jest.fn((value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0
? parsed
: undefined
- },
- toPositiveNumberWithMaxDecimalPlaces: (value: string) => {
+ }),
+ toPositiveNumberWithMaxDecimalPlaces: jest.fn((value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0
? parsed
: undefined
- },
+ }),
}))
jest.mock('../../../lib/utils/payment.utils', () => ({
@@ -178,22 +165,9 @@ const assignment: Assignment = {
const mockedUseFetchEngagement = useFetchEngagement as jest.MockedFunction
const mockedUseFetchProject = useFetchProject as jest.MockedFunction
-const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.MockedFunction<
- typeof useFetchProjectBillingAccount
->
-const mockedPartiallyUpdateEngagement = partiallyUpdateEngagement as jest.MockedFunction<
- typeof partiallyUpdateEngagement
->
beforeEach(() => {
jest.clearAllMocks()
- mockedUseFetchProjectBillingAccount.mockReturnValue({
- billingAccount: {
- id: 'billing-account-1',
- markup: 0.15,
- },
- isLoading: false,
- } as unknown as ReturnType)
})
describe('EngagementPaymentPage', () => {
@@ -219,7 +193,7 @@ describe('EngagementPaymentPage', () => {
},
} as unknown as ReturnType)
- const renderedPage: ReturnType = render(
+ render(
{
,
)
- const container: HTMLElement = renderedPage.container
expect(screen.queryByText('testing 123'))
.toBeNull()
- const labels: Array = Array.from(container.querySelectorAll('.label'))
- .map(element => element.textContent)
-
- expect(labels)
- .toEqual(expect.arrayContaining([
- 'Billing Start Date*',
- 'Rate Per Hour*',
- 'Standard Hours Per Week*',
- ]))
fireEvent.click(screen.getByRole('button', {
name: 'View other remarks for testaws1',
@@ -268,84 +232,6 @@ describe('EngagementPaymentPage', () => {
.toBeNull()
})
})
-
- it('updates active assignment details without resubmitting terminal assignment history', async () => {
- const mutateEngagement = jest.fn()
- .mockResolvedValue(undefined)
- const terminatedAssignment: Assignment = {
- ...assignment,
- agreementRate: '200.00',
- endDate: '2026-04-01T00:00:00.000Z',
- id: 'assignment-terminated',
- memberHandle: 'finished_member',
- memberId: '67890',
- status: 'TERMINATED',
- terminationReason: 'Completed elsewhere',
- }
-
- mockedUseFetchEngagement.mockReturnValue({
- engagement: {
- assignments: [assignment, terminatedAssignment],
- title: 'Test Engagement',
- },
- error: undefined,
- isError: false,
- isLoading: false,
- mutate: mutateEngagement,
- } as unknown as ReturnType)
-
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- billingAccountId: 'billing-account-1',
- name: 'Test Project',
- },
- } as unknown as ReturnType)
-
- mockedPartiallyUpdateEngagement.mockResolvedValue({
- assignments: [assignment, terminatedAssignment],
- title: 'Test Engagement',
- } as any)
-
- render(
-
-
- }
- path='/projects/:projectId/engagements/:engagementId/assignments'
- />
-
- ,
- )
-
- fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
- fireEvent.click(within(await screen.findByRole('dialog'))
- .getByRole('button', { name: 'Save' }))
-
- await waitFor(() => {
- expect(mockedPartiallyUpdateEngagement)
- .toHaveBeenCalled()
- })
-
- const payload = mockedPartiallyUpdateEngagement.mock.calls[0][1] as {
- assignmentDetails?: Array<{ memberHandle?: string }>
- }
-
- expect(payload.assignmentDetails)
- .toHaveLength(1)
- expect(payload.assignmentDetails?.[0])
- .toEqual(expect.objectContaining({
- memberHandle: 'testaws1',
- }))
- expect(payload.assignmentDetails)
- .toEqual(expect.not.arrayContaining([
- expect.objectContaining({
- memberHandle: 'finished_member',
- }),
- ]))
- })
})
describe('EditAssignmentModal', () => {
diff --git a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx
index d097aa86c..8fcff1220 100644
--- a/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementPaymentPage/EngagementPaymentPage.tsx
@@ -38,7 +38,6 @@ import {
import {
useFetchEngagement,
useFetchProject,
- useFetchProjectBillingAccount,
} from '../../../lib/hooks'
import {
Assignment,
@@ -51,7 +50,6 @@ import {
import {
calculateAssignmentRatePerWeek,
deserializeTentativeAssignmentDate,
- getCountableEngagementAssignments,
normalizeAssignmentStatus,
sanitizePositiveNumericInput,
serializeTentativeAssignmentDate,
@@ -246,7 +244,7 @@ function buildAssignmentDetailsUpdatePayload(
const assignmentIdText = String(assignmentId)
return {
- assignmentDetails: getCountableEngagementAssignments(assignments)
+ assignmentDetails: assignments
.map(assignment => {
const baseEntry = buildAssignmentDetailsPayloadEntry(assignment)
@@ -582,7 +580,6 @@ export const EngagementPaymentPage: FC = () => {
const engagementResult = useFetchEngagement(engagementId)
const projectResult = useFetchProject(projectId)
- const projectBillingAccountResult = useFetchProjectBillingAccount(projectId)
const assignments = useMemo(() => {
if (!Array.isArray(engagementResult.engagement?.assignments)) {
@@ -616,8 +613,7 @@ export const EngagementPaymentPage: FC = () => {
return
}
- const billingAccountId = projectBillingAccountResult.billingAccount?.id
- || projectResult.project?.billingAccountId
+ const billingAccountId = projectResult.project?.billingAccountId
if (!billingAccountId) {
showErrorToast('Billing account is required to create payment')
@@ -653,11 +649,7 @@ export const EngagementPaymentPage: FC = () => {
} finally {
setIsSubmittingPayment(false)
}
- }, [
- paymentMember,
- projectBillingAccountResult.billingAccount?.id,
- projectResult.project?.billingAccountId,
- ])
+ }, [paymentMember, projectResult.project?.billingAccountId])
const handleTerminateConfirm = useCallback(async (reason: string): Promise => {
if (!terminateMember) {
@@ -861,10 +853,7 @@ export const EngagementPaymentPage: FC = () => {
-
- Billing Start Date
- *
-
+ Billing Start
{formatDate(assignment.startDate)}
@@ -872,17 +861,11 @@ export const EngagementPaymentPage: FC = () => {
{formatDurationMonths(assignment.durationMonths)}
-
- Rate Per Hour
- *
-
+ Rate Per Hour
{formatCurrency(assignment.ratePerHour)}
-
- Standard Hours Per Week
- *
-
+
Hours Per Week
{assignment.standardHoursPerWeek || '-'}
@@ -984,9 +967,7 @@ export const EngagementPaymentPage: FC = () => {
/>
{
.toBe('/projects/200/engagements/111/assignments')
})
- it('links zero assigned member counts to the assignees page when completed assignments exist', () => {
- mockedGetAssignedMembersCount.mockReturnValue(0)
- mockedUseFetchEngagements.mockReturnValue({
- engagements: [
- {
- ...sampleEngagement,
- assignments: [
- {
- agreementRate: '1000',
- endDate: '2026-04-30T00:00:00.000Z',
- engagementId: 111,
- id: 'assignment-1',
- memberHandle: 'finished_member',
- memberId: 123,
- otherRemarks: '',
- startDate: '2026-04-01T00:00:00.000Z',
- status: 'COMPLETED',
- termsAccepted: true,
- },
- ],
- },
- ],
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- })
-
- renderPage('/engagements', '/engagements')
-
- const row = screen.getByText(sampleEngagement.title)
- .closest('tr') as HTMLTableRowElement
- const zeroCountLinks = within(row)
- .getAllByRole('link', { name: '0' })
-
- expect(zeroCountLinks.some(link => (
- link.getAttribute('href') === '/projects/200/engagements/111/assignments'
- )))
- .toBe(true)
- })
-
it('scopes all-engagements fetches to member projects for talent managers', async () => {
mockedUseFetchProjects.mockReturnValue({
error: undefined,
diff --git a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx
index 18040e052..06a0c9890 100644
--- a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx
+++ b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.tsx
@@ -241,7 +241,7 @@ function renderMembersAssignedCell(
const count = getAssignedMembersCount(engagement)
const handles = getAssignedMemberHandles(engagement)
- const hasAssignmentsRoute = !!engagementProjectId && !!engagement.id
+ const hasAssignmentsRoute = !!engagementProjectId && !!engagement.id && count > 0
const countElement = hasAssignmentsRoute
? (
{
billingAccountId={projectResult.project?.billingAccountId}
billingAccountName={projectResult.project?.billingAccountName}
canManageProject={canManageProject}
- displayMemberPaymentDetailsToCopilots={
- projectResult.project?.details?.displayMemberPaymentDetailsToCopilots
- }
projectId={projectId}
/>
)
diff --git a/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx b/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx
index 7e399a1ef..9c1623e06 100644
--- a/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx
+++ b/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx
@@ -26,10 +26,7 @@ import {
ErrorMessage,
LoadingSpinner,
} from '../../../lib/components'
-import {
- checkCanEditProjectDetails,
- checkCanManageProject,
-} from '../../../lib/utils'
+import { checkCanManageProject } from '../../../lib/utils'
import {
ProjectEditorForm,
@@ -122,7 +119,7 @@ export const ProjectEditorPage: FC = () => {
const canCreateProject = checkCanManageProject(userRoles, loginUserInfo?.userId)
const canManageProject = !!projectResult.project
- && checkCanEditProjectDetails(userRoles, loginUserInfo?.userId, projectResult.project)
+ && checkCanManageProject(userRoles, loginUserInfo?.userId, projectResult.project)
const shouldRedirect = shouldRedirectToProjects(
isEdit,
canCreateProject,
diff --git a/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.spec.tsx b/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.spec.tsx
deleted file mode 100644
index c2b6a0d91..000000000
--- a/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.spec.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
-import {
- fireEvent,
- render,
- screen,
- waitFor,
-} from '@testing-library/react'
-import { MemoryRouter } from 'react-router-dom'
-
-import {
- useFetchProjectBillingAccount,
-} from '../../../../../lib/hooks'
-import {
- createProject,
- updateProject,
-} from '../../../../../lib/services'
-
-import { ProjectEditorForm } from './ProjectEditorForm'
-
-jest.mock('~/config', () => ({
- EnvironmentConfig: {
- ADMIN: {
- DIRECT_URL: 'https://direct.example.com',
- REVIEW_UI_URL: 'https://review.example.com',
- },
- API: {
- V6: 'https://example.com/v6',
- },
- CHALLENGE_API_URL: 'https://example.com/v5/challenges',
- CHALLENGE_API_VERSION: 'v5',
- COMMUNITY_APP_URL: 'https://community.example.com',
- COPILOTS_URL: 'https://copilots.example.com',
- DIRECT_PROJECT_URL: 'https://direct.example.com',
- ENGAGEMENTS_URL: 'https://work.example.com',
- REVIEW_APP_URL: 'https://review.example.com',
- TC_DOMAIN: 'example.com',
- TC_FINANCE_API: 'https://finance.example.com',
- TOPCODER_URL: 'https://topcoder.example.com',
- },
-}), {
- virtual: true,
-})
-
-jest.mock('../../../../../lib/components/form', () => {
- const reactHookForm: typeof import('react-hook-form') = jest.requireActual('react-hook-form')
- const useFormContext: typeof reactHookForm.useFormContext = reactHookForm.useFormContext
-
- interface MockFieldProps {
- label: string
- name: string
- options?: Array<{
- label: string
- value: string
- }>
- placeholder?: string
- }
-
- return {
- FormBillingAccountAutocomplete: (props: MockFieldProps): JSX.Element => {
- const formContext = useFormContext()
-
- return (
-
- )
- },
- FormCheckboxField: (props: MockFieldProps): JSX.Element => {
- const formContext = useFormContext()
-
- return (
-
-
- {props.label}
-
- )
- },
- FormGroupsSelect: (): JSX.Element =>
,
- FormRadioGroup: (): JSX.Element =>
,
- FormSelectField: (props: MockFieldProps): JSX.Element => {
- const formContext = useFormContext()
-
- return (
-
- {props.label}
-
- {props.placeholder || 'Select'}
- {(props.options || []).map(option => (
-
- {option.label}
-
- ))}
-
-
- )
- },
- FormSelectOption: undefined,
- FormTextAreaField: (props: MockFieldProps): JSX.Element => {
- const formContext = useFormContext()
-
- return (
-
- {props.label}
-
-
- )
- },
- FormTextField: (props: MockFieldProps): JSX.Element => {
- const formContext = useFormContext()
-
- return (
-
- {props.label}
-
-
- )
- },
- }
-})
-
-jest.mock('../../../../../lib/hooks', () => ({
- useFetchProjectBillingAccount: jest.fn(),
-}))
-
-jest.mock('../../../../../lib/services', () => ({
- createProject: jest.fn(),
- updateProject: jest.fn(),
-}))
-
-jest.mock('../../../../../lib/utils', () => ({
- formatDate: () => '-',
- showErrorToast: jest.fn(),
- showSuccessToast: jest.fn(),
-}))
-
-jest.mock('~/libs/ui', () => ({
- Button: (props: {
- disabled?: boolean
- label: string
- type?: 'button' | 'submit'
- }): JSX.Element => (
-
- {props.label}
-
- ),
-}), {
- virtual: true,
-})
-
-const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jest.MockedFunction<
- typeof useFetchProjectBillingAccount
->
-const mockedCreateProject = createProject as jest.MockedFunction
-const mockedUpdateProject = updateProject as jest.MockedFunction
-
-describe('ProjectEditorForm', () => {
- beforeEach(() => {
- jest.clearAllMocks()
- mockedUseFetchProjectBillingAccount.mockReturnValue({
- billingAccount: undefined,
- isLoading: false,
- })
- mockedCreateProject.mockResolvedValue({
- id: 'project-1',
- name: 'Payments Project',
- status: 'draft',
- })
- mockedUpdateProject.mockResolvedValue({
- id: 'project-1',
- name: 'Payments Project',
- status: 'active',
- })
- })
-
- it('defaults the copilot payment details flag on for new projects and submits it', async () => {
- render(
-
-
- ,
- )
-
- const displayPaymentDetailsCheckbox = screen.getByLabelText(
- 'Display member payment details to copilots',
- ) as HTMLInputElement
-
- expect(displayPaymentDetailsCheckbox.checked)
- .toBe(true)
-
- fireEvent.change(screen.getByLabelText('Project Name'), {
- target: {
- value: 'Payments Project',
- },
- })
- fireEvent.change(screen.getByLabelText('Project Type'), {
- target: {
- value: 'generic',
- },
- })
- fireEvent.change(screen.getByLabelText('Description'), {
- target: {
- value: 'Project with visible copilot payment details.',
- },
- })
- await waitFor(() => expect((screen.getByRole('button', {
- name: 'Save project',
- }) as HTMLButtonElement).disabled)
- .toBe(false))
-
- fireEvent.click(screen.getByRole('button', {
- name: 'Save project',
- }))
-
- await waitFor(() => expect(mockedCreateProject)
- .toHaveBeenCalledWith(expect.objectContaining({
- details: {
- displayMemberPaymentDetailsToCopilots: true,
- },
- })))
- })
-
- it('submits null when clearing an existing project billing account', async () => {
- render(
-
-
- ,
- )
-
- fireEvent.change(screen.getByLabelText('Select New Billing Account'), {
- target: {
- value: '',
- },
- })
-
- await waitFor(() => expect((screen.getByRole('button', {
- name: 'Save project',
- }) as HTMLButtonElement).disabled)
- .toBe(false))
-
- fireEvent.click(screen.getByRole('button', {
- name: 'Save project',
- }))
-
- await waitFor(() => expect(mockedUpdateProject)
- .toHaveBeenCalledWith('project-1', expect.any(Object)))
- expect(mockedUpdateProject.mock.calls[0]?.[1].billingAccountId)
- .toBeNull()
- })
-})
diff --git a/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.tsx b/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.tsx
index 339c132db..4e72901ca 100644
--- a/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.tsx
+++ b/src/apps/work/src/pages/projects/ProjectEditorPage/components/ProjectEditorForm/ProjectEditorForm.tsx
@@ -18,7 +18,6 @@ import {
} from '../../../../../lib/constants'
import {
FormBillingAccountAutocomplete,
- FormCheckboxField,
FormGroupsSelect,
FormRadioGroup,
FormSelectField,
@@ -67,7 +66,6 @@ interface ProjectEditorFormValues {
billingAccountId: string
cancelReason: string
description: string
- displayMemberPaymentDetailsToCopilots: boolean
groups: string[]
name: string
status: ProjectStatusValue | ''
@@ -97,9 +95,6 @@ function getDefaultFormValues(
billingAccountId,
cancelReason: projectDetail?.cancelReason || '',
description: projectDetail?.description || '',
- displayMemberPaymentDetailsToCopilots: isEdit
- ? projectDetail?.details?.displayMemberPaymentDetailsToCopilots === true
- : true,
groups,
name: projectDetail?.name || '',
status: isEdit
@@ -368,10 +363,6 @@ export const ProjectEditorForm: FC = (props: ProjectEdit
const payload: CreateProjectPayload = {
billingAccountId: normalizedBillingAccountId,
description: formData.description,
- details: {
- displayMemberPaymentDetailsToCopilots:
- formData.displayMemberPaymentDetailsToCopilots,
- },
groups,
name: formData.name,
terms,
@@ -393,14 +384,8 @@ export const ProjectEditorForm: FC = (props: ProjectEdit
}
const payload: UpdateProjectPayload = {
- // eslint-disable-next-line unicorn/no-null
- billingAccountId: normalizedBillingAccountId || null,
+ billingAccountId: normalizedBillingAccountId || '',
description: formData.description,
- details: {
- ...(props.projectDetail.details || {}),
- displayMemberPaymentDetailsToCopilots:
- formData.displayMemberPaymentDetailsToCopilots,
- },
groups,
name: formData.name,
terms,
@@ -528,6 +513,7 @@ export const ProjectEditorForm: FC = (props: ProjectEdit
name='billingAccountId'
placeholder='Search billing account by name'
projectId={projectId}
+ required
selectedBillingAccount={selectedBillingAccount}
userId={props.billingAccountSearchUserId}
/>
@@ -563,11 +549,6 @@ export const ProjectEditorForm: FC = (props: ProjectEdit
label='Intended Work Groups'
name='groups'
/>
-
-
diff --git a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx
index 47e18f6be..79f069146 100644
--- a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx
+++ b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx
@@ -82,8 +82,6 @@ jest.mock('../../../lib/constants', () => ({
PROJECTS_PAGE_SIZE: 10,
}))
jest.mock('../../../lib/utils', () => ({
- checkCanEditProjectDetails:
- jest.requireActual('../../../lib/utils/permissions.utils').checkCanEditProjectDetails,
checkCanManageProject: jest.requireActual('../../../lib/utils/permissions.utils').checkCanManageProject,
}))
diff --git a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx
index 56af8d257..ce59849a6 100644
--- a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx
+++ b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx
@@ -35,10 +35,7 @@ import {
ProjectFilters,
WorkAppContextModel,
} from '../../../lib/models'
-import {
- checkCanEditProjectDetails,
- checkCanManageProject,
-} from '../../../lib/utils'
+import { checkCanManageProject } from '../../../lib/utils'
import styles from '../../../lib/components/ProjectsListPage/ProjectsListPage.module.scss'
const DEFAULT_FILTERS: ProjectFilters = {
@@ -130,7 +127,7 @@ export const ProjectsListPage: FC = () => {
const canCreateProject = checkCanManageProject(userRoles, loginUserInfo?.userId)
const canEditProject = useCallback(
- (project: Project): boolean => checkCanEditProjectDetails(userRoles, loginUserInfo?.userId, project),
+ (project: Project): boolean => checkCanManageProject(userRoles, loginUserInfo?.userId, project),
[loginUserInfo?.userId, userRoles],
)
diff --git a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx
index 2030bd089..54d53f8ed 100644
--- a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx
+++ b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.spec.tsx
@@ -13,10 +13,7 @@ import {
useFetchProject,
useFetchProjectMembers,
} from '../../../lib/hooks'
-import {
- checkCanEditProjectDetails,
- checkCanManageProject,
-} from '../../../lib/utils'
+import { checkCanManageProject } from '../../../lib/utils'
import { UsersManagementPage } from './UsersManagementPage'
@@ -98,15 +95,14 @@ jest.mock('../../../lib/services', () => ({
removeMemberFromProject: jest.fn(),
}))
jest.mock('../../../lib/utils', () => ({
- checkCanEditProjectDetails: jest.fn(() => true),
checkCanManageProject: jest.fn(() => true),
+ checkIsCopilotOrManager: jest.fn(() => false),
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
}))
const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjectMembers = useFetchProjectMembers as jest.Mock
-const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
const mockedCheckCanManageProject = checkCanManageProject as jest.Mock
const defaultContextValue: WorkAppContextModel = {
@@ -146,7 +142,6 @@ function renderPage(
describe('UsersManagementPage', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockedCheckCanEditProjectDetails.mockReturnValue(true)
mockedCheckCanManageProject.mockReturnValue(true)
mockedUseFetchProject.mockReturnValue({
@@ -216,90 +211,4 @@ describe('UsersManagementPage', () => {
expect(screen.queryByTestId('page-back-link'))
.toBeNull()
})
-
- it('hides member management actions when a global manager role cannot manage the project', () => {
- mockedCheckCanEditProjectDetails.mockReturnValue(false)
- mockedCheckCanManageProject.mockReturnValue(false)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- name: 'Restricted Project',
- status: 'active',
- },
- })
-
- renderPage('/projects/200/users', {
- ...defaultContextValue,
- isAdmin: false,
- isManager: true,
- loginUserInfo: {
- email: 'manager@example.com',
- exp: 0,
- handle: 'manager-user',
- iat: 0,
- roles: ['project manager'],
- userId: 12345,
- } as WorkAppContextModel['loginUserInfo'],
- userRoles: ['project manager'],
- })
-
- const pageRightHeader = screen.getByTestId('page-right-header')
- const pageTitleAction = screen.getByTestId('page-title-action')
-
- expect(within(pageRightHeader)
- .queryByRole('button', { name: 'Add User' }))
- .toBeNull()
- expect(within(pageRightHeader)
- .queryByRole('button', { name: 'Invite User' }))
- .toBeNull()
- expect(within(pageTitleAction)
- .queryByRole('link', { name: 'Edit project' }))
- .toBeNull()
- })
-
- it('hides project edit action when a copilot can manage but cannot edit project details', () => {
- mockedCheckCanEditProjectDetails.mockReturnValue(false)
- mockedCheckCanManageProject.mockReturnValue(true)
- mockedUseFetchProject.mockReturnValue({
- error: undefined,
- isLoading: false,
- mutate: jest.fn(),
- project: {
- id: 200,
- members: [
- {
- role: 'copilot',
- userId: 12345,
- },
- ],
- name: 'Copilot Project',
- status: 'active',
- },
- })
-
- renderPage('/projects/200/users', {
- ...defaultContextValue,
- isAdmin: false,
- isCopilot: true,
- loginUserInfo: {
- email: 'copilot@example.com',
- exp: 0,
- handle: 'copilot-user',
- iat: 0,
- roles: ['copilot'],
- userId: 12345,
- } as WorkAppContextModel['loginUserInfo'],
- userRoles: ['copilot'],
- })
-
- expect(within(screen.getByTestId('page-right-header'))
- .getByRole('button', { name: 'Add User' }))
- .toBeTruthy()
- expect(within(screen.getByTestId('page-title-action'))
- .queryByRole('link', { name: 'Edit project' }))
- .toBeNull()
- })
})
diff --git a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx
index 8fe7e5b16..ad2dd1f1e 100644
--- a/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx
+++ b/src/apps/work/src/pages/users/UsersManagementPage/UsersManagementPage.tsx
@@ -38,8 +38,8 @@ import {
removeMemberFromProject,
} from '../../../lib/services'
import {
- checkCanEditProjectDetails,
checkCanManageProject,
+ checkIsCopilotOrManager,
showErrorToast,
showSuccessToast,
} from '../../../lib/utils'
@@ -56,7 +56,7 @@ function toOptionalString(value: unknown): string | undefined {
}
interface RenderProjectTitleActionParams {
- canEditProjectDetails: boolean
+ canManageProject: boolean
projectId: string | undefined
projectStatus: ProjectStatusValue | undefined
}
@@ -71,7 +71,7 @@ function renderProjectTitleAction(params: RenderProjectTitleActionParams): JSX.E
{params.projectStatus
?
: undefined}
- {params.canEditProjectDetails
+ {params.canManageProject
? (
{
const [showInviteUserModal, setShowInviteUserModal] = useState(false)
const {
+ isAdmin,
+ isCopilot,
+ isManager,
loginUserInfo,
userRoles,
}: WorkAppContextModel = useContext(WorkAppContext)
@@ -113,19 +116,15 @@ export const UsersManagementPage: FC = () => {
const selectedProjectName = toOptionalString(projectResult.project?.name)
const pageTitle = selectedProjectName || 'Project users'
+ const loginHandle = loginUserInfo?.handle || ''
+ const canManageMembers = (isAdmin || isCopilot || isManager)
+ || checkIsCopilotOrManager(members, loginHandle)
const canManageProject = !!projectResult.project
&& checkCanManageProject(
userRoles,
loginUserInfo?.userId,
projectResult.project,
)
- const canEditProjectDetails = !!projectResult.project
- && checkCanEditProjectDetails(
- userRoles,
- loginUserInfo?.userId,
- projectResult.project,
- )
- const canManageMembers = canManageProject
const hasMembers = members.length > 0
const hasDeclinedInvites = declinedInvites.length > 0
@@ -226,7 +225,7 @@ export const UsersManagementPage: FC = () => {
setShowInviteUserModal(false)
}, [])
const titleAction = renderProjectTitleAction({
- canEditProjectDetails,
+ canManageProject,
projectId,
projectStatus: projectResult.project?.status,
})
@@ -393,7 +392,7 @@ export const UsersManagementPage: FC = () => {
)
: undefined}
- {showAddUserModal && projectId && canManageMembers
+ {showAddUserModal && projectId
? (
{
)
: undefined}
- {showInviteUserModal && projectId && canManageMembers
+ {showInviteUserModal && projectId
? (
= [
route: '/projects/:projectId/challenges',
title: 'Challenges',
},
- {
- authRequired: true,
- element: ,
- route: '/projects/:projectId',
- title: 'Project Overview',
- },
{
authRequired: true,
element: ,
@@ -225,11 +216,7 @@ export const workRoutes: ReadonlyArray = [
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: projectAssetsRouteId,
route: '/projects/:projectId/assets',
title: 'Project Assets',
@@ -268,76 +255,48 @@ export const workRoutes: ReadonlyArray = [
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
route: '/projects/:projectId/engagements',
title: 'Engagements',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementCreateRouteId,
route: '/projects/:projectId/engagements/new',
title: 'Create Engagement',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementEditRouteId,
route: '/projects/:projectId/engagements/:engagementId',
title: 'Edit Engagement',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementApplicationsRouteId,
route: '/projects/:projectId/engagements/:engagementId/applications',
title: 'Applications',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementAssignmentsRouteId,
route: '/projects/:projectId/engagements/:engagementId/assignments',
title: 'Assignments',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementFeedbackRouteId,
route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/feedback',
title: 'Feedback',
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: engagementExperienceRouteId,
route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/experience',
title: 'Experience',
@@ -384,11 +343,7 @@ export const workRoutes: ReadonlyArray = [
},
{
authRequired: true,
- element: (
-
-
-
- ),
+ element: ,
id: usersRouteId,
route: '/projects/:projectId/users',
title: 'Users',
diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts
index d29f0f7a8..399dee2d1 100644
--- a/src/config/environments/default.env.ts
+++ b/src/config/environments/default.env.ts
@@ -190,7 +190,7 @@ export const ADMIN = {
ONLINE_REVIEW_URL: 'https://software.topcoder-dev.com/review',
REVIEW_UI_URL: 'https://review.topcoder-dev.com',
SUBMISSION_SCAN_TOPIC: 'submission.scan.complete',
- WORK_MANAGER_URL: 'https://work.topcoder-dev.com',
+ WORK_MANAGER_URL: 'https://challenges.topcoder-dev.com',
}
const REVIEW_OPPORTUNITIES_URL_DEFAULT = getReactEnv(
diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts
index a334890d9..d42a3d64e 100644
--- a/src/config/environments/prod.env.ts
+++ b/src/config/environments/prod.env.ts
@@ -26,7 +26,7 @@ export const ADMIN = {
ONLINE_REVIEW_URL: 'https://software.topcoder.com/review',
REVIEW_UI_URL: 'https://review.topcoder.com',
SUBMISSION_SCAN_TOPIC: 'submission.scan.complete',
- WORK_MANAGER_URL: 'https://work.topcoder.com',
+ WORK_MANAGER_URL: 'https://challenges.topcoder.com',
}
export const REVIEW = {
diff --git a/yarn.lock b/yarn.lock
index cfdce4785..3d71a1d8d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3239,15 +3239,15 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
-"@storybook/builder-manager@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.21.tgz#13a76fa3312f2a1afe1f62a25e2f3d7c3d6fa1a3"
- integrity sha512-j6N/OiwUGHzvDSpWKlrjuR8Fp3unEAhowgtKpc8fV3Qw0xi5lEmJc4yu0R5cIGkOsSoA5Oe6nLGhjRjvddioQA==
+"@storybook/builder-manager@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.6.20.tgz#d550a3f209012e4e383e61320ea756cddfdb416e"
+ integrity sha512-e2GzpjLaw6CM/XSmc4qJRzBF8GOoOyotyu3JrSPTYOt4RD8kjUsK4QlismQM1DQRu8i39aIexxmRbiJyD74xzQ==
dependencies:
"@fal-works/esbuild-plugin-global-externals" "^2.1.2"
- "@storybook/core-common" "7.6.21"
- "@storybook/manager" "7.6.21"
- "@storybook/node-logger" "7.6.21"
+ "@storybook/core-common" "7.6.20"
+ "@storybook/manager" "7.6.20"
+ "@storybook/node-logger" "7.6.20"
"@types/ejs" "^3.1.1"
"@types/find-cache-dir" "^3.2.1"
"@yarnpkg/esbuild-plugin-pnp" "^3.0.0-rc.10"
@@ -3317,35 +3317,23 @@
telejson "^7.2.0"
tiny-invariant "^1.3.1"
-"@storybook/channels@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.6.21.tgz#882e2be537e147d40411460463940645a4394c2a"
- integrity sha512-899XbW60IXIkWDo90bS5ovjxnFUDgD8B2ZwUEJUmuhIXqQeSg2iJ8uYI699Csei+DoDn5gZYJD+BHbSUuc4g+Q==
- dependencies:
- "@storybook/client-logger" "7.6.21"
- "@storybook/core-events" "7.6.21"
- "@storybook/global" "^5.0.0"
- qs "^6.10.0"
- telejson "^7.2.0"
- tiny-invariant "^1.3.1"
-
-"@storybook/cli@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.21.tgz#bcd2bc231325a3d523672150e87f61d8b0be08c5"
- integrity sha512-8SCDEeoBm+RAQDiH4HOjsQFJhReI7EJRylXVtllVhmq6TpxyJNZz8CSWEIU0zFhznIHktevriVzRR/qAKdUXng==
+"@storybook/cli@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.6.20.tgz#498625db5f2447e8e1ad34827a7803c5940527f0"
+ integrity sha512-ZlP+BJyqg7HlnXf7ypjG2CKMI/KVOn03jFIiClItE/jQfgR6kRFgtjRU7uajh427HHfjv9DRiur8nBzuO7vapA==
dependencies:
"@babel/core" "^7.23.2"
"@babel/preset-env" "^7.23.2"
"@babel/types" "^7.23.0"
"@ndelangen/get-tarball" "^3.0.7"
- "@storybook/codemod" "7.6.21"
- "@storybook/core-common" "7.6.21"
- "@storybook/core-events" "7.6.21"
- "@storybook/core-server" "7.6.21"
- "@storybook/csf-tools" "7.6.21"
- "@storybook/node-logger" "7.6.21"
- "@storybook/telemetry" "7.6.21"
- "@storybook/types" "7.6.21"
+ "@storybook/codemod" "7.6.20"
+ "@storybook/core-common" "7.6.20"
+ "@storybook/core-events" "7.6.20"
+ "@storybook/core-server" "7.6.20"
+ "@storybook/csf-tools" "7.6.20"
+ "@storybook/node-logger" "7.6.20"
+ "@storybook/telemetry" "7.6.20"
+ "@storybook/types" "7.6.20"
"@types/semver" "^7.3.4"
"@yarnpkg/fslib" "2.10.3"
"@yarnpkg/libzip" "2.3.0"
@@ -3382,25 +3370,18 @@
dependencies:
"@storybook/global" "^5.0.0"
-"@storybook/client-logger@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.6.21.tgz#96d53fdbb3d9df203e0e98bb46b07b260c292137"
- integrity sha512-NWh32K+N6htmmPfqSPOlA6gy80vFQZLnusK8+/7Hp0sSG//OV5ahlnlSveLUOub2e97CU5EvYUL1xNmSuqk2jQ==
- dependencies:
- "@storybook/global" "^5.0.0"
-
-"@storybook/codemod@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.21.tgz#48257a0771b2f4b80b5f61a47b2b8dc7a613e77b"
- integrity sha512-AFkOB+2vSRXbjUdTI5rsvL8YdqVcmKgmJB3QgwbmLp804Qhqn/WcbOkPOT6zqdcgDTLGaFUIFigvjc7cly3fkw==
+"@storybook/codemod@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.6.20.tgz#0aa7e0c1aacc605c7691b4b06baef0a9abefe114"
+ integrity sha512-8vmSsksO4XukNw0TmqylPmk7PxnfNfE21YsxFa7mnEBmEKQcZCQsNil4ZgWfG0IzdhTfhglAN4r++Ew0WE+PYA==
dependencies:
"@babel/core" "^7.23.2"
"@babel/preset-env" "^7.23.2"
"@babel/types" "^7.23.0"
"@storybook/csf" "^0.1.2"
- "@storybook/csf-tools" "7.6.21"
- "@storybook/node-logger" "7.6.21"
- "@storybook/types" "7.6.21"
+ "@storybook/csf-tools" "7.6.20"
+ "@storybook/node-logger" "7.6.20"
+ "@storybook/types" "7.6.20"
"@types/cross-spawn" "^6.0.2"
cross-spawn "^7.0.3"
globby "^11.0.2"
@@ -3462,35 +3443,6 @@
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
-"@storybook/core-common@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.6.21.tgz#b1a83afa17e39b5b66917ba18f7b23e0b31248dd"
- integrity sha512-3xeEAsEwPIEdnWiFJcxD3ObRrF7Vy1q/TKIExbk6p8Flx+XPXQKRZd/T+m5/8/zLYevasvY6hdVN91Fhcw9S2Q==
- dependencies:
- "@storybook/core-events" "7.6.21"
- "@storybook/node-logger" "7.6.21"
- "@storybook/types" "7.6.21"
- "@types/find-cache-dir" "^3.2.1"
- "@types/node" "^18.0.0"
- "@types/node-fetch" "^2.6.4"
- "@types/pretty-hrtime" "^1.0.0"
- chalk "^4.1.0"
- esbuild "^0.18.0"
- esbuild-register "^3.5.0"
- file-system-cache "2.3.0"
- find-cache-dir "^3.0.0"
- find-up "^5.0.0"
- fs-extra "^11.1.0"
- glob "^10.0.0"
- handlebars "^4.7.7"
- lazy-universal-dotenv "^4.0.0"
- node-fetch "^2.0.0"
- picomatch "^2.3.0"
- pkg-dir "^5.0.0"
- pretty-hrtime "^1.0.3"
- resolve-from "^5.0.0"
- ts-dedent "^2.0.0"
-
"@storybook/core-events@7.6.20":
version "7.6.20"
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.20.tgz#6648d661d1c96841a4c2a710a35759b01b6a06a1"
@@ -3498,33 +3450,26 @@
dependencies:
ts-dedent "^2.0.0"
-"@storybook/core-events@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.6.21.tgz#801fe0369ecaeee3518344feb6fb47deee2c79a6"
- integrity sha512-Ez6bhYuXbEkHVCmnNB/oqN0sQwphsmtPmjYdPMlTtEpVEIXHAw2qOlaDiGakoDHkgrTaxiYvdJrPH0UcEJcWDQ==
- dependencies:
- ts-dedent "^2.0.0"
-
-"@storybook/core-server@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.21.tgz#c521ed971345b7344a7a41365989d0338c1b09aa"
- integrity sha512-1Z92JjUumCFrLNJY7ZNH9bRXyNggtFvfrhVsHjIxvOJcXvI9cfXJQtN1Pcx2Gc7tQNLQfHp6CifmDCmAw3sbXA==
+"@storybook/core-server@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.6.20.tgz#fa143fbcad64fb7b0f0dc6d555d083c506a44ab4"
+ integrity sha512-qC5BdbqqwMLTdCwMKZ1Hbc3+3AaxHYWLiJaXL9e8s8nJw89xV8c8l30QpbJOGvcDmsgY6UTtXYaJ96OsTr7MrA==
dependencies:
"@aw-web-design/x-default-browser" "1.4.126"
"@discoveryjs/json-ext" "^0.5.3"
- "@storybook/builder-manager" "7.6.21"
- "@storybook/channels" "7.6.21"
- "@storybook/core-common" "7.6.21"
- "@storybook/core-events" "7.6.21"
+ "@storybook/builder-manager" "7.6.20"
+ "@storybook/channels" "7.6.20"
+ "@storybook/core-common" "7.6.20"
+ "@storybook/core-events" "7.6.20"
"@storybook/csf" "^0.1.2"
- "@storybook/csf-tools" "7.6.21"
+ "@storybook/csf-tools" "7.6.20"
"@storybook/docs-mdx" "^0.1.0"
"@storybook/global" "^5.0.0"
- "@storybook/manager" "7.6.21"
- "@storybook/node-logger" "7.6.21"
- "@storybook/preview-api" "7.6.21"
- "@storybook/telemetry" "7.6.21"
- "@storybook/types" "7.6.21"
+ "@storybook/manager" "7.6.20"
+ "@storybook/node-logger" "7.6.20"
+ "@storybook/preview-api" "7.6.20"
+ "@storybook/telemetry" "7.6.20"
+ "@storybook/types" "7.6.20"
"@types/detect-port" "^1.3.0"
"@types/node" "^18.0.0"
"@types/pretty-hrtime" "^1.0.0"
@@ -3585,21 +3530,6 @@
recast "^0.23.1"
ts-dedent "^2.0.0"
-"@storybook/csf-tools@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.6.21.tgz#44cf46d6ce8d7c6113346885183471f86337fd51"
- integrity sha512-DBdwDo4nOsXF/QV6Ru08xgb54M1o9A0E7D8VW0+PcFK+Y8naq8+I47PkijHloTxgZxUyX8OvboaLBMTGUV275w==
- dependencies:
- "@babel/generator" "^7.23.0"
- "@babel/parser" "^7.23.0"
- "@babel/traverse" "^7.23.2"
- "@babel/types" "^7.23.0"
- "@storybook/csf" "^0.1.2"
- "@storybook/types" "7.6.21"
- fs-extra "^11.1.0"
- recast "^0.23.1"
- ts-dedent "^2.0.0"
-
"@storybook/csf@^0.1.2":
version "0.1.13"
resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.1.13.tgz#c8a9bea2ae518a3d9700546748fa30a8b07f7f80"
@@ -3650,10 +3580,10 @@
telejson "^7.2.0"
ts-dedent "^2.0.0"
-"@storybook/manager@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.21.tgz#8692bf57a8c65f3b25935963b433165f81cc6d07"
- integrity sha512-kwtG7HfxYQIZeGwDg7xFkORhNf0PH+4jRLf/9M6amR537Hctay+Vlv2MGHO6LFzw6IwT4qCtO8xNgzcV9TxZtg==
+"@storybook/manager@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.6.20.tgz#eb619fe8d33446e581a7b1c3050644c196364d39"
+ integrity sha512-0Cf6WN0t7yEG2DR29tN5j+i7H/TH5EfPppg9h9/KiQSoFHk+6KLoy2p5do94acFU+Ro4+zzxvdCGbcYGKuArpg==
"@storybook/mdx2-csf@^1.0.0":
version "1.1.0"
@@ -3665,11 +3595,6 @@
resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.20.tgz#c0ca90cf68cf31d84cdcf53c76cec22769407ece"
integrity sha512-l2i4qF1bscJkOplNffcRTsgQWYR7J51ewmizj5YrTM8BK6rslWT1RntgVJWB1RgPqvx6VsCz1gyP3yW1oKxvYw==
-"@storybook/node-logger@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.6.21.tgz#a70e829c54c119f37f5a4f9d3660c2f4f1510fbd"
- integrity sha512-X4LwhWQ0KuLU7O2aEi7U9hhg+klnuvkXqhXIqAQCZEKogUxz7ywek+2h+7QqdgHFi6V7VYNtiMmMJKllzhg+OA==
-
"@storybook/postinstall@7.6.20":
version "7.6.20"
resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.6.20.tgz#5a77ce7913375b11bd7c72388798854bd8507b91"
@@ -3731,26 +3656,6 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
-"@storybook/preview-api@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.6.21.tgz#eb90a17752a37fddadfe4c58a697731bb601a376"
- integrity sha512-L5e6VjphfsnJk/kkOIRJzDaTfX5sNpiusocqEbHKTM7c9ZDAuaLPZKluP87AJ0u16UdWMuCu6YaQ6eAakDa9gg==
- dependencies:
- "@storybook/channels" "7.6.21"
- "@storybook/client-logger" "7.6.21"
- "@storybook/core-events" "7.6.21"
- "@storybook/csf" "^0.1.2"
- "@storybook/global" "^5.0.0"
- "@storybook/types" "7.6.21"
- "@types/qs" "^6.9.5"
- dequal "^2.0.2"
- lodash "^4.17.21"
- memoizerific "^1.11.3"
- qs "^6.10.0"
- synchronous-promise "^2.0.15"
- ts-dedent "^2.0.0"
- util-deprecate "^1.0.2"
-
"@storybook/preview@7.6.20":
version "7.6.20"
resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.6.20.tgz#df39739dce6e183efaf06a8c15a9459f019e631b"
@@ -3820,14 +3725,14 @@
memoizerific "^1.11.3"
qs "^6.10.0"
-"@storybook/telemetry@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.21.tgz#f28dd3173ce04c3372c806079391dc6ee2cd3bc0"
- integrity sha512-bE68Ac6daL0JE9vjtHKwsM+uSXZ94QdoZL9RCTVvp0dI7htm7s7w7+Arm/aCxG9lnYTAjioWNRpHfeALVjsjIg==
+"@storybook/telemetry@7.6.20":
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.6.20.tgz#5b3705eb5100b21070d76767dde1040ed5d9b35b"
+ integrity sha512-dmAOCWmOscYN6aMbhCMmszQjoycg7tUPRVy2kTaWg6qX10wtMrvEtBV29W4eMvqdsoRj5kcvoNbzRdYcWBUOHQ==
dependencies:
- "@storybook/client-logger" "7.6.21"
- "@storybook/core-common" "7.6.21"
- "@storybook/csf-tools" "7.6.21"
+ "@storybook/client-logger" "7.6.20"
+ "@storybook/core-common" "7.6.20"
+ "@storybook/csf-tools" "7.6.20"
chalk "^4.1.0"
detect-package-manager "^2.0.1"
fetch-retry "^5.0.2"
@@ -3863,16 +3768,6 @@
"@types/express" "^4.7.0"
file-system-cache "2.3.0"
-"@storybook/types@7.6.21":
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.6.21.tgz#b8815c6701fd286e85be9b89e1e31b7a9fd75876"
- integrity sha512-rJaBMxzXZOsJpqZGhebFJxOguZQBw5j+MVpqbFBA6vLZPx9wEbDBeVsPUxCxj+V1XkVcrNXf9qfThyJ8ETmLBw==
- dependencies:
- "@storybook/channels" "7.6.21"
- "@types/babel__core" "^7.0.0"
- "@types/express" "^4.7.0"
- file-system-cache "2.3.0"
-
"@stripe/react-stripe-js@1.16.5":
version "1.16.5"
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.16.5.tgz#51cf862b50ca91ae6193c77a5bec889e81047f10"
@@ -5918,14 +5813,14 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6"
integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==
-axios@*, axios@^1.13.2, axios@^1.15.0, axios@^1.7.4:
- version "1.15.0"
- resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
- integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
+axios@*, axios@^1.13.2, axios@^1.7.4:
+ version "1.13.2"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687"
+ integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==
dependencies:
- follow-redirects "^1.15.11"
- form-data "^4.0.5"
- proxy-from-env "^2.1.0"
+ follow-redirects "^1.15.6"
+ form-data "^4.0.4"
+ proxy-from-env "^1.1.0"
axobject-query@^4.1.0:
version "4.1.0"
@@ -7787,7 +7682,7 @@ deep-extend@^0.6.0:
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-deep-is@^0.1.3:
+deep-is@^0.1.3, deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
@@ -7855,9 +7750,9 @@ define-properties@^1.1.3, define-properties@^1.2.1:
object-keys "^1.1.1"
defu@^6.1.4:
- version "6.1.7"
- resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.7.tgz#72543567c8e9f97ff13ce402b6dbe09ac5ae4d23"
- integrity sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==
+ version "6.1.4"
+ resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479"
+ integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
del@^6.0.0:
version "6.1.1"
@@ -8770,6 +8665,18 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
+escodegen@^1.8.1:
+ version "1.14.3"
+ resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"
+ integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==
+ dependencies:
+ esprima "^4.0.1"
+ estraverse "^4.2.0"
+ esutils "^2.0.2"
+ optionator "^0.8.1"
+ optionalDependencies:
+ source-map "~0.6.1"
+
escodegen@^2.0.0, escodegen@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17"
@@ -9100,10 +9007,10 @@ espree@^9.6.0, espree@^9.6.1:
acorn-jsx "^5.3.2"
eslint-visitor-keys "^3.4.1"
-esprima@1.2.5:
- version "1.2.5"
- resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.5.tgz#0993502feaf668138325756f30f9a51feeec11e9"
- integrity sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==
+esprima@1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b"
+ integrity sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==
esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0:
version "4.0.1"
@@ -9124,7 +9031,7 @@ esrecurse@^4.1.0, esrecurse@^4.3.0:
dependencies:
estraverse "^5.2.0"
-estraverse@^4.1.1:
+estraverse@^4.1.1, estraverse@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@@ -9387,7 +9294,7 @@ fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
-fast-levenshtein@^2.0.6:
+fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
@@ -9398,11 +9305,11 @@ fast-uri@^3.0.1:
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fast-xml-parser@^4.4.1:
- version "4.5.6"
- resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz#4ff57d4aca13a2d11aa42ad460495cf00f32b655"
- integrity sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==
+ version "4.5.3"
+ resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz#c54d6b35aa0f23dc1ea60b6c884340c006dc6efb"
+ integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==
dependencies:
- strnum "^1.0.5"
+ strnum "^1.1.1"
fastest-levenshtein@^1.0.12:
version "1.0.16"
@@ -9678,9 +9585,9 @@ flat@^5.0.2:
integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
flatted@^3.2.9:
- version "3.4.2"
- resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
- integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
+ version "3.3.3"
+ resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
+ integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
flow-parser@0.*:
version "0.293.0"
@@ -9695,10 +9602,10 @@ flux-standard-action@^2.0.3:
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
-follow-redirects@^1.0.0, follow-redirects@^1.15.11, follow-redirects@^1.15.2:
- version "1.16.0"
- resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
- integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
+follow-redirects@^1.0.0, follow-redirects@^1.15.2, follow-redirects@^1.15.6:
+ version "1.15.11"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
+ integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
for-each@^0.3.3, for-each@^0.3.5:
version "0.3.5"
@@ -9771,7 +9678,7 @@ form-data@^3.0.0:
hasown "^2.0.2"
mime-types "^2.1.35"
-form-data@^4.0.4, form-data@^4.0.5:
+form-data@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053"
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
@@ -10175,9 +10082,9 @@ handle-thing@^2.0.0:
integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==
handlebars@^4.7.7:
- version "4.7.9"
- resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f"
- integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==
+ version "4.7.8"
+ resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
+ integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
@@ -12365,13 +12272,13 @@ jsonfile@^6.0.1:
graceful-fs "^4.1.6"
jsonpath@^1.1.1:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.3.0.tgz#623197970fb433845c68024bf9e2b864f5376ab2"
- integrity sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.1.1.tgz#0ca1ed8fb65bb3309248cc9d5466d12d5b0b9901"
+ integrity sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==
dependencies:
- esprima "1.2.5"
- static-eval "2.1.1"
- underscore "1.13.6"
+ esprima "1.2.2"
+ static-eval "2.0.2"
+ underscore "1.12.1"
jsonpointer@^5.0.0:
version "5.0.1"
@@ -12482,6 +12389,14 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
+levn@~0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
+ integrity sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==
+ dependencies:
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+
lilconfig@2.1.0, lilconfig@^2.0.3:
version "2.1.0"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
@@ -12595,9 +12510,9 @@ locate-path@^7.1.0:
p-locate "^6.0.0"
lodash-es@^4.17.15, lodash-es@^4.2.1:
- version "4.18.1"
- resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
- integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
+ integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash._arrayeach@^3.0.0:
version "3.0.0"
@@ -12710,10 +12625,10 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
-lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.18.1, lodash@^4.2.1, lodash@^4.7.0:
- version "4.18.1"
- resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
- integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
+lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.7.0:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+ integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@^4.1.0:
version "4.1.0"
@@ -13956,9 +13871,9 @@ node-fetch@2.6.7, node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.7.0:
whatwg-url "^5.0.0"
node-forge@^1:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2"
- integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751"
+ integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==
node-int64@^0.4.0:
version "0.4.0"
@@ -14222,6 +14137,18 @@ opener@^1.5.2:
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
+optionator@^0.8.1:
+ version "0.8.3"
+ resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
+ integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
+ dependencies:
+ deep-is "~0.1.3"
+ fast-levenshtein "~2.0.6"
+ levn "~0.3.0"
+ prelude-ls "~1.1.2"
+ type-check "~0.3.2"
+ word-wrap "~1.2.3"
+
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
@@ -15387,6 +15314,11 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
+prelude-ls@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
+ integrity sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==
+
prettier@^2.8.0:
version "2.8.8"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
@@ -15536,16 +15468,11 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
-proxy-from-env@^1.0.0:
+proxy-from-env@^1.0.0, proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
-proxy-from-env@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
- integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
-
ps-tree@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ps-tree/-/ps-tree-1.2.0.tgz#5e7425b89508736cdd4f2224d028f7bb3f722ebd"
@@ -16828,9 +16755,9 @@ rollup-plugin-terser@^7.0.0:
terser "^5.0.0"
rollup@^2.43.1:
- version "2.80.0"
- resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.80.0.tgz#a82efc15b748e986a7c76f0f771221b1fa108a2c"
- integrity sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==
+ version "2.79.2"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090"
+ integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==
optionalDependencies:
fsevents "~2.3.2"
@@ -17562,12 +17489,12 @@ start-server-and-test@^2.1.3:
ps-tree "1.2.0"
wait-on "9.0.3"
-static-eval@2.1.1:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.1.1.tgz#71ac6a13aa32b9e14c5b5f063c362176b0d584ba"
- integrity sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==
+static-eval@2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42"
+ integrity sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==
dependencies:
- escodegen "^2.1.0"
+ escodegen "^1.8.1"
statuses@2.0.1:
version "2.0.1"
@@ -17597,12 +17524,12 @@ store2@^2.14.2:
resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.4.tgz#81b313abaddade4dcd7570c5cc0e3264a8f7a242"
integrity sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==
-storybook@7.6.21:
- version "7.6.21"
- resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.21.tgz#0856e00cbbeb5d6ec16cb413cd6aa4398fac114d"
- integrity sha512-zmicrWNy5GbrO7hZwVp6uZ6m93VWULePkhYB300jAer7Z+CH4yso/nNcyRO00rnD4zizJLy2MXeUJvydh7rOaw==
+storybook@7.6.20:
+ version "7.6.20"
+ resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.6.20.tgz#6204ff0c28471536a1a64cb16d1c97872dd33f95"
+ integrity sha512-Wt04pPTO71pwmRmsgkyZhNo4Bvdb/1pBAMsIFb9nQLykEdzzpXjvingxFFvdOG4nIowzwgxD+CLlyRqVJqnATw==
dependencies:
- "@storybook/cli" "7.6.21"
+ "@storybook/cli" "7.6.20"
stream-combiner@~0.0.4:
version "0.0.4"
@@ -17844,7 +17771,7 @@ strip-json-comments@~2.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
-strnum@^1.0.5:
+strnum@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4"
integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==
@@ -18539,6 +18466,13 @@ type-check@^0.4.0, type-check@~0.4.0:
dependencies:
prelude-ls "^1.2.1"
+type-check@~0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
+ integrity sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==
+ dependencies:
+ prelude-ls "~1.1.2"
+
type-detect@4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
@@ -18720,10 +18654,10 @@ unbox-primitive@^1.1.0:
has-symbols "^1.1.0"
which-boxed-primitive "^1.1.1"
-underscore@1.13.6:
- version "1.13.6"
- resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441"
- integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==
+underscore@1.12.1:
+ version "1.12.1"
+ resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e"
+ integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==
undici-types@~5.26.4:
version "5.26.5"
@@ -19650,7 +19584,7 @@ wildcard@^2.0.0:
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67"
integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==
-word-wrap@^1.2.5:
+word-wrap@^1.2.5, word-wrap@~1.2.3:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==