From 7fee8acb2a95a205f30e81674a1b96cb71bed1ef Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Jul 2026 14:53:40 +1000 Subject: [PATCH] PM-5504: Allow private engagements without assignees What was broken The work app engagement editor still required every private engagement member slot to have an assigned handle. After all assignments were completed and no active assignments remained, saving a private engagement as closed failed in the UI before the API request was sent. Root cause The previous backend fix allowed private engagements without active assignments, but the platform-ui Yup schema still enforced assignedMemberHandles for each required private member slot and the private assignment input was still marked required. What was changed Relaxed private assignment validation so blank member slots are allowed while preserving assignment-detail validation for any selected member handle. Removed the required marker from optional private assignment inputs. Any added/updated tests Updated engagement editor schema tests for private engagements with no assignees and with fewer assigned members than requiredMemberCount. Added form coverage that saves a private engagement with only terminal assignments without sending assignedMemberHandles or assignmentDetails. Updated the private section test mock to match the current assignment helper imports. --- .../schemas/engagement-editor.schema.spec.ts | 23 ++-- .../lib/schemas/engagement-editor.schema.ts | 72 ++++-------- .../components/EngagementEditorForm.spec.tsx | 105 ++++++++++++++++++ .../EngagementPrivateSection.spec.tsx | 6 +- .../components/EngagementPrivateSection.tsx | 1 - 5 files changed, 144 insertions(+), 63 deletions(-) diff --git a/src/apps/work/src/lib/schemas/engagement-editor.schema.spec.ts b/src/apps/work/src/lib/schemas/engagement-editor.schema.spec.ts index 1c9de99a2..da3ec4949 100644 --- a/src/apps/work/src/lib/schemas/engagement-editor.schema.spec.ts +++ b/src/apps/work/src/lib/schemas/engagement-editor.schema.spec.ts @@ -55,6 +55,7 @@ function createAssignmentDetails( durationMonths: '3', memberHandle, ratePerHour: '75', + standardHoursPerDay: '8', standardHoursPerWeek: '40', startDate: '2026-04-15T00:00:00.000Z', } @@ -78,28 +79,28 @@ describe('engagementEditorSchema', () => { ) }) - it('rejects private engagements when not all required member slots are assigned', async () => { - const validationMessages = await getValidationMessages({ + it('accepts private engagements without assigned members', async () => { + await expect(engagementEditorSchema.validate({ ...createValidFormValues(), - assignedMemberHandles: ['testaws1'], - assignmentDetails: [createAssignmentDetails('testaws1')], + assignedMemberHandles: [], + assignmentDetails: [], isPrivate: true, requiredMemberCount: 2, + }, { + abortEarly: false, + })).resolves.toMatchObject({ + assignedMemberHandles: [], + isPrivate: true, }) - - expect(validationMessages) - .toContain( - 'All 2 member assignments are required for private engagements', - ) }) - it('accepts private engagements with complete assignment details for each required member', async () => { + it('accepts private engagements with complete assignment details for assigned members', async () => { await expect(engagementEditorSchema.validate({ ...createValidFormValues(), assignedMemberHandles: ['testaws1'], assignmentDetails: [createAssignmentDetails('testaws1')], isPrivate: true, - requiredMemberCount: 1, + requiredMemberCount: 2, }, { abortEarly: false, })).resolves.toMatchObject({ diff --git a/src/apps/work/src/lib/schemas/engagement-editor.schema.ts b/src/apps/work/src/lib/schemas/engagement-editor.schema.ts index 27c861fae..f6bc2a79d 100644 --- a/src/apps/work/src/lib/schemas/engagement-editor.schema.ts +++ b/src/apps/work/src/lib/schemas/engagement-editor.schema.ts @@ -135,46 +135,8 @@ export const engagementEditorSchema: yup.ObjectSchema schema.optional(), - then: schema => schema.test( - 'private-assignment-count', - 'Assign to Member is required', - function validateAssignedMembers(value: unknown[] | undefined) { - const requiredMemberCount = toPositiveInteger(this.parent.requiredMemberCount) - const normalizedHandles = Array.isArray(value) - ? value.map(normalizeHandle) - : [] - - if (requiredMemberCount === undefined) { - if (normalizedHandles.some(Boolean)) { - return true - } - - return this.createError({ - message: 'Assign to Member is required', - }) - } - - const requiredHandles = normalizedHandles.slice(0, requiredMemberCount) - - if ( - requiredHandles.length === requiredMemberCount - && requiredHandles.every(Boolean) - ) { - return true - } - - return this.createError({ - message: requiredMemberCount === 1 - ? 'Assign to Member is required' - : `All ${requiredMemberCount} member assignments are required for private engagements`, - }) - }, - ), - }), + .defined()) + .optional(), assignmentDetails: yup .array() .optional() @@ -185,14 +147,24 @@ export const engagementEditorSchema: yup.ObjectSchema = visibleHandles + .map((memberHandle: string, index: number) => ({ + index, + memberHandle, + })) + .filter(entry => Boolean(entry.memberHandle)) + + if (assignedHandleEntries.length < 1) { return true } @@ -200,8 +172,8 @@ export const engagementEditorSchema: yup.ObjectSchema ( - hasCompleteAssignmentDetails(assignmentDetails[index], memberHandle) + const hasCompleteDetails = assignedHandleEntries.every(entry => ( + hasCompleteAssignmentDetails(assignmentDetails[entry.index], entry.memberHandle) )) if (hasCompleteDetails) { @@ -209,9 +181,11 @@ export const engagementEditorSchema: yup.ObjectSchema { })) }) + it('saves a private engagement with only terminal assignments without member assignment payload', async () => { + const user = userEvent.setup() + const completedAssignment = { + agreementRate: '800', + durationMonths: 3, + endDate: '2026-05-31T00:00:00.000Z', + engagementId: 'engagement-completed', + id: 'assignment-completed', + memberHandle: 'completed_member', + memberId: '111', + ratePerHour: '20', + standardHoursPerWeek: 40, + startDate: '2026-05-01T00:00:00.000Z', + status: 'COMPLETED', + termsAccepted: true, + } + + mockedUpdateEngagement.mockResolvedValue({ + anticipatedStart: 'Immediate', + assignedMemberHandles: [], + assignments: [completedAssignment], + compensationRange: '', + countries: ['US'], + createdAt: '', + description: 'Completed engagement description', + durationWeeks: 4, + id: 'engagement-completed', + isPrivate: true, + projectId: '123', + requiredMemberCount: 2, + role: 'SOFTWARE_DEVELOPER', + skills: [ + { + id: 'skill-1', + name: 'React', + }, + ], + status: 'Closed', + timezones: ['America/New_York'], + title: 'Completed private engagement', + updatedAt: '', + workload: 'FULL_TIME', + } as any) + + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Save Engagement' })) + + await waitFor(() => { + expect(mockedUpdateEngagement) + .toHaveBeenCalled() + }) + + const payload = mockedUpdateEngagement.mock.calls[0][1] as { + assignedMemberHandles?: string[] + assignmentDetails?: Array<{ memberHandle: string }> + requiredMemberCount?: number + status?: string + } + + expect(payload.status) + .toBe('Closed') + expect(payload.requiredMemberCount) + .toBe(2) + expect(payload) + .not + .toHaveProperty('assignedMemberHandles') + expect(payload) + .not + .toHaveProperty('assignmentDetails') + }) + it('redirects to the saved parent project engagements list after creating an engagement', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx index b2239fa33..d83f13c84 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.spec.tsx @@ -76,8 +76,9 @@ jest.mock('../../../../lib/components/form', () => { jest.mock('../../../../lib/utils', () => ({ formatAssignmentCurrency: (value?: string): string => (value ? `$${value}` : ''), - getAssignmentStandardHoursPerWeek: (detail: { standardHoursPerWeek?: string }): string => ( - detail.standardHoursPerWeek || '' + getAssignmentPaymentCycle: (): string => 'Weekly', + getAssignmentStandardHoursPerDay: (detail: { standardHoursPerDay?: string }): string => ( + detail.standardHoursPerDay || '' ), })) @@ -110,6 +111,7 @@ const defaultAssignmentDetails = { memberHandle: 'assigned_member', otherRemarks: 'active notes', ratePerHour: '20', + standardHoursPerDay: '8', standardHoursPerWeek: '40', startDate: '2026-05-01T00:00:00.000Z', } diff --git a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx index c2a357e4a..1778ccc08 100644 --- a/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx +++ b/src/apps/work/src/pages/engagements/EngagementEditorPage/components/EngagementPrivateSection.tsx @@ -260,7 +260,6 @@ export const EngagementPrivateSection: FC = ( setActiveAssignmentIndex(index) }} placeholder='Search user handle' - required valueField='handle' /> )}