diff --git a/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.spec.tsx b/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.spec.tsx new file mode 100644 index 000000000..c38f5f7f9 --- /dev/null +++ b/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.spec.tsx @@ -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 ( +
+
+ {String(!!props.isClearable)} +
+
+ {String(!!props.isMulti)} +
+
+ {props.options.map(option => option.label) + .join('|')} +
+
+ {values.map(option => option.label) + .join('|')} +
+ {isStatusSelect && ( + <> + + + + )} +
+ ) + }, +})) +jest.mock('~/libs/ui', () => ({ + IconOutline: { + SearchIcon: () => search-icon, + }, +}), { + virtual: true, +}) + +describe('EngagementsFilter', () => { + it('defaults the status multi-select to all engagement statuses without an All option', () => { + render( + , + ) + + 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( + , + ) + + 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, + }) + }) +}) diff --git a/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.tsx b/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.tsx index 99f91e740..33f7458c6 100644 --- a/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.tsx +++ b/src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react' -import Select, { SingleValue } from 'react-select' +import Select, { MultiValue, SingleValue } from 'react-select' import { IconOutline, @@ -27,7 +27,7 @@ export interface EngagementsListFilters { projectName?: string sortBy?: string sortOrder?: 'asc' | 'desc' - status?: string + status?: string[] title?: string visibility?: 'private' | 'public' } @@ -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 = (props: EngagementsFilterProps) => { @@ -114,8 +132,8 @@ export const EngagementsFilter: FC = (props: Engagements const statusOptions = useMemo(() => getStatusOptions(), []) - const selectedStatus = useMemo( - () => statusOptions.find(option => option.value === (filters.status || 'all')), + const selectedStatus = useMemo( + () => getSelectedStatusOptions(statusOptions, filters.status), [filters.status, statusOptions], ) @@ -132,12 +150,16 @@ export const EngagementsFilter: FC = (props: Engagements setProjectNameInput(event.target.value) } - function handleStatusChange(nextOption: SingleValue): void { + function handleStatusChange(nextOptions: MultiValue): 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, }) } @@ -188,20 +210,22 @@ export const EngagementsFilter: FC = (props: Engagements
- inputId='work-engagements-visibility' className='react-select-container' classNamePrefix='select' diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index 5d9809b94..7de557455 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -96,7 +96,7 @@ export interface EngagementFilters { projectIds?: Array sortBy?: string sortOrder?: 'asc' | 'desc' - status?: string + status?: string | string[] timezones?: string[] title?: string } diff --git a/src/apps/work/src/lib/services/engagements.service.spec.ts b/src/apps/work/src/lib/services/engagements.service.spec.ts index 362462c0e..a2dcc7f0a 100644 --- a/src/apps/work/src/lib/services/engagements.service.spec.ts +++ b/src/apps/work/src/lib/services/engagements.service.spec.ts @@ -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', () => ({ @@ -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' @@ -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: [], }, { diff --git a/src/apps/work/src/lib/services/engagements.service.ts b/src/apps/work/src/lib/services/engagements.service.ts index 4f8128065..076d99bd0 100644 --- a/src/apps/work/src/lib/services/engagements.service.ts +++ b/src/apps/work/src/lib/services/engagements.service.ts @@ -203,8 +203,9 @@ function createQuery( query.set('projectId', String(filters.projectId)) } - if (filters.status && filters.status !== 'all') { - query.set('status', toEngagementStatusApi(filters.status)) + const statuses = normalizeStatusFilters(filters.status) + if (statuses.length === 1) { + query.set('status', toEngagementStatusApi(statuses[0])) } if (filters.title) { @@ -262,6 +263,26 @@ function createQuery( return query.toString() } +/** + * Normalizes engagement status filters before API requests are built. + * + * The work UI can hold one or more selected status labels, while the current + * engagements API only accepts one status per request. This helper trims empty + * entries and removes legacy `all` sentinels before request fan-out. + * + * @param status single status value or selected status labels from the list filter. + * @returns non-empty status labels that should be applied to API requests. + */ +function normalizeStatusFilters(status?: string | string[]): string[] { + const statuses = Array.isArray(status) ? status : [status] + const normalizedStatuses = statuses + .map(value => String(value || '') + .trim()) + .filter(value => !!value && value.toLowerCase() !== 'all') + + return Array.from(new Set(normalizedStatuses)) +} + function serializeEngagementPayload(data: EngagementUpsertData): Record { const payload: Record = {} @@ -606,9 +627,41 @@ export async function fetchEngagements( } } + const normalizedStatuses = normalizeStatusFilters(filters.status) + + if (normalizedStatuses.length > 1) { + const responses = await Promise.all( + normalizedStatuses.map(status => fetchEngagements({ + ...filters, + projectIds: normalizedProjectIds, + status, + }, params)), + ) + const data = responses.flatMap(response => response.data) + const total = responses.reduce( + (sum, response) => sum + (response.metadata.total || response.data.length), + 0, + ) + const totalPages = Math.max( + 0, + ...responses.map(response => response.metadata.totalPages || 0), + ) + + return { + data, + metadata: { + page: params.page || 1, + perPage: params.perPage || responses[0]?.metadata.perPage || 0, + total, + totalPages, + }, + } + } + const query = createQuery({ ...filters, projectIds: normalizedProjectIds, + status: normalizedStatuses[0], }, params) const url = query ? `${ENGAGEMENTS_API_URL}?${query}` diff --git a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx index 8604763d9..c9e3b1722 100644 --- a/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx +++ b/src/apps/work/src/pages/engagements/EngagementsListPage/EngagementsListPage.spec.tsx @@ -104,8 +104,8 @@ jest.mock('../../../lib/components', () => ({
), EngagementsFilter: function MockEngagementsFilter(props: { - filters: { title?: string } - onFiltersChange: (nextFilters: { title?: string }) => void + filters: { status?: string[]; title?: string } + onFiltersChange: (nextFilters: { status?: string[]; title?: string }) => void }) { function handleApplyTitleFilter(): void { props.onFiltersChange({ @@ -114,6 +114,13 @@ jest.mock('../../../lib/components', () => ({ }) } + function handleApplyStatusFilter(): void { + props.onFiltersChange({ + ...props.filters, + status: ['Open', 'Active'], + }) + } + return (
+
) }, @@ -453,6 +466,29 @@ describe('EngagementsListPage', () => { }) }) + it('passes selected engagement statuses to engagement fetch requests', async () => { + renderPage('/engagements', '/engagements') + + fireEvent.click(screen.getByRole('button', { name: 'Apply status filter' })) + + await waitFor(() => { + expect(mockedUseFetchEngagements) + .toHaveBeenLastCalledWith( + undefined, + { + includePrivate: true, + projectId: undefined, + sortBy: 'createdAt', + sortOrder: 'desc', + status: ['Open', 'Active'], + }, + { + enabled: true, + }, + ) + }) + }) + it('renders the engagement view link without the legacy opportunities path segment', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [