From 6a25f44dd3befaa7547d3fb141544a932ff292cd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 18 Sep 2026 06:09:35 +1000 Subject: [PATCH] PM-6363: Import project details from a Salesforce opportunity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work app — Edit Project Details: - Adds a Salesforce Opportunity ID field. A valid id is looked up through projects-api-v6 and populates Customer (Subcontracting End Customer), SMU (Reporting SMU) and Deal Close Date, leaving fields Salesforce does not supply untouched. Reopening a linked project shows the link without overwriting details that were edited by hand afterwards. - Shows a "View in Salesforce" link once the opportunity resolves, opening the record in a new tab. Sales app: - Report cells carrying a Salesforce opportunity id render the opportunity name as a button that opens a popup showing the description first, then customer, SMU, close date and stage, plus a link to the record. The popup closes with its Close button or the X icon. SMU options are renamed to the Salesforce codes (APME, EURP, AMR1, AMR2) so the imported Reporting SMU matches the dropdown. Projects saved with the previous labels render and save as the current option. Co-Authored-By: Claude Opus 5 (1M context) --- src/apps/sales/README.md | 16 +- .../sales/src/OpportunityModal.module.scss | 60 +++++++ src/apps/sales/src/OpportunityModal.tsx | 117 ++++++++++++++ src/apps/sales/src/SalesPage.module.scss | 2 + src/apps/sales/src/SalesPage.spec.tsx | 79 ++++++++- src/apps/sales/src/SalesPage.tsx | 44 ++++- src/apps/sales/src/opportunity.service.ts | 44 +++++ src/apps/sales/src/sales.models.ts | 17 ++ .../ShowcasePostDetails.spec.tsx | 3 +- .../ShowcasePostDetails.tsx | 3 +- .../form/ProjectMetadataFields.module.scss | 22 +++ .../components/form/ProjectMetadataFields.tsx | 152 +++++++++++++++++- .../src/lib/constants/salesforce.constants.ts | 15 ++ .../src/lib/constants/showcase.constants.ts | 31 +++- src/apps/work/src/lib/models/Project.model.ts | 2 + .../lib/schemas/project-metadata.schema.ts | 15 +- src/apps/work/src/lib/services/index.ts | 1 + .../salesforce-opportunities.service.ts | 52 ++++++ .../ProjectEditorForm.spec.tsx | 110 ++++++++++++- .../ProjectEditorForm/ProjectEditorForm.tsx | 8 +- .../ProjectShowcasePage.spec.tsx | 8 +- .../ProjectShowcasePage.tsx | 3 +- 22 files changed, 779 insertions(+), 25 deletions(-) create mode 100644 src/apps/sales/src/OpportunityModal.module.scss create mode 100644 src/apps/sales/src/OpportunityModal.tsx create mode 100644 src/apps/sales/src/opportunity.service.ts create mode 100644 src/apps/work/src/lib/components/form/ProjectMetadataFields.module.scss create mode 100644 src/apps/work/src/lib/constants/salesforce.constants.ts create mode 100644 src/apps/work/src/lib/services/salesforce-opportunities.service.ts 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 || '',