From 002588625e850a691cc1c51c3107d325edfdf2b0 Mon Sep 17 00:00:00 2001 From: Ehsan Rezaei Date: Thu, 20 Aug 2026 16:06:03 +0200 Subject: [PATCH] AAE-49653 Updating code with the latest BE contract --- .../process-filters-cloud.component.md | 1 + .../task-filters-cloud.component.md | 1 + .../lib/models/filter-counters-cloud.model.ts | 48 +- .../process-filters-cloud.component.spec.ts | 538 +++++++++++---- .../process-filters-cloud.component.ts | 236 ++++--- .../filter-counters-cloud.service.spec.ts | 617 +++++++++--------- .../services/filter-counters-cloud.service.ts | 267 +++++--- .../lib/services/filter-counters-manager.ts | 193 ------ .../src/lib/services/public-api.ts | 1 - .../task-filters-cloud.component.spec.ts | 175 ++--- .../task-filters-cloud.component.ts | 178 +++-- .../services/task-filter-cloud.service.ts | 2 +- 12 files changed, 1229 insertions(+), 1028 deletions(-) delete mode 100644 lib/process-services-cloud/src/lib/services/filter-counters-manager.ts diff --git a/docs/process-services-cloud/components/process-filters-cloud.component.md b/docs/process-services-cloud/components/process-filters-cloud.component.md index d960153d1d..3e6012bbb1 100644 --- a/docs/process-services-cloud/components/process-filters-cloud.component.md +++ b/docs/process-services-cloud/components/process-filters-cloud.component.md @@ -27,6 +27,7 @@ Lists all available process filters and allows to select a filter. | appName | `string` | "" | (required) The application name | | filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default | | showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter | +| searchApiMethod | `'GET' \| 'POST'` | "GET" | **Deprecated:** the counters are resolved by a single `POST /query/v1/count` call, which requires Activiti 8.7.0 forward. This input is only used to resolve the counters one filter at a time, on the backends without that endpoint, and will be removed along with the `GET` method in ADF 10.0.0. | ### Events diff --git a/docs/process-services-cloud/components/task-filters-cloud.component.md b/docs/process-services-cloud/components/task-filters-cloud.component.md index d7580e5dea..e906028fce 100644 --- a/docs/process-services-cloud/components/task-filters-cloud.component.md +++ b/docs/process-services-cloud/components/task-filters-cloud.component.md @@ -36,6 +36,7 @@ Shows all available filters. | appName | `string` | "" | Display filters available to the current user for the application with the specified name. | | filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. | | showIcons | `boolean` | false | Toggles display of the filter's icons. | +| searchApiMethod | `'GET' \| 'POST'` | "GET" | **Deprecated:** the counters are resolved by a single `POST /query/v1/count` call, which requires Activiti 8.7.0 forward. This input is only used to resolve the counters one filter at a time, on the backends without that endpoint, and will be removed along with the `GET` method in ADF 10.0.0. | ### Events diff --git a/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts index 3b30a06389..4fb8232d2a 100644 --- a/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts +++ b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts @@ -15,8 +15,6 @@ * limitations under the License. */ -import { TaskCloudEngineEvent } from './engine-event-cloud.model'; - /** * Entity types accepted by the `POST /query/v1/count` endpoint. */ @@ -34,9 +32,12 @@ export interface FilterCountersQuerySort { } /** - * A single query of the batched count request, holding the criteria of one filter. + * A single query of the batched count request, holding the criteria of one filter. The counter + * resolved for the query is keyed by its `requestId` in the response. */ export interface FilterCountersQuery { + /** Identifies the query, so that its counter can be read back from the response. */ + requestId: string; status?: string[]; assignee?: string[]; sort?: FilterCountersQuerySort; @@ -51,12 +52,12 @@ export type FilterCountersRequest = { }; /** - * Shape of a task or process filter the counters are resolved for. + * Shape of a task or process filter the counters are resolved for. The key of the filter is used + * as the `requestId` of its query, so that its counter can be read back from the response. A filter + * without a key holds no identity for the batched request, so its counter is fetched on its own. */ export interface FilterCounterCandidate { - key: string; - status?: string | null; - statuses?: string[] | null; + key?: string | null; showCounter?: boolean; } @@ -68,30 +69,23 @@ export type FilterCountersFilters = { }; /** - * Counts returned by the batched count request, keyed by entity type and then by status. - * e.g. `{ TASK: { ASSIGNED: 5, CREATED: 0 }, PROCESS_INSTANCE: { RUNNING: 5 } }` + * Counts returned by the batched count request, keyed by entity type and then by the `requestId` + * of the query the count was resolved for. + * e.g. `{ TASK: { 'my-tasks': 5, 'queued-tasks': 0 }, PROCESS_INSTANCE: { 'running-processes': 5 } }` */ export type FilterCounters = { - [entityType in FilterCounterEntityType]?: { [status: string]: number }; + [entityType in FilterCounterEntityType]?: { [requestId: string]: number }; }; -export interface FilterCountersNotification { - /** Engine events of the debounced batch that triggered the count request. */ - events: TaskCloudEngineEvent[]; - /** Counts resolved by a single call to the batched count endpoint. */ - counters: FilterCounters; -} - /** - * Resolves the statuses of a filter, which the counters of the count response are keyed by. - * - * @param filter task or process filter - * @param filter.status Status of the filter - * @param filter.statuses Statuses of the filter - * @returns the statuses of the filter, empty when the filter targets every status + * Counters of the filters of one entity type, keyed by the key of the filter they were resolved for. */ -export function resolveFilterCounterStatuses(filter: { status?: string | null; statuses?: string[] | null }): string[] { - const statuses = filter?.statuses?.length ? filter.statuses : filter?.status ? [filter.status] : []; - - return statuses.filter((status) => !!status); +export interface FilterCountersResult { + /** Counters keyed by filter key. Empty when the batched count endpoint is not available. */ + counters: { [filterKey: string]: number }; + /** + * Whether the counters were resolved by the batched count endpoint. When `false`, the endpoint is + * not available on the backend of the app and the counters are to be resolved one filter at a time. + */ + batched: boolean; } diff --git a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts index 048b637eff..ed6d1327be 100644 --- a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts @@ -17,11 +17,11 @@ import { Component, SimpleChange } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; -import { EMPTY, first, of, Subject, throwError } from 'rxjs'; +import { first, of, Subject, throwError } from 'rxjs'; import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service'; import { ProcessFiltersCloudComponent } from './process-filters-cloud.component'; import { By } from '@angular/platform-browser'; -import { PROCESS_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service'; +import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service'; import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service'; import { mockProcessFilters } from '../../mock/process-filters-cloud.mock'; import { AppConfigService, AppConfigServiceMock, NoopAuthModule } from '@alfresco/adf-core'; @@ -33,7 +33,7 @@ import { MatIconHarness } from '@angular/material/icon/testing'; import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; -import { FilterCountersNotification } from '../../../../models/filter-counters-cloud.model'; +import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model'; @Component({ selector: 'adf-cloud-dummy', template: '' }) @@ -51,7 +51,9 @@ describe('ProcessFiltersCloudComponent', () => { let component: ProcessFiltersCloudComponent; let fixture: ComponentFixture; let getProcessFiltersSpy: jasmine.Spy; - let getFilterCountersNotificationsSpy: jasmine.Spy; + let getFilterCountersSpy: jasmine.Spy; + let refreshFilterCountersSpy: jasmine.Spy; + let getProcessCounterSpy: jasmine.Spy; let loader: HarnessLoader; let router: Router; @@ -60,6 +62,7 @@ describe('ProcessFiltersCloudComponent', () => { imports: [NoopAuthModule, ProcessFiltersCloudComponent, ApolloTestingModule], providers: [ { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, + { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, { provide: AppConfigService, useClass: AppConfigServiceMock }, ProcessListCloudService, { provide: ProcessFilterCloudService, useValue: ProcessFilterCloudServiceMock }, @@ -90,9 +93,10 @@ describe('ProcessFiltersCloudComponent', () => { TestBed.inject(ActivatedRoute); router = TestBed.inject(Router); await RouterTestingHarness.create(); - getProcessFiltersSpy = spyOn(processFilterService, 'getProcessFilters').and.returnValue(of(mockProcessFilters)); - getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue(EMPTY); - spyOn(processListService, 'getProcessCounter').and.returnValue(of(10)); + getProcessFiltersSpy = spyOn(filterCountersService, 'getProcessFilters').and.returnValue(of(mockProcessFilters)); + getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue(of({ counters: {}, batched: true })); + refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters'); + getProcessCounterSpy = spyOn(processListService, 'getProcessCounter').and.returnValue(of(10)); spyOn(processListService, 'getProcessListCount').and.returnValue(of(10)); }; @@ -464,105 +468,63 @@ describe('ProcessFiltersCloudComponent', () => { expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy(); }); - it('should call fetchProcessFilterCounter only if filter.showCounter is true', () => { - const filterWithCounter = { ...mockProcessFilters[0], showCounter: true }; - const filterWithoutCounter = { ...mockProcessFilters[1], showCounter: false }; - const fetchSpy = spyOn(component, 'fetchProcessFilterCounter').and.returnValue(of(42)); + it('should resolve the counter only of the filters with a counter enabled', () => { + const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true }); + const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false }); + getProcessCounterSpy.calls.reset(); component.filters = [filterWithCounter, filterWithoutCounter]; component.updateFilterCounters(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter); - expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter); + expect(getProcessCounterSpy).toHaveBeenCalledTimes(1); + expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status); }); - describe('Batched counters registration', () => { - let registerFiltersSpy: jasmine.Spy; - + describe('Batched counters', () => { beforeEach(() => { - registerFiltersSpy = spyOn(filterCountersService, 'registerFilters'); + getProcessFiltersSpy.and.returnValue( + of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }))) + ); }); - const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1]; + it('should read the counters of the process filters of the bound app', async () => { + await bindAppName('mock-app-name'); - it('should register every filter with a counter enabled', async () => { - getProcessFiltersSpy.and.returnValue( - of( - mockProcessFilters.map( - (filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true, sort: 'startDate', order: 'DESC' }) - ) - ) - ); + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE); + }); + + it('should hold the counters resolved by the batched count request', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); await bindAppName('mock-app-name'); - expect(registerFiltersSpy).toHaveBeenCalledWith('PROCESS_INSTANCE', jasmine.any(Array)); - // the first mock filter targets every status, so it holds no status to be keyed by - expect(registeredQueries().length).toBe(2); - expect(registeredQueries().map((query: any) => query.status)).toEqual([['RUNNING'], ['COMPLETED']]); + expect(component.counters['FakeRunningProcesses']).toBe(9); }); - it('should not register a filter without a counter enabled', async () => { - getProcessFiltersSpy.and.returnValue( - of([ - new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: 'startDate', order: 'DESC' }), - new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false, sort: 'startDate', order: 'DESC' }) - ]) - ); + it('should emit the filters whose counter changed', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + const updatedFilterSpy = spyOn(component.updatedFilter, 'emit'); await bindAppName('mock-app-name'); - expect(registeredQueries().length).toBe(1); - expect(registeredQueries()[0].status).toEqual(['RUNNING']); + expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses'); }); - it('should register the full criteria of a filter', async () => { - getProcessFiltersSpy.and.returnValue( - of([ - new ProcessFilterCloudModel({ - ...mockProcessFilters[1], - showCounter: true, - sort: 'startDate', - order: 'DESC', - initiator: 'mock-user', - processDefinitionName: 'mock-process' - }) - ]) - ); + it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false })); await bindAppName('mock-app-name'); - const query = registeredQueries()[0]; - expect(query.status).toEqual(['RUNNING']); - expect(query.initiator).toEqual(['mock-user']); - expect(query.processDefinitionName).toEqual(['mock-process']); - expect(query.sort).toEqual({ field: 'startDate', direction: 'desc', isProcessVariable: false }); + expect(getProcessCounterSpy).toHaveBeenCalledTimes(3); + expect(component.counters['FakeRunningProcesses']).toBe(10); }); - it('should not register a filter targeting every status', async () => { - getProcessFiltersSpy.and.returnValue( - of([new ProcessFilterCloudModel({ ...mockProcessFilters[0], showCounter: true, sort: 'startDate', order: 'DESC' })]) - ); - + it('should refresh the counters of every filter when a filter is clicked', async () => { await bindAppName('mock-app-name'); - expect(registeredQueries().length).toBe(0); - }); + component.onFilterClick(mockProcessFilters[1]); - it('should not break the filter list when the query of a filter cannot be built', async () => { - getProcessFiltersSpy.and.returnValue( - of([ - new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: undefined, order: undefined }), - new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: true, sort: 'startDate', order: 'DESC' }) - ]) - ); - - await bindAppName('mock-app-name'); - - expect(component.filters.length).toBe(2); - expect(registeredQueries().length).toBe(1); - expect(registeredQueries()[0].status).toEqual(['COMPLETED']); + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name'); }); }); @@ -597,64 +559,402 @@ describe('ProcessFiltersCloudComponent', () => { expect(component.notificationDebounceTime).toBe(5000); }); - const initNotifications = (showCounter: boolean): Subject => { - const notifications$ = new Subject(); - getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); + it('should keep the counters in sync with the counters stream', fakeAsync(() => { + const counters$ = new Subject(); + getFilterCountersSpy.and.returnValue(counters$.asObservable()); component.appName = 'mock-app-name'; fixture.detectChanges(); + component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any; - component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter })); - - return notifications$; - }; - - it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => { - const notifications$ = initNotifications(true); - - notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); + counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true }); expect(component.counters['FakeRunningProcesses']).toBe(7); flush(); })); - - it('should fetch the counters of the filters not resolved by the batched count request on their own', fakeAsync(() => { - const notifications$ = initNotifications(true); - const updateFilterCounterSpy = spyOn(component, 'updateFilterCounter'); - - notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); - - // the RUNNING filter is resolved by the batch, the other two are fetched on their own - expect(component.counters['FakeRunningProcesses']).toBe(7); - expect(updateFilterCounterSpy).toHaveBeenCalledTimes(2); - expect(updateFilterCounterSpy.calls.allArgs().map(([filter]) => filter.key)).toEqual(['FakeAllProcesses', 'completed-processes']); - flush(); - })); - - it('should not update the counter of a filter without counter enabled', fakeAsync(() => { - const notifications$ = initNotifications(false); - component.counters = {}; - - notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); - - expect(component.counters['FakeRunningProcesses']).toBeUndefined(); - flush(); - })); }); describe('Highlight Selected Filter', () => { - it('should make subscription', async () => { + const allProcessesFilterKey = mockProcessFilters[0].key; + const allProcessesFilterId = mockProcessFilters[0].id; + + it('should apply active CSS class on filter click', async () => { component.enableNotifications = true; await bindAppName('mock-app-name'); - expect(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('mock-app-name'); + + let link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement; + expect(link.getAttribute('href')).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`); + + link.click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(router.url).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`); + + link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement; + expect(link.classList).toContain('adf-active'); }); - it('should not make subscription when notifications are disabled', async () => { - const appConfigService = TestBed.inject(AppConfigService); - spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue)); + it('should add aria-current attribute with value "page" to the active filter', async () => { + component.enableNotifications = true; await bindAppName('mock-app-name'); - expect(getFilterCountersNotificationsSpy).not.toHaveBeenCalled(); + const link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement; + expect(link.getAttribute('aria-current')).toBe('page'); + }); + + it('should not have aria-current attribute when filter is not active', async () => { + component.enableNotifications = true; + await bindAppName('mock-app-name'); + + const link = fixture.debugElement.query(By.css(`[data-automation-id="${mockProcessFilters[1].key}_filter"]`)).nativeElement; + expect(link.getAttribute('aria-current')).toBeNull(); + }); + }); + }); + + describe('searchApiMethod set to POST', () => { + beforeEach(async () => { + await configureTestingModule('POST'); + }); + + it('should attach specific icon for each filter if hasIcon is true', async () => { + await bindAppName(); + + component.showIcons = true; + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.filters.length).toBe(3); + const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' })); + expect(filterIcons.length).toBe(3); + expect(await filterIcons[0].getName()).toContain('adjust'); + expect(await filterIcons[1].getName()).toContain('inbox'); + expect(await filterIcons[2].getName()).toContain('done'); + }); + + it('should not attach icons for each filter if hasIcon is false', async () => { + component.showIcons = false; + await bindAppName(); + + const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' })); + expect(filterIcons.length).toBe(0); + }); + + it('should display the filters', async () => { + await bindAppName(); + + component.showIcons = true; + + fixture.detectChanges(); + await fixture.whenStable(); + + const filters = fixture.debugElement.queryAll(By.css('.adf-process-filters__entry')); + expect(component.filters.length).toBe(3); + expect(filters.length).toBe(3); + expect(filters[0].nativeElement.innerText).toContain('FakeAllProcesses'); + expect(filters[1].nativeElement.innerText).toContain('FakeRunningProcesses'); + expect(filters[2].nativeElement.innerText).toContain('FakeCompletedProcesses'); + expect(Object.keys(component.counters).length).toBe(3); + }); + + it('should emit success with the filters when filters are loaded', async () => { + const successSpy = spyOn(component.success, 'emit'); + await bindAppName(); + + expect(successSpy).toHaveBeenCalledWith(mockProcessFilters); + expect(component.filters).toBeDefined(); + expect(component.filters[0].name).toEqual('FakeAllProcesses'); + expect(component.filters[1].name).toEqual('FakeRunningProcesses'); + expect(component.filters[2].name).toEqual('FakeCompletedProcesses'); + expect(Object.keys(component.counters).length).toBe(3); + }); + + it('should not select any filter as default', async () => { + await bindAppName(); + + expect(component.currentFilter).toBeUndefined(); + }); + + it('should filterClicked emit when a filter is clicked from the UI', async () => { + const filterClickedSpy = spyOn(component.filterClicked, 'emit'); + await bindAppName(); + + const filterButton = fixture.debugElement.nativeElement.querySelector(`[data-automation-id="${mockProcessFilters[0].key}_filter"]`); + filterButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.currentFilter).toEqual(mockProcessFilters[0]); + expect(filterClickedSpy).toHaveBeenCalledWith(mockProcessFilters[0]); + }); + }); + + describe('API agnostic', () => { + beforeEach(async () => { + await configureTestingModule('GET'); + }); + + it('should emit an error with a bad response', async () => { + getProcessFiltersSpy.and.returnValue(throwError('wrong request')); + let lastValue: any; + component.error.subscribe((err) => (lastValue = err)); + + await bindAppName(); + + expect(lastValue).toBeDefined(); + }); + + it('should not select any process filter if filter input does not exist', async () => { + const change = new SimpleChange(null, { name: 'nonexistentFilter' }, true); + fixture.detectChanges(); + await fixture.whenStable(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toBeUndefined(); + }); + + it('should select the filter based on the input by name param', async () => { + const filterSelectedSpy = spyOn(component.filterSelected, 'emit'); + const change = new SimpleChange(null, { name: 'FakeRunningProcesses' }, true); + + await bindAppName(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toEqual(mockProcessFilters[1]); + expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[1]); + }); + + it('should select the filter based on the input by key param', async () => { + const filterSelectedSpy = spyOn(component.filterSelected, 'emit'); + const change = new SimpleChange(null, { key: 'completed-processes' }, true); + + await bindAppName(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toEqual(mockProcessFilters[2]); + expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]); + }); + + it('should select the filter based on the input by index param', async () => { + const filterSelectedSpy = spyOn(component.filterSelected, 'emit'); + const change = new SimpleChange(null, { index: 2 }, true); + + await bindAppName(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toEqual(mockProcessFilters[2]); + expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]); + }); + + it('should select the filter based on the input by id param', async () => { + const filterSelectedSpy = spyOn(component.filterSelected, 'emit'); + const change = new SimpleChange(null, { id: '12' }, true); + + await bindAppName(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toEqual(mockProcessFilters[2]); + expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]); + }); + + it('should reset the filter when the param is undefined', () => { + const change = new SimpleChange(mockProcessFilters[0], undefined, false); + component.currentFilter = mockProcessFilters[0]; + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toEqual(undefined); + }); + + it('should not emit a filter clicked event when a filter is selected through the filterParam input (filterClicked emits only through a UI click action)', async () => { + const filterClickedSpy = spyOn(component.filterClicked, 'emit'); + const change = new SimpleChange(null, { id: '10' }, true); + + await bindAppName(); + component.ngOnChanges({ filterParam: change }); + + expect(component.currentFilter).toBe(mockProcessFilters[0]); + expect(filterClickedSpy).not.toHaveBeenCalled(); + }); + + it('should reload filters by appName on binding changes', () => { + spyOn(component, 'getFilters').and.stub(); + const appName = 'my-app-1'; + + const change = new SimpleChange(null, appName, true); + component.ngOnChanges({ appName: change }); + + expect(component.getFilters).toHaveBeenCalledWith(appName); + }); + + it('should not reload filters by appName null on binding changes', () => { + spyOn(component, 'getFilters').and.stub(); + const appName = null; + + const change = new SimpleChange(undefined, appName, true); + component.ngOnChanges({ appName: change }); + + expect(component.getFilters).not.toHaveBeenCalledWith(appName); + }); + + it('should reload filters by app name on binding changes', () => { + spyOn(component, 'getFilters').and.stub(); + const appName = 'fake-app-name'; + + const change = new SimpleChange(null, appName, true); + component.ngOnChanges({ appName: change }); + + expect(component.getFilters).toHaveBeenCalledWith(appName); + }); + + it('should return the current filter after one is selected', () => { + const filter = mockProcessFilters[1]; + component.filters = mockProcessFilters; + + expect(component.currentFilter).toBeUndefined(); + component.selectFilter({ id: filter.id }); + expect(component.getCurrentFilter()).toBe(filter); + }); + + it('should remove key from set of updated filters when received refreshed filter key', async () => { + const filterKeyTest = 'filter-key-test'; + component.updatedFiltersSet.add(filterKeyTest); + + expect(component.updatedFiltersSet.size).toBe(1); + processFilterService.filterKeyToBeRefreshed$ = of(filterKeyTest); + fixture.detectChanges(); + + expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy(); + }); + + it('should resolve the counter only of the filters with a counter enabled', () => { + const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true }); + const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false }); + getProcessCounterSpy.calls.reset(); + + component.filters = [filterWithCounter, filterWithoutCounter]; + component.updateFilterCounters(); + + expect(getProcessCounterSpy).toHaveBeenCalledTimes(1); + expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status); + }); + + describe('Batched counters', () => { + beforeEach(() => { + getProcessFiltersSpy.and.returnValue( + of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }))) + ); + }); + + it('should read the counters of the process filters of the bound app', async () => { + await bindAppName('mock-app-name'); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE); + }); + + it('should hold the counters resolved by the batched count request', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + + await bindAppName('mock-app-name'); + + expect(component.counters['FakeRunningProcesses']).toBe(9); + }); + + it('should emit the filters whose counter changed', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + const updatedFilterSpy = spyOn(component.updatedFilter, 'emit'); + + await bindAppName('mock-app-name'); + + expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses'); + }); + + it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false })); + + await bindAppName('mock-app-name'); + + expect(getProcessCounterSpy).toHaveBeenCalledTimes(3); + expect(component.counters['FakeRunningProcesses']).toBe(10); + }); + + it('should refresh the counters of every filter when a filter is clicked', async () => { + await bindAppName('mock-app-name'); + + component.onFilterClick(mockProcessFilters[1]); + + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name'); + }); + }); + + describe('Notifications config', () => { + it('should read enableNotifications and notificationDebounceTime from app config on init', () => { + const appConfigService = TestBed.inject(AppConfigService); + const getSpy = spyOn(appConfigService, 'get').and.callThrough(); + + fixture.detectChanges(); + + expect(getSpy).toHaveBeenCalledWith('notifications', true); + expect(getSpy).toHaveBeenCalledWith('notificationDebounceTime', 3000); + }); + + it('should default notificationDebounceTime to 3000 when not set in app config', () => { + fixture.detectChanges(); + + expect(component.notificationDebounceTime).toBe(3000); + }); + + it('should use notificationDebounceTime from app config', () => { + const appConfigService: AppConfigService = TestBed.inject(AppConfigService); + spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => { + if (key === 'notificationDebounceTime') { + return 5000; + } + return defaultValue; + }); + + fixture.detectChanges(); + + expect(component.notificationDebounceTime).toBe(5000); + }); + + it('should keep the counters in sync with the counters stream', fakeAsync(() => { + const counters$ = new Subject(); + getFilterCountersSpy.and.returnValue(counters$.asObservable()); + component.appName = 'mock-app-name'; + + fixture.detectChanges(); + component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any; + + counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true }); + + expect(component.counters['FakeRunningProcesses']).toBe(7); + flush(); + })); + + it('should resolve the counters one filter at a time when the batched endpoint is not available', fakeAsync(() => { + const counters$ = new Subject(); + getFilterCountersSpy.and.returnValue(counters$.asObservable()); + component.appName = 'mock-app-name'; + + fixture.detectChanges(); + component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any; + getProcessCounterSpy.calls.reset(); + + counters$.next({ counters: {}, batched: false }); + + expect(getProcessCounterSpy).toHaveBeenCalledTimes(3); + flush(); + })); + }); + + describe('Highlight Selected Filter', () => { + it('should read the counters of the bound app', async () => { + component.enableNotifications = true; + await bindAppName('mock-app-name'); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE); }); it('should emit filter key when filter counter is set for first time', () => { diff --git a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts index c89d0afef8..a8ef496ee9 100644 --- a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts @@ -16,7 +16,7 @@ */ import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { EMPTY, Observable } from 'rxjs'; +import { EMPTY, Observable, Subscription } from 'rxjs'; import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service'; import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model'; import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core'; @@ -26,7 +26,6 @@ import { ProcessListCloudService } from '../../../process-list/services/process- import { ProcessFilterCloudAdapter } from '../../../process-list/models/process-cloud-query-request.model'; import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; -import { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { TranslatePipe } from '@ngx-translate/core'; import { AsyncPipe } from '@angular/common'; @@ -46,7 +45,13 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { @Input() appName: string = ''; - /** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count */ + /** + * (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count. + * + * @deprecated the counters are resolved by `POST /query/v1/count`, which requires Activiti 8.7.0 + * forward. This input is only used by the backends without that endpoint and will be removed, + * along with the 'GET' method, in ADF 10.0.0. + */ @Input() searchApiMethod: 'GET' | 'POST' = 'GET'; @@ -81,11 +86,14 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { filters$: Observable; currentFilter?: ProcessFilterCloudModel; filters: ProcessFilterCloudModel[] = []; + counters: { [key: string]: number } = {}; + currentFiltersValues: { [key: string]: number } = {}; updatedFiltersSet = new Set(); enableNotifications = true; notificationDebounceTime = 3000; private filtersLoadedFor?: string; - private countersManager: FilterCountersManager; + private countersSubscription?: Subscription; + private batchedCounters = true; private readonly destroyRef = inject(DestroyRef); private readonly processFilterCloudService = inject(ProcessFilterCloudService); @@ -100,52 +108,16 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.enableNotifications = this.appConfigService.get('notifications', true); this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000); - if (!this.countersManager) { - this.initCountersManager(); - } - if (!this.filtersLoadedFor) { this.getFilters(this.appName); } - this.initProcessNotification(); - this.countersManager.subscribeToExternalRefresh(this.processFilterCloudService.filterKeyToBeRefreshed$); - } - - private initCountersManager(): void { - const counterAdapter: FilterCounterAdapter = { - getFilterCounter: (filter) => - this.searchApiMethod === 'POST' - ? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter)) - : this.processListCloudService.getProcessCounter(filter.appName, filter.status) - }; - - this.countersManager = new FilterCountersManager( - FilterCounterEntityType.PROCESS_INSTANCE, - this.filterCountersCloudService, - counterAdapter, - this.destroyRef, - { - onFilterUpdated: (filterKey) => { - this.updatedFilter.emit(filterKey); - this.updatedFiltersSet.add(filterKey); - this.counters = this.countersManager.counters; - this.currentFiltersValues = this.countersManager.currentFiltersValues; - }, - onCountersUpdated: () => { - this.counters = this.countersManager.counters; - this.currentFiltersValues = this.countersManager.currentFiltersValues; - } - } - ); + this.getFilterKeysAfterExternalRefreshing(); } ngOnChanges(changes: SimpleChanges) { const appName = changes['appName']; const filter = changes['filterParam']; if (appName?.currentValue) { - if (!this.countersManager) { - this.initCountersManager(); - } this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); @@ -159,19 +131,17 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { */ getFilters(appName: string): void { this.filtersLoadedFor = appName; - const filters$ = this.filterCountersCloudService - .getFilters(appName) - .pipe(map((filters) => filters[FilterCounterEntityType.PROCESS_INSTANCE] as ProcessFilterCloudModel[])); + const filters$ = this.filterCountersCloudService.getProcessFilters(appName); this.filters$ = filters$.pipe(catchError(() => EMPTY)); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { this.resetFilter(); this.filters = res || []; - this.countersManager.initCounters(this.filters); + this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); this.success.emit(res); - this.countersManager.loadCounters(appName); + this.loadFilterCounters(appName); }, error: (err: any) => { this.error.emit(err); @@ -179,8 +149,12 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { }); } - counters: { [key: string]: number } = {}; - currentFiltersValues: { [key: string]: number } = {}; + /** + * Initialize counter collection for filters + */ + initFilterCounters(): void { + this.filters.forEach((filter) => (this.counters[filter.key] = 0)); + } /** * Pass the selected filter as next @@ -204,20 +178,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { ); // fallback to preserve the previous behavior } - /** - * Check equality of the filter names by translating the given name strings - * - * @param name1 source name - * @param name2 target name - * @returns `true` if filter names are equal, otherwise `false` - */ - private checkFilterNamesEquality(name1: string, name2: string): boolean { - const translatedName1 = this.translationService.instant(name1); - const translatedName2 = this.translationService.instant(name2); - - return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase(); - } - /** * Selects and emits the given filter * @@ -250,8 +210,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { if (filter) { this.selectFilter(filter); this.filterClicked.emit(this.currentFilter); - this.updateFilterCounter(this.currentFilter); - this.countersManager.resetFilterUpdate(filter.key); + this.refreshFilterCounter(this.currentFilter); this.updatedFiltersSet.delete(filter.key); } else { this.currentFilter = undefined; @@ -285,6 +244,85 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { return this.filters === undefined || (this.filters && this.filters.length === 0); } + isActiveFilter(filter: ProcessFilterCloudModel): boolean { + return this.currentFilter.name === filter.name; + } + + /** + * Keeps the counters in sync with the engine events of the app. + * + * @deprecated the counters stream keeps itself in sync with the engine events, so nothing is + * subscribed here anymore. It will be removed in ADF 10.0.0. + */ + initProcessNotification(): void { + /* Kept for backwards compatibility: `getFilterCounters` subscribes to the engine events. */ + } + + /** + * Iterate over filters and update counters + * + * @deprecated the counters are resolved by the batched count request. This resolves them one + * filter at a time, for the backends without the batched count endpoint. + */ + updateFilterCounters(): void { + this.filters.forEach((filter) => this.updateFilterCounter(filter)); + } + + /** + * Get current value for filter and check if value has changed + * + * @param filter filter + * @deprecated the counters are resolved by the batched count request. This resolves the counter + * of one filter, for the backends without the batched count endpoint. + */ + updateFilterCounter(filter: ProcessFilterCloudModel): void { + if (!filter?.showCounter) { + return; + } + + this.fetchProcessFilterCounter(filter) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((counter) => { + this.checkIfFilterValuesHasBeenUpdated(filter.key, counter); + this.counters = { ...this.counters, [filter.key]: counter }; + }); + } + + checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void { + if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { + this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue }; + this.updatedFilter.emit(filterKey); + this.updatedFiltersSet.add(filterKey); + } + } + + /** + * Flags the counter of a filter as read whenever the filter is refreshed by an external action + */ + getFilterKeysAfterExternalRefreshing(): void { + this.processFilterCloudService.filterKeyToBeRefreshed$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((filterKey: string) => this.updatedFiltersSet.delete(filterKey)); + } + + isFilterUpdated(filterName: string): boolean { + return this.updatedFiltersSet.has(filterName); + } + + /** + * Check equality of the filter names by translating the given name strings + * + * @param name1 source name + * @param name2 target name + * @returns `true` if filter names are equal, otherwise `false` + */ + private checkFilterNamesEquality(name1: string, name2: string): boolean { + const translatedName1 = this.translationService.instant(name1); + const translatedName2 = this.translationService.instant(name2); + + return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase(); + } + /** * Reset the filters */ @@ -293,34 +331,56 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.currentFilter = undefined; } - isActiveFilter(filter: ProcessFilterCloudModel): boolean { - return this.currentFilter.name === filter.name; + /** + * Resolves the counters of the filters, with one request shared with the task filters. The + * counters of a backend without the batched count endpoint are resolved one filter at a time. + * + * @param appName application name + */ + private loadFilterCounters(appName: string): void { + this.countersSubscription?.unsubscribe(); + this.countersSubscription = this.filterCountersCloudService + .getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ counters, batched }) => { + this.batchedCounters = batched; + if (batched) { + this.applyFilterCounters(counters); + } else { + this.updateFilterCounters(); + } + }); } - initProcessNotification(): void { - if (this.appName && this.enableNotifications) { - this.countersManager.subscribeToNotifications(this.appName); + /** + * Holds the counters resolved by the batched count request, which are keyed by filter key. + * + * @param counters counters keyed by filter key + */ + private applyFilterCounters(counters: { [filterKey: string]: number }): void { + Object.entries(counters).forEach(([filterKey, counter]) => { + this.checkIfFilterValuesHasBeenUpdated(filterKey, counter); + this.counters = { ...this.counters, [filterKey]: counter }; + }); + } + + /** + * Resolves the counters again, with one request for every filter of the app. The counter of the + * given filter alone is resolved when the batched count endpoint is not available. + * + * @param filter filter that was clicked + */ + private refreshFilterCounter(filter: ProcessFilterCloudModel): void { + if (this.batchedCounters) { + this.filterCountersCloudService.refreshFilterCounters(this.appName); + } else { + this.updateFilterCounter(filter); } } - /** - * Iterate over filters and update counters - */ - updateFilterCounters(): void { - this.countersManager.updateAllCounters(); - } - - /** - * Get current value for filter and check if value has changed - * - * @param filter filter - */ - updateFilterCounter(filter: ProcessFilterCloudModel): void { - this.countersManager.updateSingleCounter(filter); - this.counters = this.countersManager.counters; - } - - isFilterUpdated(filterName: string): boolean { - return this.updatedFiltersSet.has(filterName); + private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable { + return this.searchApiMethod === 'POST' + ? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter)) + : this.processListCloudService.getProcessCounter(filter.appName, filter.status); } } diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts index 699833f1d2..5c5750eafd 100644 --- a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts @@ -17,11 +17,18 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core'; -import { Subject } from 'rxjs'; +import { firstValueFrom, of, Subject, throwError } from 'rxjs'; import { ApolloTestingModule } from 'apollo-angular/testing'; import { FilterCountersCloudService } from './filter-counters-cloud.service'; import { NotificationCloudService } from './notification-cloud.service'; -import { FilterCounterEntityType, FilterCounters, FilterCountersNotification } from '../models/filter-counters-cloud.model'; +import { LocalPreferenceCloudService } from './local-preference-cloud.service'; +import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from './cloud-token.service'; +import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service'; +import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service'; +import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model'; +import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model'; +import { FilterCounterEntityType, FilterCounters } from '../models/filter-counters-cloud.model'; +import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model'; describe('FilterCountersCloudService', () => { let service: FilterCountersCloudService; @@ -30,17 +37,36 @@ describe('FilterCountersCloudService', () => { let engineEvents$: Subject; let makeGQLQuerySpy: jasmine.Spy; let postSpy: jasmine.Spy; + let getTaskListFiltersSpy: jasmine.Spy; + let getProcessFiltersSpy: jasmine.Spy; const countersMock: FilterCounters = { - TASK: { ASSIGNED: 5, CREATED: 0 }, - PROCESS_INSTANCE: { RUNNING: 5 } + TASK: { 'my-tasks': 5, 'queued-tasks': 0 }, + PROCESS_INSTANCE: { 'running-processes': 5 } }; + const taskFilter = (filter: any) => new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter }); + const processFilter = (filter: any) => new ProcessFilterCloudModel({ appName: 'mock-app', sort: 'startDate', order: 'DESC', ...filter }); + + const taskFiltersMock = [ + taskFilter({ key: 'my-tasks', status: 'ASSIGNED', assignee: 'mock-user', showCounter: true }), + taskFilter({ key: 'queued-tasks', status: 'CREATED', showCounter: true }), + taskFilter({ key: 'completed-tasks', status: 'COMPLETED', showCounter: false }) + ]; + const processFiltersMock = [ + processFilter({ key: 'running-processes', status: 'RUNNING', showCounter: true }), + processFilter({ key: 'all-processes', status: '', showCounter: false }) + ]; + const emitEvent = (eventType = 'TASK_CREATED') => engineEvents$.next({ data: { engineEvents: [{ eventType, entity: {} }] } }); beforeEach(() => { TestBed.configureTestingModule({ - imports: [NoopAuthModule, ApolloTestingModule] + imports: [NoopAuthModule, ApolloTestingModule], + providers: [ + { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, + { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService } + ] }); service = TestBed.inject(FilterCountersCloudService); @@ -50,367 +76,312 @@ describe('FilterCountersCloudService', () => { engineEvents$ = new Subject(); makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery').and.returnValue(engineEvents$.asObservable() as any); - postSpy = spyOn(service, 'post').and.returnValue(new Subject().asObservable()); - - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }]); - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + postSpy = spyOn(service, 'post').and.returnValue(of(countersMock)); + getTaskListFiltersSpy = spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock)); + getProcessFiltersSpy = spyOn(TestBed.inject(ProcessFilterCloudService), 'getProcessFilters').and.returnValue(of(processFiltersMock)); }); - it('should return EMPTY when appName is not set', () => { - let completed = false; - service.getFilterCountersNotifications('').subscribe({ complete: () => (completed = true) }); - - expect(completed).toBeTrue(); - expect(makeGQLQuerySpy).not.toHaveBeenCalled(); - }); - - it('should open a single subscription for multiple subscribers of the same app', () => { - service.getFilterCountersNotifications('mock-app').subscribe(); - service.getFilterCountersNotifications('mock-app').subscribe(); - - expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); - }); - - it('should subscribe to both the task and the process engine events', () => { - service.getFilterCountersNotifications('mock-app').subscribe(); - - const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args; - expect(appName).toBe('mock-app'); - expect(query).toContain('TASK_CREATED'); - expect(query).toContain('PROCESS_STARTED'); - }); - - it('should open a separate subscription per app', () => { - service.getFilterCountersNotifications('mock-app').subscribe(); - service.getFilterCountersNotifications('other-app').subscribe(); - - expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); - }); - - it('should make a single count request for a batch of events received by multiple subscribers', fakeAsync(() => { - postSpy.and.returnValue(new Subject().asObservable()); - service.getFilterCountersNotifications('mock-app').subscribe(); - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent('TASK_CREATED'); - emitEvent('PROCESS_STARTED'); - tick(3000); - - expect(postSpy).toHaveBeenCalledTimes(1); - })); - - it('should call the batched count endpoint with the queries of the registered filters', fakeAsync(() => { - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent(); - tick(3000); - - const [url, body] = postSpy.calls.mostRecent().args; - expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count'); - expect(body).toEqual({ - TASK: [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }], - PROCESS_INSTANCE: [{ status: ['RUNNING'] }] - }); - })); - - it('should send the full criteria of every registered filter', fakeAsync(() => { - service.registerFilters(FilterCounterEntityType.TASK, [ - { status: ['ASSIGNED'], assignee: ['mock-user'] }, - { status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' }, - { status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] } - ]); - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent(); - tick(3000); - - const [, body] = postSpy.calls.mostRecent().args; - expect(body.TASK.length).toBe(3); - expect(body.TASK[1]).toEqual({ status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' }); - expect(body.TASK[2]).toEqual({ status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] }); - })); - - it('should replace the previously registered queries of an entity type', fakeAsync(() => { - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['COMPLETED'] }]); - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent(); - tick(3000); - - const [, body] = postSpy.calls.mostRecent().args; - expect(body.TASK).toEqual([{ status: ['COMPLETED'] }]); - })); - - it('should not call the batched count endpoint when no filter is registered', fakeAsync(() => { - service.registerFilters(FilterCounterEntityType.TASK, []); - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []); - let notification: FilterCountersNotification; - service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); - - emitEvent(); - tick(3000); - - expect(postSpy).not.toHaveBeenCalled(); - expect(notification.counters).toEqual({}); - })); - - it('should omit the entity type of an entity without registered filters', fakeAsync(() => { - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []); - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent(); - tick(3000); - - const [, body] = postSpy.calls.mostRecent().args; - expect(body.PROCESS_INSTANCE).toBeUndefined(); - expect(body.TASK.length).toBe(2); - })); - - it('should debounce the events using the configured debounce time', fakeAsync(() => { - spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notificationDebounceTime' ? 5000 : defaultValue)); - service.getFilterCountersNotifications('mock-app').subscribe(); - - emitEvent(); - tick(3000); - expect(postSpy).not.toHaveBeenCalled(); - - tick(2000); - expect(postSpy).toHaveBeenCalledTimes(1); - })); - - it('should emit the events along with the resolved counters', fakeAsync(() => { - const counters$ = new Subject(); - postSpy.and.returnValue(counters$.asObservable()); - let notification: FilterCountersNotification; - service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); - - emitEvent('TASK_ASSIGNED'); - tick(3000); - counters$.next(countersMock); - - expect(notification.counters).toEqual(countersMock); - expect(notification.events.length).toBe(1); - expect(notification.events[0].eventType).toBe('TASK_ASSIGNED'); - })); - - it('should emit the events with empty counters when the count request fails', fakeAsync(() => { - const counters$ = new Subject(); - postSpy.and.returnValue(counters$.asObservable()); - let notification: FilterCountersNotification; - service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); - - emitEvent(); - tick(3000); - counters$.error(new Error('count failed')); - - expect(notification.counters).toEqual({}); - expect(notification.events.length).toBe(1); - })); - - it('should keep emitting after a failed count request', fakeAsync(() => { - const notifications: FilterCountersNotification[] = []; - service.getFilterCountersNotifications('mock-app').subscribe((result) => notifications.push(result)); - - postSpy.and.callFake(() => { - const counters$ = new Subject(); - setTimeout(() => counters$.error(new Error('count failed'))); - return counters$.asObservable(); - }); - emitEvent(); - tick(3000); - tick(0); - - postSpy.and.callFake(() => { - const counters$ = new Subject(); - setTimeout(() => counters$.next(countersMock)); - return counters$.asObservable(); - }); - emitEvent(); - tick(3000); - tick(0); - - expect(notifications.length).toBe(2); - expect(notifications[1].counters).toEqual(countersMock); - })); - - describe('loadFilterCounters', () => { - beforeEach(() => { - postSpy.and.returnValue(of(countersMock)); + describe('getTaskFilters / getProcessFilters', () => { + it('should load the filters of every entity type', async () => { + expect(await firstValueFrom(service.getTaskFilters('mock-app'))).toEqual(taskFiltersMock); + expect(await firstValueFrom(service.getProcessFilters('mock-app'))).toEqual(processFiltersMock); }); - it('should resolve the counters of both entity types with a single request', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); - service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); - const taskCounters: FilterCounters[] = []; - const processCounters: FilterCounters[] = []; + it('should load the filters of an app once for every subscriber', async () => { + await firstValueFrom(service.getTaskFilters('mock-app')); + await firstValueFrom(service.getTaskFilters('mock-app')); + await firstValueFrom(service.getProcessFilters('mock-app')); + await firstValueFrom(service.getProcessFilters('mock-app')); - // both components load their filter lists independently and subscribe before registering - service.loadFilterCounters('mock-app').subscribe((counters) => taskCounters.push(counters)); - service.loadFilterCounters('mock-app').subscribe((counters) => processCounters.push(counters)); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); - tick(1000); + expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1); + expect(getProcessFiltersSpy).toHaveBeenCalledTimes(1); + }); + + it('should load the filters of every app', async () => { + await firstValueFrom(service.getTaskFilters('mock-app')); + await firstValueFrom(service.getTaskFilters('other-app')); + + expect(getTaskListFiltersSpy.calls.allArgs()).toEqual([['mock-app'], ['other-app']]); + }); + + it('should share the filters with the batched count request', async () => { + await firstValueFrom(service.getTaskFilters('mock-app')); + await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK)); + + expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1); + }); + + it('should propagate the error of the filters that fail to load', async () => { + getTaskListFiltersSpy.and.returnValue(throwError(() => new Error('filters failed'))); + + await expectAsync(firstValueFrom(service.getTaskFilters('mock-app'))).toBeRejectedWithError('filters failed'); + }); + }); + + describe('getFilterCounters', () => { + const taskCounters = (appName = 'mock-app') => firstValueFrom(service.getFilterCounters(appName, FilterCounterEntityType.TASK)); + const processCounters = (appName = 'mock-app') => + firstValueFrom(service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE)); + + it('should return EMPTY when appName is not set', () => { + let completed = false; + service.getFilterCounters('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBeTrue(); + expect(postSpy).not.toHaveBeenCalled(); + }); + + it('should resolve the counters of both entity types with a single request', () => { + const results: any[] = []; + /* Both filter components hold their subscription, so one request resolves the counters of both. */ + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe((result) => results.push(result)); expect(postSpy).toHaveBeenCalledTimes(1); - expect(taskCounters).toEqual([countersMock]); - expect(processCounters).toEqual([countersMock]); - })); + expect(results).toEqual([ + { counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }, + { counters: { 'running-processes': 5 }, batched: true } + ]); + }); - it('should send the queries of every entity type in one request', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); - service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); - - service.loadFilterCounters('mock-app').subscribe(); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }]); - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); - tick(1000); + it('should send the queries of both entity types to the batched count endpoint', async () => { + await taskCounters(); const [url, body] = postSpy.calls.mostRecent().args; expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count'); - expect(body).toEqual({ - TASK: [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }], - PROCESS_INSTANCE: [{ status: ['RUNNING'] }] + expect(Object.keys(body)).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]); + }); + + it('should identify the query of every filter by the key of the filter', async () => { + await taskCounters(); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK.map((query: any) => query.requestId)).toEqual(['my-tasks', 'queued-tasks']); + expect(body.PROCESS_INSTANCE.map((query: any) => query.requestId)).toEqual(['running-processes']); + }); + + it('should send the criteria of every filter along with its request id', async () => { + await taskCounters(); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK[0]).toEqual({ + requestId: 'my-tasks', + status: ['ASSIGNED'], + assignee: ['mock-user'], + sort: { field: 'createdDate', direction: 'desc', isProcessVariable: false } }); - })); + }); - it('should wait for every expected entity type before sending the request', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); - service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + it('should not send the filters without a counter enabled', async () => { + await taskCounters(); - service.loadFilterCounters('mock-app').subscribe(); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK.map((query: any) => query.requestId)).not.toContain('completed-tasks'); + }); - expect(postSpy).not.toHaveBeenCalled(); + it('should send the query of a filter targeting every status', async () => { + getProcessFiltersSpy.and.returnValue(of([processFilter({ key: 'all-processes', status: '', showCounter: true })])); - service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + await processCounters(); - expect(postSpy).toHaveBeenCalledTimes(1); - tick(1000); - })); + const [, body] = postSpy.calls.mostRecent().args; + expect(body.PROCESS_INSTANCE.map((query: any) => query.requestId)).toEqual(['all-processes']); + }); - it('should send the request once the only expected entity type registers', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); + it('should omit an entity type without filters with a counter enabled', async () => { + getProcessFiltersSpy.and.returnValue(of([])); - service.loadFilterCounters('mock-app').subscribe(); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + await taskCounters(); - expect(postSpy).toHaveBeenCalledTimes(1); const [, body] = postSpy.calls.mostRecent().args; expect(body.PROCESS_INSTANCE).toBeUndefined(); - tick(1000); - })); + }); - it('should not wait longer than the max wait for a missing registration', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); - service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); - let counters: FilterCounters; - - // the process filters fail to load, so they never register - service.loadFilterCounters('mock-app').subscribe((result) => (counters = result)); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); - - expect(postSpy).not.toHaveBeenCalled(); - - tick(1000); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(counters).toEqual(countersMock); - })); - - it('should use the max wait from the app config', fakeAsync(() => { - spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => - key === 'filterCounterBatchMaxWait' ? 5000 : defaultValue + it('should leave out a filter the query cannot be built for', async () => { + getTaskListFiltersSpy.and.returnValue( + of([taskFilter({ key: 'broken', status: 'ASSIGNED', showCounter: true, sort: undefined, order: undefined }), taskFiltersMock[1]]) ); - service.expectFilters(FilterCounterEntityType.TASK); - service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); - service.loadFilterCounters('mock-app').subscribe(); - tick(1000); + await taskCounters(); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK.map((query: any) => query.requestId)).toEqual(['queued-tasks']); + }); + + it('should leave out a filter without a key, since it holds no request id', async () => { + getProcessFiltersSpy.and.returnValue(of([processFilter({ key: null, status: 'RUNNING', showCounter: true })])); + + await processCounters(); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.PROCESS_INSTANCE).toBeUndefined(); + }); + + it('should resolve the counters of an entity type when the filters of the other one fail to load', async () => { + getProcessFiltersSpy.and.returnValue(throwError(() => new Error('filters failed'))); + + await taskCounters(); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK.map((query: any) => query.requestId)).toEqual(['my-tasks', 'queued-tasks']); + expect(body.PROCESS_INSTANCE).toBeUndefined(); + }); + + it('should resolve no counter when no filter has a counter enabled', async () => { + getTaskListFiltersSpy.and.returnValue(of([])); + getProcessFiltersSpy.and.returnValue(of([])); + + expect(await taskCounters()).toEqual({ counters: {}, batched: true }); expect(postSpy).not.toHaveBeenCalled(); + }); - tick(4000); - expect(postSpy).toHaveBeenCalledTimes(1); - })); + describe('when the batched count endpoint is not available', () => { + it('should report the counters as not batched', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); - it('should emit empty counters when the request fails', fakeAsync(() => { - postSpy.and.returnValue(throwError(() => new Error('count failed'))); - service.expectFilters(FilterCounterEntityType.TASK); - let counters: FilterCounters; + expect(await taskCounters()).toEqual({ counters: {}, batched: false }); + }); - service.loadFilterCounters('mock-app').subscribe((result) => (counters = result)); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); - tick(1000); + it('should not call the endpoint again for the same app', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); - expect(counters).toEqual({}); - })); + await taskCounters(); + expect(await processCounters()).toEqual({ counters: {}, batched: false }); - it('should start a new batch for a subsequent load of another app', fakeAsync(() => { - service.expectFilters(FilterCounterEntityType.TASK); + expect(postSpy).toHaveBeenCalledTimes(1); + }); - service.loadFilterCounters('mock-app').subscribe(); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); - tick(1000); + it('should keep calling the endpoint of the apps that do hold it', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); + await taskCounters(); - service.loadFilterCounters('other-app').subscribe(); - service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['CREATED'] }]); - tick(1000); + postSpy.and.returnValue(of(countersMock)); + expect(await taskCounters('other-app')).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }); + }); + + it('should keep calling the endpoint after a transient failure', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 500 }))); + expect(await taskCounters()).toEqual({ counters: {}, batched: false }); + + postSpy.and.returnValue(of(countersMock)); + service.refreshFilterCounters('mock-app'); + + expect(await taskCounters()).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }); + expect(postSpy).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('refreshFilterCounters', () => { + it('should resolve the counters again with a single request', fakeAsync(() => { + const results: any[] = []; + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + + service.refreshFilterCounters('mock-app'); expect(postSpy).toHaveBeenCalledTimes(2); - expect(postSpy.calls.allArgs().map(([url]) => url)).toEqual([ - 'https://fake-bpm-host.com/mock-app/query/v1/count', - 'https://fake-bpm-host.com/other-app/query/v1/count' - ]); + expect(results.length).toBe(2); + })); + + it('should not resolve the counters of an app without subscribers', () => { + service.refreshFilterCounters('mock-app'); + + expect(postSpy).not.toHaveBeenCalled(); + }); + }); + + describe('getEngineEvents', () => { + it('should return EMPTY when appName is not set', () => { + let completed = false; + service.getEngineEvents('').subscribe({ complete: () => (completed = true) }); + + expect(completed).toBeTrue(); + expect(makeGQLQuerySpy).not.toHaveBeenCalled(); + }); + + it('should open a single subscription for multiple subscribers of the same app', () => { + service.getEngineEvents('mock-app').subscribe(); + service.getEngineEvents('mock-app').subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to both the task and the process engine events', () => { + service.getEngineEvents('mock-app').subscribe(); + + const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args; + expect(appName).toBe('mock-app'); + expect(query).toContain('TASK_CREATED'); + expect(query).toContain('PROCESS_STARTED'); + }); + + it('should open a separate subscription per app', () => { + service.getEngineEvents('mock-app').subscribe(); + service.getEngineEvents('other-app').subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); + }); + + it('should emit the debounced batch of events', fakeAsync(() => { + const batches: TaskCloudEngineEvent[][] = []; + service.getEngineEvents('mock-app').subscribe((events) => batches.push(events)); + + emitEvent('TASK_CREATED'); + emitEvent('PROCESS_STARTED'); + tick(3000); + + expect(batches.length).toBe(1); + expect(batches[0][0].eventType).toBe('PROCESS_STARTED'); + })); + + it('should debounce the events using the configured debounce time', fakeAsync(() => { + spyOnProperty(service, 'notificationDebounceTime', 'get').and.returnValue(5000); + let emitted = false; + service.getEngineEvents('mock-app').subscribe(() => (emitted = true)); + + emitEvent(); + tick(3000); + expect(emitted).toBeFalse(); + + tick(2000); + expect(emitted).toBeTrue(); })); }); - describe('resolveFilterCounter', () => { - it('should resolve the counter of a filter by its status', () => { - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBe(5); - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'queued', statuses: ['CREATED'] })).toBe(0); - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.PROCESS_INSTANCE, { key: 'running', status: 'RUNNING' })).toBe( - 5 - ); - }); + describe('counters driven by the engine events', () => { + it('should make a single count request for a batch of events received by both entity types', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + postSpy.calls.reset(); - it('should sum the counters of a filter targeting more than one status', () => { - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'CREATED'] })).toBe( - 5 - ); - expect( - service.resolveFilterCounter({ TASK: { ASSIGNED: 5, CREATED: 2, SUSPENDED: 3 } }, FilterCounterEntityType.TASK, { - key: 'mine', - statuses: ['ASSIGNED', 'SUSPENDED'] - }) - ).toBe(8); - }); + emitEvent('TASK_CREATED'); + emitEvent('PROCESS_STARTED'); + tick(3000); - it('should resolve the counter from the statuses held by the response', () => { - expect( - service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'COMPLETED'] }) - ).toBe(5); - }); + expect(postSpy).toHaveBeenCalledTimes(1); + })); - it('should not resolve the counter of a filter not held by the response', () => { - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'done', status: 'COMPLETED' })).toBeUndefined(); - expect(service.resolveFilterCounter({}, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBeUndefined(); - }); + it('should emit the counters resolved for the batch of events', fakeAsync(() => { + const results: any[] = []; + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); - it('should not resolve the counter of a filter targeting every status', () => { - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', status: '' })).toBeUndefined(); - expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', statuses: [] })).toBeUndefined(); - }); - }); + postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } })); + emitEvent(); + tick(3000); - describe('isCounterBatched', () => { - it('should batch the counter of a filter targeting one or more statuses', () => { - expect(service.isCounterBatched({ key: 'my-tasks', status: 'ASSIGNED' })).toBeTrue(); - expect(service.isCounterBatched({ key: 'mine', statuses: ['ASSIGNED', 'SUSPENDED'] })).toBeTrue(); - }); + expect(results.length).toBe(2); + expect(results[1]).toEqual({ counters: { 'my-tasks': 9 }, batched: true }); + })); - it('should not batch the counter of a filter targeting every status', () => { - expect(service.isCounterBatched({ key: 'all', status: '' })).toBeFalse(); - expect(service.isCounterBatched({ key: 'all', statuses: [] })).toBeFalse(); - expect(service.isCounterBatched({ key: 'all' })).toBeFalse(); - }); + it('should not subscribe to the engine events when notifications are disabled', fakeAsync(() => { + spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue)); + + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe(); + tick(3000); + + expect(makeGQLQuerySpy).not.toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledTimes(1); + })); }); }); diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts index ba1bb1e617..7ddf6e7d09 100644 --- a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts @@ -16,7 +16,7 @@ */ import { inject, Injectable } from '@angular/core'; -import { combineLatest, defer, EMPTY, Observable, of } from 'rxjs'; +import { combineLatest, defer, EMPTY, merge, Observable, of, Subject } from 'rxjs'; import { catchError, debounceTime, map, shareReplay, switchMap, take } from 'rxjs/operators'; import { BaseCloudService } from './base-cloud.service'; import { NotificationCloudService } from './notification-cloud.service'; @@ -26,22 +26,25 @@ import { ProcessFilterCloudService } from '../process/process-filters/services/p import { TaskListCloudService } from '../task/task-list/services/task-list-cloud.service'; import { ProcessListCloudService } from '../process/process-list/services/process-list-cloud.service'; import { TaskFilterCloudAdapter } from '../models/filter-cloud-model'; +import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model'; +import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model'; import { ProcessFilterCloudAdapter } from '../process/process-list/models/process-cloud-query-request.model'; import { FilterCounterCandidate, FilterCounterEntityType, FilterCounters, FilterCountersFilters, - FilterCountersNotification, FilterCountersQuery, FilterCountersRequest, - resolveFilterCounterStatuses + FilterCountersResult } from '../models/filter-counters-cloud.model'; /** * Single subscription covering both the task and the process engine events, so that a batch of * events results in one call to the batched count endpoint. */ +const BATCHED_COUNTERS_UNAVAILABLE_STATUSES = [404, 501]; + const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` subscription { engineEvents(eventType: [ @@ -65,11 +68,12 @@ const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` `; /** - * Central place handling the filter counters of the task and the process filter components: - * a single engine event subscription, debounced into a single batched count request. + * Central place handling the filter counters of the task and the process filter components: it owns + * the filters of both components and a single engine event subscription, debounced into one batched + * count request. * - * Every filter with a counter enabled is registered by its component through `registerFilters`, - * so that the counters of both the task and the process filters are resolved by one request. + * Loading the filters here is what keeps the counters of both components resolved by one request, + * both on load and on every batch of engine events. */ @Injectable({ providedIn: 'root' }) export class FilterCountersCloudService extends BaseCloudService { @@ -80,66 +84,189 @@ export class FilterCountersCloudService extends BaseCloudService { private readonly taskListCloudService = inject(TaskListCloudService); private readonly processListCloudService = inject(ProcessListCloudService); - private readonly notificationsPerApp = new Map>(); - private readonly filtersPerApp = new Map>(); - private readonly countersPerApp = new Map>(); + private readonly eventsPerApp = new Map>(); + private readonly refreshPerApp = new Map>(); + private readonly appsWithoutBatchedCounters = new Set(); + private readonly taskFiltersPerApp = new Map>(); + private readonly processFiltersPerApp = new Map>(); + private readonly countersPerApp = new Map>(); get notificationDebounceTime(): number { return this.appConfigService.get('notificationDebounceTime', 3000); } /** - * Task and process filters of the app, loaded once and shared between the filter components, - * so that a single place owns the filters the counters are resolved for. + * Task filters of the app, loaded once and shared between the task filter component and the + * batched count request, so that one place owns the filters the counters are resolved for. + * + * @param appName Name of the target app + * @returns Task filters of the app + */ + getTaskFilters(appName: string): Observable { + return this.shareFilters(this.taskFiltersPerApp, appName, () => this.taskFilterCloudService.getTaskListFilters(appName)); + } + + /** + * Process filters of the app, loaded once and shared between the process filter component and + * the batched count request. + * + * @param appName Name of the target app + * @returns Process filters of the app + */ + getProcessFilters(appName: string): Observable { + return this.shareFilters(this.processFiltersPerApp, appName, () => this.processFilterCloudService.getProcessFilters(appName)); + } + + /** + * Counters of the filters of an entity type, resolved by the request shared with the filters of + * the other entity type: once on subscription, then on every debounced batch of engine events + * and on every `refreshFilterCounters` call. + * + * @param appName Name of the target app + * @param entityType Entity type the counters are read for + * @returns Counters of the filters of the entity type, keyed by filter key + */ + getFilterCounters(appName: string, entityType: FilterCounterEntityType): Observable { + if (!appName) { + return EMPTY; + } + + return this.getCounters(appName).pipe(map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched }))); + } + + /** + * Resolves the counters of the filters of the app again, for both entity types with one request. + * + * @param appName Name of the target app + */ + refreshFilterCounters(appName: string): void { + this.getRefreshTrigger(appName).next(); + } + + /** + * Debounced batches of engine events of the app, shared between all the subscribers of the app. + * + * @param appName Name of the target app + * @returns Debounced batches of engine events + */ + getEngineEvents(appName: string): Observable { + if (!appName) { + return EMPTY; + } + + let events$ = this.eventsPerApp.get(appName); + if (!events$) { + events$ = defer(() => this.notificationCloudService.makeGQLQuery(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)).pipe( + map((events: any) => (events?.data?.engineEvents ?? []) as TaskCloudEngineEvent[]), + debounceTime(this.notificationDebounceTime), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.eventsPerApp.set(appName, events$); + } + + return events$; + } + + private get notificationsEnabled(): boolean { + return this.appConfigService.get('notifications', true); + } + + /** + * Filters of both entity types, to resolve the counters of both filter components with one + * request. The filters of an entity type that fails to load are left out, so that the counters + * of the other entity type are still resolved. * * @param appName Name of the target app * @returns Task and process filters of the app */ - getFilters(appName: string): Observable { - let filters$ = this.filtersPerApp.get(appName); - if (!filters$) { - filters$ = combineLatest({ - [FilterCounterEntityType.TASK]: this.taskFilterCloudService.getTaskListFilters(appName).pipe(catchError(() => of([]))), - [FilterCounterEntityType.PROCESS_INSTANCE]: this.processFilterCloudService.getProcessFilters(appName).pipe(catchError(() => of([]))) - }).pipe(shareReplay({ bufferSize: 1, refCount: false })); + private getFiltersForCounters(appName: string): Observable { + return combineLatest({ + [FilterCounterEntityType.TASK]: this.getTaskFilters(appName).pipe(catchError(() => of([]))), + [FilterCounterEntityType.PROCESS_INSTANCE]: this.getProcessFilters(appName).pipe(catchError(() => of([]))) + }); + } - this.filtersPerApp.set(appName, filters$); + private shareFilters(cache: Map>, appName: string, loadFilters: () => Observable): Observable { + let filters$ = cache.get(appName); + if (!filters$) { + filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: false })); + cache.set(appName, filters$); } return filters$; } /** - * Counters of every filter of the app with a counter enabled, resolved by a single request - * shared between the filter components. Both the task and the process filters are loaded - * before the request is built, so that one call resolves the counters of both components. + * Counters of both entity types, resolved by one request shared between the filter components. + * The request is sent on subscription and on every trigger of the app: a batch of engine events, + * or a refresh. * * @param appName Name of the target app - * @returns Counters keyed by entity type and status + * @returns Counters of both entity types */ - loadFilterCounters(appName: string): Observable { + private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { let counters$ = this.countersPerApp.get(appName); if (!counters$) { - counters$ = this.getFilters(appName).pipe( - take(1), - switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), - catchError(() => of({} as FilterCounters)), - shareReplay({ bufferSize: 1, refCount: false }) - ); + const triggers: Observable[] = [of(undefined), this.getRefreshTrigger(appName)]; + if (this.notificationsEnabled) { + triggers.push(this.getEngineEvents(appName)); + } + counters$ = merge(...triggers).pipe( + switchMap(() => this.resolveCounters(appName)), + shareReplay({ bufferSize: 1, refCount: true }) + ); this.countersPerApp.set(appName, counters$); } return counters$; } + /** + * Sends one count request for the filters of both entity types. A backend without the batched + * count endpoint resolves no counter, so that the filter components fall back to the counters + * resolved one filter at a time. + * + * @param appName Name of the target app + * @returns Counters of both entity types + */ + private resolveCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { + if (this.appsWithoutBatchedCounters.has(appName)) { + return of({ counters: {}, batched: false }); + } + + return this.getFiltersForCounters(appName).pipe( + take(1), + switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), + map((counters) => ({ counters, batched: true })), + catchError((error) => { + if (BATCHED_COUNTERS_UNAVAILABLE_STATUSES.includes(error?.status)) { + /* The backend of the app holds no batched count endpoint, so it is not asked again. */ + this.appsWithoutBatchedCounters.add(appName); + } + + return of({ counters: {}, batched: false }); + }) + ); + } + + private getRefreshTrigger(appName: string): Subject { + let refresh$ = this.refreshPerApp.get(appName); + if (!refresh$) { + refresh$ = new Subject(); + this.refreshPerApp.set(appName, refresh$); + } + + return refresh$; + } + /** * Builds the payload of the batched count request from the filters with a counter enabled. * * @param filters Task and process filters of the app * @returns Payload of the count request */ - buildRequest(filters: FilterCountersFilters): FilterCountersRequest { + private buildRequest(filters: FilterCountersFilters): FilterCountersRequest { const request: FilterCountersRequest = {}; const taskQueries = this.buildQueries(filters[FilterCounterEntityType.TASK], (filter) => @@ -159,12 +286,16 @@ export class FilterCountersCloudService extends BaseCloudService { return request; } - private buildQueries(filters: T[], buildQuery: (filter: T) => FilterCountersQuery): FilterCountersQuery[] { + private buildQueries( + filters: T[], + buildQuery: (filter: T) => Omit + ): FilterCountersQuery[] { return (filters ?? []) .filter((filter) => filter?.showCounter && this.isCounterBatched(filter)) .map((filter) => { try { - return buildQuery(filter); + /* Only the filters holding a key reach this point, so every query is identified by one. */ + return { ...buildQuery(filter), requestId: filter.key as string }; } catch { /* A filter the query cannot be built for is left out of the batch and counted on its own. */ return undefined; @@ -173,40 +304,6 @@ export class FilterCountersCloudService extends BaseCloudService { .filter((query) => !!query); } - /** - * Engine events of the app, debounced and enriched with the counters resolved by a single - * call to the batched count endpoint. The underlying subscription and count request are - * shared between all the subscribers of the same app. - * - * @param appName Name of the target app - * @returns Debounced engine events along with the resolved counters - */ - getFilterCountersNotifications(appName: string): Observable { - if (!appName) { - return EMPTY; - } - - let notifications$ = this.notificationsPerApp.get(appName); - if (!notifications$) { - notifications$ = defer(() => this.notificationCloudService.makeGQLQuery(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)).pipe( - map((events: any) => (events?.data?.engineEvents ?? []) as TaskCloudEngineEvent[]), - debounceTime(this.notificationDebounceTime), - switchMap((events) => - this.getFilters(appName).pipe( - take(1), - switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), - map((counters) => ({ events, counters })), - catchError(() => of({ events, counters: {} as FilterCounters })) - ) - ), - shareReplay({ bufferSize: 1, refCount: true }) - ); - this.notificationsPerApp.set(appName, notifications$); - } - - return notifications$; - } - /** * Resolves the counters of the given queries with a single request. * @@ -214,7 +311,7 @@ export class FilterCountersCloudService extends BaseCloudService { * @param request Payload of the count request * @returns Counters keyed by entity type and status */ - fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable { + private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable { if (!Object.keys(request).length) { return of({}); } @@ -225,37 +322,13 @@ export class FilterCountersCloudService extends BaseCloudService { } /** - * Reads the counter of a filter from a count response. The counters are keyed by status, so - * the counter of a filter targeting more than one status is the sum of the counters of its statuses. - * A filter targeting every status holds no status to be keyed by, so its counter is not resolved - * by the batched request and is left to be fetched on its own. - * - * @param counters Counters resolved by the batched count endpoint - * @param entityType Entity type of the filter - * @param filter Filter the counter is read for - * @returns The counter of the filter, or `undefined` when the response holds no counter for it - */ - resolveFilterCounter(counters: FilterCounters, entityType: FilterCounterEntityType, filter: FilterCounterCandidate): number | undefined { - const entityCounters = counters?.[entityType]; - const statuses = resolveFilterCounterStatuses(filter); - - if (!entityCounters || !statuses.length) { - return undefined; - } - - const countedStatuses = statuses.filter((status) => entityCounters[status] !== undefined); - - return countedStatuses.length ? countedStatuses.reduce((total, status) => total + entityCounters[status], 0) : undefined; - } - - /** - * Whether the counter of a filter is resolved by the batched count request. A filter targeting - * every status is not, since the counters of the response are keyed by status. + * Whether the counter of a filter is resolved by the batched count request. A filter without a + * key holds no `requestId` its counter could be keyed by, so it is left to be fetched on its own. * * @param filter Filter with a counter enabled * @returns `true` when the counter of the filter is resolved by the batched request, otherwise `false` */ - isCounterBatched(filter: FilterCounterCandidate): boolean { - return resolveFilterCounterStatuses(filter).length > 0; + private isCounterBatched(filter: FilterCounterCandidate): boolean { + return !!filter?.key; } } diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts b/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts deleted file mode 100644 index 29178d11a0..0000000000 --- a/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts +++ /dev/null @@ -1,193 +0,0 @@ -/*! - * @license - * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { DestroyRef } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { FilterCountersCloudService } from './filter-counters-cloud.service'; -import { FilterCounterCandidate, FilterCounterEntityType, FilterCounters } from '../models/filter-counters-cloud.model'; - -export interface FilterCounterAdapter { - getFilterCounter(filter: TFilter): Observable; -} - -export interface FilterCountersManagerCallbacks { - onCountersUpdated?: () => void; - onFilterUpdated?: (filterKey: string) => void; -} - -/** - * Manages filter counters for a specific entity type using composition. - * Handles loading, updating, and notification subscriptions for filter counters. - */ -export class FilterCountersManager { - counters: { [key: string]: number } = {}; - currentFiltersValues: { [key: string]: number } = {}; - updatedFiltersSet = new Set(); - - private filters: TFilter[] = []; - - constructor( - private readonly entityType: FilterCounterEntityType, - private readonly filterCountersService: FilterCountersCloudService, - private readonly counterAdapter: FilterCounterAdapter, - private readonly destroyRef: DestroyRef, - private readonly callbacks: FilterCountersManagerCallbacks = {} - ) {} - - /** - * Initialize counters for all filters to 0 - * - * @param filters List of filters to initialize counters for - */ - initCounters(filters: TFilter[]): void { - this.filters = filters; - filters.forEach((filter) => (this.counters[filter.key] = 0)); - } - - /** - * Load filter counters on initial page load using the batched endpoint - * - * @param appName Name of the target app - */ - loadCounters(appName: string): void { - this.filterCountersService - .loadFilterCounters(appName) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((counters) => this.applyBatchedCounters(counters)); - } - - /** - * Subscribe to real-time counter updates via notifications - * - * @param appName Name of the target app - */ - subscribeToNotifications(appName: string): void { - if (!appName) { - return; - } - - this.filterCountersService - .getFilterCountersNotifications(appName) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ counters }) => { - this.applyBatchedCounters(counters); - }); - } - - /** - * Apply counters from the batched response, falling back to individual requests - * for filters not resolved by the batch. - * - * @param counters Batched filter counters - */ - private applyBatchedCounters(counters: FilterCounters): void { - this.filters.forEach((filter) => { - if (!filter?.showCounter) { - return; - } - - const filterCounter = this.filterCountersService.resolveFilterCounter(counters, this.entityType, filter); - if (filterCounter === undefined) { - this.updateSingleCounter(filter); - return; - } - - this.setCounter(filter.key, filterCounter); - }); - - this.callbacks.onCountersUpdated?.(); - } - - /** - * Update counter for a single filter using individual request - * - * @param filter Filter to update the counter for - */ - updateSingleCounter(filter: TFilter): void { - if (!filter?.showCounter) { - return; - } - - this.counterAdapter - .getFilterCounter(filter) - .pipe( - tap((filterCounter) => { - this.setCounter(filter.key, filterCounter); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(); - } - - /** - * Update counters for all filters - */ - updateAllCounters(): void { - this.filters.forEach((filter) => this.updateSingleCounter(filter)); - } - - /** - * Set counter value and track if it changed - * - * @param filterKey Key of the filter to update - * @param filterValue New counter value for the filter - */ - private setCounter(filterKey: string, filterValue: number): void { - if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { - this.currentFiltersValues[filterKey] = filterValue; - this.updatedFiltersSet.add(filterKey); - this.callbacks.onFilterUpdated?.(filterKey); - } - - this.counters = { - ...this.counters, - [filterKey]: filterValue - }; - } - - /** - * Check if a filter has been updated - * - * @param filterKey Key of the filter to check - * @returns True if the filter has been updated, false otherwise - */ - isFilterUpdated(filterKey: string): boolean { - return this.updatedFiltersSet.has(filterKey); - } - - /** - * Mark a filter as viewed/not updated - * - * @param filterKey Key of the filter to reset - */ - resetFilterUpdate(filterKey: string): void { - this.updatedFiltersSet.delete(filterKey); - } - - /** - * Subscribe to external refresh events from the filter service - * - * @param refreshSignal$ Observable emitting filter keys to refresh - */ - subscribeToExternalRefresh(refreshSignal$: Observable): void { - refreshSignal$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => { - this.updatedFiltersSet.delete(filterKey); - }); - } -} diff --git a/lib/process-services-cloud/src/lib/services/public-api.ts b/lib/process-services-cloud/src/lib/services/public-api.ts index 52af2c8e9b..e300e35a83 100644 --- a/lib/process-services-cloud/src/lib/services/public-api.ts +++ b/lib/process-services-cloud/src/lib/services/public-api.ts @@ -18,7 +18,6 @@ export * from './base-cloud.service'; export * from './cloud-token.service'; export * from './filter-counters-cloud.service'; -export * from './filter-counters-manager'; export * from './form-fields.interfaces'; export * from './local-preference-cloud.service'; export * from './notification-cloud.service'; diff --git a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts index b1073d0ed6..338c71e4cd 100644 --- a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts +++ b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts @@ -20,9 +20,9 @@ import { Component, SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { first, of, Subject, throwError } from 'rxjs'; -import { TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service'; +import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service'; import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service'; -import { defaultTaskFiltersMock, fakeAllTaskFilter, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock'; +import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock'; import { TaskFilterCloudService } from '../../services/task-filter-cloud.service'; import { TaskFiltersCloudComponent } from './task-filters-cloud.component'; import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service'; @@ -36,7 +36,7 @@ import { MatIconHarness } from '@angular/material/icon/testing'; import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; -import { FilterCountersNotification } from '../../../../models/filter-counters-cloud.model'; +import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; @Component({ selector: 'adf-cloud-dummy', template: '' }) class DummyComponent {} @@ -52,20 +52,18 @@ describe('TaskFiltersCloudComponent', () => { let getTaskFilterCounterSpy: jasmine.Spy; let getTaskListFiltersSpy: jasmine.Spy; let getTaskListCountSpy: jasmine.Spy; - let getFilterCountersNotificationsSpy: jasmine.Spy; + let getEngineEventsSpy: jasmine.Spy; let filterCountersService: FilterCountersCloudService; + let getFilterCountersSpy: jasmine.Spy; + let refreshFilterCountersSpy: jasmine.Spy; let router: Router; - const filterCountersNotificationMock: FilterCountersNotification = { - events: taskNotifications, - counters: { TASK: { ASSIGNED: 11, CREATED: 0 } } - }; - const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => { TestBed.configureTestingModule({ imports: [NoopAuthModule, TaskFiltersCloudComponent, ApolloTestingModule], providers: [ { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, + { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, provideRouter([{ path: 'task-list-cloud', component: DummyComponent }]), { provide: ActivatedRoute, @@ -87,10 +85,12 @@ describe('TaskFiltersCloudComponent', () => { filterCountersService = TestBed.inject(FilterCountersCloudService); getTaskFilterCounterSpy = spyOn(taskFilterService, 'getTaskFilterCounter').and.returnValue(of(11)); getTaskListCountSpy = spyOn(taskListService, 'getTaskListCount').and.returnValue(of(11)); - getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue( - of(filterCountersNotificationMock) + getEngineEventsSpy = spyOn(filterCountersService, 'getEngineEvents').and.returnValue(of(taskNotifications)); + getTaskListFiltersSpy = spyOn(filterCountersService, 'getTaskFilters').and.returnValue(of(fakeGlobalFilter)); + getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue( + of({ counters: { 'fake-involved-tasks': 11 }, batched: true }) ); - getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter)); + refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters'); appConfigService = TestBed.inject(AppConfigService); @@ -272,7 +272,7 @@ describe('TaskFiltersCloudComponent', () => { expect(updatedFilterCounters.length).toBe(0); }); - it('should update filter counter when filter is selected', async () => { + it('should refresh the filter counters when a filter is selected', async () => { component.showIcons = true; await bindAppName(); @@ -280,7 +280,7 @@ describe('TaskFiltersCloudComponent', () => { filterButton.click(); fixture.detectChanges(); - expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]); + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1'); }); describe('Notifications config', () => { @@ -317,12 +317,12 @@ describe('TaskFiltersCloudComponent', () => { }); it('should not subscribe to notifications when appName is missing', () => { - getFilterCountersNotificationsSpy.calls.reset(); + getEngineEventsSpy.calls.reset(); component.appName = ''; fixture.detectChanges(); - expect(getFilterCountersNotificationsSpy).not.toHaveBeenCalled(); + expect(getEngineEventsSpy).not.toHaveBeenCalled(); }); it('should subscribe to the notifications of the bound app', () => { @@ -330,45 +330,18 @@ describe('TaskFiltersCloudComponent', () => { fixture.detectChanges(); - expect(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('my-app-1'); + expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1'); }); - it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => { - const notifications$ = new Subject(); - getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); - component.appName = 'my-app-1'; - - fixture.detectChanges(); - - notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } }); - - expect(component.counters['fake-involved-tasks']).toBe(7); - flush(); - })); - - it('should not update the counters of the filters not resolved by the batched count request', fakeAsync(() => { - const notifications$ = new Subject(); - getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); - component.appName = 'my-app-1'; - - fixture.detectChanges(); - component.counters = { ...component.counters, 'fake-my-task1': 3 }; - - notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } }); - - expect(component.counters['fake-my-task1']).toBe(3); - flush(); - })); - - it('should emit the events of the batch that triggered the count request', fakeAsync(() => { - const notifications$ = new Subject(); - getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); + it('should emit the events of the debounced batch', fakeAsync(() => { + const events$ = new Subject(); + getEngineEventsSpy.and.returnValue(events$.asObservable()); const filterCounterUpdatedSpy = spyOn(component.filterCounterUpdated, 'emit'); component.appName = 'my-app-1'; fixture.detectChanges(); - notifications$.next(filterCountersNotificationMock); + events$.next(taskNotifications); expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications); flush(); @@ -479,7 +452,7 @@ describe('TaskFiltersCloudComponent', () => { expect(updatedFilterCounters.length).toBe(0); }); - it('should update filter counter when filter is selected', async () => { + it('should refresh the filter counters when a filter is selected', async () => { await bindAppName(); const filterButton = await loader.getHarness( @@ -487,6 +460,14 @@ describe('TaskFiltersCloudComponent', () => { ); await filterButton.click(); + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1'); + }); + + it('should resolve the counters with the POST method when the batched endpoint is not available', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false })); + + await bindAppName(); + expect(getTaskListCountSpy).toHaveBeenCalledWith(new TaskFilterCloudAdapter(fakeGlobalFilter[0])); }); }); @@ -699,104 +680,68 @@ describe('TaskFiltersCloudComponent', () => { expect(component.updatedCountersSet.has(fakeFilterKey)).toBe(true); }); - it('should call fetchTaskFilterCounter only if filter.showCounter is true', () => { + it('should resolve the counter only of the filters with a counter enabled', () => { const filterWithCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true }); const filterWithoutCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false }); - const fetchSpy = spyOn(component, 'fetchTaskFilterCounter').and.returnValue(of(42)); + getTaskFilterCounterSpy.calls.reset(); component.filters = [filterWithCounter, filterWithoutCounter]; component.updateFilterCounters(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter); - expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter); + expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1); + expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(filterWithCounter); }); - describe('Batched counters registration', () => { - let registerFiltersSpy: jasmine.Spy; + describe('Batched counters', () => { + it('should read the counters of the task filters of the bound app', async () => { + await bindAppName(); - beforeEach(() => { - registerFiltersSpy = spyOn(filterCountersService, 'registerFilters'); + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK); }); - const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1]; - - it('should register every filter with a counter enabled', async () => { - getTaskListFiltersSpy.and.returnValue( - of([ - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }), - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' }), - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[2], showCounter: true, sort: 'createdDate', order: 'DESC' }) - ]) - ); + it('should hold the counters resolved by the batched count request', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true })); await bindAppName(); - expect(registerFiltersSpy).toHaveBeenCalledWith('TASK', jasmine.any(Array)); - expect(registeredQueries().length).toBe(3); - expect(registeredQueries().map((query: any) => query.status)).toEqual([['CREATED'], ['ASSIGNED'], ['COMPLETED']]); + expect(component.counters['fake-involved-tasks']).toBe(9); }); - it('should not register a filter without a counter enabled', async () => { - getTaskListFiltersSpy.and.returnValue( - of([ - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }), - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false, sort: 'createdDate', order: 'DESC' }) - ]) - ); + it('should emit the filters whose counter changed', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true })); + const updatedFilterSpy = spyOn(component.updatedFilter, 'emit'); await bindAppName(); - expect(registeredQueries().length).toBe(1); - expect(registeredQueries()[0].status).toEqual(['CREATED']); + expect(updatedFilterSpy).toHaveBeenCalledWith('fake-involved-tasks'); }); - it('should register the full criteria of a filter', async () => { - getTaskListFiltersSpy.and.returnValue( - of([ - new TaskFilterCloudModel({ - ...defaultTaskFiltersMock[1], - showCounter: true, - sort: 'createdDate', - order: 'DESC', - assignee: 'mock-user', - priority: 4 - }) - ]) - ); + it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false })); await bindAppName(); - const query = registeredQueries()[0]; - expect(query.status).toEqual(['ASSIGNED']); - expect(query.assignee).toEqual(['mock-user']); - expect(query.priority).toEqual(['4']); - expect(query.sort).toEqual({ field: 'createdDate', direction: 'desc', isProcessVariable: false }); + expect(getTaskFilterCounterSpy).toHaveBeenCalled(); + expect(component.counters['fake-involved-tasks']).toBe(11); }); - it('should not register a filter targeting every status', async () => { - getTaskListFiltersSpy.and.returnValue( - of([new TaskFilterCloudModel({ ...fakeAllTaskFilter, showCounter: true, sort: 'createdDate', order: 'DESC' })]) - ); - + it('should refresh the counters of every filter when a filter is clicked', async () => { await bindAppName(); - expect(registeredQueries().length).toBe(0); + component.onFilterClick(fakeGlobalFilter[0]); + + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1'); }); - it('should not break the filter list when the query of a filter cannot be built', async () => { - getTaskListFiltersSpy.and.returnValue( - of([ - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: undefined, order: undefined }), - new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' }) - ]) - ); - + it('should refresh the counter of the clicked filter alone when the batched endpoint is not available', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false })); await bindAppName(); + getTaskFilterCounterSpy.calls.reset(); - expect(component.filters.length).toBe(2); - expect(registeredQueries().length).toBe(1); - expect(registeredQueries()[0].status).toEqual(['ASSIGNED']); + component.onFilterClick(fakeGlobalFilter[0]); + + expect(refreshFilterCountersSpy).not.toHaveBeenCalled(); + expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts index d1dfc547eb..e6486945f9 100644 --- a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts @@ -16,7 +16,7 @@ */ import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { Observable, of, Subscription } from 'rxjs'; import { TaskFilterCloudService } from '../../services/task-filter-cloud.service'; import { FilterParamsModel, TaskFilterCloudModel } from '../../models/filter-cloud.model'; import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core'; @@ -28,7 +28,6 @@ import { TaskListCloudService } from '../../../task-list/services/task-list-clou import { TaskFilterCloudAdapter } from '../../../../models/filter-cloud-model'; import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; -import { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; @@ -45,7 +44,13 @@ import { AsyncPipe } from '@angular/common'; export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent implements OnInit, OnChanges { protected readonly TASKS_ROUTE = '/task-list-cloud'; - /** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count. */ + /** + * (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count. + * + * @deprecated the counters are resolved by `POST /query/v1/count`, which requires Activiti 8.7.0 + * forward. This input is only used by the backends without that endpoint and will be removed, + * along with the 'GET' method, in ADF 10.0.0. + */ @Input() searchApiMethod: 'GET' | 'POST' = 'GET'; @@ -70,8 +75,10 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp currentFilter: TaskFilterCloudModel; enableNotifications = true; notificationDebounceTime = 3000; + currentFiltersValues: { [key: string]: number } = {}; private filtersLoadedFor?: string; - private countersManager: FilterCountersManager; + private countersSubscription?: Subscription; + private batchedCounters = true; private readonly taskFilterCloudService = inject(TaskFilterCloudService); private readonly taskListCloudService = inject(TaskListCloudService); @@ -85,49 +92,17 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.enableNotifications = this.appConfigService.get('notifications', true); this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000); - if (!this.countersManager) { - this.initCountersManager(); - } - if (!this.filtersLoadedFor) { this.getFilters(this.appName); } this.initFilterCounterNotifications(); - this.countersManager.subscribeToExternalRefresh(this.taskFilterCloudService.filterKeyToBeRefreshed$); - } - - private initCountersManager(): void { - const counterAdapter: FilterCounterAdapter = { - getFilterCounter: (filter) => - this.searchApiMethod === 'POST' - ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter)) - : this.taskFilterCloudService.getTaskFilterCounter(filter) - }; - - this.countersManager = new FilterCountersManager( - FilterCounterEntityType.TASK, - this.filterCountersCloudService, - counterAdapter, - this.destroyRef, - { - onFilterUpdated: (filterKey) => { - this.updatedFilter.emit(filterKey); - this.counters = this.countersManager.counters; - }, - onCountersUpdated: () => { - this.counters = this.countersManager.counters; - } - } - ); + this.getFilterKeysAfterExternalRefreshing(); } ngOnChanges(changes: SimpleChanges) { const appName = changes['appName']; const filter = changes['filterParam']; if (appName && appName.currentValue !== appName.previousValue) { - if (!this.countersManager) { - this.initCountersManager(); - } this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); @@ -141,18 +116,16 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp */ getFilters(appName: string): void { this.filtersLoadedFor = appName; - const filters$ = this.filterCountersCloudService - .getFilters(appName) - .pipe(map((filters) => filters[FilterCounterEntityType.TASK] as TaskFilterCloudModel[])); + const filters$ = this.filterCountersCloudService.getTaskFilters(appName); this.filters$ = filters$.pipe(catchError(() => of([]))); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { this.resetFilter(); this.filters = res || []; - this.countersManager.initCounters(this.filters); + this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); - this.countersManager.loadCounters(appName); + this.loadFilterCounters(appName); this.success.emit(res); }, error: (err) => { @@ -161,40 +134,59 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp }); } + /** + * Initialize counter collection for filters + */ + initFilterCounters(): void { + this.filters.forEach((filter) => (this.counters[filter.key] = 0)); + } + /** * Iterate over filters and update counters + * + * @deprecated the counters are resolved by the batched count request. This resolves them one + * filter at a time, for the backends without the batched count endpoint. */ updateFilterCounters(): void { - this.countersManager.updateAllCounters(); + this.filters.forEach((filter) => this.updateFilterCounter(filter)); } /** * Get current value for filter and check if value has changed * * @param filter filter + * @deprecated the counters are resolved by the batched count request. This resolves the counter + * of one filter, for the backends without the batched count endpoint. */ updateFilterCounter(filter: TaskFilterCloudModel): void { - this.countersManager.updateSingleCounter(filter); - this.counters = this.countersManager.counters; + if (!filter?.showCounter) { + return; + } + + this.fetchTaskFilterCounter(filter) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((counter) => { + this.checkIfFilterValuesHasBeenUpdated(filter.key, counter); + this.counters = { ...this.counters, [filter.key]: counter }; + }); } initFilterCounterNotifications() { - if (!this.appName || !this.enableNotifications) { + if (!this.appName) { + return; + } + if (!this.enableNotifications) { + this.counters = {}; return; } this.filterCountersCloudService - .getFilterCountersNotifications(this.appName) + .getEngineEvents(this.appName) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ events }) => { - events.forEach((taskEvent) => { - this.checkFilterCounter(taskEvent.entity); - }); - + .subscribe((events) => { + events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity)); this.filterCounterUpdated.emit(events); }); - - this.countersManager.subscribeToNotifications(this.appName); } checkFilterCounter(filterNotification: TaskDetailsCloudModel) { @@ -235,7 +227,6 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.selectFilter(newParamFilter); if (this.currentFilter) { - this.countersManager.resetFilterUpdate(this.currentFilter.key); this.resetFilterCounter(this.currentFilter.key); this.filterSelected.emit(this.currentFilter); } @@ -252,9 +243,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp onFilterClick(filter: FilterParamsModel) { if (filter) { this.selectFilter(filter); - this.updateFilterCounter(this.currentFilter); + this.refreshFilterCounter(this.currentFilter); this.filterClicked.emit(this.currentFilter); - this.countersManager.resetFilterUpdate(filter.key); this.updatedCountersSet.delete(filter.key); } else { this.currentFilter = undefined; @@ -280,6 +270,62 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp return this.filters === undefined || (this.filters && this.filters.length === 0); } + checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) { + if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { + this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue }; + this.updatedFilter.emit(filterKey); + this.updatedCountersSet.add(filterKey); + } + } + + /** + * Flags the counter of a filter as read whenever the filter is refreshed by an external action + */ + getFilterKeysAfterExternalRefreshing(): void { + this.taskFilterCloudService.filterKeyToBeRefreshed$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((filterKey: string) => this.updatedCountersSet.delete(filterKey)); + } + + /** + * Resolves the counters of the filters, with one request shared with the process filters. The + * counters of a backend without the batched count endpoint are resolved one filter at a time. + * + * @param appName application name + */ + private loadFilterCounters(appName: string): void { + this.countersSubscription?.unsubscribe(); + this.countersSubscription = this.filterCountersCloudService + .getFilterCounters(appName, FilterCounterEntityType.TASK) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ counters, batched }) => { + this.batchedCounters = batched; + if (batched) { + this.applyFilterCounters(counters); + } else { + this.updateFilterCounters(); + } + }); + } + + /** + * Holds the counters resolved by the batched count request, which are keyed by filter key. + * + * @param counters counters keyed by filter key + */ + private applyFilterCounters(counters: { [filterKey: string]: number }): void { + Object.entries(counters).forEach(([filterKey, counter]) => { + this.checkIfFilterValuesHasBeenUpdated(filterKey, counter); + this.counters = { ...this.counters, [filterKey]: counter }; + }); + } + + private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable { + return this.searchApiMethod === 'POST' + ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter)) + : this.taskFilterCloudService.getTaskFilterCounter(filter); + } + /** * Reset the filters properties */ @@ -288,13 +334,17 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.currentFilter = undefined; } - checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) { - if ( - this.countersManager.currentFiltersValues[filterKey] === undefined || - this.countersManager.currentFiltersValues[filterKey] !== filterValue - ) { - this.updatedFilter.emit(filterKey); - this.updatedCountersSet.add(filterKey); + /** + * Resolves the counters again, with one request for every filter of the app. The counter of the + * given filter alone is resolved when the batched count endpoint is not available. + * + * @param filter filter that was selected + */ + private refreshFilterCounter(filter: TaskFilterCloudModel): void { + if (this.batchedCounters) { + this.filterCountersCloudService.refreshFilterCounters(this.appName); + } else { + this.updateFilterCounter(filter); } } } diff --git a/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts b/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts index cf784dee54..644674eada 100644 --- a/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts @@ -362,7 +362,7 @@ export class TaskFilterCloudService extends BaseCloudService { } /** - * @deprecated use FilterCountersCloudService.getFilterCountersNotifications instead, which shares a single + * @deprecated use FilterCountersCloudService.getEngineEvents instead, which shares a single * subscription with the process filters and resolves the counters with a single request. * @param appName Name of the target app * @returns Task engine events