Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, react/jsx-no-bind */
import {
fireEvent,
render,
screen,
} from '@testing-library/react'

import {
EngagementsFilter,
} from './EngagementsFilter'

const mockEngagementStatuses = [
'Open',
'Active',
'On Hold',
'Cancelled',
'Closed',
] as const

jest.mock('../../constants', () => ({
ENGAGEMENT_STATUSES: [
'Open',
'Active',
'On Hold',
'Cancelled',
'Closed',
],
}))
jest.mock('react-select', () => ({
__esModule: true,
default: (props: {
inputId: string
isClearable?: boolean
isMulti?: boolean
onChange: (value: Array<{ label: string; value: string }>) => void
options: Array<{ label: string; value: string }>
value?: Array<{ label: string; value: string }> | { label: string; value: string }
}) => {
const values = Array.isArray(props.value)
? props.value
: props.value
? [props.value]
: []
const isStatusSelect = props.inputId === 'work-engagements-status'

return (
<div data-testid={props.inputId}>
<div data-testid={`${props.inputId}-is-clearable`}>
{String(!!props.isClearable)}
</div>
<div data-testid={`${props.inputId}-is-multi`}>
{String(!!props.isMulti)}
</div>
<div data-testid={`${props.inputId}-options`}>
{props.options.map(option => option.label)
.join('|')}
</div>
<div data-testid={`${props.inputId}-value`}>
{values.map(option => option.label)
.join('|')}
</div>
{isStatusSelect && (
<>
<button
type='button'
onClick={() => props.onChange([
{
label: 'Open',
value: 'Open',
},
{
label: 'Active',
value: 'Active',
},
])}
>
Select Open and Active
</button>
<button
type='button'
onClick={() => props.onChange([])}
>
Clear status
</button>
</>
)}
</div>
)
},
}))
jest.mock('~/libs/ui', () => ({
IconOutline: {
SearchIcon: () => <span>search-icon</span>,
},
}), {
virtual: true,
})

describe('EngagementsFilter', () => {
it('defaults the status multi-select to all engagement statuses without an All option', () => {
render(
<EngagementsFilter
filters={{}}
onFiltersChange={jest.fn()}
/>,
)

expect(screen.getByTestId('work-engagements-status-is-multi').textContent)
.toBe('true')
expect(screen.getByTestId('work-engagements-status-is-clearable').textContent)
.toBe('true')
expect(screen.getByTestId('work-engagements-status-options').textContent)
.toBe(mockEngagementStatuses.join('|'))
expect(screen.getByTestId('work-engagements-status-value').textContent)
.toBe(mockEngagementStatuses.join('|'))
})

it('stores selected statuses and resets to the default when cleared', () => {
const handleFiltersChange = jest.fn()

render(
<EngagementsFilter
filters={{}}
onFiltersChange={handleFiltersChange}
/>,
)

fireEvent.click(screen.getByRole('button', { name: 'Select Open and Active' }))
fireEvent.click(screen.getByRole('button', { name: 'Clear status' }))

expect(handleFiltersChange)
.toHaveBeenNthCalledWith(1, {
status: ['Open', 'Active'],
})
expect(handleFiltersChange)
.toHaveBeenNthCalledWith(2, {
status: undefined,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
useRef,
useState,
} from 'react'
import Select, { SingleValue } from 'react-select'
import Select, { MultiValue, SingleValue } from 'react-select'

import {
IconOutline,
Expand All @@ -27,7 +27,7 @@ export interface EngagementsListFilters {
projectName?: string
sortBy?: string
sortOrder?: 'asc' | 'desc'
status?: string
status?: string[]
title?: string
visibility?: 'private' | 'public'
}
Expand All @@ -54,16 +54,34 @@ const VISIBILITY_OPTIONS: SelectOption[] = [
]

function getStatusOptions(): SelectOption[] {
return [
{
label: 'All',
value: 'all',
},
...ENGAGEMENT_STATUSES.map(status => ({
label: status,
value: status,
})),
]
return ENGAGEMENT_STATUSES.map(status => ({
label: status,
value: status,
}))
}

/**
* Resolves the selected status options for the engagement status filter.
*
* The ticket default treats an empty filter value as all engagement statuses
* selected, so clearing the multi-select returns the control to the default
* unfiltered state.
*
* @param statusOptions available engagement status select options.
* @param selectedStatuses selected status labels stored in the page filters.
* @returns selected status options, or every status option for the default state.
*/
function getSelectedStatusOptions(
statusOptions: SelectOption[],
selectedStatuses?: string[],
): SelectOption[] {
if (!selectedStatuses?.length) {
return statusOptions
}

const selectedStatusValues = new Set(selectedStatuses)

return statusOptions.filter(option => selectedStatusValues.has(option.value))
}

export const EngagementsFilter: FC<EngagementsFilterProps> = (props: EngagementsFilterProps) => {
Expand Down Expand Up @@ -114,8 +132,8 @@ export const EngagementsFilter: FC<EngagementsFilterProps> = (props: Engagements

const statusOptions = useMemo<SelectOption[]>(() => getStatusOptions(), [])

const selectedStatus = useMemo(
() => statusOptions.find(option => option.value === (filters.status || 'all')),
const selectedStatus = useMemo<SelectOption[]>(
() => getSelectedStatusOptions(statusOptions, filters.status),
[filters.status, statusOptions],
)

Expand All @@ -132,12 +150,16 @@ export const EngagementsFilter: FC<EngagementsFilterProps> = (props: Engagements
setProjectNameInput(event.target.value)
}

function handleStatusChange(nextOption: SingleValue<SelectOption>): void {
function handleStatusChange(nextOptions: MultiValue<SelectOption>): void {
const selectedStatuses = Array.from(nextOptions || [])
.map(option => option.value)
.filter(Boolean)

onFiltersChange({
...filters,
status: nextOption?.value === 'all'
status: selectedStatuses.length === 0 || selectedStatuses.length === statusOptions.length
? undefined
: nextOption?.value,
: selectedStatuses,
})
}

Expand Down Expand Up @@ -188,20 +210,22 @@ export const EngagementsFilter: FC<EngagementsFilterProps> = (props: Engagements

<div className={styles.filterField}>
<label htmlFor='work-engagements-status'>Engagement Status</label>
<Select
<Select<SelectOption, true>
inputId='work-engagements-status'
className='react-select-container'
classNamePrefix='select'
options={statusOptions}
value={selectedStatus}
onChange={handleStatusChange}
isClearable={false}
closeMenuOnSelect={false}
isClearable
isMulti
/>
</div>

<div className={styles.filterField}>
<label htmlFor='work-engagements-visibility'>Visibility</label>
<Select
<Select<SelectOption, false>
inputId='work-engagements-visibility'
className='react-select-container'
classNamePrefix='select'
Expand Down
2 changes: 1 addition & 1 deletion src/apps/work/src/lib/models/Engagement.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface EngagementFilters {
projectIds?: Array<number | string>
sortBy?: string
sortOrder?: 'asc' | 'desc'
status?: string
status?: string | string[]
timezones?: string[]
title?: string
}
Expand Down
65 changes: 62 additions & 3 deletions src/apps/work/src/lib/services/engagements.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import { xhrGetPaginatedAsync } from '~/libs/core'

import {
normalizeEngagement,
toEngagementStatusApi,
} from '../utils'

import { fetchEngagements } from './engagements.service'

jest.mock('~/config', () => ({
Expand Down Expand Up @@ -49,12 +54,18 @@ jest.mock('./projects.service', () => ({
}))

describe('fetchEngagements', () => {
const mockedGetPaginatedAsync = xhrGetPaginatedAsync as jest.Mock
const mockedNormalizeEngagement = normalizeEngagement as jest.Mock
const mockedToEngagementStatusApi = toEngagementStatusApi as jest.Mock

beforeEach(() => {
jest.clearAllMocks()
mockedGetPaginatedAsync.mockReset()
mockedNormalizeEngagement.mockImplementation((engagement: unknown) => engagement)
mockedToEngagementStatusApi.mockImplementation((value?: string) => value || '')
})

it('serializes projectIds as repeated query parameters', async () => {
const mockedGetPaginatedAsync = xhrGetPaginatedAsync as jest.Mock
const expectedUrl = 'https://example.com/engagements/engagements'
+ '?includePrivate=true&projectIds=200&projectIds=300&page=1&perPage=20'

Expand All @@ -80,9 +91,57 @@ describe('fetchEngagements', () => {
)
})

it('returns an empty result without calling the API when projectIds is empty', async () => {
const mockedGetPaginatedAsync = xhrGetPaginatedAsync as jest.Mock
it('fetches each selected engagement status with a single-status API request', async () => {
mockedGetPaginatedAsync
.mockResolvedValueOnce({
data: [{ id: 'open-engagement' }],
page: 1,
perPage: 20,
total: 1,
totalPages: 1,
})
.mockResolvedValueOnce({
data: [{ id: 'active-engagement' }],
page: 1,
perPage: 20,
total: 1,
totalPages: 1,
})

const result = await fetchEngagements({
includePrivate: true,
status: ['Open', 'Active'],
}, {
page: 1,
perPage: 20,
})

expect(mockedGetPaginatedAsync)
.toHaveBeenNthCalledWith(
1,
'https://example.com/engagements/engagements?status=Open&includePrivate=true&page=1&perPage=20',
)
expect(mockedGetPaginatedAsync)
.toHaveBeenNthCalledWith(
2,
'https://example.com/engagements/engagements?status=Active&includePrivate=true&page=1&perPage=20',
)
expect(result)
.toEqual({
data: [
expect.objectContaining({ id: 'open-engagement' }),
expect.objectContaining({ id: 'active-engagement' }),
],
metadata: {
page: 1,
perPage: 20,
total: 2,
totalPages: 1,
},
})
})

it('returns an empty result without calling the API when projectIds is empty', async () => {
const result = await fetchEngagements({
projectIds: [],
}, {
Expand Down
Loading
Loading