Skip to content
Open
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
23 changes: 12 additions & 11 deletions src/apps/work/src/lib/schemas/engagement-editor.schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function createAssignmentDetails(
durationMonths: '3',
memberHandle,
ratePerHour: '75',
standardHoursPerDay: '8',
standardHoursPerWeek: '40',
startDate: '2026-04-15T00:00:00.000Z',
}
Expand All @@ -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({
Expand Down
72 changes: 23 additions & 49 deletions src/apps/work/src/lib/schemas/engagement-editor.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,46 +135,8 @@ export const engagementEditorSchema: yup.ObjectSchema<EngagementEditorSchemaData
assignedMemberHandles: yup
.array()
.of(yup.string()
.required())
.when('isPrivate', {
is: true,
otherwise: schema => 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()
Expand All @@ -185,33 +147,45 @@ export const engagementEditorSchema: yup.ObjectSchema<EngagementEditorSchemaData
'private-assignment-details',
'Assignment details are required for the assigned member.',
function validateAssignmentDetails(value: EngagementEditorSchemaAssignmentDetails[] | undefined) {
const normalizedHandles = Array.isArray(this.parent.assignedMemberHandles)
const normalizedHandles: string[] = Array.isArray(this.parent.assignedMemberHandles)
? this.parent.assignedMemberHandles.map(normalizeHandle)
: []
const requiredMemberCount = toPositiveInteger(this.parent.requiredMemberCount)
|| normalizedHandles.filter(Boolean).length
const requiredHandles = normalizedHandles.slice(0, requiredMemberCount)

if (!requiredHandles.length || !requiredHandles.every(Boolean)) {
const visibleHandles: string[] = requiredMemberCount === undefined
? normalizedHandles
: normalizedHandles.slice(0, requiredMemberCount)
const assignedHandleEntries: Array<{
index: number
memberHandle: string
}> = visibleHandles
.map((memberHandle: string, index: number) => ({
index,
memberHandle,
}))
.filter(entry => Boolean(entry.memberHandle))

if (assignedHandleEntries.length < 1) {
return true
}

const assignmentDetails = Array.isArray(value)
? value
: []

const hasCompleteDetails = requiredHandles.every((memberHandle: string, index: number) => (
hasCompleteAssignmentDetails(assignmentDetails[index], memberHandle)
const hasCompleteDetails = assignedHandleEntries.every(entry => (
hasCompleteAssignmentDetails(assignmentDetails[entry.index], entry.memberHandle)
))

if (hasCompleteDetails) {
return true
}

return this.createError({
message: requiredMemberCount === 1
message: assignedHandleEntries.length === 1
? 'Assignment details are required for the assigned member.'
: `Assignment details are required for all ${requiredMemberCount} assigned members.`,
: `Assignment details are required for all ${
assignedHandleEntries.length
} assigned members.`,
})
},
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,111 @@ describe('EngagementEditorForm', () => {
}))
})

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(
<MemoryRouter>
<EngagementEditorForm
engagement={{
anticipatedStart: 'Immediate',
assignedMemberHandles: ['completed_member'],
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}
isEditMode
projectId='123'
/>
</MemoryRouter>,
)

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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''
),
}))

Expand Down Expand Up @@ -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',
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ export const EngagementPrivateSection: FC<EngagementPrivateSectionProps> = (
setActiveAssignmentIndex(index)
}}
placeholder='Search user handle'
required
valueField='handle'
/>
)}
Expand Down
Loading