Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/work-showcase-win.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Work showcase and WIN (PM-6329)

The project editor and showcase form share `project.details.customer`, `smu`,
`smuOther`, and `dealCloseDate`. New showcases start with the current project
values. Existing showcases load those values from the post API response. Saving
a showcase updates its content and these project fields in one projects API
transaction, then refreshes the cached project.

Project metadata is optional in the project editor and required when saving the
showcase form. SMU accepts APMEA, Europe, Americas1, Americas2, or Others. Others
requires a custom value; selecting a standard SMU clears the stored custom value.
Deal Close Date is a `YYYY-MM-DD` calendar date and is not converted between timezones.

The showcase form follows this order:

1. Title
2. Type (required: Open Innovation, Private POD Delivery, Flexi-Talent Supply, AI Data Licensing)
3. Customer / SMU / Deal Close Date
4. Industry/Sector
5. Category/Technology
6. The Challenge
7. The Solution (the existing `content` API field)
8. Business Impact Realised
9. Key Win
10. Post Media
11. Topcoder Challenge Launched
12. Current Status (Delivered, In Delivery, On-Hold, Planned)
13. Owner (a name or handle)

The Challenge, The Solution and Business Impact Realised use the same Markdown
editor and sanitized rich-text preview. Key Win and Owner are single-line strings
of up to 255 characters. Existing title, taxonomy and solution requirements remain.

**Send to WIN** is unchecked for new posts and retains the saved choice on edits.
Saving with the checkbox selected makes the record available to authorized callers
of `GET /v6/reports/WIN`. This is a pull integration: the success message confirms
availability to WIN. Validation and API failures keep the form open with an error.
Unchecking removes the post from the WIN report after saving. Archived posts and
posts belonging to deleted projects are excluded from the report.

## Deployment and verification

Deploy the PM-6329 projects API migration and application before enabling this UI;
deploy the reports API change for WIN callers. No new UI environment variables are needed.

Use `.nvmrc`, then `yarn lint`, `yarn run build`, and the tests for
`ProjectShowcasePage`, `ProjectEditorForm`, `project-editor.schema`, and
`showcase-post.schema`. Manually check that editing metadata in either form is
reflected when reopening the other form, and that opting in/out changes the WIN report.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { IconOutline } from '~/libs/ui'
import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text'
import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format'

import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model'

import styles from './ShowcasePostPreview.module.scss'

export interface ShowcasePostPreviewChallenge {
Expand All @@ -16,7 +18,7 @@ export interface ShowcasePostPreviewChallenge {
numOfRegistrants?: number
}

export interface ShowcasePostPreviewData {
export interface ShowcasePostPreviewData extends ShowcaseMetadata {
title: string
content: string
categories: Array<{ id: string; name: string }>
Expand Down Expand Up @@ -99,12 +101,37 @@ const ShowcasePostPreview: FC<ShowcasePostPreviewProps> = props => {

<div className={styles.bodyWrap}>
<div className={styles.body}>
<div
className={styles.htmlContent}
dangerouslySetInnerHTML={{
__html: renderRichTextToHtml(data.content || ''),
}}
/>
<dl>
{[
['Type', data.type],
['Customer', data.customer],
['SMU', data.smu === 'Others' ? data.smuOther : data.smu],
['Deal Close Date', data.dealCloseDate],
['Key Win', data.keyWin],
['Current Status', data.currentStatus],
['Owner', data.owner],
].filter(([, value]) => !!value)
.map(([label, value]) => (
<div key={label} className={styles.subTitleItem}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{[
['The Challenge', data.challenge],
['The Solution', data.content],
['Business Impact Realised', data.businessImpact],
].filter(([, value]) => !!value)
.map(([label, value]) => (
<section key={label} className={styles.section}>
<h5 className={styles.sectionTitle}>{label}</h5>
<div
className={styles.htmlContent}
dangerouslySetInnerHTML={{ __html: renderRichTextToHtml(value || '') }}
/>
</section>
))}

<section className={styles.section}>
<h5 className={styles.sectionTitle}>Media assets</h5>
Expand Down Expand Up @@ -155,7 +182,7 @@ const ShowcasePostPreview: FC<ShowcasePostPreviewProps> = props => {
</section>

<section className={styles.section}>
<h5 className={styles.sectionTitle}>Challenges</h5>
<h5 className={styles.sectionTitle}>Topcoder Challenge Launched</h5>
{data.challenges.length > 0 ? (
<ul className={styles.challengeList}>
{data.challenges.map(challenge => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ function getSelectComponent(
return Select
}

/**
* Renders a form select with its visible label bound to the focusable input.
* @param props Field name, label, choices, and optional async/multiple-value behavior.
* @returns A React Select control connected to the surrounding form.
* @throws Requires a parent FormProvider; option-loading errors propagate to the loader.
*/
export const FormSelectField: FC<FormSelectFieldProps> = (props: FormSelectFieldProps) => {
const formContext = useFormContext()
const {
Expand Down Expand Up @@ -206,7 +212,7 @@ export const FormSelectField: FC<FormSelectFieldProps> = (props: FormSelectField
className={styles.select}
classNamePrefix='challenge-select'
defaultOptions={isAsync}
id={props.name}
inputId={props.name}
isClearable={props.isClearable}
isDisabled={props.disabled}
isMulti={isMulti}
Expand Down
59 changes: 59 additions & 0 deletions src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FC } from 'react'
import { useFormContext } from 'react-hook-form'

import { SMU_VALUES } from '../../constants/showcase.constants'

import { FormSelectField } from './FormSelectField'
import { FormTextField } from './FormTextField'

interface ProjectMetadataFieldsProps {
className?: string
required?: boolean
}

/**
* 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.
* @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<ProjectMetadataFieldsProps> = props => {
const formContext = useFormContext()
const smu = formContext.watch('smu')

return (
<>
<FormTextField
className={props.className}
label='Customer'
name='customer'
maxLength={255}
required={props.required}
/>
<FormSelectField
className={props.className}
label='SMU'
name='smu'
options={SMU_VALUES.map(value => ({ label: value, value }))}
isClearable={!props.required}
required={props.required}
/>
{smu === 'Others' && (
<FormTextField
className={props.className}
label='Other SMU'
name='smuOther'
maxLength={255}
required
/>
)}
<FormTextField
className={props.className}
label='Deal Close Date'
name='dealCloseDate'
type='date'
required={props.required}
/>
</>
)
}
10 changes: 10 additions & 0 deletions src/apps/work/src/lib/constants/showcase.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const SMU_VALUES = ['APMEA', 'Europe', 'Americas1', 'Americas2', 'Others']

export const SHOWCASE_TYPE_VALUES = [
'Open Innovation',
'Private POD Delivery',
'Flexi-Talent Supply',
'AI Data Licensing',
]

export const SHOWCASE_CURRENT_STATUS_VALUES = ['Delivered', 'In Delivery', 'On-Hold', 'Planned']
9 changes: 8 additions & 1 deletion src/apps/work/src/lib/models/Project.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import { TaasJob } from './TaasJob.model'

export type ProjectStatus = typeof PROJECT_STATUSES[number]['value']

export interface ProjectDetails extends Record<string, unknown> {
export interface ProjectMetadata {
customer?: string
smu?: string
smuOther?: string
dealCloseDate?: string
}

export interface ProjectDetails extends Record<string, unknown>, ProjectMetadata {
displayMemberPaymentDetailsToCopilots?: boolean
taasDefinition?: {
taasJobs?: TaasJob[]
Expand Down
15 changes: 13 additions & 2 deletions src/apps/work/src/lib/models/ProjectShowcasePost.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SortOrder } from '../utils/sorting.utils'

import { PaginationModel } from './Pagination.model'
import { ProjectMetadata } from './Project.model'

export interface ProjectShowcasePostTaxonomyItem {
id: string
Expand Down Expand Up @@ -28,7 +29,17 @@ export interface ProjectShowcasePostChallengeMetadataItem {
countries: string[]
}

export interface ProjectShowcasePost {
export interface ShowcaseMetadata extends ProjectMetadata {
type?: string
challenge?: string
businessImpact?: string
keyWin?: string
currentStatus?: string
owner?: string
sendToWin?: boolean
}

export interface ProjectShowcasePost extends ShowcaseMetadata {
id: string
title: string
content?: string
Expand All @@ -38,7 +49,7 @@ export interface ProjectShowcasePost {
createdAt: string
createdById: number
createdByHandle?: string
publishedAt?: number
publishedAt?: number | string
publishedBy?: string
industries: ProjectShowcasePostTaxonomyItem[]
categories: ProjectShowcasePostTaxonomyItem[]
Expand Down
1 change: 1 addition & 0 deletions src/apps/work/src/lib/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
ProjectPhaseProduct,
Project,
ProjectDetails,
ProjectMetadata,
ProjectFilters,
ProjectStatus as ProjectStatusValue,
ProjectType,
Expand Down
13 changes: 12 additions & 1 deletion src/apps/work/src/lib/schemas/project-editor.schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as yup from 'yup'

import { PROJECT_STATUS } from '../constants'
import { ProjectMetadata } from '../models/Project.model'

export interface ProjectEditorSchemaData {
import { projectMetadataSchemaFields } from './project-metadata.schema'

export interface ProjectEditorSchemaData extends ProjectMetadata {
billingAccountId?: string
name: string
description: string
Expand All @@ -14,12 +17,20 @@ export interface ProjectEditorSchemaData {
groups?: string[]
}

/**
* Validates project edits and creation, including optional shared showcase metadata.
* @param isEdit Whether an existing project is being edited.
* @param canManage Whether the user can change project status.
* @returns The project form schema; validation rejects invalid or missing required values.
* @throws Does not throw when constructing the schema.
*/
export function createProjectEditorSchema(
isEdit: boolean,
canManage: boolean,
): yup.ObjectSchema<ProjectEditorSchemaData> {
return yup
.object({
...projectMetadataSchemaFields(false),
billingAccountId: yup
.string()
.optional(),
Expand Down
48 changes: 48 additions & 0 deletions src/apps/work/src/lib/schemas/project-metadata.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as yup from 'yup'

import { SMU_VALUES } from '../constants/showcase.constants'

/**
* Checks a date-only value without converting its calendar day to a local timezone.
* @param value Optional YYYY-MM-DD input from the project or showcase form.
* @returns True for empty optional values or an existing calendar date.
* @throws Does not throw.
*/
function isCalendarDate(value: string | undefined): boolean {
if (!value) return true
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
const date = new Date(`${value}T00:00:00.000Z`)
return Number.isFinite(date.getTime()) && date.toISOString()
.slice(0, 10) === value
}

/**
* Builds shared project metadata validation for the project editor and showcase form.
* @param required Whether Customer, SMU and Deal Close Date must be populated.
* @returns Yup fields; Other SMU is always required when Others is selected.
* @throws Does not throw; Yup reports invalid input when the form is validated.
*/
export function projectMetadataSchemaFields(required: boolean): {
customer: yup.StringSchema<string | undefined>
dealCloseDate: yup.StringSchema<string | undefined>
smu: yup.StringSchema<string | undefined>
smuOther: yup.StringSchema<string | undefined>
} {
const text = yup.string()
.trim()
.max(255)
const smu = yup.string()
.oneOf([...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(),
smu: required ? smu.required('SMU is required') : smu.optional(),
smuOther: text.when('smu', {
is: 'Others',
otherwise: schema => schema.optional(),
then: schema => schema.required('Other SMU is required'),
}),
}
}
32 changes: 32 additions & 0 deletions src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { showcasePostSchema } from './showcase-post.schema'

describe('Showcase metadata validation', () => {
const valid = {
categoryIds: ['1'],
content: 'The solution',
customer: 'Customer',
dealCloseDate: '2024-02-29',
industryIds: ['1'],
smu: 'Europe',
title: 'Title',
type: 'Open Innovation',
}

it('requires showcase metadata and accepts a real leap day', async () => {
await expect(showcasePostSchema.isValid(valid)).resolves.toBe(true)
for (const field of ['customer', 'dealCloseDate', 'smu', 'type']) {
// eslint-disable-next-line no-await-in-loop
await expect(showcasePostSchema.isValid({ ...valid, [field]: '' })).resolves.toBe(false)
}
})

it('requires the custom SMU only when Others is selected', async () => {
await expect(showcasePostSchema.isValid({ ...valid, smu: 'Others' })).resolves.toBe(false)
await expect(showcasePostSchema.isValid({ ...valid, smu: 'Others', smuOther: 'Custom' })).resolves.toBe(true)
})

it.each(['2026-02-29', '2026-04-31', 'not a date'])('rejects the invalid calendar date %s', async dealCloseDate => {
await expect(showcasePostSchema.isValid({ ...valid, dealCloseDate })).resolves.toBe(false)
})

})
Loading
Loading