AAE-49653 Code improvements

This commit is contained in:
Ehsan Rezaei
2026-08-24 13:32:24 +02:00
parent 85a751f8c8
commit f243d1efa3
9 changed files with 412 additions and 273 deletions
@@ -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;
}
@@ -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']);
});
@@ -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<ProcessFilterCloudModel[]>): 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);
@@ -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
*/
@@ -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<EngineEventsResult>;
let taskEvents$: Subject<EngineEventsResult>;
let processEvents$: Subject<EngineEventsResult>;
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<EngineEventsResult>();
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<EngineEventsResult>();
processEvents$ = new Subject<EngineEventsResult>();
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;
@@ -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, string> = {
[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<string, Observable<TaskCloudEngineEvent[]>>();
private readonly refreshPerApp = new Map<string, Subject<void>>();
private readonly eventsPerEntityType = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly rawEventsPerEntityType = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly recountPerApp = new Map<string, Subject<void>>();
private readonly eventRecountPerApp = new Map<string, Subject<void>>();
private readonly activeEntityTypesPerApp = new Map<string, Set<FilterCounterEntityType>>();
private readonly subscribersPerEntityType = new Map<string, number>();
private readonly eventSubscriptionsPerEntityType = new Map<string, Subscription>();
private readonly appsWithoutBatchedCounters = new Set<string>();
private readonly taskFiltersPerApp = new Map<string, Observable<TaskFilterCloudModel[]>>();
private readonly processFiltersPerApp = new Map<string, Observable<ProcessFilterCloudModel[]>>();
@@ -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<FilterCountersResult> {
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<TaskCloudEngineEvent[]> {
getEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable<TaskCloudEngineEvent[]> {
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<TaskCloudEngineEvent[]> {
const key = this.entityTypeKey(appName, entityType);
let events$ = this.rawEventsPerEntityType.get(key);
if (!events$) {
events$ = defer(() =>
this.notificationCloudService.makeGQLQuery<EngineEventsData>(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)
this.notificationCloudService.makeGQLQuery<EngineEventsData>(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<FilterCounterEntityType> {
let activeEntityTypes = this.activeEntityTypesPerApp.get(appName);
if (!activeEntityTypes) {
activeEntityTypes = new Set<FilterCounterEntityType>();
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<FilterCountersFilters> {
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<unknown>[] = [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<void> {
let refresh$ = this.refreshPerApp.get(appName);
if (!refresh$) {
refresh$ = new Subject<void>();
this.refreshPerApp.set(appName, refresh$);
private recounts(appName: string): Observable<unknown> {
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<void> {
let recount$ = this.recountPerApp.get(appName);
if (!recount$) {
recount$ = new Subject<void>();
this.recountPerApp.set(appName, recount$);
}
return recount$;
}
private eventRecountTrigger(appName: string): Subject<void> {
let eventRecount$ = this.eventRecountPerApp.get(appName);
if (!eventRecount$) {
eventRecount$ = new Subject<void>();
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<FilterCounters> {
if (!Object.keys(request).length) {
return of({});
@@ -336,13 +399,7 @@ export class FilterCountersCloudService extends BaseCloudService {
return this.post<FilterCountersRequest, FilterCounters>(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;
}
@@ -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<TaskFilterCloudModel[]>();
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();
@@ -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<TaskFilterCloudModel[]>): 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);
@@ -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
*/