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..31b3b4a6fe 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 | +| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. | ### 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..acc85e1a68 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. | +| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. | ### 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 new file mode 100644 index 0000000000..5725360d1a --- /dev/null +++ b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts @@ -0,0 +1,55 @@ +/*! + * @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. + */ + +export const FilterCounterEntityType = { + TASK: 'TASK', + PROCESS_INSTANCE: 'PROCESS_INSTANCE' +} as const; + +export type FilterCounterEntityType = (typeof FilterCounterEntityType)[keyof typeof FilterCounterEntityType]; + +export interface FilterCountersQuerySort { + field: string; + direction: string; + isProcessVariable: boolean; +} + +export interface FilterCountersQuery { + requestId: string; + status?: string[]; + assignee?: string[]; + sort?: FilterCountersQuerySort; + [criteria: string]: unknown; +} + +export type FilterCountersRequest = { + [entityType in FilterCounterEntityType]?: FilterCountersQuery[]; +}; + +export interface FilterCounterCandidate { + key?: string | null; + showCounter?: boolean; +} + +export type FilterCounters = { + [entityType in FilterCounterEntityType]?: { [requestId: string]: number }; +}; + +export interface FilterCountersResult { + counters: { [filterKey: string]: number }; + 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 8c90e5f42a..b3ca4b78f7 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 @@ -16,15 +16,15 @@ */ import { Component, SimpleChange } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; 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 } from '@alfresco/adf-core'; +import { AppConfigService, AppConfigServiceMock, NoopAuthModule } from '@alfresco/adf-core'; import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service'; import { ApolloTestingModule } from 'apollo-angular/testing'; import { HarnessLoader } from '@angular/cdk/testing'; @@ -32,39 +32,39 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatIconHarness } from '@angular/material/icon/testing'; import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; -import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model'; +import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; +import { FilterCounterEntityType, FilterCountersResult } from '../../../../models/filter-counters-cloud.model'; +import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model'; @Component({ selector: 'adf-cloud-dummy', template: '' }) class DummyComponent {} const ProcessFilterCloudServiceMock = { getProcessFilters: () => of(mockProcessFilters), - getProcessNotificationSubscription: () => of([]), filterKeyToBeRefreshed$: of(mockProcessFilters[0].key) }; describe('ProcessFiltersCloudComponent', () => { let processFilterService: ProcessFilterCloudService; + let filterCountersService: FilterCountersCloudService; + let processListService: ProcessListCloudService; let component: ProcessFiltersCloudComponent; let fixture: ComponentFixture; let getProcessFiltersSpy: jasmine.Spy; - let getProcessNotificationSubscriptionSpy: jasmine.Spy; + let getFilterCountersSpy: jasmine.Spy; + let refreshFilterCountersSpy: jasmine.Spy; + let getProcessCounterSpy: jasmine.Spy; let loader: HarnessLoader; let router: Router; const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => { TestBed.configureTestingModule({ - imports: [ProcessFiltersCloudComponent, ApolloTestingModule], + imports: [NoopAuthModule, ProcessFiltersCloudComponent, ApolloTestingModule], providers: [ { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, + { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, { provide: AppConfigService, useClass: AppConfigServiceMock }, - { - provide: ProcessListCloudService, - useValue: { - getProcessCounter: () => of(10), - getProcessListCount: () => of(10) - } - }, + ProcessListCloudService, { provide: ProcessFilterCloudService, useValue: ProcessFilterCloudServiceMock }, provideRouter([{ path: 'process-list-cloud', component: DummyComponent }]), { @@ -88,11 +88,16 @@ describe('ProcessFiltersCloudComponent', () => { component.searchApiMethod = searchApiMethod; processFilterService = TestBed.inject(ProcessFilterCloudService); + filterCountersService = TestBed.inject(FilterCountersCloudService); + processListService = TestBed.inject(ProcessListCloudService); TestBed.inject(ActivatedRoute); router = TestBed.inject(Router); await RouterTestingHarness.create(); - getProcessFiltersSpy = spyOn(processFilterService, 'getProcessFilters').and.returnValue(of(mockProcessFilters)); - getProcessNotificationSubscriptionSpy = spyOn(processFilterService, 'getProcessNotificationSubscription').and.returnValue(of([])); + 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)); }; const bindAppName = async (appName = 'my-app-1') => { @@ -463,17 +468,98 @@ 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', () => { + 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, false); + }); + + it('should not ask for the batched count endpoint by default', async () => { + await bindAppName('mock-app-name'); + + expect(component.useBatchedCounters).toBeFalse(); + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false); + }); + + it('should ask for the batched count endpoint when the input is set', async () => { + fixture.componentRef.setInput('useBatchedCounters', true); + + await bindAppName('mock-app-name'); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true); + }); + + 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 resolve the counters of the filters the batch left out on their own', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + + await bindAppName('mock-app-name'); + + expect(component.counters['FakeRunningProcesses']).toBe(9); + expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']); + }); + + it('should keep the counters of the other filters when one counter cannot be resolved', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + getProcessCounterSpy.and.throwError('the query of the filter cannot be built'); + + await bindAppName('mock-app-name'); + + expect(component.counters['FakeRunningProcesses']).toBe(9); + expect(component.counters['completed-processes']).toBe(0); + }); + + 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', () => { @@ -507,39 +593,436 @@ describe('ProcessFiltersCloudComponent', () => { expect(component.notificationDebounceTime).toBe(5000); }); - it('should debounce notification subscription using the configured debounce time', fakeAsync(() => { - const notifications$ = new Subject(); - getProcessNotificationSubscriptionSpy.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) => new ProcessFilterCloudModel({ ...filter, showCounter: true })); - const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters'); - - notifications$.next([]); - tick(1000); - expect(updateFilterCountersSpy).not.toHaveBeenCalled(); - - tick(2000); - expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1); + counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true }); + expect(component.counters['FakeRunningProcesses']).toBe(7); 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(getProcessNotificationSubscriptionSpy).toHaveBeenCalled(); + + 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(getProcessNotificationSubscriptionSpy).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, false); + }); + + it('should not ask for the batched count endpoint by default', async () => { + await bindAppName('mock-app-name'); + + expect(component.useBatchedCounters).toBeFalse(); + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false); + }); + + it('should ask for the batched count endpoint when the input is set', async () => { + fixture.componentRef.setInput('useBatchedCounters', true); + + await bindAppName('mock-app-name'); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true); + }); + + 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 resolve the counters of the filters the batch left out on their own', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + + await bindAppName('mock-app-name'); + + expect(component.counters['FakeRunningProcesses']).toBe(9); + expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']); + }); + + it('should keep the counters of the other filters when one counter cannot be resolved', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); + getProcessCounterSpy.and.throwError('the query of the filter cannot be built'); + + await bindAppName('mock-app-name'); + + expect(component.counters['FakeRunningProcesses']).toBe(9); + expect(component.counters['completed-processes']).toBe(0); + }); + + 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) => new ProcessFilterCloudModel({ ...filter, showCounter: true })); + + 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) => new ProcessFilterCloudModel({ ...filter, showCounter: true })); + 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, false); }); 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 9e7fc7ba5f..d7001220d4 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,14 +16,16 @@ */ import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { EMPTY, Observable } from 'rxjs'; +import { combineLatest, defer, EMPTY, Observable, of, 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'; import { FilterParamsModel } from '../../../../task/task-filters/models/filter-cloud.model'; -import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service'; 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 { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { TranslatePipe } from '@ngx-translate/core'; import { AsyncPipe } from '@angular/common'; @@ -43,10 +45,21 @@ 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. + * + */ @Input() searchApiMethod: 'GET' | 'POST' = 'GET'; + /** + * (optional) Resolves the counters of the task and the process filters with a single call to + * `POST /query/v1/count`. Both filter components have to + * ask for it, otherwise the counters are resolved one filter at a time. + */ + @Input() + useBatchedCounters = false; + /** (optional) The filter to be selected by default */ @Input() filterParam: FilterParamsModel; @@ -79,27 +92,31 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { currentFilter?: ProcessFilterCloudModel; filters: ProcessFilterCloudModel[] = []; counters: { [key: string]: number } = {}; - enableNotifications = true; - notificationDebounceTime = 3000; currentFiltersValues: { [key: string]: number } = {}; updatedFiltersSet = new Set(); + enableNotifications = true; + notificationDebounceTime = 3000; private filtersLoadedFor?: string; + private countersSubscription?: Subscription; + private countersFilters$?: Observable; + private batchedCounters = true; private readonly destroyRef = inject(DestroyRef); private readonly processFilterCloudService = inject(ProcessFilterCloudService); private readonly translationService = inject(TranslationService); private readonly appConfigService = inject(AppConfigService); private readonly processListCloudService = inject(ProcessListCloudService); + private readonly filterCountersCloudService = inject(FilterCountersCloudService); private readonly activatedRoute = inject(ActivatedRoute); protected readonly currentRouteFilterId = toSignal(this.activatedRoute.queryParamMap.pipe(map((params) => params.get('filterId')))); ngOnInit() { this.enableNotifications = this.appConfigService.get('notifications', true); this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000); + if (!this.filtersLoadedFor) { this.getFilters(this.appName); } - this.initProcessNotification(); this.getFilterKeysAfterExternalRefreshing(); } @@ -110,6 +127,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); + } else if (changes['useBatchedCounters'] && !changes['useBatchedCounters'].firstChange && this.filtersLoadedFor) { + this.loadFilterCounters(this.filtersLoadedFor); } } @@ -120,8 +139,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { */ getFilters(appName: string): void { this.filtersLoadedFor = appName; - const filters$ = this.processFilterCloudService.getProcessFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true })); - this.filters$ = filters$.pipe(catchError(() => EMPTY)); + const filters$ = this.filterCountersCloudService.getProcessFilters(appName); + this.filters$ = filters$.pipe(catchError(() => of([]))); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { @@ -130,19 +149,25 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); this.success.emit(res); - this.updateFilterCounters(); }, - error: (err: any) => { + error: (err: unknown) => { this.error.emit(err); } }); + + this.countersFilters$ = filters$; + this.loadFilterCounters(appName); } /** * Initialize counter collection for filters */ - initFilterCounters() { - this.filters.forEach((filter) => (this.counters[filter.key] = 0)); + initFilterCounters(): void { + this.filters.forEach((filter) => { + if (filter.key) { + this.counters[filter.key] = 0; + } + }); } /** @@ -167,20 +192,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 * @@ -213,7 +224,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { if (filter) { this.selectFilter(filter); this.filterClicked.emit(this.currentFilter); - this.updateFilterCounter(this.currentFilter); + this.refreshFilterCounter(this.currentFilter); this.updatedFiltersSet.delete(filter.key); } else { this.currentFilter = undefined; @@ -247,6 +258,83 @@ 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; + } + + /** + * @deprecated does nothing: the counters keep themselves in sync. Removed in ADF 10.0.0. + */ + initProcessNotification(): void {} + + /** + * Iterate over filters and update counters + * + * @deprecated counts one filter at a time. Removed in ADF 10.0.0. + */ + updateFilterCounters(): void { + this.filters.forEach((filter) => this.updateFilterCounter(filter)); + } + + /** + * Get current value for filter and check if value has changed + * + * @param filter filter + * @deprecated counts one filter at a time. Removed in ADF 10.0.0. + */ + updateFilterCounter(filter: ProcessFilterCloudModel): void { + const filterKey = filter?.showCounter ? filter.key : undefined; + if (!filterKey) { + return; + } + + defer(() => this.fetchProcessFilterCounter(filter)) + .pipe( + catchError(() => EMPTY), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((counter) => { + this.checkIfFilterValuesHasBeenUpdated(filterKey, counter); + this.counters = { ...this.counters, [filterKey]: 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); + } + } + + /** + * Get filer key when filter was refreshed by 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 */ @@ -255,76 +343,53 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.currentFilter = undefined; } - isActiveFilter(filter: ProcessFilterCloudModel): boolean { - return this.currentFilter.name === filter.name; - } - - initProcessNotification(): void { - if (this.appName && this.enableNotifications) { - this.processFilterCloudService - .getProcessNotificationSubscription(this.appName) - .pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.updateFilterCounters(); - }); - } - } - - /** - * Iterate over filters and update counters - */ - updateFilterCounters(): void { - this.filters.forEach((filter: ProcessFilterCloudModel) => { - this.updateFilterCounter(filter); - }); - } - - /** - * Get current value for filter and check if value has changed - * - * @param filter filter - */ - updateFilterCounter(filter: ProcessFilterCloudModel): void { - if (!filter?.showCounter) { + private loadFilterCounters(appName: string): void { + if (!this.countersFilters$) { return; } - this.fetchProcessFilterCounter(filter) - .pipe( - tap((filterCounter) => { - this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter); - }) - ) - .subscribe((data) => { - this.counters = { - ...this.counters, - [filter.key]: data - }; + this.countersSubscription?.unsubscribe(); + this.countersSubscription = combineLatest([ + this.countersFilters$.pipe(catchError(() => of([]))), + this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, this.useBatchedCounters) + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([, { counters, batched }]) => { + this.batchedCounters = batched; + if (batched) { + this.applyFilterCounters(counters); + } else { + this.updateFilterCounters(); + } }); } - checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void { - if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { - this.currentFiltersValues[filterKey] = filterValue; - this.updatedFilter.emit(filterKey); - this.updatedFiltersSet.add(filterKey); - } - } + private applyFilterCounters(counters: { [filterKey: string]: number }): void { + this.filters.forEach((filter) => { + const filterKey = filter?.showCounter ? filter.key : undefined; + if (!filterKey) { + return; + } - isFilterUpdated(filterName: string): boolean { - return this.updatedFiltersSet.has(filterName); - } + const counter = counters[filterKey]; + if (counter === undefined) { + this.updateFilterCounter(filter); + return; + } - /** - * Get filer key when filter was refreshed by external action - * - */ - getFilterKeysAfterExternalRefreshing(): void { - this.processFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => { - this.updatedFiltersSet.delete(filterKey); + this.checkIfFilterValuesHasBeenUpdated(filterKey, counter); + this.counters = { ...this.counters, [filterKey]: counter }; }); } + private refreshFilterCounter(filter?: ProcessFilterCloudModel): void { + if (this.batchedCounters) { + this.filterCountersCloudService.refreshFilterCounters(this.appName); + } else if (filter) { + this.updateFilterCounter(filter); + } + } + private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable { return this.searchApiMethod === 'POST' ? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter)) diff --git a/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts b/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts index 316933cba6..9839470ea2 100644 --- a/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts @@ -404,6 +404,12 @@ export class ProcessFilterCloudService { ]; } + /** + * @deprecated use FilterCountersCloudService.getEngineEvents instead. + * + * @param appName Name of the target app + * @returns Process engine events + */ getProcessNotificationSubscription(appName: string): Observable { return this.notificationCloudService .makeGQLQuery(appName, PROCESS_EVENT_SUBSCRIPTION_QUERY) diff --git a/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts b/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts index 84c5a97cfb..8316bf1f09 100644 --- a/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts @@ -100,7 +100,7 @@ export class ProcessListCloudService extends BaseCloudService { ); } - protected buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } { + buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } { const queryData: { [key: string]: any } = { name: requestNode.name, id: requestNode.id, 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 new file mode 100644 index 0000000000..c0846418f6 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts @@ -0,0 +1,651 @@ +/*! + * @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 { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core'; +import { BehaviorSubject, combineLatest, firstValueFrom, Observable, 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 { 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, + FilterCountersQuery, + FilterCountersRequest, + FilterCountersResult +} from '../models/filter-counters-cloud.model'; +import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model'; +import { FetchResult } from '@apollo/client/core'; + +type EngineEventsResult = FetchResult<{ engineEvents?: TaskCloudEngineEvent[] }>; + +interface CountEndpoint { + post: (url: string, request: FilterCountersRequest) => Observable; +} + +describe('FilterCountersCloudService', () => { + let service: FilterCountersCloudService; + let notificationCloudService: NotificationCloudService; + let appConfigService: AppConfigService; + let taskEvents$: Subject; + let processEvents$: Subject; + let makeGQLQuerySpy: jasmine.Spy; + let postSpy: jasmine.Spy; + let getTaskListFiltersSpy: jasmine.Spy; + let getProcessFiltersSpy: jasmine.Spy; + + const countRequest = (): FilterCountersRequest => postSpy.calls.mostRecent().args[1]; + const countUrl = (): string => postSpy.calls.mostRecent().args[0]; + const countQueries = (entityType: FilterCounterEntityType): FilterCountersQuery[] => countRequest()[entityType] ?? []; + const countRequestIds = (entityType: FilterCounterEntityType): string[] => countQueries(entityType).map((query) => query.requestId); + + const countersMock: FilterCounters = { + TASK: { 'my-tasks': 5, 'queued-tasks': 0 }, + PROCESS_INSTANCE: { 'running-processes': 5 } + }; + + const taskFilter = (filter: Partial) => + new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter }); + const processFilter = (filter: Partial) => + 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 engineEvents = (eventType: string): EngineEventsResult => ({ + data: { engineEvents: [{ eventType, entity: {} } as TaskCloudEngineEvent] } + }); + const emitTaskEvent = (eventType = 'TASK_CREATED') => taskEvents$.next(engineEvents(eventType)); + const emitProcessEvent = (eventType = 'PROCESS_STARTED') => processEvents$.next(engineEvents(eventType)); + + const counters = (entityType: FilterCounterEntityType, appName = 'mock-app') => + firstValueFrom(service.getFilterCounters(appName, entityType, true)); + const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName); + const processCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.PROCESS_INSTANCE, appName); + const bothCounters = (appName = 'mock-app') => + firstValueFrom( + combineLatest([ + service.getFilterCounters(appName, FilterCounterEntityType.TASK, true), + service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, true) + ]) + ); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAuthModule, ApolloTestingModule], + providers: [ + { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }, + { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService } + ] + }); + + service = TestBed.inject(FilterCountersCloudService); + notificationCloudService = TestBed.inject(NotificationCloudService); + appConfigService = TestBed.inject(AppConfigService); + appConfigService.config.bpmHost = 'https://fake-bpm-host.com'; + + taskEvents$ = new Subject(); + processEvents$ = new Subject(); + makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery'); + makeGQLQuerySpy.and.callFake((_appName: string, query: string) => + (query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable() + ); + postSpy = spyOn(service as unknown as CountEndpoint, '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)); + }); + + 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 load the filters of an app once for concurrent subscribers', async () => { + await firstValueFrom(combineLatest([service.getTaskFilters('mock-app'), service.getTaskFilters('mock-app')])); + await firstValueFrom(combineLatest([service.getProcessFilters('mock-app'), service.getProcessFilters('mock-app')])); + + 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 () => { + const subscription = service.getTaskFilters('mock-app').subscribe(); + await taskCounters(); + subscription.unsubscribe(); + + 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', () => { + 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', async () => { + expect(await bothCounters()).toEqual([ + { counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }, + { counters: { 'running-processes': 5 }, batched: true } + ]); + + expect(postSpy).toHaveBeenCalledTimes(1); + }); + + it('should call the batched count endpoint of the app', async () => { + await taskCounters(); + + expect(countUrl()).toBe('https://fake-bpm-host.com/mock-app/query/v1/count'); + }); + + it('should identify the query of every filter by the key of the filter', async () => { + await bothCounters(); + + expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']); + expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['running-processes']); + }); + + it('should send the criteria of every filter along with its request id', async () => { + await taskCounters(); + + expect(countQueries(FilterCounterEntityType.TASK)[0]).toEqual({ + requestId: 'my-tasks', + status: ['ASSIGNED'], + assignee: ['mock-user'], + sort: { field: 'createdDate', direction: 'desc', isProcessVariable: false } + }); + }); + + it('should not send the filters without a counter enabled', async () => { + await taskCounters(); + + expect(countRequestIds(FilterCounterEntityType.TASK)).not.toContain('completed-tasks'); + }); + + it('should send the query of a filter targeting every status', async () => { + getProcessFiltersSpy.and.returnValue(of([processFilter({ key: 'all-processes', status: '', showCounter: true })])); + + await processCounters(); + + expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['all-processes']); + }); + + it('should omit an entity type without filters with a counter enabled', async () => { + getProcessFiltersSpy.and.returnValue(of([])); + + await bothCounters(); + + expect(countRequest().PROCESS_INSTANCE).toBeUndefined(); + }); + + 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]]) + ); + + await taskCounters(); + + expect(countRequestIds(FilterCounterEntityType.TASK)).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 })])); + + expect(await processCounters()).toEqual({ counters: {}, batched: true }); + expect(postSpy).not.toHaveBeenCalled(); + }); + + 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 bothCounters(); + + expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']); + expect(countRequest().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(); + }); + + describe('when the batched count endpoint is not available', () => { + it('should report the counters as not batched', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); + + expect(await taskCounters()).toEqual({ counters: {}, batched: false }); + }); + + it('should not call the endpoint again for the same app', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); + + await taskCounters(); + expect(await processCounters()).toEqual({ counters: {}, batched: false }); + + expect(postSpy).toHaveBeenCalledTimes(1); + }); + + it('should keep calling the endpoint of the apps that do hold it', async () => { + postSpy.and.returnValue(throwError(() => ({ status: 404 }))); + await taskCounters(); + + 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)); + expect(await taskCounters()).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }); + expect(postSpy).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('batched counters opted in by the filter components', () => { + it('should not call the batched count endpoint when it was not asked for', async () => { + const result = await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK)); + + expect(result).toEqual({ counters: {}, batched: false }); + expect(postSpy).not.toHaveBeenCalled(); + }); + + it('should not load the filters when the batched count endpoint was not asked for', async () => { + await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK)); + + expect(getTaskListFiltersSpy).not.toHaveBeenCalled(); + }); + + it('should call the batched count endpoint when every entity type on screen asked for it', async () => { + await bothCounters(); + + expect(postSpy).toHaveBeenCalledTimes(1); + }); + + it('should not call the batched count endpoint when one entity type on screen did not ask for it', fakeAsync(() => { + const results: FilterCountersResult[] = []; + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result)); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe(); + tick(0); + + expect(postSpy).not.toHaveBeenCalled(); + expect(results).toEqual([{ counters: {}, batched: false }]); + })); + + it('should call the batched count endpoint once the entity type that opted out leaves the screen', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + const processSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe(); + tick(0); + + processSubscription.unsubscribe(); + service.refreshFilterCounters('mock-app'); + tick(0); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]); + })); + }); + + describe('counters scoped to the entity types on screen', () => { + it('should send the queries of the entity type on screen alone', async () => { + await taskCounters(); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]); + }); + + it('should not load the filters of an entity type that is not on screen', async () => { + await taskCounters(); + + expect(getTaskListFiltersSpy).toHaveBeenCalled(); + expect(getProcessFiltersSpy).not.toHaveBeenCalled(); + }); + + it('should send the queries of both entity types when both are on screen', async () => { + await bothCounters(); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]); + }); + + it('should resolve the counters again when an entity type joins the ones on screen', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]); + + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + + expect(postSpy).toHaveBeenCalledTimes(2); + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]); + })); + + it('should stop covering an entity type once its counters hold no subscriber', fakeAsync(() => { + const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + + taskSubscription.unsubscribe(); + service.refreshFilterCounters('mock-app'); + tick(0); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.PROCESS_INSTANCE]); + })); + }); + + describe('teardown', () => { + it('should close the engine event subscription once the counters hold no subscriber', fakeAsync(() => { + const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); + })); + + it('should keep the engine event subscription while another subscriber holds the same entity type', fakeAsync(() => { + const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + + subscription.unsubscribe(); + emitTaskEvent(); + tick(3000); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledTimes(2); + })); + + it('should release the filters subscription once nothing reads them', fakeAsync(() => { + const filters$ = new BehaviorSubject(taskFiltersMock); + getTaskListFiltersSpy.and.returnValue(filters$.asObservable()); + + const subscriptions = [ + service.getTaskFilters('mock-app').subscribe(), + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe() + ]; + tick(0); + expect(filters$.observed).toBeTrue(); + + subscriptions.forEach((subscription) => subscription.unsubscribe()); + + expect(filters$.observed).toBeFalse(); + })); + + it('should resolve the counters again for a subscriber that comes after a full teardown', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe().unsubscribe(); + tick(0); + postSpy.calls.reset(); + + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + + expect(postSpy).toHaveBeenCalledTimes(1); + })); + }); + + describe('refreshFilterCounters', () => { + it('should resolve the counters again with a single request', fakeAsync(() => { + const results: FilterCountersResult[] = []; + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result)); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + + service.refreshFilterCounters('mock-app'); + tick(0); + + expect(postSpy).toHaveBeenCalledTimes(2); + expect(results.length).toBe(2); + })); + + it('should not resolve the counters of an app without subscribers', fakeAsync(() => { + service.refreshFilterCounters('mock-app'); + tick(0); + + expect(postSpy).not.toHaveBeenCalled(); + })); + }); + + describe('when only one of the two filter families is wired', () => { + const configureTasksOnly = () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [NoopAuthModule, ApolloTestingModule], + providers: [{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }] + }); + + const tasksOnlyService = TestBed.inject(FilterCountersCloudService); + TestBed.inject(AppConfigService).config.bpmHost = 'https://fake-bpm-host.com'; + spyOn(TestBed.inject(NotificationCloudService), 'makeGQLQuery').and.returnValue(new Subject().asObservable()); + spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock)); + postSpy = spyOn(tasksOnlyService as unknown as CountEndpoint, 'post').and.returnValue(of(countersMock)); + + return tasksOnlyService; + }; + + it('should resolve the counters of the wired family', async () => { + const tasksOnlyService = configureTasksOnly(); + + const result = await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true)); + + expect(result).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }); + }); + + it('should leave the filters of the family that is not wired out of the request', async () => { + const tasksOnlyService = configureTasksOnly(); + + await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true)); + + expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']); + expect(countRequest().PROCESS_INSTANCE).toBeUndefined(); + }); + }); + + describe('getEngineEvents', () => { + it('should return EMPTY when appName is not set', () => { + let completed = false; + service.getEngineEvents('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBeTrue(); + expect(makeGQLQuerySpy).not.toHaveBeenCalled(); + }); + + it('should subscribe to the events of the task entity type alone', () => { + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(); + + const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args; + expect(appName).toBe('mock-app'); + expect(query).toContain('TASK_CREATED'); + expect(query).not.toContain('PROCESS_STARTED'); + }); + + it('should subscribe to the events of the process entity type alone', () => { + service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + + const [, query] = makeGQLQuerySpy.calls.mostRecent().args; + expect(query).toContain('PROCESS_STARTED'); + expect(query).not.toContain('TASK_CREATED'); + }); + + it('should open a single subscription for multiple subscribers of the same entity type', () => { + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(); + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + }); + + it('should open a separate subscription per entity type', () => { + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(); + service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); + }); + + it('should open a separate subscription per app', () => { + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(); + service.getEngineEvents('other-app', FilterCounterEntityType.TASK).subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); + }); + + it('should emit the debounced batch of events', fakeAsync(() => { + const batches: TaskCloudEngineEvent[][] = []; + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe((events) => batches.push(events)); + + emitTaskEvent('TASK_CREATED'); + emitTaskEvent('TASK_ASSIGNED'); + tick(3000); + + expect(batches.length).toBe(1); + expect(batches[0][0].eventType).toBe('TASK_ASSIGNED'); + })); + + 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', FilterCounterEntityType.TASK).subscribe(() => (emitted = true)); + + emitTaskEvent(); + tick(3000); + expect(emitted).toBeFalse(); + + tick(2000); + expect(emitted).toBeTrue(); + })); + }); + + describe('counters driven by the engine events', () => { + it('should make a single count request for a batch of events of both entity types', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + postSpy.calls.reset(); + + emitTaskEvent(); + emitProcessEvent(); + tick(3000); + + expect(postSpy).toHaveBeenCalledTimes(1); + })); + + it('should make a single count request for the events of both entity types arriving apart', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + postSpy.calls.reset(); + + emitTaskEvent(); + tick(1000); + emitProcessEvent(); + tick(3000); + + expect(postSpy).toHaveBeenCalledTimes(1); + })); + + it('should emit the counters resolved for the batch of events', fakeAsync(() => { + const results: FilterCountersResult[] = []; + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result)); + tick(0); + + postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } })); + emitTaskEvent(); + tick(3000); + + expect(results.length).toBe(2); + expect(results[1]).toEqual({ counters: { 'my-tasks': 9 }, batched: true }); + })); + + it('should not subscribe to the events of an entity type that is not on screen', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + expect(makeGQLQuerySpy.calls.mostRecent().args[1]).toContain('TASK_CREATED'); + })); + + it('should not resolve the counters again on the events of an entity type that is not on screen', fakeAsync(() => { + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + tick(0); + postSpy.calls.reset(); + + emitProcessEvent(); + tick(3000); + + expect(postSpy).not.toHaveBeenCalled(); + })); + + it('should stop resolving the counters on the events of an entity type that left the screen', fakeAsync(() => { + const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe(); + tick(0); + + taskSubscription.unsubscribe(); + postSpy.calls.reset(); + emitTaskEvent(); + tick(3000); + + expect(postSpy).not.toHaveBeenCalled(); + })); + + it('should not subscribe to the engine events when notifications are disabled', fakeAsync(() => { + appConfigService.config.notifications = false; + + service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).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 new file mode 100644 index 0000000000..0d0efb20dc --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts @@ -0,0 +1,375 @@ +/*! + * @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 { inject, Injectable, Injector } from '@angular/core'; +import { asapScheduler, combineLatest, defer, EMPTY, merge, Observable, of, Subject, Subscription } from 'rxjs'; +import { catchError, debounceTime, finalize, map, shareReplay, switchMap, take } from 'rxjs/operators'; +import { BaseCloudService } from './base-cloud.service'; +import { NotificationCloudService } from './notification-cloud.service'; +import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model'; +import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service'; +import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service'; +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, + FilterCountersQuery, + FilterCountersRequest, + FilterCountersResult +} from '../models/filter-counters-cloud.model'; +const BATCHED_COUNTERS_UNAVAILABLE_STATUSES = [404, 501]; + +interface FilterCountersFilters { + [FilterCounterEntityType.TASK]: TaskFilterCloudModel[]; + [FilterCounterEntityType.PROCESS_INSTANCE]: ProcessFilterCloudModel[]; +} + +interface EngineEventsData { + engineEvents?: TaskCloudEngineEvent[]; +} + +const ENGINE_EVENTS_SUBSCRIPTION_QUERIES: Record = { + [FilterCounterEntityType.TASK]: ` + subscription { + engineEvents(eventType: [ + TASK_COMPLETED + TASK_ASSIGNED + TASK_ACTIVATED + TASK_SUSPENDED + TASK_CANCELLED + TASK_CREATED + ]) { + eventType + entity + } + } +`, + [FilterCounterEntityType.PROCESS_INSTANCE]: ` + subscription { + engineEvents(eventType: [ + PROCESS_CANCELLED + PROCESS_COMPLETED + PROCESS_CREATED + PROCESS_RESUMED + PROCESS_SUSPENDED + PROCESS_STARTED + ]) { + eventType + entity + } + } +` +}; + +@Injectable({ providedIn: 'root' }) +export class FilterCountersCloudService extends BaseCloudService { + private readonly notificationCloudService = inject(NotificationCloudService); + private readonly taskListCloudService = inject(TaskListCloudService); + private readonly processListCloudService = inject(ProcessListCloudService); + private readonly injector = inject(Injector); + + private readonly eventsPerEntityType = new Map>(); + private readonly rawEventsPerEntityType = new Map>(); + private readonly recountPerApp = new Map>(); + private readonly eventRecountPerApp = new Map>(); + private readonly activeEntityTypesPerApp = new Map>(); + private readonly subscribersPerEntityType = new Map(); + private readonly batchedCountersPerEntityType = new Map(); + private readonly eventSubscriptionsPerEntityType = 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); + } + + getTaskFilters(appName: string): Observable { + return this.shareFilters(this.taskFiltersPerApp, appName, () => this.injector.get(TaskFilterCloudService).getTaskListFilters(appName)); + } + + getProcessFilters(appName: string): Observable { + return this.shareFilters(this.processFiltersPerApp, appName, () => this.injector.get(ProcessFilterCloudService).getProcessFilters(appName)); + } + + getFilterCounters(appName: string, entityType: FilterCounterEntityType, batchedCounters = false): Observable { + if (!appName) { + return EMPTY; + } + + return defer(() => { + this.activateEntityType(appName, entityType, batchedCounters); + + return this.getCounters(appName); + }).pipe( + map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched })), + finalize(() => this.deactivateEntityType(appName, entityType)) + ); + } + + refreshFilterCounters(appName: string): void { + this.recount(appName); + } + + getEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable { + if (!appName) { + return EMPTY; + } + + const key = this.entityTypeKey(appName, entityType); + let events$ = this.eventsPerEntityType.get(key); + if (!events$) { + events$ = this.rawEngineEvents(appName, entityType).pipe( + debounceTime(this.notificationDebounceTime), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.eventsPerEntityType.set(key, events$); + } + + return events$; + } + + private rawEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable { + const key = this.entityTypeKey(appName, entityType); + let events$ = this.rawEventsPerEntityType.get(key); + if (!events$) { + events$ = defer(() => + this.notificationCloudService.makeGQLQuery(appName, ENGINE_EVENTS_SUBSCRIPTION_QUERIES[entityType]) + ).pipe( + map((result) => result.data?.engineEvents ?? []), + catchError(() => EMPTY), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.rawEventsPerEntityType.set(key, events$); + } + + return events$; + } + + private get notificationsEnabled(): boolean { + return this.appConfigService.get('notifications', true); + } + + private activateEntityType(appName: string, entityType: FilterCounterEntityType, batchedCounters: boolean): void { + const key = this.entityTypeKey(appName, entityType); + const subscribers = (this.subscribersPerEntityType.get(key) ?? 0) + 1; + this.subscribersPerEntityType.set(key, subscribers); + + if (subscribers > 1) { + return; + } + + this.batchedCountersPerEntityType.set(key, batchedCounters); + + const activeEntityTypes = this.activeEntityTypes(appName); + const joinsResolvedCounters = activeEntityTypes.size > 0; + activeEntityTypes.add(entityType); + + if (this.notificationsEnabled) { + this.eventSubscriptionsPerEntityType.set( + key, + this.rawEngineEvents(appName, entityType).subscribe(() => this.eventRecountTrigger(appName).next()) + ); + } + + if (joinsResolvedCounters) { + this.recount(appName); + } + } + + private deactivateEntityType(appName: string, entityType: FilterCounterEntityType): void { + const key = this.entityTypeKey(appName, entityType); + const subscribers = (this.subscribersPerEntityType.get(key) ?? 1) - 1; + + if (subscribers > 0) { + this.subscribersPerEntityType.set(key, subscribers); + return; + } + + this.subscribersPerEntityType.delete(key); + this.batchedCountersPerEntityType.delete(key); + this.activeEntityTypes(appName).delete(entityType); + this.eventSubscriptionsPerEntityType.get(key)?.unsubscribe(); + this.eventSubscriptionsPerEntityType.delete(key); + } + + private activeEntityTypes(appName: string): Set { + let activeEntityTypes = this.activeEntityTypesPerApp.get(appName); + if (!activeEntityTypes) { + activeEntityTypes = new Set(); + this.activeEntityTypesPerApp.set(appName, activeEntityTypes); + } + + return activeEntityTypes; + } + + private entityTypeKey(appName: string, entityType: FilterCounterEntityType): string { + return `${appName}|${entityType}`; + } + + private recount(appName: string): void { + this.recountTrigger(appName).next(); + } + + private getFiltersForCounters(appName: string): Observable { + const activeEntityTypes = this.activeEntityTypes(appName); + + return combineLatest({ + [FilterCounterEntityType.TASK]: activeEntityTypes.has(FilterCounterEntityType.TASK) + ? this.getTaskFilters(appName).pipe(catchError(() => of([]))) + : of([]), + [FilterCounterEntityType.PROCESS_INSTANCE]: activeEntityTypes.has(FilterCounterEntityType.PROCESS_INSTANCE) + ? this.getProcessFilters(appName).pipe(catchError(() => of([]))) + : of([]) + }); + } + + private shareFilters(cache: Map>, appName: string, loadFilters: () => Observable): Observable { + let filters$ = cache.get(appName); + if (!filters$) { + filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: true })); + cache.set(appName, filters$); + } + + return filters$; + } + + private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { + let counters$ = this.countersPerApp.get(appName); + if (!counters$) { + counters$ = this.recounts(appName).pipe( + switchMap(() => this.resolveCounters(appName)), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.countersPerApp.set(appName, counters$); + } + + return counters$; + } + + private resolveCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { + if (!this.batchedCountersEnabled(appName) || 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)) { + this.appsWithoutBatchedCounters.add(appName); + } + + return of({ counters: {}, batched: false }); + }) + ); + } + + private batchedCountersEnabled(appName: string): boolean { + const activeEntityTypes = [...this.activeEntityTypes(appName)]; + + return ( + activeEntityTypes.length > 0 && + activeEntityTypes.every((entityType) => this.batchedCountersPerEntityType.get(this.entityTypeKey(appName, entityType))) + ); + } + + private recounts(appName: string): Observable { + return merge( + merge(of(undefined), this.recountTrigger(appName)).pipe(debounceTime(0, asapScheduler)), + this.eventRecountTrigger(appName).pipe(debounceTime(this.notificationDebounceTime)) + ); + } + + private recountTrigger(appName: string): Subject { + let recount$ = this.recountPerApp.get(appName); + if (!recount$) { + recount$ = new Subject(); + this.recountPerApp.set(appName, recount$); + } + + return recount$; + } + + private eventRecountTrigger(appName: string): Subject { + let eventRecount$ = this.eventRecountPerApp.get(appName); + if (!eventRecount$) { + eventRecount$ = new Subject(); + this.eventRecountPerApp.set(appName, eventRecount$); + } + + return eventRecount$; + } + + private buildRequest(filters: FilterCountersFilters): FilterCountersRequest { + const request: FilterCountersRequest = {}; + + const taskQueries = this.buildQueries(filters[FilterCounterEntityType.TASK], (filter) => + this.taskListCloudService.buildQueryData(new TaskFilterCloudAdapter(filter)) + ); + if (taskQueries.length) { + request[FilterCounterEntityType.TASK] = taskQueries; + } + + const processQueries = this.buildQueries(filters[FilterCounterEntityType.PROCESS_INSTANCE], (filter) => + this.processListCloudService.buildQueryData(new ProcessFilterCloudAdapter(filter)) + ); + if (processQueries.length) { + request[FilterCounterEntityType.PROCESS_INSTANCE] = processQueries; + } + + return request; + } + + private buildQueries( + filters: T[], + buildQuery: (filter: T) => Omit + ): FilterCountersQuery[] { + return (filters ?? []) + .filter((filter) => filter?.showCounter && this.isCounterBatched(filter)) + .map((filter) => { + try { + return { ...buildQuery(filter), requestId: filter.key as string }; + } catch { + return undefined; + } + }) + .filter((query): query is FilterCountersQuery => !!query); + } + + private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable { + if (!Object.keys(request).length) { + return of({}); + } + + const queryUrl = `${this.getBasePath(appName)}/query/v1/count`; + + return this.post(queryUrl, request).pipe(map((counters) => counters || {})); + } + + private isCounterBatched(filter: FilterCounterCandidate): boolean { + return !!filter?.key; + } +} diff --git a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts index ae0cfe68fc..7b5a46571a 100644 --- a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts @@ -15,8 +15,9 @@ * limitations under the License. */ -import { gql } from '@apollo/client/core'; +import { FetchResult, gql } from '@apollo/client/core'; import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; import { WebSocketService } from './web-socket.service'; @Injectable({ providedIn: 'root' @@ -24,8 +25,8 @@ import { WebSocketService } from './web-socket.service'; export class NotificationCloudService { private readonly webSocketService = inject(WebSocketService); - makeGQLQuery(appName: string, gqlQuery: string) { - return this.webSocketService.getSubscription({ + makeGQLQuery(appName: string, gqlQuery: string): Observable> { + return this.webSocketService.getSubscription({ apolloClientName: appName, wsUrl: `${appName}/notifications`, httpUrl: `${appName}/notifications/v2/ws/graphql`, 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 e3ab7c3b05..e300e35a83 100644 --- a/lib/process-services-cloud/src/lib/services/public-api.ts +++ b/lib/process-services-cloud/src/lib/services/public-api.ts @@ -17,6 +17,7 @@ export * from './base-cloud.service'; export * from './cloud-token.service'; +export * from './filter-counters-cloud.service'; 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 5fb1f21daf..6fda86d7a0 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 @@ -17,10 +17,10 @@ import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core'; import { Component, SimpleChange } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; +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 { first, NEVER, of, Subject, throwError } from 'rxjs'; +import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service'; import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service'; import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock'; import { TaskFilterCloudService } from '../../services/task-filter-cloud.service'; @@ -35,6 +35,9 @@ import { TaskFilterCloudModel } from '../../models/filter-cloud.model'; 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 { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; +import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model'; @Component({ selector: 'adf-cloud-dummy', template: '' }) class DummyComponent {} @@ -50,7 +53,10 @@ describe('TaskFiltersCloudComponent', () => { let getTaskFilterCounterSpy: jasmine.Spy; let getTaskListFiltersSpy: jasmine.Spy; let getTaskListCountSpy: jasmine.Spy; - let getTaskNotificationSubscriptionSpy: jasmine.Spy; + let getEngineEventsSpy: jasmine.Spy; + let filterCountersService: FilterCountersCloudService; + let getFilterCountersSpy: jasmine.Spy; + let refreshFilterCountersSpy: jasmine.Spy; let router: Router; const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => { @@ -58,6 +64,7 @@ describe('TaskFiltersCloudComponent', () => { 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, @@ -76,10 +83,15 @@ describe('TaskFiltersCloudComponent', () => { }); taskFilterService = TestBed.inject(TaskFilterCloudService); taskListService = TestBed.inject(TaskListCloudService); + filterCountersService = TestBed.inject(FilterCountersCloudService); getTaskFilterCounterSpy = spyOn(taskFilterService, 'getTaskFilterCounter').and.returnValue(of(11)); getTaskListCountSpy = spyOn(taskListService, 'getTaskListCount').and.returnValue(of(11)); - getTaskNotificationSubscriptionSpy = spyOn(taskFilterService, 'getTaskNotificationSubscription').and.returnValue(of(taskNotifications)); - getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter)); + 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 }) + ); + refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters'); appConfigService = TestBed.inject(AppConfigService); @@ -261,7 +273,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(); @@ -269,7 +281,7 @@ describe('TaskFiltersCloudComponent', () => { filterButton.click(); fixture.detectChanges(); - expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]); + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1'); }); describe('Notifications config', () => { @@ -306,30 +318,33 @@ describe('TaskFiltersCloudComponent', () => { }); it('should not subscribe to notifications when appName is missing', () => { - getTaskNotificationSubscriptionSpy.calls.reset(); + getEngineEventsSpy.calls.reset(); component.appName = ''; fixture.detectChanges(); - expect(getTaskNotificationSubscriptionSpy).not.toHaveBeenCalled(); + expect(getEngineEventsSpy).not.toHaveBeenCalled(); }); - it('should debounce notification subscription using the configured debounce time', fakeAsync(() => { - const notifications$ = new Subject(); - getTaskNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable()); + it('should subscribe to the notifications of the bound app', () => { component.appName = 'my-app-1'; fixture.detectChanges(); - const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters'); + expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK); + }); - notifications$.next(taskNotifications); - tick(1000); - expect(updateFilterCountersSpy).not.toHaveBeenCalled(); + 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'; - tick(2000); - expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1); + fixture.detectChanges(); + events$.next(taskNotifications); + + expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications); flush(); })); }); @@ -438,7 +453,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( @@ -446,6 +461,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])); }); }); @@ -658,17 +681,137 @@ 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', () => { + it('should read the counters without waiting for the filters', async () => { + getTaskListFiltersSpy.and.returnValue(NEVER); + + await bindAppName(); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false); + }); + + it('should hold the counters until the filters they belong to arrive', async () => { + const filters$ = new Subject(); + getTaskListFiltersSpy.and.returnValue(filters$.asObservable()); + getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true })); + + await bindAppName(); + expect(component.counters['fake-involved-tasks']).toBeUndefined(); + + filters$.next(fakeGlobalFilter); + fixture.detectChanges(); + + expect(component.counters['fake-involved-tasks']).toBe(9); + }); + + it('should read the counters of the task filters of the bound app', async () => { + await bindAppName(); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false); + }); + + it('should not ask for the batched count endpoint by default', async () => { + await bindAppName(); + + expect(component.useBatchedCounters).toBeFalse(); + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false); + }); + + it('should ask for the batched count endpoint when the input is set', async () => { + fixture.componentRef.setInput('useBatchedCounters', true); + + await bindAppName(); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true); + }); + + it('should read the counters again when the input changes', async () => { + await bindAppName(); + getFilterCountersSpy.calls.reset(); + + fixture.componentRef.setInput('useBatchedCounters', true); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true); + }); + + 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(component.counters['fake-involved-tasks']).toBe(9); + }); + + 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(updatedFilterSpy).toHaveBeenCalledWith('fake-involved-tasks'); + }); + + it('should resolve the counter of a filter the batch left out on its own', async () => { + getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: true })); + + await bindAppName(); + + expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]); + expect(component.counters['fake-involved-tasks']).toBe(11); + }); + + it('should keep the counters of the other filters when one counter cannot be resolved', async () => { + getTaskListFiltersSpy.and.returnValue(of([fakeGlobalFilter[0], { ...fakeGlobalFilter[1], showCounter: true }])); + getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 4 }, batched: true })); + getTaskFilterCounterSpy.and.throwError('the query of the filter cannot be built'); + + await bindAppName(); + + expect(component.counters['fake-involved-tasks']).toBe(4); + expect(component.counters['fake-my-task1']).toBe(0); + }); + + 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(); + + expect(getTaskFilterCounterSpy).toHaveBeenCalled(); + expect(component.counters['fake-involved-tasks']).toBe(11); + }); + + it('should refresh the counters of every filter when a filter is clicked', async () => { + await bindAppName(); + + component.onFilterClick(fakeGlobalFilter[0]); + + expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1'); + }); + + 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(); + + component.onFilterClick(fakeGlobalFilter[0]); + + expect(refreshFilterCountersSpy).not.toHaveBeenCalled(); + expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1); + }); }); describe('Highlight Selected Filter', () => { 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 3c553aa7c2..54f2891877 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,16 +16,18 @@ */ import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { EMPTY, Observable } from 'rxjs'; +import { combineLatest, defer, EMPTY, 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'; -import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { BaseTaskFiltersCloudComponent } from '../base-task-filters-cloud.component'; import { TaskDetailsCloudModel } from '../../../models/task-details-cloud.model'; import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model'; import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service'; import { TaskFilterCloudAdapter } from '../../../../models/filter-cloud-model'; +import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service'; +import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; @@ -42,10 +44,21 @@ 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. + * + */ @Input() searchApiMethod: 'GET' | 'POST' = 'GET'; + /** + * (optional) Resolves the counters of the task and the process filters with a single call to + * `POST /query/v1/count`. Both filter components have to + * ask for it, otherwise the counters are resolved one filter at a time. + */ + @Input() + useBatchedCounters = false; + /** Emitted when a filter is being selected based on the filterParam input. */ @Output() filterSelected = new EventEmitter(); @@ -69,9 +82,13 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp notificationDebounceTime = 3000; currentFiltersValues: { [key: string]: number } = {}; private filtersLoadedFor?: string; + private countersSubscription?: Subscription; + private countersFilters$?: Observable; + private batchedCounters = true; private readonly taskFilterCloudService = inject(TaskFilterCloudService); private readonly taskListCloudService = inject(TaskListCloudService); + private readonly filterCountersCloudService = inject(FilterCountersCloudService); private readonly translationService = inject(TranslationService); private readonly appConfigService = inject(AppConfigService); private readonly activatedRoute = inject(ActivatedRoute); @@ -80,6 +97,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp ngOnInit() { this.enableNotifications = this.appConfigService.get('notifications', true); this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000); + if (!this.filtersLoadedFor) { this.getFilters(this.appName); } @@ -94,6 +112,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); + } else if (changes['useBatchedCounters'] && !changes['useBatchedCounters'].firstChange && this.filtersLoadedFor) { + this.loadFilterCounters(this.filtersLoadedFor); } } @@ -104,8 +124,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp */ getFilters(appName: string): void { this.filtersLoadedFor = appName; - const filters$ = this.taskFilterCloudService.getTaskListFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true })); - this.filters$ = filters$.pipe(catchError(() => EMPTY)); + const filters$ = this.filterCountersCloudService.getTaskFilters(appName); + this.filters$ = filters$.pipe(catchError(() => of([]))); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { @@ -113,13 +133,15 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.filters = res || []; this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); - this.updateFilterCounters(); this.success.emit(res); }, error: (err) => { this.error.emit(err); } }); + + this.countersFilters$ = filters$; + this.loadFilterCounters(appName); } /** @@ -131,55 +153,47 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp /** * Iterate over filters and update counters + * + * @deprecated counts one filter at a time. Removed in ADF 10.0.0. */ updateFilterCounters(): void { - this.filters.forEach((filter: TaskFilterCloudModel) => this.updateFilterCounter(filter)); + this.filters.forEach((filter) => this.updateFilterCounter(filter)); } /** * Get current value for filter and check if value has changed * * @param filter filter + * @deprecated counts one filter at a time. Removed in ADF 10.0.0. */ updateFilterCounter(filter: TaskFilterCloudModel): void { if (!filter?.showCounter) { return; } - this.fetchTaskFilterCounter(filter) + + defer(() => this.fetchTaskFilterCounter(filter)) .pipe( - tap((filterCounter) => { - this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter); - }) + catchError(() => EMPTY), + takeUntilDestroyed(this.destroyRef) ) - .subscribe((data) => { - this.counters = { - ...this.counters, - [filter.key]: data - }; + .subscribe((counter) => { + this.checkIfFilterValuesHasBeenUpdated(filter.key, counter); + this.counters = { ...this.counters, [filter.key]: counter }; }); } - private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable { - return this.searchApiMethod === 'POST' - ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter)) - : this.taskFilterCloudService.getTaskFilterCounter(filter); - } - - initFilterCounterNotifications() { + initFilterCounterNotifications(): void { if (!this.appName) { return; } - if (this.enableNotifications) { - this.taskFilterCloudService - .getTaskNotificationSubscription(this.appName) - .pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - result.forEach((taskEvent) => { - this.checkFilterCounter(taskEvent.entity); - }); - this.updateFilterCounters(); - this.filterCounterUpdated.emit(result); + if (this.enableNotifications) { + this.filterCountersCloudService + .getEngineEvents(this.appName, FilterCounterEntityType.TASK) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((events) => { + events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity)); + this.filterCounterUpdated.emit(events); }); } else { this.counters = {}; @@ -240,7 +254,7 @@ 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.updatedCountersSet.delete(filter.key); } else { @@ -267,17 +281,9 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp return this.filters === undefined || (this.filters && this.filters.length === 0); } - /** - * Reset the filters properties - */ - private resetFilter() { - this.filters = []; - this.currentFilter = undefined; - } - checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) { if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { - this.currentFiltersValues[filterKey] = filterValue; + this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue }; this.updatedFilter.emit(filterKey); this.updatedCountersSet.add(filterKey); } @@ -288,8 +294,69 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp * */ getFilterKeysAfterExternalRefreshing(): void { - this.taskFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => { - this.updatedCountersSet.delete(filterKey); + this.taskFilterCloudService.filterKeyToBeRefreshed$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((filterKey: string) => this.updatedCountersSet.delete(filterKey)); + } + + private loadFilterCounters(appName: string): void { + if (!this.countersFilters$) { + return; + } + + this.countersSubscription?.unsubscribe(); + this.countersSubscription = combineLatest([ + this.countersFilters$.pipe(catchError(() => of([]))), + this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK, this.useBatchedCounters) + ]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([, { counters, batched }]) => { + this.batchedCounters = batched; + if (batched) { + this.applyFilterCounters(counters); + } else { + this.updateFilterCounters(); + } + }); + } + + private applyFilterCounters(counters: { [filterKey: string]: number }): void { + this.filters.forEach((filter) => { + const filterKey = filter?.showCounter ? filter.key : undefined; + if (!filterKey) { + return; + } + + const counter = counters[filterKey]; + if (counter === undefined) { + this.updateFilterCounter(filter); + return; + } + + 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 + */ + private resetFilter() { + this.filters = []; + this.currentFilter = undefined; + } + + 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 0018f4231f..e8a38a776b 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 @@ -361,6 +361,11 @@ export class TaskFilterCloudService extends BaseCloudService { ]; } + /** + * @deprecated use FilterCountersCloudService.getEngineEvents instead. + * @param appName Name of the target app + * @returns Task engine events + */ getTaskNotificationSubscription(appName: string): Observable { return this.notificationCloudService .makeGQLQuery(appName, TASK_EVENT_SUBSCRIPTION_QUERY) diff --git a/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts b/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts index 7d6f6ce04c..c46f7f7ef0 100644 --- a/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts @@ -135,7 +135,7 @@ export class TaskListCloudService extends BaseCloudService implements TaskListCl return this.post(queryUrl, queryData).pipe(map((response) => response || 0)); } - protected buildQueryData(requestNode: TaskListRequestModel) { + buildQueryData(requestNode: TaskListRequestModel) { const queryData: any = { id: requestNode.id, parentId: requestNode.parentId, diff --git a/lib/process-services-cloud/src/public-api.ts b/lib/process-services-cloud/src/public-api.ts index fa63874f47..b945932abf 100644 --- a/lib/process-services-cloud/src/public-api.ts +++ b/lib/process-services-cloud/src/public-api.ts @@ -33,6 +33,7 @@ export * from './lib/models/application-version.model'; export * from './lib/models/engine-event-cloud.model'; export * from './lib/models/task-cloud.model'; export * from './lib/models/filter-cloud-model'; +export * from './lib/models/filter-counters-cloud.model'; export * from './lib/models/task-list-sorting.model'; export * from './lib/models/process-instance-variable.model'; export * from './lib/models/variable-definition';