diff --git a/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts index 9cd1f54b0c..e02196e0c9 100644 --- a/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts +++ b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts @@ -31,17 +31,13 @@ export interface FilterCountersQuerySort { isProcessVariable: boolean; } -/** - * A single query of the batched count request, holding the criteria of one filter. The counter - * resolved for the query is keyed by its `requestId` in the response. - */ +/** A single query of the batched count request, holding the criteria of one filter. */ export interface FilterCountersQuery { - /** Identifies the query, so that its counter can be read back from the response. */ + /** Identifies the query, so its counter can be read back from the response. */ requestId: string; status?: string[]; assignee?: string[]; sort?: FilterCountersQuerySort; - /** Every other criteria of the filter the query was built from. */ [criteria: string]: unknown; } @@ -52,34 +48,23 @@ export type FilterCountersRequest = { [entityType in FilterCounterEntityType]?: FilterCountersQuery[]; }; -/** - * Shape of a task or process filter the counters are resolved for. The key of the filter is used - * as the `requestId` of its query, so that its counter can be read back from the response. A filter - * without a key holds no identity for the batched request, so its counter is fetched on its own. - */ +/** Shape of a task or process filter the counters are resolved for. Its key is the `requestId`. */ export interface FilterCounterCandidate { key?: string | null; showCounter?: boolean; } /** - * Counts returned by the batched count request, keyed by entity type and then by the `requestId` - * of the query the count was resolved for. - * e.g. `{ TASK: { 'my-tasks': 5, 'queued-tasks': 0 }, PROCESS_INSTANCE: { 'running-processes': 5 } }` + * Counts returned by the batched count request, keyed by entity type and then by `requestId`. + * e.g. `{ TASK: { 'my-tasks': 5 }, PROCESS_INSTANCE: { 'running-processes': 5 } }` */ export type FilterCounters = { [entityType in FilterCounterEntityType]?: { [requestId: string]: number }; }; -/** - * Counters of the filters of one entity type, keyed by the key of the filter they were resolved for. - */ export interface FilterCountersResult { /** Counters keyed by filter key. Empty when the batched count endpoint is not available. */ counters: { [filterKey: string]: number }; - /** - * Whether the counters were resolved by the batched count endpoint. When `false`, the endpoint is - * not available on the backend of the app and the counters are to be resolved one filter at a time. - */ + /** When `false`, the backend holds no batched count endpoint: count one filter at a time. */ batched: boolean; } diff --git a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts index 508543c054..7e372538b8 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 @@ -520,13 +520,11 @@ describe('ProcessFiltersCloudComponent', () => { }); it('should resolve the counters of the filters the batch left out on their own', async () => { - /* A filter without a key, or one the query cannot be built for, is left out of the batch. */ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); await bindAppName('mock-app-name'); expect(component.counters['FakeRunningProcesses']).toBe(9); - /* The model holds no status for the filter targeting every status. */ expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']); }); @@ -901,13 +899,11 @@ describe('ProcessFiltersCloudComponent', () => { }); it('should resolve the counters of the filters the batch left out on their own', async () => { - /* A filter without a key, or one the query cannot be built for, is left out of the batch. */ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true })); await bindAppName('mock-app-name'); expect(component.counters['FakeRunningProcesses']).toBe(9); - /* The model holds no status for the filter targeting every status. */ expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']); }); 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 1c29355937..8af2969b68 100644 --- a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts @@ -16,7 +16,7 @@ */ import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { defer, EMPTY, Observable, Subscription } from 'rxjs'; +import { combineLatest, defer, EMPTY, Observable, Subscription } from 'rxjs'; import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service'; import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model'; import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core'; @@ -48,8 +48,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { /** * (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count. * - * @deprecated the counters are resolved by `POST /query/v1/count`, which requires Activiti 8.7.0 - * forward. This input is only used by the backends without that endpoint and will be removed, + * @deprecated only used by the backends without `POST /query/v1/count`. It will be removed, * along with the 'GET' method, in ADF 10.0.0. */ @Input() @@ -141,12 +140,14 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); this.success.emit(res); - this.loadFilterCounters(appName); }, error: (err: unknown) => { this.error.emit(err); } }); + + /* Read along with the filters, not once they arrive, so both components share one request. */ + this.loadFilterCounters(appName, filters$); } /** @@ -253,20 +254,18 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { } /** - * Keeps the counters in sync with the engine events of the app. - * - * @deprecated the counters stream keeps itself in sync with the engine events, so nothing is + * @deprecated `getFilterCounters` keeps the counters in sync with the engine events, so nothing is * subscribed here anymore. It will be removed in ADF 10.0.0. */ initProcessNotification(): void { - /* Kept for backwards compatibility: `getFilterCounters` subscribes to the engine events. */ + /* Kept for backwards compatibility. */ } /** * Iterate over filters and update counters * - * @deprecated the counters are resolved by the batched count request. This resolves them one - * filter at a time, for the backends without the batched count endpoint. + * @deprecated resolves the counters one filter at a time, for the backends without the batched + * count endpoint. It will be removed in ADF 10.0.0. */ updateFilterCounters(): void { this.filters.forEach((filter) => this.updateFilterCounter(filter)); @@ -276,8 +275,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { * Get current value for filter and check if value has changed * * @param filter filter - * @deprecated the counters are resolved by the batched count request. This resolves the counter - * of one filter, for the backends without the batched count endpoint. + * @deprecated resolves the counter of one filter, for the backends without the batched count + * endpoint. It will be removed in ADF 10.0.0. */ updateFilterCounter(filter: ProcessFilterCloudModel): void { const filterKey = filter?.showCounter ? filter.key : undefined; @@ -285,8 +284,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { return; } - /* `defer` turns the query building of the count request into a failure of the stream, so that a - filter the counter cannot be resolved for is left without one instead of breaking the others. */ + /* `defer` keeps a query that cannot be built from breaking the counters of the other filters. */ defer(() => this.fetchProcessFilterCounter(filter)) .pipe( catchError(() => EMPTY), @@ -306,9 +304,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { } } - /** - * Flags the counter of a filter as read whenever the filter is refreshed by an external action - */ + /** Flags the counter of a filter as read whenever the filter is refreshed elsewhere */ getFilterKeysAfterExternalRefreshing(): void { this.processFilterCloudService.filterKeyToBeRefreshed$ .pipe(takeUntilDestroyed(this.destroyRef)) @@ -341,18 +337,15 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { this.currentFilter = undefined; } - /** - * Resolves the counters of the filters, with one request shared with the task filters. The - * counters of a backend without the batched count endpoint are resolved one filter at a time. - * - * @param appName application name - */ - private loadFilterCounters(appName: string): void { + private loadFilterCounters(appName: string, filters$: Observable): void { this.countersSubscription?.unsubscribe(); - this.countersSubscription = this.filterCountersCloudService - .getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE) + /* Counters are keyed by filter key, so they are applied once the filters are known. */ + this.countersSubscription = combineLatest([ + filters$.pipe(catchError(() => EMPTY)), + this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE) + ]) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ counters, batched }) => { + .subscribe(([, { counters, batched }]) => { this.batchedCounters = batched; if (batched) { this.applyFilterCounters(counters); @@ -362,16 +355,9 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { }); } - /** - * Holds the counters resolved by the batched count request, which are keyed by filter key. The - * counter of a filter the request holds none for is resolved on its own: a filter without a key, - * or one the count query cannot be built for, is left out of the batch. - * - * @param counters counters keyed by filter key - */ private applyFilterCounters(counters: { [filterKey: string]: number }): void { this.filters.forEach((filter) => { - /* A filter without a key holds no request id, so no counter can be keyed by it. */ + /* A filter without a key holds no request id. */ const filterKey = filter?.showCounter ? filter.key : undefined; if (!filterKey) { return; @@ -379,7 +365,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { const counter = counters[filterKey]; if (counter === undefined) { - /* The batch holds no counter for a filter the count query cannot be built for. */ + /* Left out of the batch: counted on its own. */ this.updateFilterCounter(filter); return; } @@ -389,12 +375,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges { }); } - /** - * Resolves the counters again, with one request for every filter of the app. The counter of the - * given filter alone is resolved when the batched count endpoint is not available. - * - * @param filter filter that was clicked - */ private refreshFilterCounter(filter?: ProcessFilterCloudModel): void { if (this.batchedCounters) { this.filterCountersCloudService.refreshFilterCounters(this.appName); 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 3a02efaeea..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 @@ -405,8 +405,8 @@ export class ProcessFilterCloudService { } /** - * @deprecated use FilterCountersCloudService.getEngineEvents instead, which shares a single - * subscription with the task filters and provides a debounced engine-event stream used to drive counter refreshes. + * @deprecated use FilterCountersCloudService.getEngineEvents instead. + * * @param appName Name of the target app * @returns Process engine events */ diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts index 57ac73e65a..5d937ad656 100644 --- a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts @@ -17,7 +17,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core'; -import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs'; +import { 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'; @@ -47,18 +47,18 @@ describe('FilterCountersCloudService', () => { let service: FilterCountersCloudService; let notificationCloudService: NotificationCloudService; let appConfigService: AppConfigService; - let engineEvents$: Subject; + let taskEvents$: Subject; + let processEvents$: Subject; let makeGQLQuerySpy: jasmine.Spy; let postSpy: jasmine.Spy; - /** Payload the batched count endpoint was called with. */ - const countRequest = (): FilterCountersRequest => postSpy.calls.mostRecent().args[1]; - const countUrl = (): string => postSpy.calls.mostRecent().args[0]; - /** Queries the batched count endpoint was called with for an entity type. */ - const countQueries = (entityType: FilterCounterEntityType): FilterCountersQuery[] => countRequest()[entityType] ?? []; - const countRequestIds = (entityType: FilterCounterEntityType): string[] => countQueries(entityType).map((query) => query.requestId); 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 } @@ -79,8 +79,23 @@ describe('FilterCountersCloudService', () => { processFilter({ key: 'all-processes', status: '', showCounter: false }) ]; - const emitEvent = (eventType = 'TASK_CREATED') => - engineEvents$.next({ data: { engineEvents: [{ eventType, entity: {} } as TaskCloudEngineEvent] } }); + 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)); + const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName); + const processCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.PROCESS_INSTANCE, appName); + /** As read when both filter components are on screen. */ + const bothCounters = (appName = 'mock-app') => + firstValueFrom( + combineLatest([ + service.getFilterCounters(appName, FilterCounterEntityType.TASK), + service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE) + ]) + ); beforeEach(() => { TestBed.configureTestingModule({ @@ -96,9 +111,14 @@ describe('FilterCountersCloudService', () => { appConfigService = TestBed.inject(AppConfigService); appConfigService.config.bpmHost = 'https://fake-bpm-host.com'; - engineEvents$ = new Subject(); - makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery').and.returnValue(engineEvents$.asObservable()); - /* `post` is protected on BaseCloudService, so it is reached through the shape it is spied on. */ + taskEvents$ = new Subject(); + processEvents$ = new Subject(); + makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery'); + /* Every entity type holds its own subscription. */ + makeGQLQuerySpy.and.callFake((_appName: string, query: string) => + (query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable() + ); + /* `post` is protected, so it is reached through the shape it is spied on. */ 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)); @@ -129,7 +149,7 @@ describe('FilterCountersCloudService', () => { it('should share the filters with the batched count request', async () => { await firstValueFrom(service.getTaskFilters('mock-app')); - await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK)); + await taskCounters(); expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1); }); @@ -142,10 +162,6 @@ describe('FilterCountersCloudService', () => { }); describe('getFilterCounters', () => { - const taskCounters = (appName = 'mock-app') => firstValueFrom(service.getFilterCounters(appName, FilterCounterEntityType.TASK)); - const processCounters = (appName = 'mock-app') => - firstValueFrom(service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE)); - it('should return EMPTY when appName is not set', () => { let completed = false; service.getFilterCounters('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) }); @@ -154,28 +170,23 @@ describe('FilterCountersCloudService', () => { expect(postSpy).not.toHaveBeenCalled(); }); - it('should resolve the counters of both entity types with a single request', () => { - const results: FilterCountersResult[] = []; - /* Both filter components hold their subscription, so one request resolves the counters of both. */ - service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); - service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe((result) => results.push(result)); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(results).toEqual([ + 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 send the queries of both entity types to the batched count endpoint', async () => { + 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'); - expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]); }); it('should identify the query of every filter by the key of the filter', async () => { - await taskCounters(); + await bothCounters(); expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']); expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['running-processes']); @@ -209,7 +220,7 @@ describe('FilterCountersCloudService', () => { it('should omit an entity type without filters with a counter enabled', async () => { getProcessFiltersSpy.and.returnValue(of([])); - await taskCounters(); + await bothCounters(); expect(countRequest().PROCESS_INSTANCE).toBeUndefined(); }); @@ -227,15 +238,14 @@ describe('FilterCountersCloudService', () => { it('should leave out a filter without a key, since it holds no request id', async () => { getProcessFiltersSpy.and.returnValue(of([processFilter({ key: null, status: 'RUNNING', showCounter: true })])); - await processCounters(); - - expect(countRequest().PROCESS_INSTANCE).toBeUndefined(); + 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 taskCounters(); + await bothCounters(); expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']); expect(countRequest().PROCESS_INSTANCE).toBeUndefined(); @@ -278,35 +288,81 @@ describe('FilterCountersCloudService', () => { expect(await taskCounters()).toEqual({ counters: {}, batched: false }); postSpy.and.returnValue(of(countersMock)); - service.refreshFilterCounters('mock-app'); - expect(await taskCounters()).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true }); expect(postSpy).toHaveBeenCalledTimes(2); }); }); }); + describe('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).subscribe(); + tick(0); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]); + + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).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).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + tick(0); + + taskSubscription.unsubscribe(); + service.refreshFilterCounters('mock-app'); + tick(0); + + expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.PROCESS_INSTANCE]); + })); + }); + describe('refreshFilterCounters', () => { it('should resolve the counters again with a single request', fakeAsync(() => { const results: FilterCountersResult[] = []; service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).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', () => { + 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', () => { - /* An app holding only the task filters provides the task preferences service alone. */ const configureTasksOnly = () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ @@ -344,53 +400,68 @@ describe('FilterCountersCloudService', () => { describe('getEngineEvents', () => { it('should return EMPTY when appName is not set', () => { let completed = false; - service.getEngineEvents('').subscribe({ complete: () => (completed = true) }); + service.getEngineEvents('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) }); expect(completed).toBeTrue(); expect(makeGQLQuerySpy).not.toHaveBeenCalled(); }); - it('should open a single subscription for multiple subscribers of the same app', () => { - service.getEngineEvents('mock-app').subscribe(); - service.getEngineEvents('mock-app').subscribe(); - - expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1); - }); - - it('should subscribe to both the task and the process engine events', () => { - service.getEngineEvents('mock-app').subscribe(); + 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').subscribe(); - service.getEngineEvents('other-app').subscribe(); + 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').subscribe((events) => batches.push(events)); + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe((events) => batches.push(events)); - emitEvent('TASK_CREATED'); - emitEvent('PROCESS_STARTED'); + emitTaskEvent('TASK_CREATED'); + emitTaskEvent('TASK_ASSIGNED'); tick(3000); expect(batches.length).toBe(1); - expect(batches[0][0].eventType).toBe('PROCESS_STARTED'); + 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').subscribe(() => (emitted = true)); + service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(() => (emitted = true)); - emitEvent(); + emitTaskEvent(); tick(3000); expect(emitted).toBeFalse(); @@ -400,13 +471,28 @@ describe('FilterCountersCloudService', () => { }); describe('counters driven by the engine events', () => { - it('should make a single count request for a batch of events received by both entity types', fakeAsync(() => { + it('should make a single count request for a batch of events of both entity types', fakeAsync(() => { service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe(); service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + tick(0); postSpy.calls.reset(); - emitEvent('TASK_CREATED'); - emitEvent('PROCESS_STARTED'); + 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).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe(); + tick(0); + postSpy.calls.reset(); + + emitTaskEvent(); + tick(1000); + emitProcessEvent(); tick(3000); expect(postSpy).toHaveBeenCalledTimes(1); @@ -415,15 +501,48 @@ describe('FilterCountersCloudService', () => { it('should emit the counters resolved for the batch of events', fakeAsync(() => { const results: FilterCountersResult[] = []; service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result)); + tick(0); postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } })); - emitEvent(); + 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).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).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).subscribe(); + service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).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; diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts index 13bc1c3477..8ea7d39c65 100644 --- a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts +++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts @@ -16,8 +16,8 @@ */ import { inject, Injectable, Injector } from '@angular/core'; -import { combineLatest, defer, EMPTY, merge, Observable, of, Subject } from 'rxjs'; -import { catchError, debounceTime, map, shareReplay, switchMap, take } from 'rxjs/operators'; +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'; @@ -38,24 +38,20 @@ import { FilterCountersResult } from '../models/filter-counters-cloud.model'; -/** - * Single subscription covering both the task and the process engine events, so that a batch of - * events results in one call to the batched count endpoint. - */ const BATCHED_COUNTERS_UNAVAILABLE_STATUSES = [404, 501]; -/** Filters of both entity types, to resolve the counters of both filter components with one request. */ interface FilterCountersFilters { [FilterCounterEntityType.TASK]: TaskFilterCloudModel[]; [FilterCounterEntityType.PROCESS_INSTANCE]: ProcessFilterCloudModel[]; } -/** Data selected by the engine event subscription. */ interface EngineEventsData { engineEvents?: TaskCloudEngineEvent[]; } -const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` +/** One subscription per entity type, so an app showing one of them is not notified of the other. */ +const ENGINE_EVENTS_SUBSCRIPTION_QUERIES: Record = { + [FilterCounterEntityType.TASK]: ` subscription { engineEvents(eventType: [ TASK_COMPLETED @@ -64,6 +60,15 @@ const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` TASK_SUSPENDED TASK_CANCELLED TASK_CREATED + ]) { + eventType + entity + } + } +`, + [FilterCounterEntityType.PROCESS_INSTANCE]: ` + subscription { + engineEvents(eventType: [ PROCESS_CANCELLED PROCESS_COMPLETED PROCESS_CREATED @@ -75,29 +80,28 @@ const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = ` entity } } -`; +` +}; /** - * Central place handling the filter counters of the task and the process filter components: it owns - * the filters of both components and a single engine event subscription, debounced into one batched - * count request. - * - * Loading the filters here is what keeps the counters of both components resolved by one request, - * both on load and on every batch of engine events. + * Resolves the counters of the task and the process filters with one batched count request, covering + * the entity types whose counters are subscribed. */ @Injectable({ providedIn: 'root' }) export class FilterCountersCloudService extends BaseCloudService { private readonly notificationCloudService = inject(NotificationCloudService); private readonly taskListCloudService = inject(TaskListCloudService); private readonly processListCloudService = inject(ProcessListCloudService); - /** - * The filter services are resolved on demand, so that an app holding only one of the two filter - * components is not forced to provide the preferences service of the other one. - */ + /** The filter services are resolved on demand: an app showing one family must not wire the other. */ private readonly injector = inject(Injector); - private readonly eventsPerApp = new Map>(); - private readonly refreshPerApp = new Map>(); + 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 eventSubscriptionsPerEntityType = new Map(); private readonly appsWithoutBatchedCounters = new Set(); private readonly taskFiltersPerApp = new Map>(); private readonly processFiltersPerApp = new Map>(); @@ -108,8 +112,7 @@ export class FilterCountersCloudService extends BaseCloudService { } /** - * Task filters of the app, loaded once and shared between the task filter component and the - * batched count request, so that one place owns the filters the counters are resolved for. + * Task filters of the app, loaded once and shared with the batched count request. * * @param appName Name of the target app * @returns Task filters of the app @@ -119,8 +122,7 @@ export class FilterCountersCloudService extends BaseCloudService { } /** - * Process filters of the app, loaded once and shared between the process filter component and - * the batched count request. + * Process filters of the app, loaded once and shared with the batched count request. * * @param appName Name of the target app * @returns Process filters of the app @@ -130,53 +132,74 @@ export class FilterCountersCloudService extends BaseCloudService { } /** - * Counters of the filters of an entity type, resolved by the request shared with the filters of - * the other entity type: once on subscription, then on every debounced batch of engine events - * and on every `refreshFilterCounters` call. + * Counters of the filters of an entity type, resolved on subscription and kept in sync with the + * engine events of the app. Subscribers of both entity types share one request. * * @param appName Name of the target app * @param entityType Entity type the counters are read for - * @returns Counters of the filters of the entity type, keyed by filter key + * @returns Counters keyed by filter key */ getFilterCounters(appName: string, entityType: FilterCounterEntityType): Observable { if (!appName) { return EMPTY; } - return this.getCounters(appName).pipe(map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched }))); + return defer(() => { + this.activateEntityType(appName, entityType); + + return this.getCounters(appName); + }).pipe( + map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched })), + finalize(() => this.deactivateEntityType(appName, entityType)) + ); } /** - * Resolves the counters of the filters of the app again, for both entity types with one request. + * Resolves the counters of the app again, with one request. * * @param appName Name of the target app */ refreshFilterCounters(appName: string): void { - this.getRefreshTrigger(appName).next(); + this.recount(appName); } /** - * Debounced batches of engine events of the app, shared between all the subscribers of the app. + * Debounced batches of the engine events of an entity type, shared between its subscribers. * * @param appName Name of the target app + * @param entityType Entity type the events are read for * @returns Debounced batches of engine events */ - getEngineEvents(appName: string): Observable { + getEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable { if (!appName) { return EMPTY; } - let events$ = this.eventsPerApp.get(appName); + 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, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY) + this.notificationCloudService.makeGQLQuery(appName, ENGINE_EVENTS_SUBSCRIPTION_QUERIES[entityType]) ).pipe( map((result) => result.data?.engineEvents ?? []), - debounceTime(this.notificationDebounceTime), catchError(() => EMPTY), shareReplay({ bufferSize: 1, refCount: true }) ); - this.eventsPerApp.set(appName, events$); + this.rawEventsPerEntityType.set(key, events$); } return events$; @@ -186,18 +209,75 @@ export class FilterCountersCloudService extends BaseCloudService { return this.appConfigService.get('notifications', true); } - /** - * Filters of both entity types, to resolve the counters of both filter components with one - * request. The filters of an entity type that fails to load are left out, so that the counters - * of the other entity type are still resolved. - * - * @param appName Name of the target app - * @returns Task and process filters of the app - */ + private activateEntityType(appName: string, entityType: FilterCounterEntityType): void { + const key = this.entityTypeKey(appName, entityType); + const subscribers = (this.subscribersPerEntityType.get(key) ?? 0) + 1; + this.subscribersPerEntityType.set(key, subscribers); + + if (subscribers > 1) { + return; + } + + 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.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(); + } + + // The filters of an entity type that fails to load are left out, so the other one is still counted. private getFiltersForCounters(appName: string): Observable { + const activeEntityTypes = this.activeEntityTypes(appName); + return combineLatest({ - [FilterCounterEntityType.TASK]: this.getTaskFilters(appName).pipe(catchError(() => of([]))), - [FilterCounterEntityType.PROCESS_INSTANCE]: this.getProcessFilters(appName).pipe(catchError(() => of([]))) + [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([]) }); } @@ -211,23 +291,10 @@ export class FilterCountersCloudService extends BaseCloudService { return filters$; } - /** - * Counters of both entity types, resolved by one request shared between the filter components. - * The request is sent on subscription and on every trigger of the app: a batch of engine events, - * or a refresh. - * - * @param appName Name of the target app - * @returns Counters of both entity types - */ private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { let counters$ = this.countersPerApp.get(appName); if (!counters$) { - const triggers: Observable[] = [of(undefined), this.getRefreshTrigger(appName)]; - if (this.notificationsEnabled) { - triggers.push(this.getEngineEvents(appName)); - } - - counters$ = merge(...triggers).pipe( + counters$ = this.recounts(appName).pipe( switchMap(() => this.resolveCounters(appName)), shareReplay({ bufferSize: 1, refCount: true }) ); @@ -237,14 +304,6 @@ export class FilterCountersCloudService extends BaseCloudService { return counters$; } - /** - * Sends one count request for the filters of both entity types. A backend without the batched - * count endpoint resolves no counter, so that the filter components fall back to the counters - * resolved one filter at a time. - * - * @param appName Name of the target app - * @returns Counters of both entity types - */ private resolveCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> { if (this.appsWithoutBatchedCounters.has(appName)) { return of({ counters: {}, batched: false }); @@ -256,7 +315,6 @@ export class FilterCountersCloudService extends BaseCloudService { map((counters) => ({ counters, batched: true })), catchError((error) => { if (BATCHED_COUNTERS_UNAVAILABLE_STATUSES.includes(error?.status)) { - /* The backend of the app holds no batched count endpoint, so it is not asked again. */ this.appsWithoutBatchedCounters.add(appName); } @@ -265,22 +323,35 @@ export class FilterCountersCloudService extends BaseCloudService { ); } - private getRefreshTrigger(appName: string): Subject { - let refresh$ = this.refreshPerApp.get(appName); - if (!refresh$) { - refresh$ = new Subject(); - this.refreshPerApp.set(appName, refresh$); + private recounts(appName: string): Observable { + return merge( + /* Reads landing in the same task are merged, so both filter components share one request. */ + merge(of(undefined), this.recountTrigger(appName)).pipe(debounceTime(0, asapScheduler)), + /* One debounce over every entity type, so a batch of events also results in one request. */ + 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 refresh$; + return eventRecount$; } - /** - * 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 - */ private buildRequest(filters: FilterCountersFilters): FilterCountersRequest { const request: FilterCountersRequest = {}; @@ -309,23 +380,15 @@ export class FilterCountersCloudService extends BaseCloudService { .filter((filter) => filter?.showCounter && this.isCounterBatched(filter)) .map((filter) => { try { - /* Only the filters holding a key reach this point, so every query is identified by one. */ return { ...buildQuery(filter), requestId: filter.key as string }; } catch { - /* A filter the query cannot be built for is left out of the batch and counted on its own. */ + /* Left out of the batch and counted on its own. */ return undefined; } }) .filter((query): query is FilterCountersQuery => !!query); } - /** - * 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 - */ private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable { if (!Object.keys(request).length) { return of({}); @@ -336,13 +399,7 @@ export class FilterCountersCloudService extends BaseCloudService { return this.post(queryUrl, request).pipe(map((counters) => counters || {})); } - /** - * Whether the counter of a filter is resolved by the batched count request. A filter without a - * key holds no `requestId` its counter could be keyed by, so it is left to be fetched on its own. - * - * @param filter Filter with a counter enabled - * @returns `true` when the counter of the filter is resolved by the batched request, otherwise `false` - */ + // A filter without a key holds no `requestId` its counter could be keyed by. private isCounterBatched(filter: FilterCounterCandidate): boolean { return !!filter?.key; } 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 96ee175964..cbc46aa37b 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 @@ -19,7 +19,7 @@ import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core'; import { Component, SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { first, of, Subject, throwError } from 'rxjs'; +import { 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'; @@ -331,7 +331,7 @@ describe('TaskFiltersCloudComponent', () => { fixture.detectChanges(); - expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1'); + expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK); }); it('should emit the events of the debounced batch', fakeAsync(() => { @@ -694,6 +694,28 @@ describe('TaskFiltersCloudComponent', () => { }); 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); + }); + + 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(); @@ -718,7 +740,6 @@ describe('TaskFiltersCloudComponent', () => { }); it('should resolve the counter of a filter the batch left out on its own', async () => { - /* A filter without a key, or one the query cannot be built for, is left out of the batch. */ getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: true })); await bindAppName(); 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 70457326f8..69fdfba96a 100644 --- a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts +++ b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts @@ -16,7 +16,7 @@ */ import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { defer, EMPTY, Observable, of, Subscription } 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'; @@ -47,8 +47,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp /** * (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count. * - * @deprecated the counters are resolved by `POST /query/v1/count`, which requires Activiti 8.7.0 - * forward. This input is only used by the backends without that endpoint and will be removed, + * @deprecated only used by the backends without `POST /query/v1/count`. It will be removed, * along with the 'GET' method, in ADF 10.0.0. */ @Input() @@ -125,13 +124,15 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.filters = res || []; this.initFilterCounters(); this.selectFilterAndEmit(this.filterParam); - this.loadFilterCounters(appName); this.success.emit(res); }, error: (err) => { this.error.emit(err); } }); + + /* Read along with the filters, not once they arrive, so both components share one request. */ + this.loadFilterCounters(appName, filters$); } /** @@ -144,8 +145,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp /** * Iterate over filters and update counters * - * @deprecated the counters are resolved by the batched count request. This resolves them one - * filter at a time, for the backends without the batched count endpoint. + * @deprecated resolves the counters one filter at a time, for the backends without the batched + * count endpoint. It will be removed in ADF 10.0.0. */ updateFilterCounters(): void { this.filters.forEach((filter) => this.updateFilterCounter(filter)); @@ -155,16 +156,15 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp * Get current value for filter and check if value has changed * * @param filter filter - * @deprecated the counters are resolved by the batched count request. This resolves the counter - * of one filter, for the backends without the batched count endpoint. + * @deprecated resolves the counter of one filter, for the backends without the batched count + * endpoint. It will be removed in ADF 10.0.0. */ updateFilterCounter(filter: TaskFilterCloudModel): void { if (!filter?.showCounter) { return; } - /* `defer` turns the query building of the count request into a failure of the stream, so that a - filter the counter cannot be resolved for is left without one instead of breaking the others. */ + /* `defer` keeps a query that cannot be built from breaking the counters of the other filters. */ defer(() => this.fetchTaskFilterCounter(filter)) .pipe( catchError(() => EMPTY), @@ -186,7 +186,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp } this.filterCountersCloudService - .getEngineEvents(this.appName) + .getEngineEvents(this.appName, FilterCounterEntityType.TASK) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((events) => { events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity)); @@ -283,27 +283,22 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp } } - /** - * Flags the counter of a filter as read whenever the filter is refreshed by an external action - */ + /** Flags the counter of a filter as read whenever the filter is refreshed elsewhere */ getFilterKeysAfterExternalRefreshing(): void { this.taskFilterCloudService.filterKeyToBeRefreshed$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((filterKey: string) => this.updatedCountersSet.delete(filterKey)); } - /** - * Resolves the counters of the filters, with one request shared with the process filters. The - * counters of a backend without the batched count endpoint are resolved one filter at a time. - * - * @param appName application name - */ - private loadFilterCounters(appName: string): void { + private loadFilterCounters(appName: string, filters$: Observable): void { this.countersSubscription?.unsubscribe(); - this.countersSubscription = this.filterCountersCloudService - .getFilterCounters(appName, FilterCounterEntityType.TASK) + /* Counters are keyed by filter key, so they are applied once the filters are known. */ + this.countersSubscription = combineLatest([ + filters$.pipe(catchError(() => of([]))), + this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK) + ]) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ counters, batched }) => { + .subscribe(([, { counters, batched }]) => { this.batchedCounters = batched; if (batched) { this.applyFilterCounters(counters); @@ -313,16 +308,9 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp }); } - /** - * Holds the counters resolved by the batched count request, which are keyed by filter key. The - * counter of a filter the request holds none for is resolved on its own: a filter without a key, - * or one the count query cannot be built for, is left out of the batch. - * - * @param counters counters keyed by filter key - */ private applyFilterCounters(counters: { [filterKey: string]: number }): void { this.filters.forEach((filter) => { - /* A filter without a key holds no request id, so no counter can be keyed by it. */ + /* A filter without a key holds no request id. */ const filterKey = filter?.showCounter ? filter.key : undefined; if (!filterKey) { return; @@ -353,12 +341,6 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp this.currentFilter = undefined; } - /** - * Resolves the counters again, with one request for every filter of the app. The counter of the - * given filter alone is resolved when the batched count endpoint is not available. - * - * @param filter filter that was selected - */ private refreshFilterCounter(filter: TaskFilterCloudModel): void { if (this.batchedCounters) { this.filterCountersCloudService.refreshFilterCounters(this.appName); 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 644674eada..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 @@ -362,8 +362,7 @@ export class TaskFilterCloudService extends BaseCloudService { } /** - * @deprecated use FilterCountersCloudService.getEngineEvents instead, which shares a single - * subscription with the process filters and resolves the counters with a single request. + * @deprecated use FilterCountersCloudService.getEngineEvents instead. * @param appName Name of the target app * @returns Task engine events */