diff --git a/docs/work-showcase-win.md b/docs/work-showcase-win.md new file mode 100644 index 000000000..964be2ed1 --- /dev/null +++ b/docs/work-showcase-win.md @@ -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. diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx index d20c1e18e..aa62cb286 100644 --- a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx @@ -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 { @@ -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 }> @@ -99,12 +101,37 @@ const ShowcasePostPreview: FC = props => {
-
+
+ {[ + ['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]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ {[ + ['The Challenge', data.challenge], + ['The Solution', data.content], + ['Business Impact Realised', data.businessImpact], + ].filter(([, value]) => !!value) + .map(([label, value]) => ( +
+
{label}
+
+
+ ))}
Media assets
@@ -155,7 +182,7 @@ const ShowcasePostPreview: FC = props => {
-
Challenges
+
Topcoder Challenge Launched
{data.challenges.length > 0 ? (
    {data.challenges.map(challenge => { diff --git a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx index cd71a5e41..b50db572f 100644 --- a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx +++ b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx @@ -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 = (props: FormSelectFieldProps) => { const formContext = useFormContext() const { @@ -206,7 +212,7 @@ export const FormSelectField: FC = (props: FormSelectField className={styles.select} classNamePrefix='challenge-select' defaultOptions={isAsync} - id={props.name} + inputId={props.name} isClearable={props.isClearable} isDisabled={props.disabled} isMulti={isMulti} diff --git a/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx new file mode 100644 index 000000000..5b35ea6cc --- /dev/null +++ b/src/apps/work/src/lib/components/form/ProjectMetadataFields.tsx @@ -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 = props => { + const formContext = useFormContext() + const smu = formContext.watch('smu') + + return ( + <> + + ({ label: value, value }))} + isClearable={!props.required} + required={props.required} + /> + {smu === 'Others' && ( + + )} + + + ) +} diff --git a/src/apps/work/src/lib/constants/showcase.constants.ts b/src/apps/work/src/lib/constants/showcase.constants.ts new file mode 100644 index 000000000..c7f4f98b3 --- /dev/null +++ b/src/apps/work/src/lib/constants/showcase.constants.ts @@ -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'] diff --git a/src/apps/work/src/lib/models/Project.model.ts b/src/apps/work/src/lib/models/Project.model.ts index 2ed1d07e5..7e440187a 100644 --- a/src/apps/work/src/lib/models/Project.model.ts +++ b/src/apps/work/src/lib/models/Project.model.ts @@ -8,7 +8,14 @@ import { TaasJob } from './TaasJob.model' export type ProjectStatus = typeof PROJECT_STATUSES[number]['value'] -export interface ProjectDetails extends Record { +export interface ProjectMetadata { + customer?: string + smu?: string + smuOther?: string + dealCloseDate?: string +} + +export interface ProjectDetails extends Record, ProjectMetadata { displayMemberPaymentDetailsToCopilots?: boolean taasDefinition?: { taasJobs?: TaasJob[] diff --git a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts index 9b49cd787..fa4d314fa 100644 --- a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts +++ b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts @@ -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 @@ -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 @@ -38,7 +49,7 @@ export interface ProjectShowcasePost { createdAt: string createdById: number createdByHandle?: string - publishedAt?: number + publishedAt?: number | string publishedBy?: string industries: ProjectShowcasePostTaxonomyItem[] categories: ProjectShowcasePostTaxonomyItem[] diff --git a/src/apps/work/src/lib/models/index.ts b/src/apps/work/src/lib/models/index.ts index cbd7b96dd..c42ac45cb 100644 --- a/src/apps/work/src/lib/models/index.ts +++ b/src/apps/work/src/lib/models/index.ts @@ -18,6 +18,7 @@ export type { ProjectPhaseProduct, Project, ProjectDetails, + ProjectMetadata, ProjectFilters, ProjectStatus as ProjectStatusValue, ProjectType, 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..cd4cd94a5 100644 --- a/src/apps/work/src/lib/schemas/project-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/project-editor.schema.ts @@ -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 @@ -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 { return yup .object({ + ...projectMetadataSchemaFields(false), billingAccountId: yup .string() .optional(), diff --git a/src/apps/work/src/lib/schemas/project-metadata.schema.ts b/src/apps/work/src/lib/schemas/project-metadata.schema.ts new file mode 100644 index 000000000..1a1167e9a --- /dev/null +++ b/src/apps/work/src/lib/schemas/project-metadata.schema.ts @@ -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 + dealCloseDate: yup.StringSchema + smu: yup.StringSchema + smuOther: yup.StringSchema +} { + 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'), + }), + } +} diff --git a/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts b/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts new file mode 100644 index 000000000..397137259 --- /dev/null +++ b/src/apps/work/src/lib/schemas/showcase-post.schema.spec.ts @@ -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) + }) + +}) diff --git a/src/apps/work/src/lib/schemas/showcase-post.schema.ts b/src/apps/work/src/lib/schemas/showcase-post.schema.ts new file mode 100644 index 000000000..4f0189f92 --- /dev/null +++ b/src/apps/work/src/lib/schemas/showcase-post.schema.ts @@ -0,0 +1,32 @@ +import * as yup from 'yup' + +import { SHOWCASE_CURRENT_STATUS_VALUES, SHOWCASE_TYPE_VALUES } from '../constants/showcase.constants' + +import { projectMetadataSchemaFields } from './project-metadata.schema' + +export const showcasePostSchema = yup.object({ + ...projectMetadataSchemaFields(true), + categoryIds: yup.array() + .of(yup.string()) + .min(1, 'Select at least one category.'), + content: yup.string() + .trim() + .required('The Solution is required.'), + currentStatus: yup.string() + .oneOf([...SHOWCASE_CURRENT_STATUS_VALUES, '']), + industryIds: yup.array() + .of(yup.string()) + .min(1, 'Select at least one industry.'), + keyWin: yup.string() + .trim() + .max(255), + owner: yup.string() + .trim() + .max(255), + title: yup.string() + .trim() + .required('Title is required.'), + type: yup.string() + .oneOf(SHOWCASE_TYPE_VALUES) + .required('Type is required.'), +}) diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index 84f5f6c2d..62f825691 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -18,6 +18,7 @@ import type { ProjectShowcasePostIndustry, ProjectShowcasePostMedia, ProjectShowcasePostTaxonomyItem, + ShowcaseMetadata, } from '../models' import { fetchMembersByUserIds } from './members.service' @@ -102,6 +103,12 @@ function buildProjectShowcasePostsUrl( return `${urlBase}?${query.toString()}` } +/** + * Fetches a paginated showcase list including shared metadata and owner values. + * @param params Project, pagination, sorting and taxonomy filters. + * @returns Normalized posts and pagination data, enriched with creator handles. + * @throws A normalized error when a required API request fails. + */ export async function fetchProjectShowcasePosts( params: FetchProjectShowcasePostsParams, ): Promise { @@ -110,38 +117,9 @@ export async function fetchProjectShowcasePosts( buildProjectShowcasePostsUrl(params), ) - const posts = (response.data || []).map((post: any) => ({ - categories: Array.isArray(post.categories) - ? post.categories.map((category: any) => ({ - id: String(category.id), - name: String(category.name || ''), - })) - : [], - content: String(post.content || ''), - createdAt: String(post.createdAt || ''), - createdByHandle: post.createdByHandle !== undefined && post.createdByHandle !== null - ? String(post.createdByHandle) - : undefined, - createdById: Number(post.createdById || 0), - id: String(post.id), - industries: Array.isArray(post.industries) - ? post.industries.map((industry: any) => ({ - id: String(industry.id), - name: String(industry.name || ''), - })) - : [], - media: Array.isArray(post.media) - ? post.media.map((mediaItem: any) => ({ - id: String(mediaItem.id || ''), - type: String(mediaItem.type || ''), - url: String(mediaItem.url || ''), - })) - .filter((item: any) => item.url) - : [], - projectId: String(post.projectId || ''), - status: String(post.status || ''), - title: String(post.title || ''), - })) + const posts = (response.data || []) + .map(normalizeProjectShowcasePost) + .filter((post: ProjectShowcasePostDetails | undefined): post is ProjectShowcasePostDetails => !!post) const creatorUserIds: string[] = Array.from(new Set( posts @@ -225,6 +203,12 @@ function normalizeProjectShowcasePostMediaArray(value: unknown): ProjectShowcase .filter(item => item.url) } +/** + * Normalizes API post data and the shared project metadata for forms and previews. + * @param value Raw projects API response. + * @returns A showcase post or undefined when the response is not an object. + * @throws Does not throw for valid JSON responses. + */ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetails | undefined { if (typeof value !== 'object' || value === null) { return undefined @@ -233,7 +217,9 @@ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetail const post = value as Record return { + businessImpact: normalizeStringOrUndefined(post.businessImpact), categories: normalizeTaxonomyArray(post.categories), + challenge: normalizeStringOrUndefined(post.challenge), challengeIds: Array.isArray(post.challengeIds) ? post.challengeIds.map((item: any) => String(item)) : [], @@ -242,13 +228,24 @@ function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePostDetail createdAt: normalizeString(post.createdAt), createdByHandle: normalizeStringOrUndefined(post.createdByHandle), createdById: Number(post.createdById || 0), + currentStatus: normalizeStringOrUndefined(post.currentStatus), + customer: normalizeStringOrUndefined(post.customer), + dealCloseDate: normalizeStringOrUndefined(post.dealCloseDate), id: normalizeString(post.id), industries: normalizeTaxonomyArray(post.industries), + keyWin: normalizeStringOrUndefined(post.keyWin), media: normalizeProjectShowcasePostMediaArray(post.media), + owner: normalizeStringOrUndefined(post.owner), projectId: normalizeStringOrUndefined(post.projectId), projectTitle: String(post.projectTitle || ''), + publishedAt: normalizeStringOrUndefined(post.publishedAt), + publishedBy: normalizeStringOrUndefined(post.publishedBy), + sendToWin: post.sendToWin === true, + smu: normalizeStringOrUndefined(post.smu), + smuOther: normalizeStringOrUndefined(post.smuOther), status: normalizeString(post.status), title: normalizeString(post.title), + type: normalizeStringOrUndefined(post.type), } } @@ -272,9 +269,16 @@ export async function fetchProjectShowcasePost( } } +/** + * Creates a showcase and saves its shared project metadata atomically. + * @param projectId Project owning the showcase post. + * @param payload Showcase fields, project metadata, and WIN opt-in. + * @returns The saved showcase post. + * @throws A normalized API error when validation or saving fails. + */ export async function createProjectShowcasePost( projectId: string, - payload: { + payload: ShowcaseMetadata & { title: string content: string industryIds: string[] @@ -300,10 +304,18 @@ export async function createProjectShowcasePost( } } +/** + * Updates supplied post fields and shared project metadata; omitted fields stay unchanged. + * @param projectId Project owning the showcase post. + * @param postId Showcase post to update. + * @param payload Showcase fields, project metadata, and WIN opt-in. + * @returns The saved showcase post. + * @throws A normalized API error when validation or saving fails. + */ export async function updateProjectShowcasePost( projectId: string, postId: string, - payload: { + payload: ShowcaseMetadata & { title?: string content?: string industryIds?: string[] 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 c2b6a0d91..c628a8458 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 @@ -241,9 +241,9 @@ describe('ProjectEditorForm', () => { await waitFor(() => expect(mockedCreateProject) .toHaveBeenCalledWith(expect.objectContaining({ - details: { + details: expect.objectContaining({ displayMemberPaymentDetailsToCopilots: true, - }, + }), }))) }) @@ -285,4 +285,43 @@ describe('ProjectEditorForm', () => { expect(mockedUpdateProject.mock.calls[0]?.[1].billingAccountId) .toBeNull() }) + it('prefills shared metadata and saves changes while retaining other project details', async () => { + render( + + + , + ) + expect((screen.getByLabelText(/^Other SMU/) as HTMLInputElement).value) + .toBe('Custom') + fireEvent.change(screen.getByLabelText('Customer'), { target: { value: 'Updated' } }) + fireEvent.keyDown(screen.getByLabelText('SMU'), { code: 'ArrowDown', key: 'ArrowDown' }) + fireEvent.click(screen.getByText('Europe')) + 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: '', + }), + }))) + }) + }) 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..283fca6df 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 @@ -51,6 +51,7 @@ import { showErrorToast, showSuccessToast, } from '../../../../../lib/utils' +import { ProjectMetadataFields } from '../../../../../lib/components/form/ProjectMetadataFields' import styles from './ProjectEditorForm.module.scss' @@ -64,6 +65,10 @@ interface ProjectEditorFormProps { } interface ProjectEditorFormValues { + customer: string + smu: string + smuOther: string + dealCloseDate: string billingAccountId: string cancelReason: string description: string @@ -83,6 +88,13 @@ interface CurrentBillingAccountDetails { status: string } +/** + * Initializes project fields from the project record, including shared showcase metadata. + * @param isEdit Whether the form edits an existing project. + * @param projectDetail The loaded project, when editing. + * @returns Complete form values with empty defaults for optional metadata. + * @throws Does not throw. + */ function getDefaultFormValues( isEdit: boolean, projectDetail?: Project, @@ -96,12 +108,16 @@ function getDefaultFormValues( return { billingAccountId, cancelReason: projectDetail?.cancelReason || '', + customer: projectDetail?.details?.customer || '', + dealCloseDate: projectDetail?.details?.dealCloseDate || '', description: projectDetail?.description || '', displayMemberPaymentDetailsToCopilots: isEdit ? projectDetail?.details?.displayMemberPaymentDetailsToCopilots === true : true, groups, name: projectDetail?.name || '', + smu: projectDetail?.details?.smu || '', + smuOther: projectDetail?.details?.smuOther || '', status: isEdit ? (projectDetail?.status || PROJECT_STATUS.DRAFT) : PROJECT_STATUS.DRAFT, @@ -363,12 +379,19 @@ export const ProjectEditorForm: FC = (props: ProjectEdit ? [termsValue] : undefined const groups = normalizeStringList(formData.groups) + const projectMetadata = { + customer: formData.customer.trim(), + dealCloseDate: formData.dealCloseDate, + smu: formData.smu, + smuOther: formData.smu === 'Others' ? formData.smuOther.trim() : '', + } if (!props.isEdit) { const payload: CreateProjectPayload = { billingAccountId: normalizedBillingAccountId, description: formData.description, details: { + ...projectMetadata, displayMemberPaymentDetailsToCopilots: formData.displayMemberPaymentDetailsToCopilots, }, @@ -398,6 +421,7 @@ export const ProjectEditorForm: FC = (props: ProjectEdit description: formData.description, details: { ...(props.projectDetail.details || {}), + ...projectMetadata, displayMemberPaymentDetailsToCopilots: formData.displayMemberPaymentDetailsToCopilots, }, @@ -543,6 +567,10 @@ export const ProjectEditorForm: FC = (props: ProjectEdit />
+
+ +
+
({ AppSubdomain: { work: 'work' }, EnvironmentConfig: {} }), { virtual: true }) +jest.mock('filestack-js', () => ({ init: jest.fn() })) +jest.mock('react-router-dom', () => ({ useParams: () => ({ projectId: '123' }) })) +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: any): JSX.Element => (props.open ?
{props.children}
: <>), + Button: (props: any): JSX.Element => ( + + ), + LoadingSpinner: (): JSX.Element =>
, + useConfirmationModal: () => ({}), +}), { virtual: true }) +jest.mock('../../../lib/contexts', () => { + const React: typeof import('react') = jest.requireActual('react') + return { WorkAppContext: React.createContext({ isAdmin: true, loginUserInfo: { userId: 42 }, userRoles: [] }) } +}) +jest.mock('../../../lib/components', () => ({ + ErrorMessage: (props: any): JSX.Element =>
{props.message}
, + Pagination: (): JSX.Element =>
, + ProjectPageWrapper: (props: any): JSX.Element => ( +
+ {props.headerActions} + {props.children} +
+ ), + ProjectsShowcaseFilter: (): JSX.Element =>
, + ShowcasePostPreview: (): JSX.Element =>
, +})) +jest.mock('../../../lib/components/form', () => { + const forms: typeof import('react-hook-form') = jest.requireActual('react-hook-form') + return { + FormCheckboxField: (props: any): JSX.Element => { + const form = forms.useFormContext() + return + }, + FormMarkdownEditor: (props: any): JSX.Element => { + const form = forms.useFormContext() + return