From f657aba0d7a14b127860cb4950ad54b83815f7db Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Jul 2026 10:53:15 +1000 Subject: [PATCH] PM-5503: Support multi-status engagement filter What was broken The work app engagement list only offered a single-select Engagement Status dropdown with an "All" option, so users could not filter by multiple statuses or clear back to all selected statuses. Root cause Engagement list filters and engagement fetch filters modeled status as one string, and the fetch path serialized only one status value. What was changed Replaced the status "All" option with a clearable multi-select that defaults to all statuses selected. Clearing the selection restores the default unfiltered state. Added multi-status fetch fan-out so the UI supports multiple selected statuses while the current engagements API still accepts only one status per request. Any added/updated tests Added EngagementsFilter coverage for the default all-selected multi-select and clear behavior. Updated engagement list and service specs to cover selected status arrays and multi-status API fan-out. --- .../EngagementsFilter.spec.tsx | 140 ++++++++++++++++++ .../EngagementsFilter/EngagementsFilter.tsx | 64 +++++--- .../work/src/lib/models/Engagement.model.ts | 2 +- .../lib/services/engagements.service.spec.ts | 65 +++++++- .../src/lib/services/engagements.service.ts | 57 ++++++- .../EngagementsListPage.spec.tsx | 40 ++++- 6 files changed, 340 insertions(+), 28 deletions(-) create mode 100644 src/apps/work/src/lib/components/EngagementsFilter/EngagementsFilter.spec.tsx 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: [