mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-49653 Code improvements
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
-4
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
+22
-42
@@ -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);
|
||||
|
||||
+2
-2
@@ -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
|
||||
*/
|
||||
|
||||
+180
-61
@@ -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;
|
||||
}
|
||||
|
||||
+24
-3
@@ -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();
|
||||
|
||||
+20
-38
@@ -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);
|
||||
|
||||
+1
-2
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user