diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md index 321b259e6..1a15c71a9 100644 --- a/src/apps/sales/README.md +++ b/src/apps/sales/README.md @@ -1,13 +1,21 @@ -# Sales (PM-6343) +# Sales (PM-6343, PM-6363) Read-only Salesforce reporting for Administrators and Talent Managers. Available at `sales.topcoder.com` / `sales.topcoder-dev.com`, `/sales` on the combined host, and the **Sales** tab inside Work (`/sales` on the Work host, `/work/sales` on the combined host). Route guards and the Reports API independently enforce access. -The page calls `GET {REPORTS_API}/sales` with the signed-in user's token. All -Salesforce credentials stay in `reports-api-v6`. No create, update, delete, -export, machine credentials or direct Salesforce API calls exist in the UI. +The page calls `GET {REPORTS_API}/sales` with the signed-in user's token, and +`GET {API.V6}/projects/salesforce/opportunities/{id}` for the opportunity popup. +All Salesforce credentials stay in `reports-api-v6` and `projects-api-v6`. No +create, update, delete, export, machine credentials or direct Salesforce API +calls exist in the UI. + +Report cells whose value is a Salesforce opportunity id (the `006` key prefix) +render the opportunity name as a button. Opening it shows a popup with the +opportunity description first, followed by the customer, SMU, close date and +stage when Salesforce provides them, plus a link to the record. The popup closes +with its Close button or the X icon; obsolete lookups are aborted. Report metadata determines every displayed column, including grouped Stage. Search and column substring filters apply automatically as the user types (debounced); headers sort globally diff --git a/src/apps/sales/src/OpportunityModal.module.scss b/src/apps/sales/src/OpportunityModal.module.scss new file mode 100644 index 000000000..37b3c02dc --- /dev/null +++ b/src/apps/sales/src/OpportunityModal.module.scss @@ -0,0 +1,60 @@ +@import '@libs/ui/styles/2026/tokens'; + +.body { + display: block; +} + +.state { + padding: 40px 0; + min-height: 140px; + position: relative; +} + +.error { + border: 1px solid $tc-2026-danger; + border-radius: $tc-2026-radius-md; + color: $tc-2026-danger; + padding: 16px; +} + +.sectionTitle { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: $tc-2026-muted; +} + +.description { + margin-top: 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.details { + margin-top: 24px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} + +.detail dt { + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: $tc-2026-muted; +} + +.detail dd { + margin-top: 4px; + overflow-wrap: anywhere; +} + +.link { + display: inline-block; + margin-top: 24px; + font-weight: 700; + color: $tc-2026-teal; + text-decoration: underline; +} diff --git a/src/apps/sales/src/OpportunityModal.tsx b/src/apps/sales/src/OpportunityModal.tsx new file mode 100644 index 000000000..cfdf38973 --- /dev/null +++ b/src/apps/sales/src/OpportunityModal.tsx @@ -0,0 +1,117 @@ +/* The modal body renders only the opportunity fields the report already exposes. */ +import { FC, useEffect, useState } from 'react' + +import { BaseModal, Button, LoadingSpinner } from '~/libs/ui' + +import { + fetchOpportunity, + opportunityErrorMessage, + SalesOpportunity, +} from './opportunity.service' +import styles from './OpportunityModal.module.scss' + +export interface OpportunityModalProps { + /** Salesforce opportunity id taken from the report cell. */ + opportunityId: string + /** Opportunity name shown while the details are still loading. */ + opportunityName: string + onClose: () => void + open: boolean +} + +type ModalState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; opportunity: SalesOpportunity } + +/** + * Shows the details of one Salesforce opportunity, starting with its description. + * @param props The opportunity to load, whether the popup is open, and the close handler. + * @returns A dialog closed with either the Close button or the X icon. + * @throws Does not throw request failures; shows the sanitized message inline. + */ +export const OpportunityModal: FC = props => { + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + if (!props.open) { + return undefined + } + + const controller = new AbortController() + setState({ status: 'loading' }) + fetchOpportunity(props.opportunityId, controller.signal) + .then(opportunity => { + if (!controller.signal.aborted) { + setState({ opportunity, status: 'ready' }) + } + }) + .catch(error => { + if (!controller.signal.aborted) { + setState({ message: opportunityErrorMessage(error), status: 'error' }) + } + }) + + return () => controller.abort() + }, [props.open, props.opportunityId]) + + const opportunity = state.status === 'ready' ? state.opportunity : undefined + const details: Array<{ label: string; value: string | undefined }> = [ + { label: 'Customer', value: opportunity?.customer }, + { label: 'SMU', value: opportunity?.reportingSmu }, + { label: 'Close Date', value: opportunity?.closeDate }, + { label: 'Stage', value: opportunity?.stageName }, + ].filter(item => !!item.value) + + return ( + Close + )} + onClose={props.onClose} + open={props.open} + size='md' + title={opportunity?.name || props.opportunityName} + > + {state.status === 'loading' && ( +
+ )} + + {state.status === 'error' && ( +
{state.message}
+ )} + + {opportunity && ( + <> +

Description

+

+ {opportunity.description || 'This opportunity does not have a description yet.'} +

+ + {details.length > 0 && ( +
+ {details.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ )} + + + View in Salesforce + + + )} +
+ ) +} + +export default OpportunityModal diff --git a/src/apps/sales/src/SalesPage.module.scss b/src/apps/sales/src/SalesPage.module.scss index 83de541e5..f6bc8d41b 100644 --- a/src/apps/sales/src/SalesPage.module.scss +++ b/src/apps/sales/src/SalesPage.module.scss @@ -48,6 +48,8 @@ .table th button:hover { color: $tc-2026-teal; } .table td { border-bottom: 1px solid $tc-2026-border; padding: 16px 20px; min-width: 140px; max-width: 420px; overflow-wrap: anywhere; vertical-align: top; } .table tr:last-child td { border-bottom: 0; } +.opportunityButton { padding: 0; border: 0; background: transparent; text-align: left; color: $tc-2026-teal; font-weight: 700; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; } +.opportunityButton:hover, .opportunityButton:focus-visible { color: $tc-2026-body; } .table tbody tr:hover { background: $tc-2026-canvas; } .pagination { padding: 20px 24px; border-top: 1px solid $tc-2026-border; flex-wrap: wrap; font-size: 13px; } .pageControls { flex-wrap: wrap; gap: 12px; } diff --git a/src/apps/sales/src/SalesPage.spec.tsx b/src/apps/sales/src/SalesPage.spec.tsx index c51c352e3..621104511 100644 --- a/src/apps/sales/src/SalesPage.spec.tsx +++ b/src/apps/sales/src/SalesPage.spec.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ButtonHTMLAttributes, ReactNode } from 'react' import SalesPage from './SalesPage' +import { fetchOpportunity } from './opportunity.service' import { SalesReport } from './sales.models' import { fetchSalesReport } from './sales.service' @@ -12,7 +13,21 @@ jest.mock('./sales.service', () => ({ salesErrorMessage: () => 'Unable to refresh. Please try again.', })) +jest.mock('./opportunity.service', () => ({ + fetchOpportunity: jest.fn(), + opportunityErrorMessage: () => 'We could not load the opportunity details. Please try again.', +})) + jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { buttons?: ReactNode; children: ReactNode; open: boolean; title: ReactNode }) => ( + props.open ? ( +
+

{props.title}

+ {props.children} + {props.buttons} +
+ ) : undefined + ), Button: (props: ButtonHTMLAttributes) => ( + ) : label} + + ) + })} ))} @@ -362,6 +385,15 @@ const SalesPage: FC = () => { )} + + {openedOpportunity && ( + setOpenedOpportunity(undefined)} + open + opportunityId={openedOpportunity.id} + opportunityName={openedOpportunity.name} + /> + )} ) } diff --git a/src/apps/sales/src/opportunity.service.ts b/src/apps/sales/src/opportunity.service.ts new file mode 100644 index 000000000..31b979aec --- /dev/null +++ b/src/apps/sales/src/opportunity.service.ts @@ -0,0 +1,44 @@ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +export interface SalesOpportunity { + id: string + name: string + description?: string + customer?: string + smu?: string + reportingSmu?: string + closeDate?: string + stageName?: string + url: string +} + +/** + * Reads a Salesforce opportunity through the role-protected Projects API. + * @param opportunityId Salesforce opportunity id taken from a report cell. + * @param signal Cancels the request when the popup closes or another row is opened. + * @returns The opportunity details shown in the popup. + * @throws Propagates network, authorization and sanitized Projects API errors. + */ +export function fetchOpportunity( + opportunityId: string, + signal?: AbortSignal, +): Promise { + const url = `${EnvironmentConfig.API.V6}/projects/salesforce/opportunities/${encodeURIComponent(opportunityId)}` + return xhrGetAsync(url, undefined, { signal }) +} + +/** + * Explains an opportunity lookup failure without exposing transport internals. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance shown inside the popup. + * @throws Does not throw. + */ +export function opportunityErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 401) return 'Your session has expired. Sign in again to view opportunity details.' + if (status === 403) return 'You do not have permission to read Salesforce opportunities.' + if (status === 404) return 'This opportunity is no longer available in Salesforce.' + if (status === 503) return 'The Salesforce connection is not configured yet. Contact your administrator.' + return 'We could not load the opportunity details. Please try again.' +} diff --git a/src/apps/sales/src/sales.models.ts b/src/apps/sales/src/sales.models.ts index 41664ddc7..3b2b39513 100644 --- a/src/apps/sales/src/sales.models.ts +++ b/src/apps/sales/src/sales.models.ts @@ -27,3 +27,20 @@ export interface SalesQuery { sortOrder?: 'asc' | 'desc' refresh?: boolean } + +/** + * Salesforce opportunity record ids: the `006` key prefix followed by 12 or 15 + * case-sensitive alphanumeric characters. Report cells carrying the opportunity + * name expose this id as their value. + */ +const OPPORTUNITY_ID_PATTERN = /^006[a-zA-Z0-9]{12}(?:[a-zA-Z0-9]{3})?$/ + +/** + * Detects the report cell value that identifies a Salesforce opportunity. + * @param value Raw cell value from the sales report. + * @returns The opportunity id when the cell carries one, otherwise undefined. + * @throws Does not throw. + */ +export function toOpportunityId(value: string | number | boolean | null): string | undefined { + return typeof value === 'string' && OPPORTUNITY_ID_PATTERN.test(value) ? value : undefined +} diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx index 582b1a5ad..b4baa0d40 100644 --- a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx @@ -46,7 +46,8 @@ describe('Showcase post details', () => { view.rerender() expect(screen.getByText('SMU')) .toBeInTheDocument() - expect(screen.getByText('Americas1')) + // Projects stored before the Salesforce naming alignment render the current label. + expect(screen.getByText('AMR1')) .toBeInTheDocument() expect(screen.getByText('Owner')) .toBeInTheDocument() diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx index bd2e999cf..337f40b3f 100644 --- a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' +import { normalizeSmuValue } from '../../constants/showcase.constants' import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model' import styles from './ShowcasePostDetails.module.scss' @@ -47,7 +48,7 @@ function formatDealCloseDate(value: string | undefined): string | undefined { export function getShowcaseDetailItems(data: ShowcaseMetadata): ShowcasePostDetailItem[] { const smu = data.smu === 'Others' ? data.smuOther?.trim() || data.smu - : data.smu + : normalizeSmuValue(data.smu) return [ { label: 'Type', value: data.type }, diff --git a/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss b/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss new file mode 100644 index 000000000..aa190f150 --- /dev/null +++ b/src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss @@ -0,0 +1,22 @@ +@import '@libs/ui/styles/includes'; + +.opportunityStatus { + color: $black-60; + font-size: 12px; + line-height: 1.5; + margin-top: -4px; +} + +.opportunityError { + color: $red-100; +} + +.opportunityLink { + color: $blue-100; + font-weight: 500; + text-decoration: underline; +} + +.opportunityName { + color: $black-80; +} diff --git a/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx index 5b35ea6cc..8de38e1e4 100644 --- a/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx +++ b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx @@ -1,28 +1,176 @@ -import { FC } from 'react' +import { + FC, + useCallback, + useEffect, + useRef, + useState, +} from 'react' import { useFormContext } from 'react-hook-form' +import classNames from 'classnames' +import { isSalesforceOpportunityId } from '../../constants/salesforce.constants' import { SMU_VALUES } from '../../constants/showcase.constants' +import { + fetchSalesforceOpportunity, + SalesforceOpportunity, + salesforceOpportunityErrorMessage, +} from '../../services/salesforce-opportunities.service' import { FormSelectField } from './FormSelectField' import { FormTextField } from './FormTextField' +import styles from './ProjectMetadataFields.module.scss' + +const lookupDebounceMs = 400 interface ProjectMetadataFieldsProps { className?: string required?: boolean + /** Enables the Salesforce Opportunity ID lookup that populates the fields below. */ + showSalesforceOpportunity?: boolean } +type LookupState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'ready'; opportunity: SalesforceOpportunity } + /** * Renders the shared Customer, SMU and Deal Close Date fields in both project forms. - * @param props Optional field styling and whether showcase metadata is required. + * @param props Optional field styling, whether showcase metadata is required, and + * whether the Salesforce Opportunity ID lookup is offered. * @returns Fields bound to the surrounding React Hook Form, including custom SMU input. * @throws Requires a parent FormProvider, like the other work form controls. */ export const ProjectMetadataFields: FC = props => { const formContext = useFormContext() const smu = formContext.watch('smu') + const opportunityId = formContext.watch('salesforceOpportunityId') + const [lookup, setLookup] = useState({ status: 'idle' }) + // Populating the fields is a response to typing, so reopening a linked + // project never overwrites details that were edited by hand afterwards. + const enteredByUser = useRef(false) + + const setValue = formContext.setValue + + const applyOpportunity = useCallback( + (opportunity: SalesforceOpportunity): void => { + const options = { shouldDirty: true, shouldValidate: true } + + if (opportunity.customer) { + setValue('customer', opportunity.customer, options) + } + + if (opportunity.smu) { + setValue('smu', opportunity.smu, options) + setValue( + 'smuOther', + opportunity.smu === 'Others' ? opportunity.smuOther || '' : '', + options, + ) + } + + if (opportunity.closeDate) { + setValue('dealCloseDate', opportunity.closeDate, options) + } + }, + [setValue], + ) + + useEffect(() => { + if (!props.showSalesforceOpportunity) { + return undefined + } + + const trimmedId = (opportunityId || '').trim() + if (!trimmedId || !isSalesforceOpportunityId(trimmedId)) { + setLookup({ status: 'idle' }) + return undefined + } + + const controller = new AbortController() + const timer = window.setTimeout(() => { + setLookup({ status: 'loading' }) + fetchSalesforceOpportunity(trimmedId, controller.signal) + .then(opportunity => { + if (controller.signal.aborted) { + return + } + + setLookup({ opportunity, status: 'ready' }) + + if (enteredByUser.current) { + applyOpportunity(opportunity) + } + }) + .catch(error => { + if (controller.signal.aborted) { + return + } + + setLookup({ + message: salesforceOpportunityErrorMessage(error), + status: 'error', + }) + }) + }, lookupDebounceMs) + + return () => { + window.clearTimeout(timer) + controller.abort() + } + }, [applyOpportunity, opportunityId, props.showSalesforceOpportunity]) + + /** @param value Raw field input. @returns Nothing; marks the id as user-entered. Does not throw. */ + function handleOpportunityIdChange(): void { + enteredByUser.current = true + } return ( <> + {props.showSalesforceOpportunity && ( + <> + + {lookup.status !== 'idle' && ( +
+ {lookup.status === 'loading' && 'Looking up the Salesforce opportunity…'} + {lookup.status === 'error' && lookup.message} + {lookup.status === 'ready' && ( + <> + + View in Salesforce + + {lookup.opportunity.name && ( + + {` — ${lookup.opportunity.name}`} + + )} + + )} +
+ )} + + )} = { + Americas1: 'AMR1', + Americas2: 'AMR2', + APMEA: 'APME', + Europe: 'EURP', +} + +/** + * Maps a stored SMU label onto a currently supported option. + * @param value SMU value loaded from a project or showcase post. + * @returns The supported option, or the original value when it is not a legacy label. + * @throws Does not throw. + */ +export function normalizeSmuValue(value: string | undefined): string { + if (!value) { + return '' + } + + return LEGACY_SMU_VALUES[value] ?? value +} export const SHOWCASE_TYPE_VALUES = [ 'Open Innovation', diff --git a/src/apps/work/src/lib/models/Project.model.ts b/src/apps/work/src/lib/models/Project.model.ts index 7e440187a..c450a3008 100644 --- a/src/apps/work/src/lib/models/Project.model.ts +++ b/src/apps/work/src/lib/models/Project.model.ts @@ -13,6 +13,8 @@ export interface ProjectMetadata { smu?: string smuOther?: string dealCloseDate?: string + /** Salesforce opportunity the project details were imported from. */ + salesforceOpportunityId?: string } export interface ProjectDetails extends Record, ProjectMetadata { diff --git a/src/apps/work/src/lib/schemas/project-metadata.schema.ts b/src/apps/work/src/lib/schemas/project-metadata.schema.ts index 1a1167e9a..d676eeb12 100644 --- a/src/apps/work/src/lib/schemas/project-metadata.schema.ts +++ b/src/apps/work/src/lib/schemas/project-metadata.schema.ts @@ -1,6 +1,7 @@ import * as yup from 'yup' -import { SMU_VALUES } from '../constants/showcase.constants' +import { SALESFORCE_OPPORTUNITY_ID_PATTERN } from '../constants/salesforce.constants' +import { LEGACY_SMU_VALUES, SMU_VALUES } from '../constants/showcase.constants' /** * Checks a date-only value without converting its calendar day to a local timezone. @@ -25,19 +26,29 @@ function isCalendarDate(value: string | undefined): boolean { export function projectMetadataSchemaFields(required: boolean): { customer: yup.StringSchema dealCloseDate: yup.StringSchema + salesforceOpportunityId: yup.StringSchema smu: yup.StringSchema smuOther: yup.StringSchema } { const text = yup.string() .trim() .max(255) + // Projects saved before the Salesforce naming alignment still hold the legacy labels. const smu = yup.string() - .oneOf([...SMU_VALUES, '']) + .oneOf([...SMU_VALUES, ...Object.keys(LEGACY_SMU_VALUES), '']) const date = yup.string() .test('calendar-date', 'Enter a valid deal close date', isCalendarDate) return { customer: required ? text.required('Customer is required') : text.optional(), dealCloseDate: required ? date.required('Deal Close Date is required') : date.optional(), + salesforceOpportunityId: yup.string() + .trim() + .test( + 'salesforce-opportunity-id', + 'Enter a valid 15 or 18 character Salesforce Opportunity ID', + value => !value || SALESFORCE_OPPORTUNITY_ID_PATTERN.test(value), + ) + .optional(), smu: required ? smu.required('SMU is required') : smu.optional(), smuOther: text.when('smu', { is: 'Others', diff --git a/src/apps/work/src/lib/services/index.ts b/src/apps/work/src/lib/services/index.ts index 4c88e547a..85447b60c 100644 --- a/src/apps/work/src/lib/services/index.ts +++ b/src/apps/work/src/lib/services/index.ts @@ -36,6 +36,7 @@ export type { ProjectSummary, } from './projects.service' export * from './resources.service' +export * from './salesforce-opportunities.service' export * from './reviews.service' export * from './skills.service' export * from './submissions.service' diff --git a/src/apps/work/src/lib/services/salesforce-opportunities.service.ts b/src/apps/work/src/lib/services/salesforce-opportunities.service.ts new file mode 100644 index 000000000..1505e1309 --- /dev/null +++ b/src/apps/work/src/lib/services/salesforce-opportunities.service.ts @@ -0,0 +1,52 @@ +import { xhrGetAsync } from '~/libs/core' + +import { PROJECTS_API_URL } from '../constants' + +export interface SalesforceOpportunity { + id: string + name: string + description?: string + /** Subcontracting End Customer account name. */ + customer?: string + /** Reporting SMU already mapped onto a supported SMU option. */ + smu?: string + /** Raw Reporting SMU, populated only when `smu` is `Others`. */ + smuOther?: string + reportingSmu?: string + /** Close Date as a YYYY-MM-DD calendar date. */ + closeDate?: string + stageName?: string + /** Deep link to the opportunity record in Salesforce. */ + url: string +} + +/** + * Reads a Salesforce opportunity through the role-protected Projects API. + * @param opportunityId 15 or 18 character Salesforce opportunity id. + * @param signal Cancels an obsolete lookup when the input changes or the form unmounts. + * @returns The opportunity fields used to populate project details. + * @throws Propagates network, authorization and sanitized Projects API errors. + */ +export function fetchSalesforceOpportunity( + opportunityId: string, + signal?: AbortSignal, +): Promise { + const url = `${PROJECTS_API_URL}/salesforce/opportunities/${encodeURIComponent(opportunityId)}` + return xhrGetAsync(url, undefined, { signal }) +} + +/** + * Explains an opportunity lookup failure without exposing transport internals. + * @param error Unknown request rejection. + * @returns User-facing recovery guidance for the inline field error. + * @throws Does not throw. + */ +export function salesforceOpportunityErrorMessage(error: unknown): string { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 400) return 'Enter a valid 15 or 18 character Salesforce Opportunity ID.' + if (status === 401) return 'Your session has expired. Sign in again to look up the opportunity.' + if (status === 403) return 'You do not have permission to read Salesforce opportunities.' + if (status === 404) return 'No Salesforce opportunity was found for that ID.' + if (status === 503) return 'The Salesforce connection is not configured yet. Contact your administrator.' + return 'We could not reach Salesforce. Please try again.' +} 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 index c628a8458..c7d7e791f 100644 --- 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 @@ -14,6 +14,9 @@ import { createProject, updateProject, } from '../../../../../lib/services' +import { + fetchSalesforceOpportunity, +} from '../../../../../lib/services/salesforce-opportunities.service' import { ProjectEditorForm } from './ProjectEditorForm' @@ -144,6 +147,11 @@ jest.mock('../../../../../lib/services', () => ({ updateProject: jest.fn(), })) +jest.mock('../../../../../lib/services/salesforce-opportunities.service', () => ({ + fetchSalesforceOpportunity: jest.fn(), + salesforceOpportunityErrorMessage: () => 'We could not reach Salesforce. Please try again.', +})) + jest.mock('../../../../../lib/utils', () => ({ formatDate: () => '-', showErrorToast: jest.fn(), @@ -173,6 +181,9 @@ const mockedUseFetchProjectBillingAccount = useFetchProjectBillingAccount as jes typeof useFetchProjectBillingAccount > const mockedCreateProject = createProject as jest.MockedFunction +const mockedFetchSalesforceOpportunity = fetchSalesforceOpportunity as jest.MockedFunction< + typeof fetchSalesforceOpportunity +> const mockedUpdateProject = updateProject as jest.MockedFunction describe('ProjectEditorForm', () => { @@ -312,16 +323,111 @@ describe('ProjectEditorForm', () => { .toBe('Custom') fireEvent.change(screen.getByLabelText('Customer'), { target: { value: 'Updated' } }) fireEvent.keyDown(screen.getByLabelText('SMU'), { code: 'ArrowDown', key: 'ArrowDown' }) - fireEvent.click(screen.getByText('Europe')) + fireEvent.click(screen.getByText('EURP')) expect(screen.queryByLabelText(/^Other SMU/)) .toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Save project' })) await waitFor(() => expect(mockedUpdateProject) .toHaveBeenCalledWith('project-1', expect.objectContaining({ details: expect.objectContaining({ - customer: 'Updated', dealCloseDate: '2026-09-16', retained: true, smu: 'Europe', smuOther: '', + customer: 'Updated', dealCloseDate: '2026-09-16', retained: true, smu: 'EURP', smuOther: '', }), }))) }) + it('shows the current option for a project saved with a legacy SMU label', () => { + render( + + + , + ) + + expect(screen.getByText('AMR1')) + .toBeTruthy() + }) + + it('populates the shared metadata from a Salesforce opportunity and links to it', async () => { + mockedFetchSalesforceOpportunity.mockResolvedValue({ + closeDate: '2026-07-31', + customer: 'Novartis Pharmaceuticals', + id: '006UN00000XamntYAB', + name: 'EMEA - AWS - PS BFSI', + smu: 'AMR1', + url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + }) + + render( + + + , + ) + + fireEvent.change(screen.getByLabelText(/^Salesforce Opportunity ID/), { + target: { value: '006UN00000XamntYAB' }, + }) + + await waitFor(() => expect(mockedFetchSalesforceOpportunity) + .toHaveBeenCalledWith('006UN00000XamntYAB', expect.any(AbortSignal))) + await waitFor(() => expect((screen.getByLabelText('Customer') as HTMLInputElement).value) + .toBe('Novartis Pharmaceuticals')) + expect((screen.getByLabelText('Deal Close Date') as HTMLInputElement).value) + .toBe('2026-07-31') + expect(screen.getByText('AMR1')) + .toBeTruthy() + expect(screen.getByRole('link', { name: 'View in Salesforce' }) + .getAttribute('href')) + .toBe('https://topcoder.my.salesforce.com/006UN00000XamntYAB') + }) + + it('does not look up an incomplete Salesforce opportunity ID', async () => { + render( + + + , + ) + + fireEvent.change(screen.getByLabelText(/^Salesforce Opportunity ID/), { + target: { value: '006UN000' }, + }) + + await waitFor(() => expect(screen.getByText(/valid 15 or 18 character/)) + .toBeTruthy()) + expect(mockedFetchSalesforceOpportunity) + .not + .toHaveBeenCalled() + }) + }) 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 283fca6df..8cca35143 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 @@ -16,6 +16,7 @@ import { PROJECT_STATUS, PROJECT_STATUSES, } from '../../../../../lib/constants' +import { normalizeSmuValue } from '../../../../../lib/constants/showcase.constants' import { FormBillingAccountAutocomplete, FormCheckboxField, @@ -66,6 +67,7 @@ interface ProjectEditorFormProps { interface ProjectEditorFormValues { customer: string + salesforceOpportunityId: string smu: string smuOther: string dealCloseDate: string @@ -116,7 +118,8 @@ function getDefaultFormValues( : true, groups, name: projectDetail?.name || '', - smu: projectDetail?.details?.smu || '', + salesforceOpportunityId: projectDetail?.details?.salesforceOpportunityId || '', + smu: normalizeSmuValue(projectDetail?.details?.smu), smuOther: projectDetail?.details?.smuOther || '', status: isEdit ? (projectDetail?.status || PROJECT_STATUS.DRAFT) @@ -382,6 +385,7 @@ export const ProjectEditorForm: FC = (props: ProjectEdit const projectMetadata = { customer: formData.customer.trim(), dealCloseDate: formData.dealCloseDate, + salesforceOpportunityId: formData.salesforceOpportunityId.trim(), smu: formData.smu, smuOther: formData.smu === 'Others' ? formData.smuOther.trim() : '', } @@ -568,7 +572,7 @@ export const ProjectEditorForm: FC = (props: ProjectEdit
- +
diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.spec.tsx b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.spec.tsx index d5520db4d..eadb74eda 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.spec.tsx +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.spec.tsx @@ -73,6 +73,11 @@ jest.mock('../../../lib/components/form', () => { }, } }) +jest.mock('../../../lib/services/salesforce-opportunities.service', () => ({ + fetchSalesforceOpportunity: jest.fn(), + salesforceOpportunityErrorMessage: () => 'We could not reach Salesforce. Please try again.', +})) + jest.mock('../../../lib/services', () => ({ createProjectShowcasePost: jest.fn(), })) @@ -144,7 +149,8 @@ describe('Showcase creation', () => { customer: 'Updated customer', dealCloseDate: '2026-09-16', sendToWin: true, - smu: 'Europe', + // Legacy labels are upgraded to the Salesforce-aligned option on save. + smu: 'EURP', type: 'Open Innovation', }))) await waitFor(() => expect(refreshProject) diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index 7633cf5d7..551947b9c 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -71,6 +71,7 @@ import type { } from '../../../lib/models' import { ProjectMetadataFields } from '../../../lib/components/form/ProjectMetadataFields' import { + normalizeSmuValue, SHOWCASE_CURRENT_STATUS_VALUES, SHOWCASE_TYPE_VALUES, } from '../../../lib/constants/showcase.constants' @@ -250,7 +251,7 @@ function mapPostToFormData( })) || [], owner: post?.owner || '', sendToWin: post?.sendToWin === true, - smu: metadata?.smu || '', + smu: normalizeSmuValue(metadata?.smu), smuOther: metadata?.smuOther || '', title: post?.title || '', type: post?.type || '',