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..3b30a06389 --- /dev/null +++ b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts @@ -0,0 +1,97 @@ +/*! + * @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 { TaskCloudEngineEvent } from './engine-event-cloud.model'; + +/** + * Entity types accepted by the `POST /query/v1/count` endpoint. + */ +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; +} + +/** + * A single query of the batched count request, holding the criteria of one filter. + */ +export interface FilterCountersQuery { + status?: string[]; + assignee?: string[]; + sort?: FilterCountersQuerySort; + [criteria: string]: any; +} + +/** + * Payload of the batched count request, one entry per counter to be resolved. + */ +export type FilterCountersRequest = { + [entityType in FilterCounterEntityType]?: FilterCountersQuery[]; +}; + +/** + * Shape of a task or process filter the counters are resolved for. + */ +export interface FilterCounterCandidate { + key: string; + status?: string | null; + statuses?: string[] | null; + showCounter?: boolean; +} + +/** + * Filters of every entity type the counters are resolved for. + */ +export type FilterCountersFilters = { + [entityType in FilterCounterEntityType]: any[]; +}; + +/** + * Counts returned by the batched count request, keyed by entity type and then by status. + * e.g. `{ TASK: { ASSIGNED: 5, CREATED: 0 }, PROCESS_INSTANCE: { RUNNING: 5 } }` + */ +export type FilterCounters = { + [entityType in FilterCounterEntityType]?: { [status: string]: number }; +}; + +export interface FilterCountersNotification { + /** Engine events of the debounced batch that triggered the count request. */ + events: TaskCloudEngineEvent[]; + /** Counts resolved by a single call to the batched count endpoint. */ + counters: FilterCounters; +} + +/** + * Resolves the statuses of a filter, which the counters of the count response are keyed by. + * + * @param filter task or process filter + * @param filter.status Status of the filter + * @param filter.statuses Statuses of the filter + * @returns the statuses of the filter, empty when the filter targets every status + */ +export function resolveFilterCounterStatuses(filter: { status?: string | null; statuses?: string[] | null }): string[] { + const statuses = filter?.statuses?.length ? filter.statuses : filter?.status ? [filter.status] : []; + + return statuses.filter((status) => !!status); +} 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..048b637eff 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 { first, of, Subject, throwError } from 'rxjs'; +import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { EMPTY, 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 { 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,36 @@ 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 { FilterCountersNotification } 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 getFilterCountersNotificationsSpy: 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: 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 +85,15 @@ 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([])); + getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue(EMPTY); + spyOn(processListService, 'getProcessCounter').and.returnValue(of(10)); + spyOn(processListService, 'getProcessListCount').and.returnValue(of(10)); }; const bindAppName = async (appName = 'my-app-1') => { @@ -476,6 +477,95 @@ describe('ProcessFiltersCloudComponent', () => { expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter); }); + describe('Batched counters registration', () => { + let registerFiltersSpy: jasmine.Spy; + + beforeEach(() => { + registerFiltersSpy = spyOn(filterCountersService, 'registerFilters'); + }); + + const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1]; + + it('should register every filter with a counter enabled', async () => { + getProcessFiltersSpy.and.returnValue( + of( + mockProcessFilters.map( + (filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true, sort: 'startDate', order: 'DESC' }) + ) + ) + ); + + await bindAppName('mock-app-name'); + + expect(registerFiltersSpy).toHaveBeenCalledWith('PROCESS_INSTANCE', jasmine.any(Array)); + // the first mock filter targets every status, so it holds no status to be keyed by + expect(registeredQueries().length).toBe(2); + expect(registeredQueries().map((query: any) => query.status)).toEqual([['RUNNING'], ['COMPLETED']]); + }); + + it('should not register a filter without a counter enabled', async () => { + getProcessFiltersSpy.and.returnValue( + of([ + new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: 'startDate', order: 'DESC' }), + new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false, sort: 'startDate', order: 'DESC' }) + ]) + ); + + await bindAppName('mock-app-name'); + + expect(registeredQueries().length).toBe(1); + expect(registeredQueries()[0].status).toEqual(['RUNNING']); + }); + + it('should register the full criteria of a filter', async () => { + getProcessFiltersSpy.and.returnValue( + of([ + new ProcessFilterCloudModel({ + ...mockProcessFilters[1], + showCounter: true, + sort: 'startDate', + order: 'DESC', + initiator: 'mock-user', + processDefinitionName: 'mock-process' + }) + ]) + ); + + await bindAppName('mock-app-name'); + + const query = registeredQueries()[0]; + expect(query.status).toEqual(['RUNNING']); + expect(query.initiator).toEqual(['mock-user']); + expect(query.processDefinitionName).toEqual(['mock-process']); + expect(query.sort).toEqual({ field: 'startDate', direction: 'desc', isProcessVariable: false }); + }); + + it('should not register a filter targeting every status', async () => { + getProcessFiltersSpy.and.returnValue( + of([new ProcessFilterCloudModel({ ...mockProcessFilters[0], showCounter: true, sort: 'startDate', order: 'DESC' })]) + ); + + await bindAppName('mock-app-name'); + + expect(registeredQueries().length).toBe(0); + }); + + it('should not break the filter list when the query of a filter cannot be built', async () => { + getProcessFiltersSpy.and.returnValue( + of([ + new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: undefined, order: undefined }), + new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: true, sort: 'startDate', order: 'DESC' }) + ]) + ); + + await bindAppName('mock-app-name'); + + expect(component.filters.length).toBe(2); + expect(registeredQueries().length).toBe(1); + expect(registeredQueries()[0].status).toEqual(['COMPLETED']); + }); + }); + describe('Notifications config', () => { it('should read enableNotifications and notificationDebounceTime from app config on init', () => { const appConfigService = TestBed.inject(AppConfigService); @@ -507,22 +597,47 @@ 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()); + const initNotifications = (showCounter: boolean): Subject => { + const notifications$ = new Subject(); + getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); component.appName = 'mock-app-name'; fixture.detectChanges(); - const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters'); + component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter })); - notifications$.next([]); - tick(1000); - expect(updateFilterCountersSpy).not.toHaveBeenCalled(); + return notifications$; + }; - tick(2000); - expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1); + it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => { + const notifications$ = initNotifications(true); + notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); + + expect(component.counters['FakeRunningProcesses']).toBe(7); + flush(); + })); + + it('should fetch the counters of the filters not resolved by the batched count request on their own', fakeAsync(() => { + const notifications$ = initNotifications(true); + const updateFilterCounterSpy = spyOn(component, 'updateFilterCounter'); + + notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); + + // the RUNNING filter is resolved by the batch, the other two are fetched on their own + expect(component.counters['FakeRunningProcesses']).toBe(7); + expect(updateFilterCounterSpy).toHaveBeenCalledTimes(2); + expect(updateFilterCounterSpy.calls.allArgs().map(([filter]) => filter.key)).toEqual(['FakeAllProcesses', 'completed-processes']); + flush(); + })); + + it('should not update the counter of a filter without counter enabled', fakeAsync(() => { + const notifications$ = initNotifications(false); + component.counters = {}; + + notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } }); + + expect(component.counters['FakeRunningProcesses']).toBeUndefined(); flush(); })); }); @@ -531,7 +646,7 @@ describe('ProcessFiltersCloudComponent', () => { it('should make subscription', async () => { component.enableNotifications = true; await bindAppName('mock-app-name'); - expect(getProcessNotificationSubscriptionSpy).toHaveBeenCalled(); + expect(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('mock-app-name'); }); it('should not make subscription when notifications are disabled', async () => { @@ -539,7 +654,7 @@ describe('ProcessFiltersCloudComponent', () => { spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue)); await bindAppName('mock-app-name'); - expect(getProcessNotificationSubscriptionSpy).not.toHaveBeenCalled(); + expect(getFilterCountersNotificationsSpy).not.toHaveBeenCalled(); }); 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..c89d0afef8 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 @@ -21,9 +21,12 @@ import { ProcessFilterCloudService } from '../../services/process-filter-cloud.s 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 { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { TranslatePipe } from '@ngx-translate/core'; import { AsyncPipe } from '@angular/common'; @@ -78,35 +81,71 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { filters$: Observable; currentFilter?: ProcessFilterCloudModel; filters: ProcessFilterCloudModel[] = []; - counters: { [key: string]: number } = {}; + updatedFiltersSet = new Set(); enableNotifications = true; notificationDebounceTime = 3000; - currentFiltersValues: { [key: string]: number } = {}; - updatedFiltersSet = new Set(); private filtersLoadedFor?: string; + private countersManager: FilterCountersManager; 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.countersManager) { + this.initCountersManager(); + } + if (!this.filtersLoadedFor) { this.getFilters(this.appName); } this.initProcessNotification(); - this.getFilterKeysAfterExternalRefreshing(); + this.countersManager.subscribeToExternalRefresh(this.processFilterCloudService.filterKeyToBeRefreshed$); + } + + private initCountersManager(): void { + const counterAdapter: FilterCounterAdapter = { + getFilterCounter: (filter) => + this.searchApiMethod === 'POST' + ? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter)) + : this.processListCloudService.getProcessCounter(filter.appName, filter.status) + }; + + this.countersManager = new FilterCountersManager( + FilterCounterEntityType.PROCESS_INSTANCE, + this.filterCountersCloudService, + counterAdapter, + this.destroyRef, + { + onFilterUpdated: (filterKey) => { + this.updatedFilter.emit(filterKey); + this.updatedFiltersSet.add(filterKey); + this.counters = this.countersManager.counters; + this.currentFiltersValues = this.countersManager.currentFiltersValues; + }, + onCountersUpdated: () => { + this.counters = this.countersManager.counters; + this.currentFiltersValues = this.countersManager.currentFiltersValues; + } + } + ); } ngOnChanges(changes: SimpleChanges) { const appName = changes['appName']; const filter = changes['filterParam']; if (appName?.currentValue) { + if (!this.countersManager) { + this.initCountersManager(); + } this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); @@ -120,17 +159,19 @@ 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 })); + const filters$ = this.filterCountersCloudService + .getFilters(appName) + .pipe(map((filters) => filters[FilterCounterEntityType.PROCESS_INSTANCE] as ProcessFilterCloudModel[])); this.filters$ = filters$.pipe(catchError(() => EMPTY)); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { this.resetFilter(); this.filters = res || []; - this.initFilterCounters(); + this.countersManager.initCounters(this.filters); this.selectFilterAndEmit(this.filterParam); this.success.emit(res); - this.updateFilterCounters(); + this.countersManager.loadCounters(appName); }, error: (err: any) => { this.error.emit(err); @@ -138,12 +179,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { }); } - /** - * Initialize counter collection for filters - */ - initFilterCounters() { - this.filters.forEach((filter) => (this.counters[filter.key] = 0)); - } + counters: { [key: string]: number } = {}; + currentFiltersValues: { [key: string]: number } = {}; /** * Pass the selected filter as next @@ -214,6 +251,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.selectFilter(filter); this.filterClicked.emit(this.currentFilter); this.updateFilterCounter(this.currentFilter); + this.countersManager.resetFilterUpdate(filter.key); this.updatedFiltersSet.delete(filter.key); } else { this.currentFilter = undefined; @@ -261,12 +299,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { initProcessNotification(): void { if (this.appName && this.enableNotifications) { - this.processFilterCloudService - .getProcessNotificationSubscription(this.appName) - .pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.updateFilterCounters(); - }); + this.countersManager.subscribeToNotifications(this.appName); } } @@ -274,9 +307,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { * Iterate over filters and update counters */ updateFilterCounters(): void { - this.filters.forEach((filter: ProcessFilterCloudModel) => { - this.updateFilterCounter(filter); - }); + this.countersManager.updateAllCounters(); } /** @@ -285,49 +316,11 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { * @param filter filter */ updateFilterCounter(filter: ProcessFilterCloudModel): void { - if (!filter?.showCounter) { - return; - } - - this.fetchProcessFilterCounter(filter) - .pipe( - tap((filterCounter) => { - this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter); - }) - ) - .subscribe((data) => { - this.counters = { - ...this.counters, - [filter.key]: data - }; - }); - } - - 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); - } + this.countersManager.updateSingleCounter(filter); + this.counters = this.countersManager.counters; } isFilterUpdated(filterName: string): boolean { return this.updatedFiltersSet.has(filterName); } - - /** - * 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); - }); - } - - private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable { - return this.searchApiMethod === 'POST' - ? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter)) - : this.processListCloudService.getProcessCounter(filter.appName, filter.status); - } } diff --git a/lib/process-services-cloud/src/lib/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..ab600bed74 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.getFilterCountersNotifications instead, which shares a single + * subscription with the task filters and resolves the counters with a single request. + * @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..bffa0f95dd 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,13 @@ export class ProcessListCloudService extends BaseCloudService { ); } - protected buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } { + /** + * Builds the body of a process query, with the empty properties of the request stripped out. + * + * @param requestNode Query object + * @returns Body of the query + */ + 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..699833f1d2 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts @@ -0,0 +1,416 @@ +/*! + * @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 { Subject } from 'rxjs'; +import { ApolloTestingModule } from 'apollo-angular/testing'; +import { FilterCountersCloudService } from './filter-counters-cloud.service'; +import { NotificationCloudService } from './notification-cloud.service'; +import { FilterCounterEntityType, FilterCounters, FilterCountersNotification } from '../models/filter-counters-cloud.model'; + +describe('FilterCountersCloudService', () => { + let service: FilterCountersCloudService; + let notificationCloudService: NotificationCloudService; + let appConfigService: AppConfigService; + let engineEvents$: Subject; + let makeGQLQuerySpy: jasmine.Spy; + let postSpy: jasmine.Spy; + + const countersMock: FilterCounters = { + TASK: { ASSIGNED: 5, CREATED: 0 }, + PROCESS_INSTANCE: { RUNNING: 5 } + }; + + const emitEvent = (eventType = 'TASK_CREATED') => engineEvents$.next({ data: { engineEvents: [{ eventType, entity: {} }] } }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAuthModule, ApolloTestingModule] + }); + + service = TestBed.inject(FilterCountersCloudService); + notificationCloudService = TestBed.inject(NotificationCloudService); + appConfigService = TestBed.inject(AppConfigService); + appConfigService.config.bpmHost = 'https://fake-bpm-host.com'; + + engineEvents$ = new Subject(); + makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery').and.returnValue(engineEvents$.asObservable() as any); + postSpy = spyOn(service, 'post').and.returnValue(new Subject().asObservable()); + + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }]); + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + }); + + it('should return EMPTY when appName is not set', () => { + let completed = false; + service.getFilterCountersNotifications('').subscribe({ complete: () => (completed = true) }); + + expect(completed).toBeTrue(); + expect(makeGQLQuerySpy).not.toHaveBeenCalled(); + }); + + it('should open a single subscription for multiple subscribers of the same app', () => { + service.getFilterCountersNotifications('mock-app').subscribe(); + service.getFilterCountersNotifications('mock-app').subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to both the task and the process engine events', () => { + service.getFilterCountersNotifications('mock-app').subscribe(); + + const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args; + expect(appName).toBe('mock-app'); + expect(query).toContain('TASK_CREATED'); + expect(query).toContain('PROCESS_STARTED'); + }); + + it('should open a separate subscription per app', () => { + service.getFilterCountersNotifications('mock-app').subscribe(); + service.getFilterCountersNotifications('other-app').subscribe(); + + expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); + }); + + it('should make a single count request for a batch of events received by multiple subscribers', fakeAsync(() => { + postSpy.and.returnValue(new Subject().asObservable()); + service.getFilterCountersNotifications('mock-app').subscribe(); + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent('TASK_CREATED'); + emitEvent('PROCESS_STARTED'); + tick(3000); + + expect(postSpy).toHaveBeenCalledTimes(1); + })); + + it('should call the batched count endpoint with the queries of the registered filters', fakeAsync(() => { + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent(); + tick(3000); + + const [url, body] = postSpy.calls.mostRecent().args; + expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count'); + expect(body).toEqual({ + TASK: [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }], + PROCESS_INSTANCE: [{ status: ['RUNNING'] }] + }); + })); + + it('should send the full criteria of every registered filter', fakeAsync(() => { + service.registerFilters(FilterCounterEntityType.TASK, [ + { status: ['ASSIGNED'], assignee: ['mock-user'] }, + { status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' }, + { status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] } + ]); + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent(); + tick(3000); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK.length).toBe(3); + expect(body.TASK[1]).toEqual({ status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' }); + expect(body.TASK[2]).toEqual({ status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] }); + })); + + it('should replace the previously registered queries of an entity type', fakeAsync(() => { + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['COMPLETED'] }]); + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent(); + tick(3000); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.TASK).toEqual([{ status: ['COMPLETED'] }]); + })); + + it('should not call the batched count endpoint when no filter is registered', fakeAsync(() => { + service.registerFilters(FilterCounterEntityType.TASK, []); + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []); + let notification: FilterCountersNotification; + service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); + + emitEvent(); + tick(3000); + + expect(postSpy).not.toHaveBeenCalled(); + expect(notification.counters).toEqual({}); + })); + + it('should omit the entity type of an entity without registered filters', fakeAsync(() => { + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []); + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent(); + tick(3000); + + const [, body] = postSpy.calls.mostRecent().args; + expect(body.PROCESS_INSTANCE).toBeUndefined(); + expect(body.TASK.length).toBe(2); + })); + + it('should debounce the events using the configured debounce time', fakeAsync(() => { + spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notificationDebounceTime' ? 5000 : defaultValue)); + service.getFilterCountersNotifications('mock-app').subscribe(); + + emitEvent(); + tick(3000); + expect(postSpy).not.toHaveBeenCalled(); + + tick(2000); + expect(postSpy).toHaveBeenCalledTimes(1); + })); + + it('should emit the events along with the resolved counters', fakeAsync(() => { + const counters$ = new Subject(); + postSpy.and.returnValue(counters$.asObservable()); + let notification: FilterCountersNotification; + service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); + + emitEvent('TASK_ASSIGNED'); + tick(3000); + counters$.next(countersMock); + + expect(notification.counters).toEqual(countersMock); + expect(notification.events.length).toBe(1); + expect(notification.events[0].eventType).toBe('TASK_ASSIGNED'); + })); + + it('should emit the events with empty counters when the count request fails', fakeAsync(() => { + const counters$ = new Subject(); + postSpy.and.returnValue(counters$.asObservable()); + let notification: FilterCountersNotification; + service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result)); + + emitEvent(); + tick(3000); + counters$.error(new Error('count failed')); + + expect(notification.counters).toEqual({}); + expect(notification.events.length).toBe(1); + })); + + it('should keep emitting after a failed count request', fakeAsync(() => { + const notifications: FilterCountersNotification[] = []; + service.getFilterCountersNotifications('mock-app').subscribe((result) => notifications.push(result)); + + postSpy.and.callFake(() => { + const counters$ = new Subject(); + setTimeout(() => counters$.error(new Error('count failed'))); + return counters$.asObservable(); + }); + emitEvent(); + tick(3000); + tick(0); + + postSpy.and.callFake(() => { + const counters$ = new Subject(); + setTimeout(() => counters$.next(countersMock)); + return counters$.asObservable(); + }); + emitEvent(); + tick(3000); + tick(0); + + expect(notifications.length).toBe(2); + expect(notifications[1].counters).toEqual(countersMock); + })); + + describe('loadFilterCounters', () => { + beforeEach(() => { + postSpy.and.returnValue(of(countersMock)); + }); + + it('should resolve the counters of both entity types with a single request', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + const taskCounters: FilterCounters[] = []; + const processCounters: FilterCounters[] = []; + + // both components load their filter lists independently and subscribe before registering + service.loadFilterCounters('mock-app').subscribe((counters) => taskCounters.push(counters)); + service.loadFilterCounters('mock-app').subscribe((counters) => processCounters.push(counters)); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + tick(1000); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(taskCounters).toEqual([countersMock]); + expect(processCounters).toEqual([countersMock]); + })); + + it('should send the queries of every entity type in one request', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + + service.loadFilterCounters('mock-app').subscribe(); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }]); + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + tick(1000); + + const [url, body] = postSpy.calls.mostRecent().args; + expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count'); + expect(body).toEqual({ + TASK: [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }], + PROCESS_INSTANCE: [{ status: ['RUNNING'] }] + }); + })); + + it('should wait for every expected entity type before sending the request', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + + service.loadFilterCounters('mock-app').subscribe(); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + + expect(postSpy).not.toHaveBeenCalled(); + + service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]); + + expect(postSpy).toHaveBeenCalledTimes(1); + tick(1000); + })); + + it('should send the request once the only expected entity type registers', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + + service.loadFilterCounters('mock-app').subscribe(); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + + expect(postSpy).toHaveBeenCalledTimes(1); + const [, body] = postSpy.calls.mostRecent().args; + expect(body.PROCESS_INSTANCE).toBeUndefined(); + tick(1000); + })); + + it('should not wait longer than the max wait for a missing registration', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + let counters: FilterCounters; + + // the process filters fail to load, so they never register + service.loadFilterCounters('mock-app').subscribe((result) => (counters = result)); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + + expect(postSpy).not.toHaveBeenCalled(); + + tick(1000); + + expect(postSpy).toHaveBeenCalledTimes(1); + expect(counters).toEqual(countersMock); + })); + + it('should use the max wait from the app config', fakeAsync(() => { + spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => + key === 'filterCounterBatchMaxWait' ? 5000 : defaultValue + ); + service.expectFilters(FilterCounterEntityType.TASK); + service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE); + + service.loadFilterCounters('mock-app').subscribe(); + tick(1000); + expect(postSpy).not.toHaveBeenCalled(); + + tick(4000); + expect(postSpy).toHaveBeenCalledTimes(1); + })); + + it('should emit empty counters when the request fails', fakeAsync(() => { + postSpy.and.returnValue(throwError(() => new Error('count failed'))); + service.expectFilters(FilterCounterEntityType.TASK); + let counters: FilterCounters; + + service.loadFilterCounters('mock-app').subscribe((result) => (counters = result)); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + tick(1000); + + expect(counters).toEqual({}); + })); + + it('should start a new batch for a subsequent load of another app', fakeAsync(() => { + service.expectFilters(FilterCounterEntityType.TASK); + + service.loadFilterCounters('mock-app').subscribe(); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]); + tick(1000); + + service.loadFilterCounters('other-app').subscribe(); + service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['CREATED'] }]); + tick(1000); + + expect(postSpy).toHaveBeenCalledTimes(2); + expect(postSpy.calls.allArgs().map(([url]) => url)).toEqual([ + 'https://fake-bpm-host.com/mock-app/query/v1/count', + 'https://fake-bpm-host.com/other-app/query/v1/count' + ]); + })); + }); + + describe('resolveFilterCounter', () => { + it('should resolve the counter of a filter by its status', () => { + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBe(5); + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'queued', statuses: ['CREATED'] })).toBe(0); + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.PROCESS_INSTANCE, { key: 'running', status: 'RUNNING' })).toBe( + 5 + ); + }); + + it('should sum the counters of a filter targeting more than one status', () => { + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'CREATED'] })).toBe( + 5 + ); + expect( + service.resolveFilterCounter({ TASK: { ASSIGNED: 5, CREATED: 2, SUSPENDED: 3 } }, FilterCounterEntityType.TASK, { + key: 'mine', + statuses: ['ASSIGNED', 'SUSPENDED'] + }) + ).toBe(8); + }); + + it('should resolve the counter from the statuses held by the response', () => { + expect( + service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'COMPLETED'] }) + ).toBe(5); + }); + + it('should not resolve the counter of a filter not held by the response', () => { + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'done', status: 'COMPLETED' })).toBeUndefined(); + expect(service.resolveFilterCounter({}, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBeUndefined(); + }); + + it('should not resolve the counter of a filter targeting every status', () => { + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', status: '' })).toBeUndefined(); + expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', statuses: [] })).toBeUndefined(); + }); + }); + + describe('isCounterBatched', () => { + it('should batch the counter of a filter targeting one or more statuses', () => { + expect(service.isCounterBatched({ key: 'my-tasks', status: 'ASSIGNED' })).toBeTrue(); + expect(service.isCounterBatched({ key: 'mine', statuses: ['ASSIGNED', 'SUSPENDED'] })).toBeTrue(); + }); + + it('should not batch the counter of a filter targeting every status', () => { + expect(service.isCounterBatched({ key: 'all', status: '' })).toBeFalse(); + expect(service.isCounterBatched({ key: 'all', statuses: [] })).toBeFalse(); + expect(service.isCounterBatched({ key: 'all' })).toBeFalse(); + }); + }); +}); 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..ba1bb1e617 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts @@ -0,0 +1,261 @@ +/*! + * @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 } from '@angular/core'; +import { combineLatest, defer, EMPTY, Observable, of } from 'rxjs'; +import { catchError, debounceTime, 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 { ProcessFilterCloudAdapter } from '../process/process-list/models/process-cloud-query-request.model'; +import { + FilterCounterCandidate, + FilterCounterEntityType, + FilterCounters, + FilterCountersFilters, + FilterCountersNotification, + FilterCountersQuery, + FilterCountersRequest, + resolveFilterCounterStatuses +} from '../models/filter-counters-cloud.model'; + +/** + * Single subscription covering both the task and the process engine events, so that a batch of + * events results in one call to the batched count endpoint. + */ +const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` + subscription { + engineEvents(eventType: [ + TASK_COMPLETED + TASK_ASSIGNED + TASK_ACTIVATED + TASK_SUSPENDED + TASK_CANCELLED + TASK_CREATED + PROCESS_CANCELLED + PROCESS_COMPLETED + PROCESS_CREATED + PROCESS_RESUMED + PROCESS_SUSPENDED + PROCESS_STARTED + ]) { + eventType + entity + } + } +`; + +/** + * Central place handling the filter counters of the task and the process filter components: + * a single engine event subscription, debounced into a single batched count request. + * + * Every filter with a counter enabled is registered by its component through `registerFilters`, + * so that the counters of both the task and the process filters are resolved by one request. + */ +@Injectable({ providedIn: 'root' }) +export class FilterCountersCloudService extends BaseCloudService { + private readonly notificationCloudService = inject(NotificationCloudService); + + private readonly taskFilterCloudService = inject(TaskFilterCloudService); + private readonly processFilterCloudService = inject(ProcessFilterCloudService); + private readonly taskListCloudService = inject(TaskListCloudService); + private readonly processListCloudService = inject(ProcessListCloudService); + + private readonly notificationsPerApp = new Map>(); + private readonly filtersPerApp = new Map>(); + private readonly countersPerApp = new Map>(); + + get notificationDebounceTime(): number { + return this.appConfigService.get('notificationDebounceTime', 3000); + } + + /** + * Task and process filters of the app, loaded once and shared between the filter components, + * so that a single place owns the filters the counters are resolved for. + * + * @param appName Name of the target app + * @returns Task and process filters of the app + */ + getFilters(appName: string): Observable { + let filters$ = this.filtersPerApp.get(appName); + if (!filters$) { + filters$ = combineLatest({ + [FilterCounterEntityType.TASK]: this.taskFilterCloudService.getTaskListFilters(appName).pipe(catchError(() => of([]))), + [FilterCounterEntityType.PROCESS_INSTANCE]: this.processFilterCloudService.getProcessFilters(appName).pipe(catchError(() => of([]))) + }).pipe(shareReplay({ bufferSize: 1, refCount: false })); + + this.filtersPerApp.set(appName, filters$); + } + + return filters$; + } + + /** + * Counters of every filter of the app with a counter enabled, resolved by a single request + * shared between the filter components. Both the task and the process filters are loaded + * before the request is built, so that one call resolves the counters of both components. + * + * @param appName Name of the target app + * @returns Counters keyed by entity type and status + */ + loadFilterCounters(appName: string): Observable { + let counters$ = this.countersPerApp.get(appName); + if (!counters$) { + counters$ = this.getFilters(appName).pipe( + take(1), + switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), + catchError(() => of({} as FilterCounters)), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + this.countersPerApp.set(appName, counters$); + } + + return counters$; + } + + /** + * Builds the payload of the batched count request from the filters with a counter enabled. + * + * @param filters Task and process filters of the app + * @returns Payload of the count request + */ + buildRequest(filters: FilterCountersFilters): FilterCountersRequest { + 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) => FilterCountersQuery): FilterCountersQuery[] { + return (filters ?? []) + .filter((filter) => filter?.showCounter && this.isCounterBatched(filter)) + .map((filter) => { + try { + return buildQuery(filter); + } catch { + /* A filter the query cannot be built for is left out of the batch and counted on its own. */ + return undefined; + } + }) + .filter((query) => !!query); + } + + /** + * Engine events of the app, debounced and enriched with the counters resolved by a single + * call to the batched count endpoint. The underlying subscription and count request are + * shared between all the subscribers of the same app. + * + * @param appName Name of the target app + * @returns Debounced engine events along with the resolved counters + */ + getFilterCountersNotifications(appName: string): Observable { + if (!appName) { + return EMPTY; + } + + let notifications$ = this.notificationsPerApp.get(appName); + if (!notifications$) { + notifications$ = defer(() => this.notificationCloudService.makeGQLQuery(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)).pipe( + map((events: any) => (events?.data?.engineEvents ?? []) as TaskCloudEngineEvent[]), + debounceTime(this.notificationDebounceTime), + switchMap((events) => + this.getFilters(appName).pipe( + take(1), + switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), + map((counters) => ({ events, counters })), + catchError(() => of({ events, counters: {} as FilterCounters })) + ) + ), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.notificationsPerApp.set(appName, notifications$); + } + + return notifications$; + } + + /** + * Resolves the counters of the given queries with a single request. + * + * @param appName Name of the target app + * @param request Payload of the count request + * @returns Counters keyed by entity type and status + */ + 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 || {})); + } + + /** + * Reads the counter of a filter from a count response. The counters are keyed by status, so + * the counter of a filter targeting more than one status is the sum of the counters of its statuses. + * A filter targeting every status holds no status to be keyed by, so its counter is not resolved + * by the batched request and is left to be fetched on its own. + * + * @param counters Counters resolved by the batched count endpoint + * @param entityType Entity type of the filter + * @param filter Filter the counter is read for + * @returns The counter of the filter, or `undefined` when the response holds no counter for it + */ + resolveFilterCounter(counters: FilterCounters, entityType: FilterCounterEntityType, filter: FilterCounterCandidate): number | undefined { + const entityCounters = counters?.[entityType]; + const statuses = resolveFilterCounterStatuses(filter); + + if (!entityCounters || !statuses.length) { + return undefined; + } + + const countedStatuses = statuses.filter((status) => entityCounters[status] !== undefined); + + return countedStatuses.length ? countedStatuses.reduce((total, status) => total + entityCounters[status], 0) : undefined; + } + + /** + * Whether the counter of a filter is resolved by the batched count request. A filter targeting + * every status is not, since the counters of the response are keyed by status. + * + * @param filter Filter with a counter enabled + * @returns `true` when the counter of the filter is resolved by the batched request, otherwise `false` + */ + isCounterBatched(filter: FilterCounterCandidate): boolean { + return resolveFilterCounterStatuses(filter).length > 0; + } +} diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts b/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts new file mode 100644 index 0000000000..29178d11a0 --- /dev/null +++ b/lib/process-services-cloud/src/lib/services/filter-counters-manager.ts @@ -0,0 +1,193 @@ +/*! + * @license + * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { FilterCountersCloudService } from './filter-counters-cloud.service'; +import { FilterCounterCandidate, FilterCounterEntityType, FilterCounters } from '../models/filter-counters-cloud.model'; + +export interface FilterCounterAdapter { + getFilterCounter(filter: TFilter): Observable; +} + +export interface FilterCountersManagerCallbacks { + onCountersUpdated?: () => void; + onFilterUpdated?: (filterKey: string) => void; +} + +/** + * Manages filter counters for a specific entity type using composition. + * Handles loading, updating, and notification subscriptions for filter counters. + */ +export class FilterCountersManager { + counters: { [key: string]: number } = {}; + currentFiltersValues: { [key: string]: number } = {}; + updatedFiltersSet = new Set(); + + private filters: TFilter[] = []; + + constructor( + private readonly entityType: FilterCounterEntityType, + private readonly filterCountersService: FilterCountersCloudService, + private readonly counterAdapter: FilterCounterAdapter, + private readonly destroyRef: DestroyRef, + private readonly callbacks: FilterCountersManagerCallbacks = {} + ) {} + + /** + * Initialize counters for all filters to 0 + * + * @param filters List of filters to initialize counters for + */ + initCounters(filters: TFilter[]): void { + this.filters = filters; + filters.forEach((filter) => (this.counters[filter.key] = 0)); + } + + /** + * Load filter counters on initial page load using the batched endpoint + * + * @param appName Name of the target app + */ + loadCounters(appName: string): void { + this.filterCountersService + .loadFilterCounters(appName) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((counters) => this.applyBatchedCounters(counters)); + } + + /** + * Subscribe to real-time counter updates via notifications + * + * @param appName Name of the target app + */ + subscribeToNotifications(appName: string): void { + if (!appName) { + return; + } + + this.filterCountersService + .getFilterCountersNotifications(appName) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ counters }) => { + this.applyBatchedCounters(counters); + }); + } + + /** + * Apply counters from the batched response, falling back to individual requests + * for filters not resolved by the batch. + * + * @param counters Batched filter counters + */ + private applyBatchedCounters(counters: FilterCounters): void { + this.filters.forEach((filter) => { + if (!filter?.showCounter) { + return; + } + + const filterCounter = this.filterCountersService.resolveFilterCounter(counters, this.entityType, filter); + if (filterCounter === undefined) { + this.updateSingleCounter(filter); + return; + } + + this.setCounter(filter.key, filterCounter); + }); + + this.callbacks.onCountersUpdated?.(); + } + + /** + * Update counter for a single filter using individual request + * + * @param filter Filter to update the counter for + */ + updateSingleCounter(filter: TFilter): void { + if (!filter?.showCounter) { + return; + } + + this.counterAdapter + .getFilterCounter(filter) + .pipe( + tap((filterCounter) => { + this.setCounter(filter.key, filterCounter); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); + } + + /** + * Update counters for all filters + */ + updateAllCounters(): void { + this.filters.forEach((filter) => this.updateSingleCounter(filter)); + } + + /** + * Set counter value and track if it changed + * + * @param filterKey Key of the filter to update + * @param filterValue New counter value for the filter + */ + private setCounter(filterKey: string, filterValue: number): void { + if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { + this.currentFiltersValues[filterKey] = filterValue; + this.updatedFiltersSet.add(filterKey); + this.callbacks.onFilterUpdated?.(filterKey); + } + + this.counters = { + ...this.counters, + [filterKey]: filterValue + }; + } + + /** + * Check if a filter has been updated + * + * @param filterKey Key of the filter to check + * @returns True if the filter has been updated, false otherwise + */ + isFilterUpdated(filterKey: string): boolean { + return this.updatedFiltersSet.has(filterKey); + } + + /** + * Mark a filter as viewed/not updated + * + * @param filterKey Key of the filter to reset + */ + resetFilterUpdate(filterKey: string): void { + this.updatedFiltersSet.delete(filterKey); + } + + /** + * Subscribe to external refresh events from the filter service + * + * @param refreshSignal$ Observable emitting filter keys to refresh + */ + subscribeToExternalRefresh(refreshSignal$: Observable): void { + refreshSignal$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => { + this.updatedFiltersSet.delete(filterKey); + }); + } +} diff --git a/lib/process-services-cloud/src/lib/services/public-api.ts b/lib/process-services-cloud/src/lib/services/public-api.ts index e3ab7c3b05..52af2c8e9b 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,8 @@ export * from './base-cloud.service'; export * from './cloud-token.service'; +export * from './filter-counters-cloud.service'; +export * from './filter-counters-manager'; export * from './form-fields.interfaces'; export * from './local-preference-cloud.service'; export * from './notification-cloud.service'; diff --git a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts index 5fb1f21daf..b1073d0ed6 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,12 +17,12 @@ 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 { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service'; -import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock'; +import { defaultTaskFiltersMock, fakeAllTaskFilter, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock'; import { TaskFilterCloudService } from '../../services/task-filter-cloud.service'; import { TaskFiltersCloudComponent } from './task-filters-cloud.component'; import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service'; @@ -35,6 +35,8 @@ 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 { FilterCountersNotification } from '../../../../models/filter-counters-cloud.model'; @Component({ selector: 'adf-cloud-dummy', template: '' }) class DummyComponent {} @@ -50,9 +52,15 @@ describe('TaskFiltersCloudComponent', () => { let getTaskFilterCounterSpy: jasmine.Spy; let getTaskListFiltersSpy: jasmine.Spy; let getTaskListCountSpy: jasmine.Spy; - let getTaskNotificationSubscriptionSpy: jasmine.Spy; + let getFilterCountersNotificationsSpy: jasmine.Spy; + let filterCountersService: FilterCountersCloudService; let router: Router; + const filterCountersNotificationMock: FilterCountersNotification = { + events: taskNotifications, + counters: { TASK: { ASSIGNED: 11, CREATED: 0 } } + }; + const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => { TestBed.configureTestingModule({ imports: [NoopAuthModule, TaskFiltersCloudComponent, ApolloTestingModule], @@ -76,9 +84,12 @@ 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)); + getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue( + of(filterCountersNotificationMock) + ); getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter)); appConfigService = TestBed.inject(AppConfigService); @@ -306,30 +317,60 @@ describe('TaskFiltersCloudComponent', () => { }); it('should not subscribe to notifications when appName is missing', () => { - getTaskNotificationSubscriptionSpy.calls.reset(); + getFilterCountersNotificationsSpy.calls.reset(); component.appName = ''; fixture.detectChanges(); - expect(getTaskNotificationSubscriptionSpy).not.toHaveBeenCalled(); + expect(getFilterCountersNotificationsSpy).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(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('my-app-1'); + }); - notifications$.next(taskNotifications); - tick(1000); - expect(updateFilterCountersSpy).not.toHaveBeenCalled(); + it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => { + const notifications$ = new Subject(); + getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); + component.appName = 'my-app-1'; - tick(2000); - expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1); + fixture.detectChanges(); + notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } }); + + expect(component.counters['fake-involved-tasks']).toBe(7); + flush(); + })); + + it('should not update the counters of the filters not resolved by the batched count request', fakeAsync(() => { + const notifications$ = new Subject(); + getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); + component.appName = 'my-app-1'; + + fixture.detectChanges(); + component.counters = { ...component.counters, 'fake-my-task1': 3 }; + + notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } }); + + expect(component.counters['fake-my-task1']).toBe(3); + flush(); + })); + + it('should emit the events of the batch that triggered the count request', fakeAsync(() => { + const notifications$ = new Subject(); + getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable()); + const filterCounterUpdatedSpy = spyOn(component.filterCounterUpdated, 'emit'); + component.appName = 'my-app-1'; + + fixture.detectChanges(); + + notifications$.next(filterCountersNotificationMock); + + expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications); flush(); })); }); @@ -671,6 +712,94 @@ describe('TaskFiltersCloudComponent', () => { expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter); }); + describe('Batched counters registration', () => { + let registerFiltersSpy: jasmine.Spy; + + beforeEach(() => { + registerFiltersSpy = spyOn(filterCountersService, 'registerFilters'); + }); + + const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1]; + + it('should register every filter with a counter enabled', async () => { + getTaskListFiltersSpy.and.returnValue( + of([ + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }), + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' }), + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[2], showCounter: true, sort: 'createdDate', order: 'DESC' }) + ]) + ); + + await bindAppName(); + + expect(registerFiltersSpy).toHaveBeenCalledWith('TASK', jasmine.any(Array)); + expect(registeredQueries().length).toBe(3); + expect(registeredQueries().map((query: any) => query.status)).toEqual([['CREATED'], ['ASSIGNED'], ['COMPLETED']]); + }); + + it('should not register a filter without a counter enabled', async () => { + getTaskListFiltersSpy.and.returnValue( + of([ + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }), + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false, sort: 'createdDate', order: 'DESC' }) + ]) + ); + + await bindAppName(); + + expect(registeredQueries().length).toBe(1); + expect(registeredQueries()[0].status).toEqual(['CREATED']); + }); + + it('should register the full criteria of a filter', async () => { + getTaskListFiltersSpy.and.returnValue( + of([ + new TaskFilterCloudModel({ + ...defaultTaskFiltersMock[1], + showCounter: true, + sort: 'createdDate', + order: 'DESC', + assignee: 'mock-user', + priority: 4 + }) + ]) + ); + + await bindAppName(); + + const query = registeredQueries()[0]; + expect(query.status).toEqual(['ASSIGNED']); + expect(query.assignee).toEqual(['mock-user']); + expect(query.priority).toEqual(['4']); + expect(query.sort).toEqual({ field: 'createdDate', direction: 'desc', isProcessVariable: false }); + }); + + it('should not register a filter targeting every status', async () => { + getTaskListFiltersSpy.and.returnValue( + of([new TaskFilterCloudModel({ ...fakeAllTaskFilter, showCounter: true, sort: 'createdDate', order: 'DESC' })]) + ); + + await bindAppName(); + + expect(registeredQueries().length).toBe(0); + }); + + it('should not break the filter list when the query of a filter cannot be built', async () => { + getTaskListFiltersSpy.and.returnValue( + of([ + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: undefined, order: undefined }), + new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' }) + ]) + ); + + await bindAppName(); + + expect(component.filters.length).toBe(2); + expect(registeredQueries().length).toBe(1); + expect(registeredQueries()[0].status).toEqual(['ASSIGNED']); + }); + }); + describe('Highlight Selected Filter', () => { const assignedTasksFilterKey = defaultTaskFiltersMock[0].key; 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..d1dfc547eb 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,19 @@ */ import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { EMPTY, Observable } from 'rxjs'; +import { Observable, of } 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 { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; @@ -67,11 +70,12 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp currentFilter: TaskFilterCloudModel; enableNotifications = true; notificationDebounceTime = 3000; - currentFiltersValues: { [key: string]: number } = {}; private filtersLoadedFor?: string; + private countersManager: FilterCountersManager; 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,17 +84,50 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp ngOnInit() { this.enableNotifications = this.appConfigService.get('notifications', true); this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000); + + if (!this.countersManager) { + this.initCountersManager(); + } + if (!this.filtersLoadedFor) { this.getFilters(this.appName); } this.initFilterCounterNotifications(); - this.getFilterKeysAfterExternalRefreshing(); + this.countersManager.subscribeToExternalRefresh(this.taskFilterCloudService.filterKeyToBeRefreshed$); + } + + private initCountersManager(): void { + const counterAdapter: FilterCounterAdapter = { + getFilterCounter: (filter) => + this.searchApiMethod === 'POST' + ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter)) + : this.taskFilterCloudService.getTaskFilterCounter(filter) + }; + + this.countersManager = new FilterCountersManager( + FilterCounterEntityType.TASK, + this.filterCountersCloudService, + counterAdapter, + this.destroyRef, + { + onFilterUpdated: (filterKey) => { + this.updatedFilter.emit(filterKey); + this.counters = this.countersManager.counters; + }, + onCountersUpdated: () => { + this.counters = this.countersManager.counters; + } + } + ); } ngOnChanges(changes: SimpleChanges) { const appName = changes['appName']; const filter = changes['filterParam']; if (appName && appName.currentValue !== appName.previousValue) { + if (!this.countersManager) { + this.initCountersManager(); + } this.getFilters(appName.currentValue); } else if (filter && filter.currentValue !== filter.previousValue) { this.selectFilterAndEmit(filter.currentValue); @@ -104,16 +141,18 @@ 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 + .getFilters(appName) + .pipe(map((filters) => filters[FilterCounterEntityType.TASK] as TaskFilterCloudModel[])); + this.filters$ = filters$.pipe(catchError(() => of([]))); filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: (res) => { this.resetFilter(); this.filters = res || []; - this.initFilterCounters(); + this.countersManager.initCounters(this.filters); this.selectFilterAndEmit(this.filterParam); - this.updateFilterCounters(); + this.countersManager.loadCounters(appName); this.success.emit(res); }, error: (err) => { @@ -122,18 +161,11 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp }); } - /** - * Initialize counter collection for filters - */ - initFilterCounters(): void { - this.filters.forEach((filter) => (this.counters[filter.key] = 0)); - } - /** * Iterate over filters and update counters */ updateFilterCounters(): void { - this.filters.forEach((filter: TaskFilterCloudModel) => this.updateFilterCounter(filter)); + this.countersManager.updateAllCounters(); } /** @@ -142,48 +174,27 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp * @param filter filter */ updateFilterCounter(filter: TaskFilterCloudModel): void { - if (!filter?.showCounter) { - return; - } - this.fetchTaskFilterCounter(filter) - .pipe( - tap((filterCounter) => { - this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter); - }) - ) - .subscribe((data) => { - this.counters = { - ...this.counters, - [filter.key]: data - }; - }); - } - - private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable { - return this.searchApiMethod === 'POST' - ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter)) - : this.taskFilterCloudService.getTaskFilterCounter(filter); + this.countersManager.updateSingleCounter(filter); + this.counters = this.countersManager.counters; } initFilterCounterNotifications() { - if (!this.appName) { + if (!this.appName || !this.enableNotifications) { 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); + this.filterCountersCloudService + .getFilterCountersNotifications(this.appName) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ events }) => { + events.forEach((taskEvent) => { + this.checkFilterCounter(taskEvent.entity); }); - } else { - this.counters = {}; - } + + this.filterCounterUpdated.emit(events); + }); + + this.countersManager.subscribeToNotifications(this.appName); } checkFilterCounter(filterNotification: TaskDetailsCloudModel) { @@ -224,6 +235,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.selectFilter(newParamFilter); if (this.currentFilter) { + this.countersManager.resetFilterUpdate(this.currentFilter.key); this.resetFilterCounter(this.currentFilter.key); this.filterSelected.emit(this.currentFilter); } @@ -242,6 +254,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.selectFilter(filter); this.updateFilterCounter(this.currentFilter); this.filterClicked.emit(this.currentFilter); + this.countersManager.resetFilterUpdate(filter.key); this.updatedCountersSet.delete(filter.key); } else { this.currentFilter = undefined; @@ -276,20 +289,12 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp } checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) { - if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) { - this.currentFiltersValues[filterKey] = filterValue; + if ( + this.countersManager.currentFiltersValues[filterKey] === undefined || + this.countersManager.currentFiltersValues[filterKey] !== filterValue + ) { this.updatedFilter.emit(filterKey); this.updatedCountersSet.add(filterKey); } } - - /** - * Get filer key when filter was refreshed by external action - * - */ - getFilterKeysAfterExternalRefreshing(): void { - this.taskFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => { - this.updatedCountersSet.delete(filterKey); - }); - } } 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..cf784dee54 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,12 @@ export class TaskFilterCloudService extends BaseCloudService { ]; } + /** + * @deprecated use FilterCountersCloudService.getFilterCountersNotifications instead, which shares a single + * subscription with the process filters and resolves the counters with a single request. + * @param appName Name of the target app + * @returns Task engine events + */ 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..6ee9c33fab 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,13 @@ export class TaskListCloudService extends BaseCloudService implements TaskListCl return this.post(queryUrl, queryData).pipe(map((response) => response || 0)); } - protected buildQueryData(requestNode: TaskListRequestModel) { + /** + * Builds the body of a task query, with the empty properties of the request stripped out. + * + * @param requestNode Query object + * @returns Body of the query + */ + 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';