AAE-49653 Making filter key mandatory string

This commit is contained in:
Ehsan Rezaei
2026-08-25 18:02:45 +02:00
parent 416a61fb9e
commit ba916b7c22
8 changed files with 30 additions and 83 deletions
@@ -15,9 +15,6 @@
* limitations under the License. * limitations under the License.
*/ */
/**
* Entity types accepted by the `POST /query/v1/count` endpoint.
*/
export const FilterCounterEntityType = { export const FilterCounterEntityType = {
TASK: 'TASK', TASK: 'TASK',
PROCESS_INSTANCE: 'PROCESS_INSTANCE' PROCESS_INSTANCE: 'PROCESS_INSTANCE'
@@ -31,7 +28,6 @@ export interface FilterCountersQuerySort {
isProcessVariable: boolean; isProcessVariable: boolean;
} }
/** A single query of the batched count request, holding the criteria of one filter. */
export interface FilterCountersQuery { export interface FilterCountersQuery {
/** Identifies the query, so its counter can be read back from the response. */ /** Identifies the query, so its counter can be read back from the response. */
requestId: string; requestId: string;
@@ -41,28 +37,19 @@ export interface FilterCountersQuery {
[criteria: string]: unknown; [criteria: string]: unknown;
} }
/**
* Payload of the batched count request, one entry per counter to be resolved.
*/
export type FilterCountersRequest = { export type FilterCountersRequest = {
[entityType in FilterCounterEntityType]?: FilterCountersQuery[]; [entityType in FilterCounterEntityType]?: FilterCountersQuery[];
}; };
/** Shape of a task or process filter the counters are resolved for. Its key is the `requestId`. */
export interface FilterCounterCandidate { export interface FilterCounterCandidate {
key?: string | null; key: string;
showCounter?: boolean; showCounter?: boolean;
} }
/**
* 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 = { export type FilterCounters = {
[entityType in FilterCounterEntityType]?: { [requestId: string]: number }; [entityType in FilterCounterEntityType]?: { [requestId: string]: number };
}; };
/** Counters of the filters of one entity type, keyed by filter key. */
export interface FilterCountersResult { export interface FilterCountersResult {
[filterKey: string]: number; [filterKey: string]: number;
} }
@@ -133,7 +133,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
} }
}); });
/* Read along with the filters, not once they arrive, so both components share one request. */
this.loadFilterCounters(appName, filters$); this.loadFilterCounters(appName, filters$);
} }
@@ -141,11 +140,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
* Initialize counter collection for filters * Initialize counter collection for filters
*/ */
initFilterCounters(): void { initFilterCounters(): void {
this.filters.forEach((filter) => { this.filters.forEach((filter) => (this.counters[filter.key] = 0));
if (filter.key) {
this.counters[filter.key] = 0;
}
});
} }
/** /**
@@ -283,7 +278,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
private loadFilterCounters(appName: string, filters$: Observable<ProcessFilterCloudModel[]>): void { private loadFilterCounters(appName: string, filters$: Observable<ProcessFilterCloudModel[]>): void {
this.countersSubscription?.unsubscribe(); this.countersSubscription?.unsubscribe();
/* Counters are keyed by filter key, so they are applied once the filters are known. */
this.countersSubscription = combineLatest([ this.countersSubscription = combineLatest([
filters$.pipe(catchError(() => EMPTY)), filters$.pipe(catchError(() => EMPTY)),
this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE) this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE)
@@ -293,21 +287,16 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
} }
private applyFilterCounters(counters: FilterCountersResult): void { private applyFilterCounters(counters: FilterCountersResult): void {
this.filters.forEach((filter) => { this.filters
/* A filter without a key holds no request id. */ .filter((filter) => filter?.showCounter)
const filterKey = filter?.showCounter ? filter.key : undefined; .forEach((filter) => {
if (!filterKey) { const counter = counters[filter.key];
return; if (counter === undefined) {
} return;
}
const counter = counters[filterKey]; this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
/* A filter the request left out keeps the counter it holds, rather than showing a wrong one. */ this.counters = { ...this.counters, [filter.key]: counter };
if (counter === undefined) { });
return;
}
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
} }
} }
@@ -25,7 +25,7 @@ import { ProcessVariableFilterModel } from '../../../models/process-variable-fil
export class ProcessFilterCloudModel { export class ProcessFilterCloudModel {
id: string; id: string;
name: string | null; name: string | null;
key: string | null; key: string;
icon: string | null; icon: string | null;
index: number | null; index: number | null;
appName: string | null; appName: string | null;
@@ -79,7 +79,7 @@ export class ProcessFilterCloudModel {
this.id = obj.id || Math.random().toString(36).substring(2, 9); this.id = obj.id || Math.random().toString(36).substring(2, 9);
this.name = obj.name || null; this.name = obj.name || null;
this.key = obj.key || null; this.key = obj.key;
this.environmentId = obj.environmentId || null; this.environmentId = obj.environmentId || null;
this.showCounter = obj.showCounter || false; this.showCounter = obj.showCounter || false;
this.icon = obj.icon || null; this.icon = obj.icon || null;
@@ -88,7 +88,6 @@ describe('FilterCountersCloudService', () => {
const counters = (entityType: FilterCounterEntityType, appName = 'mock-app') => firstValueFrom(service.getFilterCounters(appName, entityType)); const counters = (entityType: FilterCounterEntityType, appName = 'mock-app') => firstValueFrom(service.getFilterCounters(appName, entityType));
const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName); const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName);
const processCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.PROCESS_INSTANCE, 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') => const bothCounters = (appName = 'mock-app') =>
firstValueFrom( firstValueFrom(
combineLatest([ combineLatest([
@@ -114,7 +113,6 @@ describe('FilterCountersCloudService', () => {
taskEvents$ = new Subject<EngineEventsResult>(); taskEvents$ = new Subject<EngineEventsResult>();
processEvents$ = new Subject<EngineEventsResult>(); processEvents$ = new Subject<EngineEventsResult>();
makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery'); makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery');
/* Every entity type holds its own subscription. */
makeGQLQuerySpy.and.callFake((_appName: string, query: string) => makeGQLQuerySpy.and.callFake((_appName: string, query: string) =>
(query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable() (query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable()
); );
@@ -146,7 +144,6 @@ describe('FilterCountersCloudService', () => {
}); });
it('should share the filters with the batched count request', async () => { it('should share the filters with the batched count request', async () => {
/* The filter component holds its subscription while the counters are resolved. */
const subscription = service.getTaskFilters('mock-app').subscribe(); const subscription = service.getTaskFilters('mock-app').subscribe();
await taskCounters(); await taskCounters();
subscription.unsubscribe(); subscription.unsubscribe();
@@ -232,13 +229,6 @@ describe('FilterCountersCloudService', () => {
expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['queued-tasks']); expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['queued-tasks']);
}); });
it('should leave out a filter without a key, since it holds no request id', async () => {
getProcessFiltersSpy.and.returnValue(of([processFilter({ key: null, status: 'RUNNING', showCounter: true })]));
expect(await processCounters()).toEqual({});
expect(postSpy).not.toHaveBeenCalled();
});
it('should resolve the counters of an entity type when the filters of the other one fail to load', async () => { 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'))); getProcessFiltersSpy.and.returnValue(throwError(() => new Error('filters failed')));
@@ -330,7 +320,6 @@ describe('FilterCountersCloudService', () => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe(); service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe();
tick(0); tick(0);
/* Opened again, so the first subscription was closed rather than left behind. */
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2); expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
})); }));
@@ -348,11 +337,9 @@ describe('FilterCountersCloudService', () => {
})); }));
it('should release the filters subscription once nothing reads them', fakeAsync(() => { it('should release the filters subscription once nothing reads them', fakeAsync(() => {
/* `TaskFilterCloudService.filters$` never completes, so a subscription left behind would be held. */
const filters$ = new BehaviorSubject(taskFiltersMock); const filters$ = new BehaviorSubject(taskFiltersMock);
getTaskListFiltersSpy.and.returnValue(filters$.asObservable()); getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
/* As the filter component does: the filters are held while the counters are read. */
const subscriptions = [ const subscriptions = [
service.getTaskFilters('mock-app').subscribe(), service.getTaskFilters('mock-app').subscribe(),
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe() service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe()
@@ -47,7 +47,6 @@ interface EngineEventsData {
engineEvents?: TaskCloudEngineEvent[]; engineEvents?: TaskCloudEngineEvent[];
} }
/** 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> = { const ENGINE_EVENTS_SUBSCRIPTION_QUERIES: Record<FilterCounterEntityType, string> = {
[FilterCounterEntityType.TASK]: ` [FilterCounterEntityType.TASK]: `
subscription { subscription {
@@ -264,7 +263,6 @@ export class FilterCountersCloudService extends BaseCloudService {
this.recountTrigger(appName).next(); 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> { private getFiltersForCounters(appName: string): Observable<FilterCountersFilters> {
const activeEntityTypes = this.activeEntityTypes(appName); const activeEntityTypes = this.activeEntityTypes(appName);
@@ -305,7 +303,6 @@ export class FilterCountersCloudService extends BaseCloudService {
return this.getFiltersForCounters(appName).pipe( return this.getFiltersForCounters(appName).pipe(
take(1), take(1),
switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))), switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))),
/* A failed count leaves the counters as they are, rather than breaking the stream. */
catchError(() => of({})) catchError(() => of({}))
); );
} }
@@ -314,7 +311,6 @@ export class FilterCountersCloudService extends BaseCloudService {
return merge( return merge(
/* Reads landing in the same task are merged, so both filter components share one request. */ /* 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)), 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)) this.eventRecountTrigger(appName).pipe(debounceTime(this.notificationDebounceTime))
); );
} }
@@ -364,12 +360,12 @@ export class FilterCountersCloudService extends BaseCloudService {
buildQuery: (filter: T) => Omit<FilterCountersQuery, 'requestId'> buildQuery: (filter: T) => Omit<FilterCountersQuery, 'requestId'>
): FilterCountersQuery[] { ): FilterCountersQuery[] {
return (filters ?? []) return (filters ?? [])
.filter((filter) => filter?.showCounter && this.isCounterBatched(filter)) .filter((filter) => filter?.showCounter)
.map((filter) => { .map((filter) => {
try { try {
return { ...buildQuery(filter), requestId: filter.key as string }; return { ...buildQuery(filter), requestId: filter.key };
} catch { } catch {
/* A malformed filter is left without a counter, so the others still hold one. */ /* Left without a counter, so the other filters still hold theirs. */
return undefined; return undefined;
} }
}) })
@@ -385,9 +381,4 @@ export class FilterCountersCloudService extends BaseCloudService {
return this.post<FilterCountersRequest, FilterCounters>(queryUrl, request).pipe(map((counters) => counters || {})); return this.post<FilterCountersRequest, FilterCounters>(queryUrl, request).pipe(map((counters) => counters || {}));
} }
// A filter without a key holds no `requestId` its counter could be keyed by.
private isCounterBatched(filter: FilterCounterCandidate): boolean {
return !!filter?.key;
}
} }
@@ -108,7 +108,7 @@ export class ServiceTaskFiltersCloudComponent extends BaseTaskFiltersCloudCompon
this.filters.find( this.filters.find(
(filter, index) => (filter, index) =>
paramFilter.index === index || paramFilter.index === index ||
paramFilter.key === filter.key || (!!paramFilter.key && paramFilter.key === filter.key) ||
paramFilter.id === filter.id || paramFilter.id === filter.id ||
(paramFilter.name && paramFilter.name.toLocaleLowerCase() === this.translationService.instant(filter.name).toLocaleLowerCase()) (paramFilter.name && paramFilter.name.toLocaleLowerCase() === this.translationService.instant(filter.name).toLocaleLowerCase())
); // fallback to preserve the previous behavior ); // fallback to preserve the previous behavior
@@ -118,7 +118,6 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
} }
}); });
/* Read along with the filters, not once they arrive, so both components share one request. */
this.loadFilterCounters(appName, filters$); this.loadFilterCounters(appName, filters$);
} }
@@ -170,7 +169,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.filters.find( this.filters.find(
(filter, index) => (filter, index) =>
paramFilter.index === index || paramFilter.index === index ||
paramFilter.key === filter.key || (!!paramFilter.key && paramFilter.key === filter.key) ||
paramFilter.id === filter.id || paramFilter.id === filter.id ||
(paramFilter.name && paramFilter.name.toLocaleLowerCase() === this.translationService.instant(filter.name).toLocaleLowerCase()) (paramFilter.name && paramFilter.name.toLocaleLowerCase() === this.translationService.instant(filter.name).toLocaleLowerCase())
); // fallback to preserve the previous behavior ); // fallback to preserve the previous behavior
@@ -241,7 +240,6 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
private loadFilterCounters(appName: string, filters$: Observable<TaskFilterCloudModel[]>): void { private loadFilterCounters(appName: string, filters$: Observable<TaskFilterCloudModel[]>): void {
this.countersSubscription?.unsubscribe(); this.countersSubscription?.unsubscribe();
/* Counters are keyed by filter key, so they are applied once the filters are known. */
this.countersSubscription = combineLatest([ this.countersSubscription = combineLatest([
filters$.pipe(catchError(() => of([]))), filters$.pipe(catchError(() => of([]))),
this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK) this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK)
@@ -251,22 +249,17 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
} }
private applyFilterCounters(counters: FilterCountersResult): void { private applyFilterCounters(counters: FilterCountersResult): void {
this.filters.forEach((filter) => { this.filters
/* A filter without a key holds no request id. */ .filter((filter) => filter?.showCounter)
const filterKey = filter?.showCounter ? filter.key : undefined; .forEach((filter) => {
if (!filterKey) { const counter = counters[filter.key];
return; if (counter === undefined) {
} return;
}
const counter = counters[filterKey]; this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
/* A filter the request left out keeps the counter it holds, rather than showing a wrong one. */ this.counters = { ...this.counters, [filter.key]: counter };
if (counter === undefined) { });
return;
}
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
} }
/** /**
@@ -84,7 +84,7 @@ export class TaskFilterCloudModel {
if (obj) { if (obj) {
this.id = obj.id || Math.random().toString(36).substr(2, 9); this.id = obj.id || Math.random().toString(36).substr(2, 9);
this.name = obj.name || null; this.name = obj.name || null;
this.key = obj.key || null; this.key = obj.key;
this.environmentId = obj.environmentId || null; this.environmentId = obj.environmentId || null;
this.icon = obj.icon || null; this.icon = obj.icon || null;
this.index = obj.index || null; this.index = obj.index || null;