AAE-49653 Updating code with the latest BE contract

This commit is contained in:
Ehsan Rezaei
2026-08-21 10:56:43 +02:00
parent 565c07e809
commit 002588625e
12 changed files with 1229 additions and 1028 deletions
@@ -27,6 +27,7 @@ Lists all available process filters and allows to select a filter.
| appName | `string` | "" | (required) The application name |
| filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default |
| showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter |
| searchApiMethod | `'GET' \| 'POST'` | "GET" | **Deprecated:** the counters are resolved by a single `POST /query/v1/count` call, which requires Activiti 8.7.0 forward. This input is only used to resolve the counters one filter at a time, on the backends without that endpoint, and will be removed along with the `GET` method in ADF 10.0.0. |
### Events
@@ -36,6 +36,7 @@ Shows all available filters.
| appName | `string` | "" | Display filters available to the current user for the application with the specified name. |
| filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. |
| showIcons | `boolean` | false | Toggles display of the filter's icons. |
| searchApiMethod | `'GET' \| 'POST'` | "GET" | **Deprecated:** the counters are resolved by a single `POST /query/v1/count` call, which requires Activiti 8.7.0 forward. This input is only used to resolve the counters one filter at a time, on the backends without that endpoint, and will be removed along with the `GET` method in ADF 10.0.0. |
### Events
@@ -15,8 +15,6 @@
* limitations under the License.
*/
import { TaskCloudEngineEvent } from './engine-event-cloud.model';
/**
* Entity types accepted by the `POST /query/v1/count` endpoint.
*/
@@ -34,9 +32,12 @@ export interface FilterCountersQuerySort {
}
/**
* A single query of the batched count request, holding the criteria of one filter.
* 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.
*/
export interface FilterCountersQuery {
/** Identifies the query, so that its counter can be read back from the response. */
requestId: string;
status?: string[];
assignee?: string[];
sort?: FilterCountersQuerySort;
@@ -51,12 +52,12 @@ export type FilterCountersRequest = {
};
/**
* Shape of a task or process filter the counters are resolved for.
* 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.
*/
export interface FilterCounterCandidate {
key: string;
status?: string | null;
statuses?: string[] | null;
key?: string | null;
showCounter?: boolean;
}
@@ -68,30 +69,23 @@ export type FilterCountersFilters = {
};
/**
* Counts returned by the batched count request, keyed by entity type and then by status.
* e.g. `{ TASK: { ASSIGNED: 5, CREATED: 0 }, PROCESS_INSTANCE: { RUNNING: 5 } }`
* 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 } }`
*/
export type FilterCounters = {
[entityType in FilterCounterEntityType]?: { [status: string]: number };
[entityType in FilterCounterEntityType]?: { [requestId: string]: number };
};
export interface FilterCountersNotification {
/** Engine events of the debounced batch that triggered the count request. */
events: TaskCloudEngineEvent[];
/** Counts resolved by a single call to the batched count endpoint. */
counters: FilterCounters;
}
/**
* Resolves the statuses of a filter, which the counters of the count response are keyed by.
*
* @param filter task or process filter
* @param filter.status Status of the filter
* @param filter.statuses Statuses of the filter
* @returns the statuses of the filter, empty when the filter targets every status
* Counters of the filters of one entity type, keyed by the key of the filter they were resolved for.
*/
export function resolveFilterCounterStatuses(filter: { status?: string | null; statuses?: string[] | null }): string[] {
const statuses = filter?.statuses?.length ? filter.statuses : filter?.status ? [filter.status] : [];
return statuses.filter((status) => !!status);
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.
*/
batched: boolean;
}
@@ -17,11 +17,11 @@
import { Component, SimpleChange } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
import { EMPTY, first, of, Subject, throwError } from 'rxjs';
import { first, of, Subject, throwError } from 'rxjs';
import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service';
import { ProcessFiltersCloudComponent } from './process-filters-cloud.component';
import { By } from '@angular/platform-browser';
import { PROCESS_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service';
import { mockProcessFilters } from '../../mock/process-filters-cloud.mock';
import { AppConfigService, AppConfigServiceMock, NoopAuthModule } from '@alfresco/adf-core';
@@ -33,7 +33,7 @@ import { MatIconHarness } from '@angular/material/icon/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCountersNotification } from '../../../../models/filter-counters-cloud.model';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
@@ -51,7 +51,9 @@ describe('ProcessFiltersCloudComponent', () => {
let component: ProcessFiltersCloudComponent;
let fixture: ComponentFixture<ProcessFiltersCloudComponent>;
let getProcessFiltersSpy: jasmine.Spy;
let getFilterCountersNotificationsSpy: jasmine.Spy;
let getFilterCountersSpy: jasmine.Spy;
let refreshFilterCountersSpy: jasmine.Spy;
let getProcessCounterSpy: jasmine.Spy;
let loader: HarnessLoader;
let router: Router;
@@ -60,6 +62,7 @@ describe('ProcessFiltersCloudComponent', () => {
imports: [NoopAuthModule, ProcessFiltersCloudComponent, ApolloTestingModule],
providers: [
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: AppConfigService, useClass: AppConfigServiceMock },
ProcessListCloudService,
{ provide: ProcessFilterCloudService, useValue: ProcessFilterCloudServiceMock },
@@ -90,9 +93,10 @@ describe('ProcessFiltersCloudComponent', () => {
TestBed.inject(ActivatedRoute);
router = TestBed.inject(Router);
await RouterTestingHarness.create();
getProcessFiltersSpy = spyOn(processFilterService, 'getProcessFilters').and.returnValue(of(mockProcessFilters));
getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue(EMPTY);
spyOn(processListService, 'getProcessCounter').and.returnValue(of(10));
getProcessFiltersSpy = spyOn(filterCountersService, 'getProcessFilters').and.returnValue(of(mockProcessFilters));
getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue(of({ counters: {}, batched: true }));
refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters');
getProcessCounterSpy = spyOn(processListService, 'getProcessCounter').and.returnValue(of(10));
spyOn(processListService, 'getProcessListCount').and.returnValue(of(10));
};
@@ -464,105 +468,63 @@ describe('ProcessFiltersCloudComponent', () => {
expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy();
});
it('should call fetchProcessFilterCounter only if filter.showCounter is true', () => {
const filterWithCounter = { ...mockProcessFilters[0], showCounter: true };
const filterWithoutCounter = { ...mockProcessFilters[1], showCounter: false };
const fetchSpy = spyOn<any>(component, 'fetchProcessFilterCounter').and.returnValue(of(42));
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true });
const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false });
getProcessCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getProcessCounterSpy).toHaveBeenCalledTimes(1);
expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status);
});
describe('Batched counters registration', () => {
let registerFiltersSpy: jasmine.Spy;
describe('Batched counters', () => {
beforeEach(() => {
registerFiltersSpy = spyOn(filterCountersService, 'registerFilters');
getProcessFiltersSpy.and.returnValue(
of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true })))
);
});
const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1];
it('should read the counters of the process filters of the bound app', async () => {
await bindAppName('mock-app-name');
it('should register every filter with a counter enabled', async () => {
getProcessFiltersSpy.and.returnValue(
of(
mockProcessFilters.map(
(filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true, sort: 'startDate', order: 'DESC' })
)
)
);
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE);
});
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(registerFiltersSpy).toHaveBeenCalledWith('PROCESS_INSTANCE', jasmine.any(Array));
// the first mock filter targets every status, so it holds no status to be keyed by
expect(registeredQueries().length).toBe(2);
expect(registeredQueries().map((query: any) => query.status)).toEqual([['RUNNING'], ['COMPLETED']]);
expect(component.counters['FakeRunningProcesses']).toBe(9);
});
it('should not register a filter without a counter enabled', async () => {
getProcessFiltersSpy.and.returnValue(
of([
new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: 'startDate', order: 'DESC' }),
new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false, sort: 'startDate', order: 'DESC' })
])
);
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName('mock-app-name');
expect(registeredQueries().length).toBe(1);
expect(registeredQueries()[0].status).toEqual(['RUNNING']);
expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses');
});
it('should register the full criteria of a filter', async () => {
getProcessFiltersSpy.and.returnValue(
of([
new ProcessFilterCloudModel({
...mockProcessFilters[1],
showCounter: true,
sort: 'startDate',
order: 'DESC',
initiator: 'mock-user',
processDefinitionName: 'mock-process'
})
])
);
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName('mock-app-name');
const query = registeredQueries()[0];
expect(query.status).toEqual(['RUNNING']);
expect(query.initiator).toEqual(['mock-user']);
expect(query.processDefinitionName).toEqual(['mock-process']);
expect(query.sort).toEqual({ field: 'startDate', direction: 'desc', isProcessVariable: false });
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
expect(component.counters['FakeRunningProcesses']).toBe(10);
});
it('should not register a filter targeting every status', async () => {
getProcessFiltersSpy.and.returnValue(
of([new ProcessFilterCloudModel({ ...mockProcessFilters[0], showCounter: true, sort: 'startDate', order: 'DESC' })])
);
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName('mock-app-name');
expect(registeredQueries().length).toBe(0);
});
component.onFilterClick(mockProcessFilters[1]);
it('should not break the filter list when the query of a filter cannot be built', async () => {
getProcessFiltersSpy.and.returnValue(
of([
new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true, sort: undefined, order: undefined }),
new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: true, sort: 'startDate', order: 'DESC' })
])
);
await bindAppName('mock-app-name');
expect(component.filters.length).toBe(2);
expect(registeredQueries().length).toBe(1);
expect(registeredQueries()[0].status).toEqual(['COMPLETED']);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name');
});
});
@@ -597,64 +559,402 @@ describe('ProcessFiltersCloudComponent', () => {
expect(component.notificationDebounceTime).toBe(5000);
});
const initNotifications = (showCounter: boolean): Subject<FilterCountersNotification> => {
const notifications$ = new Subject<FilterCountersNotification>();
getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable());
it('should keep the counters in sync with the counters stream', fakeAsync(() => {
const counters$ = new Subject<any>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any;
component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter }));
return notifications$;
};
it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => {
const notifications$ = initNotifications(true);
notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } });
counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true });
expect(component.counters['FakeRunningProcesses']).toBe(7);
flush();
}));
it('should fetch the counters of the filters not resolved by the batched count request on their own', fakeAsync(() => {
const notifications$ = initNotifications(true);
const updateFilterCounterSpy = spyOn(component, 'updateFilterCounter');
notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } });
// the RUNNING filter is resolved by the batch, the other two are fetched on their own
expect(component.counters['FakeRunningProcesses']).toBe(7);
expect(updateFilterCounterSpy).toHaveBeenCalledTimes(2);
expect(updateFilterCounterSpy.calls.allArgs().map(([filter]) => filter.key)).toEqual(['FakeAllProcesses', 'completed-processes']);
flush();
}));
it('should not update the counter of a filter without counter enabled', fakeAsync(() => {
const notifications$ = initNotifications(false);
component.counters = {};
notifications$.next({ events: [], counters: { PROCESS_INSTANCE: { RUNNING: 7 } } });
expect(component.counters['FakeRunningProcesses']).toBeUndefined();
flush();
}));
});
describe('Highlight Selected Filter', () => {
it('should make subscription', async () => {
const allProcessesFilterKey = mockProcessFilters[0].key;
const allProcessesFilterId = mockProcessFilters[0].id;
it('should apply active CSS class on filter click', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('mock-app-name');
let link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.getAttribute('href')).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`);
link.click();
fixture.detectChanges();
await fixture.whenStable();
expect(router.url).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`);
link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.classList).toContain('adf-active');
});
it('should not make subscription when notifications are disabled', async () => {
const appConfigService = TestBed.inject(AppConfigService);
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue));
it('should add aria-current attribute with value "page" to the active filter', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getFilterCountersNotificationsSpy).not.toHaveBeenCalled();
const link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.getAttribute('aria-current')).toBe('page');
});
it('should not have aria-current attribute when filter is not active', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
const link = fixture.debugElement.query(By.css(`[data-automation-id="${mockProcessFilters[1].key}_filter"]`)).nativeElement;
expect(link.getAttribute('aria-current')).toBeNull();
});
});
});
describe('searchApiMethod set to POST', () => {
beforeEach(async () => {
await configureTestingModule('POST');
});
it('should attach specific icon for each filter if hasIcon is true', async () => {
await bindAppName();
component.showIcons = true;
fixture.detectChanges();
await fixture.whenStable();
expect(component.filters.length).toBe(3);
const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' }));
expect(filterIcons.length).toBe(3);
expect(await filterIcons[0].getName()).toContain('adjust');
expect(await filterIcons[1].getName()).toContain('inbox');
expect(await filterIcons[2].getName()).toContain('done');
});
it('should not attach icons for each filter if hasIcon is false', async () => {
component.showIcons = false;
await bindAppName();
const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' }));
expect(filterIcons.length).toBe(0);
});
it('should display the filters', async () => {
await bindAppName();
component.showIcons = true;
fixture.detectChanges();
await fixture.whenStable();
const filters = fixture.debugElement.queryAll(By.css('.adf-process-filters__entry'));
expect(component.filters.length).toBe(3);
expect(filters.length).toBe(3);
expect(filters[0].nativeElement.innerText).toContain('FakeAllProcesses');
expect(filters[1].nativeElement.innerText).toContain('FakeRunningProcesses');
expect(filters[2].nativeElement.innerText).toContain('FakeCompletedProcesses');
expect(Object.keys(component.counters).length).toBe(3);
});
it('should emit success with the filters when filters are loaded', async () => {
const successSpy = spyOn(component.success, 'emit');
await bindAppName();
expect(successSpy).toHaveBeenCalledWith(mockProcessFilters);
expect(component.filters).toBeDefined();
expect(component.filters[0].name).toEqual('FakeAllProcesses');
expect(component.filters[1].name).toEqual('FakeRunningProcesses');
expect(component.filters[2].name).toEqual('FakeCompletedProcesses');
expect(Object.keys(component.counters).length).toBe(3);
});
it('should not select any filter as default', async () => {
await bindAppName();
expect(component.currentFilter).toBeUndefined();
});
it('should filterClicked emit when a filter is clicked from the UI', async () => {
const filterClickedSpy = spyOn(component.filterClicked, 'emit');
await bindAppName();
const filterButton = fixture.debugElement.nativeElement.querySelector(`[data-automation-id="${mockProcessFilters[0].key}_filter"]`);
filterButton.click();
fixture.detectChanges();
await fixture.whenStable();
expect(component.currentFilter).toEqual(mockProcessFilters[0]);
expect(filterClickedSpy).toHaveBeenCalledWith(mockProcessFilters[0]);
});
});
describe('API agnostic', () => {
beforeEach(async () => {
await configureTestingModule('GET');
});
it('should emit an error with a bad response', async () => {
getProcessFiltersSpy.and.returnValue(throwError('wrong request'));
let lastValue: any;
component.error.subscribe((err) => (lastValue = err));
await bindAppName();
expect(lastValue).toBeDefined();
});
it('should not select any process filter if filter input does not exist', async () => {
const change = new SimpleChange(null, { name: 'nonexistentFilter' }, true);
fixture.detectChanges();
await fixture.whenStable();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toBeUndefined();
});
it('should select the filter based on the input by name param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { name: 'FakeRunningProcesses' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[1]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[1]);
});
it('should select the filter based on the input by key param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { key: 'completed-processes' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should select the filter based on the input by index param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { index: 2 }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should select the filter based on the input by id param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { id: '12' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should reset the filter when the param is undefined', () => {
const change = new SimpleChange(mockProcessFilters[0], undefined, false);
component.currentFilter = mockProcessFilters[0];
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(undefined);
});
it('should not emit a filter clicked event when a filter is selected through the filterParam input (filterClicked emits only through a UI click action)', async () => {
const filterClickedSpy = spyOn(component.filterClicked, 'emit');
const change = new SimpleChange(null, { id: '10' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toBe(mockProcessFilters[0]);
expect(filterClickedSpy).not.toHaveBeenCalled();
});
it('should reload filters by appName on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = 'my-app-1';
const change = new SimpleChange(null, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).toHaveBeenCalledWith(appName);
});
it('should not reload filters by appName null on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = null;
const change = new SimpleChange(undefined, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).not.toHaveBeenCalledWith(appName);
});
it('should reload filters by app name on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = 'fake-app-name';
const change = new SimpleChange(null, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).toHaveBeenCalledWith(appName);
});
it('should return the current filter after one is selected', () => {
const filter = mockProcessFilters[1];
component.filters = mockProcessFilters;
expect(component.currentFilter).toBeUndefined();
component.selectFilter({ id: filter.id });
expect(component.getCurrentFilter()).toBe(filter);
});
it('should remove key from set of updated filters when received refreshed filter key', async () => {
const filterKeyTest = 'filter-key-test';
component.updatedFiltersSet.add(filterKeyTest);
expect(component.updatedFiltersSet.size).toBe(1);
processFilterService.filterKeyToBeRefreshed$ = of(filterKeyTest);
fixture.detectChanges();
expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy();
});
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true });
const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false });
getProcessCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(getProcessCounterSpy).toHaveBeenCalledTimes(1);
expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status);
});
describe('Batched counters', () => {
beforeEach(() => {
getProcessFiltersSpy.and.returnValue(
of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true })))
);
});
it('should read the counters of the process filters of the bound app', async () => {
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE);
});
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
});
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName('mock-app-name');
expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses');
});
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName('mock-app-name');
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
expect(component.counters['FakeRunningProcesses']).toBe(10);
});
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName('mock-app-name');
component.onFilterClick(mockProcessFilters[1]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name');
});
});
describe('Notifications config', () => {
it('should read enableNotifications and notificationDebounceTime from app config on init', () => {
const appConfigService = TestBed.inject(AppConfigService);
const getSpy = spyOn(appConfigService, 'get').and.callThrough();
fixture.detectChanges();
expect(getSpy).toHaveBeenCalledWith('notifications', true);
expect(getSpy).toHaveBeenCalledWith('notificationDebounceTime', 3000);
});
it('should default notificationDebounceTime to 3000 when not set in app config', () => {
fixture.detectChanges();
expect(component.notificationDebounceTime).toBe(3000);
});
it('should use notificationDebounceTime from app config', () => {
const appConfigService: AppConfigService = TestBed.inject(AppConfigService);
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => {
if (key === 'notificationDebounceTime') {
return 5000;
}
return defaultValue;
});
fixture.detectChanges();
expect(component.notificationDebounceTime).toBe(5000);
});
it('should keep the counters in sync with the counters stream', fakeAsync(() => {
const counters$ = new Subject<any>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any;
counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true });
expect(component.counters['FakeRunningProcesses']).toBe(7);
flush();
}));
it('should resolve the counters one filter at a time when the batched endpoint is not available', fakeAsync(() => {
const counters$ = new Subject<any>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => ({ ...filter, showCounter: true })) as any;
getProcessCounterSpy.calls.reset();
counters$.next({ counters: {}, batched: false });
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
flush();
}));
});
describe('Highlight Selected Filter', () => {
it('should read the counters of the bound app', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE);
});
it('should emit filter key when filter counter is set for first time', () => {
@@ -16,7 +16,7 @@
*/
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { EMPTY, Observable } from 'rxjs';
import { 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';
@@ -26,7 +26,6 @@ import { ProcessListCloudService } from '../../../process-list/services/process-
import { ProcessFilterCloudAdapter } from '../../../process-list/models/process-cloud-query-request.model';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe } from '@ngx-translate/core';
import { AsyncPipe } from '@angular/common';
@@ -46,7 +45,13 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
@Input()
appName: string = '';
/** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count */
/**
* (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,
* along with the 'GET' method, in ADF 10.0.0.
*/
@Input()
searchApiMethod: 'GET' | 'POST' = 'GET';
@@ -81,11 +86,14 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
filters$: Observable<ProcessFilterCloudModel[]>;
currentFilter?: ProcessFilterCloudModel;
filters: ProcessFilterCloudModel[] = [];
counters: { [key: string]: number } = {};
currentFiltersValues: { [key: string]: number } = {};
updatedFiltersSet = new Set<string>();
enableNotifications = true;
notificationDebounceTime = 3000;
private filtersLoadedFor?: string;
private countersManager: FilterCountersManager<ProcessFilterCloudModel>;
private countersSubscription?: Subscription;
private batchedCounters = true;
private readonly destroyRef = inject(DestroyRef);
private readonly processFilterCloudService = inject(ProcessFilterCloudService);
@@ -100,52 +108,16 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
if (!this.countersManager) {
this.initCountersManager();
}
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
this.initProcessNotification();
this.countersManager.subscribeToExternalRefresh(this.processFilterCloudService.filterKeyToBeRefreshed$);
}
private initCountersManager(): void {
const counterAdapter: FilterCounterAdapter<ProcessFilterCloudModel> = {
getFilterCounter: (filter) =>
this.searchApiMethod === 'POST'
? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter))
: this.processListCloudService.getProcessCounter(filter.appName, filter.status)
};
this.countersManager = new FilterCountersManager(
FilterCounterEntityType.PROCESS_INSTANCE,
this.filterCountersCloudService,
counterAdapter,
this.destroyRef,
{
onFilterUpdated: (filterKey) => {
this.updatedFilter.emit(filterKey);
this.updatedFiltersSet.add(filterKey);
this.counters = this.countersManager.counters;
this.currentFiltersValues = this.countersManager.currentFiltersValues;
},
onCountersUpdated: () => {
this.counters = this.countersManager.counters;
this.currentFiltersValues = this.countersManager.currentFiltersValues;
}
}
);
this.getFilterKeysAfterExternalRefreshing();
}
ngOnChanges(changes: SimpleChanges) {
const appName = changes['appName'];
const filter = changes['filterParam'];
if (appName?.currentValue) {
if (!this.countersManager) {
this.initCountersManager();
}
this.getFilters(appName.currentValue);
} else if (filter && filter.currentValue !== filter.previousValue) {
this.selectFilterAndEmit(filter.currentValue);
@@ -159,19 +131,17 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
const filters$ = this.filterCountersCloudService
.getFilters(appName)
.pipe(map((filters) => filters[FilterCounterEntityType.PROCESS_INSTANCE] as ProcessFilterCloudModel[]));
const filters$ = this.filterCountersCloudService.getProcessFilters(appName);
this.filters$ = filters$.pipe(catchError(() => EMPTY));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
this.resetFilter();
this.filters = res || [];
this.countersManager.initCounters(this.filters);
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
this.success.emit(res);
this.countersManager.loadCounters(appName);
this.loadFilterCounters(appName);
},
error: (err: any) => {
this.error.emit(err);
@@ -179,8 +149,12 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
});
}
counters: { [key: string]: number } = {};
currentFiltersValues: { [key: string]: number } = {};
/**
* Initialize counter collection for filters
*/
initFilterCounters(): void {
this.filters.forEach((filter) => (this.counters[filter.key] = 0));
}
/**
* Pass the selected filter as next
@@ -204,20 +178,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
); // fallback to preserve the previous behavior
}
/**
* Check equality of the filter names by translating the given name strings
*
* @param name1 source name
* @param name2 target name
* @returns `true` if filter names are equal, otherwise `false`
*/
private checkFilterNamesEquality(name1: string, name2: string): boolean {
const translatedName1 = this.translationService.instant(name1);
const translatedName2 = this.translationService.instant(name2);
return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase();
}
/**
* Selects and emits the given filter
*
@@ -250,8 +210,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
if (filter) {
this.selectFilter(filter);
this.filterClicked.emit(this.currentFilter);
this.updateFilterCounter(this.currentFilter);
this.countersManager.resetFilterUpdate(filter.key);
this.refreshFilterCounter(this.currentFilter);
this.updatedFiltersSet.delete(filter.key);
} else {
this.currentFilter = undefined;
@@ -285,6 +244,85 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
return this.filters === undefined || (this.filters && this.filters.length === 0);
}
isActiveFilter(filter: ProcessFilterCloudModel): boolean {
return this.currentFilter.name === filter.name;
}
/**
* 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
* subscribed here anymore. It will be removed in ADF 10.0.0.
*/
initProcessNotification(): void {
/* Kept for backwards compatibility: `getFilterCounters` subscribes to the engine events. */
}
/**
* 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.
*/
updateFilterCounters(): void {
this.filters.forEach((filter) => this.updateFilterCounter(filter));
}
/**
* 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.
*/
updateFilterCounter(filter: ProcessFilterCloudModel): void {
if (!filter?.showCounter) {
return;
}
this.fetchProcessFilterCounter(filter)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((counter) => {
this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
this.counters = { ...this.counters, [filter.key]: counter };
});
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue };
this.updatedFilter.emit(filterKey);
this.updatedFiltersSet.add(filterKey);
}
}
/**
* Flags the counter of a filter as read whenever the filter is refreshed by an external action
*/
getFilterKeysAfterExternalRefreshing(): void {
this.processFilterCloudService.filterKeyToBeRefreshed$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((filterKey: string) => this.updatedFiltersSet.delete(filterKey));
}
isFilterUpdated(filterName: string): boolean {
return this.updatedFiltersSet.has(filterName);
}
/**
* Check equality of the filter names by translating the given name strings
*
* @param name1 source name
* @param name2 target name
* @returns `true` if filter names are equal, otherwise `false`
*/
private checkFilterNamesEquality(name1: string, name2: string): boolean {
const translatedName1 = this.translationService.instant(name1);
const translatedName2 = this.translationService.instant(name2);
return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase();
}
/**
* Reset the filters
*/
@@ -293,34 +331,56 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.currentFilter = undefined;
}
isActiveFilter(filter: ProcessFilterCloudModel): boolean {
return this.currentFilter.name === filter.name;
/**
* 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 {
this.countersSubscription?.unsubscribe();
this.countersSubscription = this.filterCountersCloudService
.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ counters, batched }) => {
this.batchedCounters = batched;
if (batched) {
this.applyFilterCounters(counters);
} else {
this.updateFilterCounters();
}
});
}
initProcessNotification(): void {
if (this.appName && this.enableNotifications) {
this.countersManager.subscribeToNotifications(this.appName);
/**
* Holds the counters resolved by the batched count request, which are keyed by filter key.
*
* @param counters counters keyed by filter key
*/
private applyFilterCounters(counters: { [filterKey: string]: number }): void {
Object.entries(counters).forEach(([filterKey, counter]) => {
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
}
/**
* 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);
} else {
this.updateFilterCounter(filter);
}
}
/**
* Iterate over filters and update counters
*/
updateFilterCounters(): void {
this.countersManager.updateAllCounters();
}
/**
* Get current value for filter and check if value has changed
*
* @param filter filter
*/
updateFilterCounter(filter: ProcessFilterCloudModel): void {
this.countersManager.updateSingleCounter(filter);
this.counters = this.countersManager.counters;
}
isFilterUpdated(filterName: string): boolean {
return this.updatedFiltersSet.has(filterName);
private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable<number> {
return this.searchApiMethod === 'POST'
? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter))
: this.processListCloudService.getProcessCounter(filter.appName, filter.status);
}
}
@@ -17,11 +17,18 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core';
import { Subject } from 'rxjs';
import { firstValueFrom, of, Subject, throwError } from 'rxjs';
import { ApolloTestingModule } from 'apollo-angular/testing';
import { FilterCountersCloudService } from './filter-counters-cloud.service';
import { NotificationCloudService } from './notification-cloud.service';
import { FilterCounterEntityType, FilterCounters, FilterCountersNotification } from '../models/filter-counters-cloud.model';
import { LocalPreferenceCloudService } from './local-preference-cloud.service';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from './cloud-token.service';
import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service';
import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service';
import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model';
import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model';
import { FilterCounterEntityType, FilterCounters } from '../models/filter-counters-cloud.model';
import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model';
describe('FilterCountersCloudService', () => {
let service: FilterCountersCloudService;
@@ -30,17 +37,36 @@ describe('FilterCountersCloudService', () => {
let engineEvents$: Subject<any>;
let makeGQLQuerySpy: jasmine.Spy;
let postSpy: jasmine.Spy;
let getTaskListFiltersSpy: jasmine.Spy;
let getProcessFiltersSpy: jasmine.Spy;
const countersMock: FilterCounters = {
TASK: { ASSIGNED: 5, CREATED: 0 },
PROCESS_INSTANCE: { RUNNING: 5 }
TASK: { 'my-tasks': 5, 'queued-tasks': 0 },
PROCESS_INSTANCE: { 'running-processes': 5 }
};
const taskFilter = (filter: any) => new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter });
const processFilter = (filter: any) => new ProcessFilterCloudModel({ appName: 'mock-app', sort: 'startDate', order: 'DESC', ...filter });
const taskFiltersMock = [
taskFilter({ key: 'my-tasks', status: 'ASSIGNED', assignee: 'mock-user', showCounter: true }),
taskFilter({ key: 'queued-tasks', status: 'CREATED', showCounter: true }),
taskFilter({ key: 'completed-tasks', status: 'COMPLETED', showCounter: false })
];
const processFiltersMock = [
processFilter({ key: 'running-processes', status: 'RUNNING', showCounter: true }),
processFilter({ key: 'all-processes', status: '', showCounter: false })
];
const emitEvent = (eventType = 'TASK_CREATED') => engineEvents$.next({ data: { engineEvents: [{ eventType, entity: {} }] } });
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopAuthModule, ApolloTestingModule]
imports: [NoopAuthModule, ApolloTestingModule],
providers: [
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }
]
});
service = TestBed.inject(FilterCountersCloudService);
@@ -50,367 +76,312 @@ describe('FilterCountersCloudService', () => {
engineEvents$ = new Subject<any>();
makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery').and.returnValue(engineEvents$.asObservable() as any);
postSpy = spyOn<any>(service, 'post').and.returnValue(new Subject<FilterCounters>().asObservable());
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }]);
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]);
postSpy = spyOn<any>(service, '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));
});
it('should return EMPTY when appName is not set', () => {
let completed = false;
service.getFilterCountersNotifications('').subscribe({ complete: () => (completed = true) });
expect(completed).toBeTrue();
expect(makeGQLQuerySpy).not.toHaveBeenCalled();
});
it('should open a single subscription for multiple subscribers of the same app', () => {
service.getFilterCountersNotifications('mock-app').subscribe();
service.getFilterCountersNotifications('mock-app').subscribe();
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
});
it('should subscribe to both the task and the process engine events', () => {
service.getFilterCountersNotifications('mock-app').subscribe();
const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args;
expect(appName).toBe('mock-app');
expect(query).toContain('TASK_CREATED');
expect(query).toContain('PROCESS_STARTED');
});
it('should open a separate subscription per app', () => {
service.getFilterCountersNotifications('mock-app').subscribe();
service.getFilterCountersNotifications('other-app').subscribe();
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
});
it('should make a single count request for a batch of events received by multiple subscribers', fakeAsync(() => {
postSpy.and.returnValue(new Subject<FilterCounters>().asObservable());
service.getFilterCountersNotifications('mock-app').subscribe();
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent('TASK_CREATED');
emitEvent('PROCESS_STARTED');
tick(3000);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
it('should call the batched count endpoint with the queries of the registered filters', fakeAsync(() => {
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent();
tick(3000);
const [url, body] = postSpy.calls.mostRecent().args;
expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count');
expect(body).toEqual({
TASK: [{ status: ['ASSIGNED'], assignee: ['mock-user'] }, { status: ['CREATED'] }],
PROCESS_INSTANCE: [{ status: ['RUNNING'] }]
});
}));
it('should send the full criteria of every registered filter', fakeAsync(() => {
service.registerFilters(FilterCounterEntityType.TASK, [
{ status: ['ASSIGNED'], assignee: ['mock-user'] },
{ status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' },
{ status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] }
]);
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent();
tick(3000);
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK.length).toBe(3);
expect(body.TASK[1]).toEqual({ status: ['ASSIGNED'], priority: ['4'], dueDateFrom: '2026-01-01' });
expect(body.TASK[2]).toEqual({ status: ['SUSPENDED', 'CREATED'], processVariableFilters: [{ name: 'amount', value: '10' }] });
}));
it('should replace the previously registered queries of an entity type', fakeAsync(() => {
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['COMPLETED'] }]);
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent();
tick(3000);
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK).toEqual([{ status: ['COMPLETED'] }]);
}));
it('should not call the batched count endpoint when no filter is registered', fakeAsync(() => {
service.registerFilters(FilterCounterEntityType.TASK, []);
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []);
let notification: FilterCountersNotification;
service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result));
emitEvent();
tick(3000);
expect(postSpy).not.toHaveBeenCalled();
expect(notification.counters).toEqual({});
}));
it('should omit the entity type of an entity without registered filters', fakeAsync(() => {
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, []);
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent();
tick(3000);
const [, body] = postSpy.calls.mostRecent().args;
expect(body.PROCESS_INSTANCE).toBeUndefined();
expect(body.TASK.length).toBe(2);
}));
it('should debounce the events using the configured debounce time', fakeAsync(() => {
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notificationDebounceTime' ? 5000 : defaultValue));
service.getFilterCountersNotifications('mock-app').subscribe();
emitEvent();
tick(3000);
expect(postSpy).not.toHaveBeenCalled();
tick(2000);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
it('should emit the events along with the resolved counters', fakeAsync(() => {
const counters$ = new Subject<FilterCounters>();
postSpy.and.returnValue(counters$.asObservable());
let notification: FilterCountersNotification;
service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result));
emitEvent('TASK_ASSIGNED');
tick(3000);
counters$.next(countersMock);
expect(notification.counters).toEqual(countersMock);
expect(notification.events.length).toBe(1);
expect(notification.events[0].eventType).toBe('TASK_ASSIGNED');
}));
it('should emit the events with empty counters when the count request fails', fakeAsync(() => {
const counters$ = new Subject<FilterCounters>();
postSpy.and.returnValue(counters$.asObservable());
let notification: FilterCountersNotification;
service.getFilterCountersNotifications('mock-app').subscribe((result) => (notification = result));
emitEvent();
tick(3000);
counters$.error(new Error('count failed'));
expect(notification.counters).toEqual({});
expect(notification.events.length).toBe(1);
}));
it('should keep emitting after a failed count request', fakeAsync(() => {
const notifications: FilterCountersNotification[] = [];
service.getFilterCountersNotifications('mock-app').subscribe((result) => notifications.push(result));
postSpy.and.callFake(() => {
const counters$ = new Subject<FilterCounters>();
setTimeout(() => counters$.error(new Error('count failed')));
return counters$.asObservable();
});
emitEvent();
tick(3000);
tick(0);
postSpy.and.callFake(() => {
const counters$ = new Subject<FilterCounters>();
setTimeout(() => counters$.next(countersMock));
return counters$.asObservable();
});
emitEvent();
tick(3000);
tick(0);
expect(notifications.length).toBe(2);
expect(notifications[1].counters).toEqual(countersMock);
}));
describe('loadFilterCounters', () => {
beforeEach(() => {
postSpy.and.returnValue(of(countersMock));
describe('getTaskFilters / getProcessFilters', () => {
it('should load the filters of every entity type', async () => {
expect(await firstValueFrom(service.getTaskFilters('mock-app'))).toEqual(taskFiltersMock);
expect(await firstValueFrom(service.getProcessFilters('mock-app'))).toEqual(processFiltersMock);
});
it('should resolve the counters of both entity types with a single request', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE);
const taskCounters: FilterCounters[] = [];
const processCounters: FilterCounters[] = [];
it('should load the filters of an app once for every subscriber', async () => {
await firstValueFrom(service.getTaskFilters('mock-app'));
await firstValueFrom(service.getTaskFilters('mock-app'));
await firstValueFrom(service.getProcessFilters('mock-app'));
await firstValueFrom(service.getProcessFilters('mock-app'));
// both components load their filter lists independently and subscribe before registering
service.loadFilterCounters('mock-app').subscribe((counters) => taskCounters.push(counters));
service.loadFilterCounters('mock-app').subscribe((counters) => processCounters.push(counters));
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]);
tick(1000);
expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1);
expect(getProcessFiltersSpy).toHaveBeenCalledTimes(1);
});
it('should load the filters of every app', async () => {
await firstValueFrom(service.getTaskFilters('mock-app'));
await firstValueFrom(service.getTaskFilters('other-app'));
expect(getTaskListFiltersSpy.calls.allArgs()).toEqual([['mock-app'], ['other-app']]);
});
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));
expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1);
});
it('should propagate the error of the filters that fail to load', async () => {
getTaskListFiltersSpy.and.returnValue(throwError(() => new Error('filters failed')));
await expectAsync(firstValueFrom(service.getTaskFilters('mock-app'))).toBeRejectedWithError('filters failed');
});
});
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) });
expect(completed).toBeTrue();
expect(postSpy).not.toHaveBeenCalled();
});
it('should resolve the counters of both entity types with a single request', () => {
const results: any[] = [];
/* 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(taskCounters).toEqual([countersMock]);
expect(processCounters).toEqual([countersMock]);
}));
expect(results).toEqual([
{ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true },
{ counters: { 'running-processes': 5 }, batched: true }
]);
});
it('should send the queries of every entity type in one request', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE);
service.loadFilterCounters('mock-app').subscribe();
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }]);
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]);
tick(1000);
it('should send the queries of both entity types to the batched count endpoint', async () => {
await taskCounters();
const [url, body] = postSpy.calls.mostRecent().args;
expect(url).toBe('https://fake-bpm-host.com/mock-app/query/v1/count');
expect(body).toEqual({
TASK: [{ status: ['ASSIGNED'] }, { status: ['CREATED'] }],
PROCESS_INSTANCE: [{ status: ['RUNNING'] }]
expect(Object.keys(body)).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]);
});
it('should identify the query of every filter by the key of the filter', async () => {
await taskCounters();
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK.map((query: any) => query.requestId)).toEqual(['my-tasks', 'queued-tasks']);
expect(body.PROCESS_INSTANCE.map((query: any) => query.requestId)).toEqual(['running-processes']);
});
it('should send the criteria of every filter along with its request id', async () => {
await taskCounters();
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK[0]).toEqual({
requestId: 'my-tasks',
status: ['ASSIGNED'],
assignee: ['mock-user'],
sort: { field: 'createdDate', direction: 'desc', isProcessVariable: false }
});
}));
});
it('should wait for every expected entity type before sending the request', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE);
it('should not send the filters without a counter enabled', async () => {
await taskCounters();
service.loadFilterCounters('mock-app').subscribe();
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK.map((query: any) => query.requestId)).not.toContain('completed-tasks');
});
expect(postSpy).not.toHaveBeenCalled();
it('should send the query of a filter targeting every status', async () => {
getProcessFiltersSpy.and.returnValue(of([processFilter({ key: 'all-processes', status: '', showCounter: true })]));
service.registerFilters(FilterCounterEntityType.PROCESS_INSTANCE, [{ status: ['RUNNING'] }]);
await processCounters();
expect(postSpy).toHaveBeenCalledTimes(1);
tick(1000);
}));
const [, body] = postSpy.calls.mostRecent().args;
expect(body.PROCESS_INSTANCE.map((query: any) => query.requestId)).toEqual(['all-processes']);
});
it('should send the request once the only expected entity type registers', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
it('should omit an entity type without filters with a counter enabled', async () => {
getProcessFiltersSpy.and.returnValue(of([]));
service.loadFilterCounters('mock-app').subscribe();
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
await taskCounters();
expect(postSpy).toHaveBeenCalledTimes(1);
const [, body] = postSpy.calls.mostRecent().args;
expect(body.PROCESS_INSTANCE).toBeUndefined();
tick(1000);
}));
});
it('should not wait longer than the max wait for a missing registration', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE);
let counters: FilterCounters;
// the process filters fail to load, so they never register
service.loadFilterCounters('mock-app').subscribe((result) => (counters = result));
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
expect(postSpy).not.toHaveBeenCalled();
tick(1000);
expect(postSpy).toHaveBeenCalledTimes(1);
expect(counters).toEqual(countersMock);
}));
it('should use the max wait from the app config', fakeAsync(() => {
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) =>
key === 'filterCounterBatchMaxWait' ? 5000 : defaultValue
it('should leave out a filter the query cannot be built for', async () => {
getTaskListFiltersSpy.and.returnValue(
of([taskFilter({ key: 'broken', status: 'ASSIGNED', showCounter: true, sort: undefined, order: undefined }), taskFiltersMock[1]])
);
service.expectFilters(FilterCounterEntityType.TASK);
service.expectFilters(FilterCounterEntityType.PROCESS_INSTANCE);
service.loadFilterCounters('mock-app').subscribe();
tick(1000);
await taskCounters();
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK.map((query: any) => query.requestId)).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 })]));
await processCounters();
const [, body] = postSpy.calls.mostRecent().args;
expect(body.PROCESS_INSTANCE).toBeUndefined();
});
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();
const [, body] = postSpy.calls.mostRecent().args;
expect(body.TASK.map((query: any) => query.requestId)).toEqual(['my-tasks', 'queued-tasks']);
expect(body.PROCESS_INSTANCE).toBeUndefined();
});
it('should resolve no counter when no filter has a counter enabled', async () => {
getTaskListFiltersSpy.and.returnValue(of([]));
getProcessFiltersSpy.and.returnValue(of([]));
expect(await taskCounters()).toEqual({ counters: {}, batched: true });
expect(postSpy).not.toHaveBeenCalled();
});
tick(4000);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
describe('when the batched count endpoint is not available', () => {
it('should report the counters as not batched', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
it('should emit empty counters when the request fails', fakeAsync(() => {
postSpy.and.returnValue(throwError(() => new Error('count failed')));
service.expectFilters(FilterCounterEntityType.TASK);
let counters: FilterCounters;
expect(await taskCounters()).toEqual({ counters: {}, batched: false });
});
service.loadFilterCounters('mock-app').subscribe((result) => (counters = result));
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
tick(1000);
it('should not call the endpoint again for the same app', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
expect(counters).toEqual({});
}));
await taskCounters();
expect(await processCounters()).toEqual({ counters: {}, batched: false });
it('should start a new batch for a subsequent load of another app', fakeAsync(() => {
service.expectFilters(FilterCounterEntityType.TASK);
expect(postSpy).toHaveBeenCalledTimes(1);
});
service.loadFilterCounters('mock-app').subscribe();
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['ASSIGNED'] }]);
tick(1000);
it('should keep calling the endpoint of the apps that do hold it', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
await taskCounters();
service.loadFilterCounters('other-app').subscribe();
service.registerFilters(FilterCounterEntityType.TASK, [{ status: ['CREATED'] }]);
tick(1000);
postSpy.and.returnValue(of(countersMock));
expect(await taskCounters('other-app')).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
});
it('should keep calling the endpoint after a transient failure', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 500 })));
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('refreshFilterCounters', () => {
it('should resolve the counters again with a single request', fakeAsync(() => {
const results: any[] = [];
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result));
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
service.refreshFilterCounters('mock-app');
expect(postSpy).toHaveBeenCalledTimes(2);
expect(postSpy.calls.allArgs().map(([url]) => url)).toEqual([
'https://fake-bpm-host.com/mock-app/query/v1/count',
'https://fake-bpm-host.com/other-app/query/v1/count'
]);
expect(results.length).toBe(2);
}));
it('should not resolve the counters of an app without subscribers', () => {
service.refreshFilterCounters('mock-app');
expect(postSpy).not.toHaveBeenCalled();
});
});
describe('getEngineEvents', () => {
it('should return EMPTY when appName is not set', () => {
let completed = false;
service.getEngineEvents('').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();
const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args;
expect(appName).toBe('mock-app');
expect(query).toContain('TASK_CREATED');
expect(query).toContain('PROCESS_STARTED');
});
it('should open a separate subscription per app', () => {
service.getEngineEvents('mock-app').subscribe();
service.getEngineEvents('other-app').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));
emitEvent('TASK_CREATED');
emitEvent('PROCESS_STARTED');
tick(3000);
expect(batches.length).toBe(1);
expect(batches[0][0].eventType).toBe('PROCESS_STARTED');
}));
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));
emitEvent();
tick(3000);
expect(emitted).toBeFalse();
tick(2000);
expect(emitted).toBeTrue();
}));
});
describe('resolveFilterCounter', () => {
it('should resolve the counter of a filter by its status', () => {
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBe(5);
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'queued', statuses: ['CREATED'] })).toBe(0);
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.PROCESS_INSTANCE, { key: 'running', status: 'RUNNING' })).toBe(
5
);
});
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(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
postSpy.calls.reset();
it('should sum the counters of a filter targeting more than one status', () => {
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'CREATED'] })).toBe(
5
);
expect(
service.resolveFilterCounter({ TASK: { ASSIGNED: 5, CREATED: 2, SUSPENDED: 3 } }, FilterCounterEntityType.TASK, {
key: 'mine',
statuses: ['ASSIGNED', 'SUSPENDED']
})
).toBe(8);
});
emitEvent('TASK_CREATED');
emitEvent('PROCESS_STARTED');
tick(3000);
it('should resolve the counter from the statuses held by the response', () => {
expect(
service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'mine', statuses: ['ASSIGNED', 'COMPLETED'] })
).toBe(5);
});
expect(postSpy).toHaveBeenCalledTimes(1);
}));
it('should not resolve the counter of a filter not held by the response', () => {
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'done', status: 'COMPLETED' })).toBeUndefined();
expect(service.resolveFilterCounter({}, FilterCounterEntityType.TASK, { key: 'my-tasks', status: 'ASSIGNED' })).toBeUndefined();
});
it('should emit the counters resolved for the batch of events', fakeAsync(() => {
const results: any[] = [];
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe((result) => results.push(result));
it('should not resolve the counter of a filter targeting every status', () => {
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', status: '' })).toBeUndefined();
expect(service.resolveFilterCounter(countersMock, FilterCounterEntityType.TASK, { key: 'all', statuses: [] })).toBeUndefined();
});
});
postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } }));
emitEvent();
tick(3000);
describe('isCounterBatched', () => {
it('should batch the counter of a filter targeting one or more statuses', () => {
expect(service.isCounterBatched({ key: 'my-tasks', status: 'ASSIGNED' })).toBeTrue();
expect(service.isCounterBatched({ key: 'mine', statuses: ['ASSIGNED', 'SUSPENDED'] })).toBeTrue();
});
expect(results.length).toBe(2);
expect(results[1]).toEqual({ counters: { 'my-tasks': 9 }, batched: true });
}));
it('should not batch the counter of a filter targeting every status', () => {
expect(service.isCounterBatched({ key: 'all', status: '' })).toBeFalse();
expect(service.isCounterBatched({ key: 'all', statuses: [] })).toBeFalse();
expect(service.isCounterBatched({ key: 'all' })).toBeFalse();
});
it('should not subscribe to the engine events when notifications are disabled', fakeAsync(() => {
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue));
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK).subscribe();
tick(3000);
expect(makeGQLQuerySpy).not.toHaveBeenCalled();
expect(postSpy).toHaveBeenCalledTimes(1);
}));
});
});
@@ -16,7 +16,7 @@
*/
import { inject, Injectable } from '@angular/core';
import { combineLatest, defer, EMPTY, Observable, of } from 'rxjs';
import { combineLatest, defer, EMPTY, merge, Observable, of, Subject } from 'rxjs';
import { catchError, debounceTime, map, shareReplay, switchMap, take } from 'rxjs/operators';
import { BaseCloudService } from './base-cloud.service';
import { NotificationCloudService } from './notification-cloud.service';
@@ -26,22 +26,25 @@ import { ProcessFilterCloudService } from '../process/process-filters/services/p
import { TaskListCloudService } from '../task/task-list/services/task-list-cloud.service';
import { ProcessListCloudService } from '../process/process-list/services/process-list-cloud.service';
import { TaskFilterCloudAdapter } from '../models/filter-cloud-model';
import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model';
import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model';
import { ProcessFilterCloudAdapter } from '../process/process-list/models/process-cloud-query-request.model';
import {
FilterCounterCandidate,
FilterCounterEntityType,
FilterCounters,
FilterCountersFilters,
FilterCountersNotification,
FilterCountersQuery,
FilterCountersRequest,
resolveFilterCounterStatuses
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];
const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = `
subscription {
engineEvents(eventType: [
@@ -65,11 +68,12 @@ const FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY = `
`;
/**
* Central place handling the filter counters of the task and the process filter components:
* a single engine event subscription, debounced into a single batched count request.
* 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.
*
* Every filter with a counter enabled is registered by its component through `registerFilters`,
* so that the counters of both the task and the process filters are resolved by one request.
* 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.
*/
@Injectable({ providedIn: 'root' })
export class FilterCountersCloudService extends BaseCloudService {
@@ -80,66 +84,189 @@ export class FilterCountersCloudService extends BaseCloudService {
private readonly taskListCloudService = inject(TaskListCloudService);
private readonly processListCloudService = inject(ProcessListCloudService);
private readonly notificationsPerApp = new Map<string, Observable<FilterCountersNotification>>();
private readonly filtersPerApp = new Map<string, Observable<FilterCountersFilters>>();
private readonly countersPerApp = new Map<string, Observable<FilterCounters>>();
private readonly eventsPerApp = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly refreshPerApp = new Map<string, Subject<void>>();
private readonly appsWithoutBatchedCounters = new Set<string>();
private readonly taskFiltersPerApp = new Map<string, Observable<TaskFilterCloudModel[]>>();
private readonly processFiltersPerApp = new Map<string, Observable<ProcessFilterCloudModel[]>>();
private readonly countersPerApp = new Map<string, Observable<{ counters: FilterCounters; batched: boolean }>>();
get notificationDebounceTime(): number {
return this.appConfigService.get('notificationDebounceTime', 3000);
}
/**
* Task and process filters of the app, loaded once and shared between the filter components,
* so that a single place owns the filters the counters are resolved for.
* 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.
*
* @param appName Name of the target app
* @returns Task filters of the app
*/
getTaskFilters(appName: string): Observable<TaskFilterCloudModel[]> {
return this.shareFilters(this.taskFiltersPerApp, appName, () => this.taskFilterCloudService.getTaskListFilters(appName));
}
/**
* Process filters of the app, loaded once and shared between the process filter component and
* the batched count request.
*
* @param appName Name of the target app
* @returns Process filters of the app
*/
getProcessFilters(appName: string): Observable<ProcessFilterCloudModel[]> {
return this.shareFilters(this.processFiltersPerApp, appName, () => this.processFilterCloudService.getProcessFilters(appName));
}
/**
* 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.
*
* @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
*/
getFilterCounters(appName: string, entityType: FilterCounterEntityType): Observable<FilterCountersResult> {
if (!appName) {
return EMPTY;
}
return this.getCounters(appName).pipe(map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched })));
}
/**
* Resolves the counters of the filters of the app again, for both entity types with one request.
*
* @param appName Name of the target app
*/
refreshFilterCounters(appName: string): void {
this.getRefreshTrigger(appName).next();
}
/**
* Debounced batches of engine events of the app, shared between all the subscribers of the app.
*
* @param appName Name of the target app
* @returns Debounced batches of engine events
*/
getEngineEvents(appName: string): Observable<TaskCloudEngineEvent[]> {
if (!appName) {
return EMPTY;
}
let events$ = this.eventsPerApp.get(appName);
if (!events$) {
events$ = defer(() => this.notificationCloudService.makeGQLQuery(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)).pipe(
map((events: any) => (events?.data?.engineEvents ?? []) as TaskCloudEngineEvent[]),
debounceTime(this.notificationDebounceTime),
shareReplay({ bufferSize: 1, refCount: true })
);
this.eventsPerApp.set(appName, events$);
}
return events$;
}
private get notificationsEnabled(): boolean {
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
*/
getFilters(appName: string): Observable<FilterCountersFilters> {
let filters$ = this.filtersPerApp.get(appName);
if (!filters$) {
filters$ = combineLatest({
[FilterCounterEntityType.TASK]: this.taskFilterCloudService.getTaskListFilters(appName).pipe(catchError(() => of([]))),
[FilterCounterEntityType.PROCESS_INSTANCE]: this.processFilterCloudService.getProcessFilters(appName).pipe(catchError(() => of([])))
}).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private getFiltersForCounters(appName: string): Observable<FilterCountersFilters> {
return combineLatest({
[FilterCounterEntityType.TASK]: this.getTaskFilters(appName).pipe(catchError(() => of([]))),
[FilterCounterEntityType.PROCESS_INSTANCE]: this.getProcessFilters(appName).pipe(catchError(() => of([])))
});
}
this.filtersPerApp.set(appName, filters$);
private shareFilters<T>(cache: Map<string, Observable<T[]>>, appName: string, loadFilters: () => Observable<T[]>): Observable<T[]> {
let filters$ = cache.get(appName);
if (!filters$) {
filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: false }));
cache.set(appName, filters$);
}
return filters$;
}
/**
* Counters of every filter of the app with a counter enabled, resolved by a single request
* shared between the filter components. Both the task and the process filters are loaded
* before the request is built, so that one call resolves the counters of both components.
* 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 keyed by entity type and status
* @returns Counters of both entity types
*/
loadFilterCounters(appName: string): Observable<FilterCounters> {
private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> {
let counters$ = this.countersPerApp.get(appName);
if (!counters$) {
counters$ = this.getFilters(appName).pipe(
take(1),
switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))),
catchError(() => of({} as FilterCounters)),
shareReplay({ bufferSize: 1, refCount: false })
);
const triggers: Observable<unknown>[] = [of(undefined), this.getRefreshTrigger(appName)];
if (this.notificationsEnabled) {
triggers.push(this.getEngineEvents(appName));
}
counters$ = merge(...triggers).pipe(
switchMap(() => this.resolveCounters(appName)),
shareReplay({ bufferSize: 1, refCount: true })
);
this.countersPerApp.set(appName, counters$);
}
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 });
}
return this.getFiltersForCounters(appName).pipe(
take(1),
switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))),
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);
}
return of({ counters: {}, batched: false });
})
);
}
private getRefreshTrigger(appName: string): Subject<void> {
let refresh$ = this.refreshPerApp.get(appName);
if (!refresh$) {
refresh$ = new Subject<void>();
this.refreshPerApp.set(appName, refresh$);
}
return refresh$;
}
/**
* Builds the payload of the batched count request from the filters with a counter enabled.
*
* @param filters Task and process filters of the app
* @returns Payload of the count request
*/
buildRequest(filters: FilterCountersFilters): FilterCountersRequest {
private buildRequest(filters: FilterCountersFilters): FilterCountersRequest {
const request: FilterCountersRequest = {};
const taskQueries = this.buildQueries(filters[FilterCounterEntityType.TASK], (filter) =>
@@ -159,12 +286,16 @@ export class FilterCountersCloudService extends BaseCloudService {
return request;
}
private buildQueries<T extends FilterCounterCandidate>(filters: T[], buildQuery: (filter: T) => FilterCountersQuery): FilterCountersQuery[] {
private buildQueries<T extends FilterCounterCandidate>(
filters: T[],
buildQuery: (filter: T) => Omit<FilterCountersQuery, 'requestId'>
): FilterCountersQuery[] {
return (filters ?? [])
.filter((filter) => filter?.showCounter && this.isCounterBatched(filter))
.map((filter) => {
try {
return buildQuery(filter);
/* 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. */
return undefined;
@@ -173,40 +304,6 @@ export class FilterCountersCloudService extends BaseCloudService {
.filter((query) => !!query);
}
/**
* Engine events of the app, debounced and enriched with the counters resolved by a single
* call to the batched count endpoint. The underlying subscription and count request are
* shared between all the subscribers of the same app.
*
* @param appName Name of the target app
* @returns Debounced engine events along with the resolved counters
*/
getFilterCountersNotifications(appName: string): Observable<FilterCountersNotification> {
if (!appName) {
return EMPTY;
}
let notifications$ = this.notificationsPerApp.get(appName);
if (!notifications$) {
notifications$ = defer(() => this.notificationCloudService.makeGQLQuery(appName, FILTER_COUNTERS_EVENT_SUBSCRIPTION_QUERY)).pipe(
map((events: any) => (events?.data?.engineEvents ?? []) as TaskCloudEngineEvent[]),
debounceTime(this.notificationDebounceTime),
switchMap((events) =>
this.getFilters(appName).pipe(
take(1),
switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))),
map((counters) => ({ events, counters })),
catchError(() => of({ events, counters: {} as FilterCounters }))
)
),
shareReplay({ bufferSize: 1, refCount: true })
);
this.notificationsPerApp.set(appName, notifications$);
}
return notifications$;
}
/**
* Resolves the counters of the given queries with a single request.
*
@@ -214,7 +311,7 @@ export class FilterCountersCloudService extends BaseCloudService {
* @param request Payload of the count request
* @returns Counters keyed by entity type and status
*/
fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable<FilterCounters> {
private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable<FilterCounters> {
if (!Object.keys(request).length) {
return of({});
}
@@ -225,37 +322,13 @@ export class FilterCountersCloudService extends BaseCloudService {
}
/**
* Reads the counter of a filter from a count response. The counters are keyed by status, so
* the counter of a filter targeting more than one status is the sum of the counters of its statuses.
* A filter targeting every status holds no status to be keyed by, so its counter is not resolved
* by the batched request and is left to be fetched on its own.
*
* @param counters Counters resolved by the batched count endpoint
* @param entityType Entity type of the filter
* @param filter Filter the counter is read for
* @returns The counter of the filter, or `undefined` when the response holds no counter for it
*/
resolveFilterCounter(counters: FilterCounters, entityType: FilterCounterEntityType, filter: FilterCounterCandidate): number | undefined {
const entityCounters = counters?.[entityType];
const statuses = resolveFilterCounterStatuses(filter);
if (!entityCounters || !statuses.length) {
return undefined;
}
const countedStatuses = statuses.filter((status) => entityCounters[status] !== undefined);
return countedStatuses.length ? countedStatuses.reduce((total, status) => total + entityCounters[status], 0) : undefined;
}
/**
* Whether the counter of a filter is resolved by the batched count request. A filter targeting
* every status is not, since the counters of the response are keyed by status.
* 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`
*/
isCounterBatched(filter: FilterCounterCandidate): boolean {
return resolveFilterCounterStatuses(filter).length > 0;
private isCounterBatched(filter: FilterCounterCandidate): boolean {
return !!filter?.key;
}
}
@@ -1,193 +0,0 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { FilterCountersCloudService } from './filter-counters-cloud.service';
import { FilterCounterCandidate, FilterCounterEntityType, FilterCounters } from '../models/filter-counters-cloud.model';
export interface FilterCounterAdapter<TFilter> {
getFilterCounter(filter: TFilter): Observable<number>;
}
export interface FilterCountersManagerCallbacks {
onCountersUpdated?: () => void;
onFilterUpdated?: (filterKey: string) => void;
}
/**
* Manages filter counters for a specific entity type using composition.
* Handles loading, updating, and notification subscriptions for filter counters.
*/
export class FilterCountersManager<TFilter extends FilterCounterCandidate> {
counters: { [key: string]: number } = {};
currentFiltersValues: { [key: string]: number } = {};
updatedFiltersSet = new Set<string>();
private filters: TFilter[] = [];
constructor(
private readonly entityType: FilterCounterEntityType,
private readonly filterCountersService: FilterCountersCloudService,
private readonly counterAdapter: FilterCounterAdapter<TFilter>,
private readonly destroyRef: DestroyRef,
private readonly callbacks: FilterCountersManagerCallbacks = {}
) {}
/**
* Initialize counters for all filters to 0
*
* @param filters List of filters to initialize counters for
*/
initCounters(filters: TFilter[]): void {
this.filters = filters;
filters.forEach((filter) => (this.counters[filter.key] = 0));
}
/**
* Load filter counters on initial page load using the batched endpoint
*
* @param appName Name of the target app
*/
loadCounters(appName: string): void {
this.filterCountersService
.loadFilterCounters(appName)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((counters) => this.applyBatchedCounters(counters));
}
/**
* Subscribe to real-time counter updates via notifications
*
* @param appName Name of the target app
*/
subscribeToNotifications(appName: string): void {
if (!appName) {
return;
}
this.filterCountersService
.getFilterCountersNotifications(appName)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ counters }) => {
this.applyBatchedCounters(counters);
});
}
/**
* Apply counters from the batched response, falling back to individual requests
* for filters not resolved by the batch.
*
* @param counters Batched filter counters
*/
private applyBatchedCounters(counters: FilterCounters): void {
this.filters.forEach((filter) => {
if (!filter?.showCounter) {
return;
}
const filterCounter = this.filterCountersService.resolveFilterCounter(counters, this.entityType, filter);
if (filterCounter === undefined) {
this.updateSingleCounter(filter);
return;
}
this.setCounter(filter.key, filterCounter);
});
this.callbacks.onCountersUpdated?.();
}
/**
* Update counter for a single filter using individual request
*
* @param filter Filter to update the counter for
*/
updateSingleCounter(filter: TFilter): void {
if (!filter?.showCounter) {
return;
}
this.counterAdapter
.getFilterCounter(filter)
.pipe(
tap((filterCounter) => {
this.setCounter(filter.key, filterCounter);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
/**
* Update counters for all filters
*/
updateAllCounters(): void {
this.filters.forEach((filter) => this.updateSingleCounter(filter));
}
/**
* Set counter value and track if it changed
*
* @param filterKey Key of the filter to update
* @param filterValue New counter value for the filter
*/
private setCounter(filterKey: string, filterValue: number): void {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues[filterKey] = filterValue;
this.updatedFiltersSet.add(filterKey);
this.callbacks.onFilterUpdated?.(filterKey);
}
this.counters = {
...this.counters,
[filterKey]: filterValue
};
}
/**
* Check if a filter has been updated
*
* @param filterKey Key of the filter to check
* @returns True if the filter has been updated, false otherwise
*/
isFilterUpdated(filterKey: string): boolean {
return this.updatedFiltersSet.has(filterKey);
}
/**
* Mark a filter as viewed/not updated
*
* @param filterKey Key of the filter to reset
*/
resetFilterUpdate(filterKey: string): void {
this.updatedFiltersSet.delete(filterKey);
}
/**
* Subscribe to external refresh events from the filter service
*
* @param refreshSignal$ Observable emitting filter keys to refresh
*/
subscribeToExternalRefresh(refreshSignal$: Observable<string>): void {
refreshSignal$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => {
this.updatedFiltersSet.delete(filterKey);
});
}
}
@@ -18,7 +18,6 @@
export * from './base-cloud.service';
export * from './cloud-token.service';
export * from './filter-counters-cloud.service';
export * from './filter-counters-manager';
export * from './form-fields.interfaces';
export * from './local-preference-cloud.service';
export * from './notification-cloud.service';
@@ -20,9 +20,9 @@ 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 { TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service';
import { defaultTaskFiltersMock, fakeAllTaskFilter, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock';
import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock';
import { TaskFilterCloudService } from '../../services/task-filter-cloud.service';
import { TaskFiltersCloudComponent } from './task-filters-cloud.component';
import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service';
@@ -36,7 +36,7 @@ import { MatIconHarness } from '@angular/material/icon/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCountersNotification } from '../../../../models/filter-counters-cloud.model';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
class DummyComponent {}
@@ -52,20 +52,18 @@ describe('TaskFiltersCloudComponent', () => {
let getTaskFilterCounterSpy: jasmine.Spy;
let getTaskListFiltersSpy: jasmine.Spy;
let getTaskListCountSpy: jasmine.Spy;
let getFilterCountersNotificationsSpy: jasmine.Spy;
let getEngineEventsSpy: jasmine.Spy;
let filterCountersService: FilterCountersCloudService;
let getFilterCountersSpy: jasmine.Spy;
let refreshFilterCountersSpy: jasmine.Spy;
let router: Router;
const filterCountersNotificationMock: FilterCountersNotification = {
events: taskNotifications,
counters: { TASK: { ASSIGNED: 11, CREATED: 0 } }
};
const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => {
TestBed.configureTestingModule({
imports: [NoopAuthModule, TaskFiltersCloudComponent, ApolloTestingModule],
providers: [
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
provideRouter([{ path: 'task-list-cloud', component: DummyComponent }]),
{
provide: ActivatedRoute,
@@ -87,10 +85,12 @@ describe('TaskFiltersCloudComponent', () => {
filterCountersService = TestBed.inject(FilterCountersCloudService);
getTaskFilterCounterSpy = spyOn(taskFilterService, 'getTaskFilterCounter').and.returnValue(of(11));
getTaskListCountSpy = spyOn(taskListService, 'getTaskListCount').and.returnValue(of(11));
getFilterCountersNotificationsSpy = spyOn(filterCountersService, 'getFilterCountersNotifications').and.returnValue(
of(filterCountersNotificationMock)
getEngineEventsSpy = spyOn(filterCountersService, 'getEngineEvents').and.returnValue(of(taskNotifications));
getTaskListFiltersSpy = spyOn(filterCountersService, 'getTaskFilters').and.returnValue(of(fakeGlobalFilter));
getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue(
of({ counters: { 'fake-involved-tasks': 11 }, batched: true })
);
getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter));
refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters');
appConfigService = TestBed.inject(AppConfigService);
@@ -272,7 +272,7 @@ describe('TaskFiltersCloudComponent', () => {
expect(updatedFilterCounters.length).toBe(0);
});
it('should update filter counter when filter is selected', async () => {
it('should refresh the filter counters when a filter is selected', async () => {
component.showIcons = true;
await bindAppName();
@@ -280,7 +280,7 @@ describe('TaskFiltersCloudComponent', () => {
filterButton.click();
fixture.detectChanges();
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
describe('Notifications config', () => {
@@ -317,12 +317,12 @@ describe('TaskFiltersCloudComponent', () => {
});
it('should not subscribe to notifications when appName is missing', () => {
getFilterCountersNotificationsSpy.calls.reset();
getEngineEventsSpy.calls.reset();
component.appName = '';
fixture.detectChanges();
expect(getFilterCountersNotificationsSpy).not.toHaveBeenCalled();
expect(getEngineEventsSpy).not.toHaveBeenCalled();
});
it('should subscribe to the notifications of the bound app', () => {
@@ -330,45 +330,18 @@ describe('TaskFiltersCloudComponent', () => {
fixture.detectChanges();
expect(getFilterCountersNotificationsSpy).toHaveBeenCalledWith('my-app-1');
expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1');
});
it('should update the counters with the counts resolved by the batched count request', fakeAsync(() => {
const notifications$ = new Subject<FilterCountersNotification>();
getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable());
component.appName = 'my-app-1';
fixture.detectChanges();
notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } });
expect(component.counters['fake-involved-tasks']).toBe(7);
flush();
}));
it('should not update the counters of the filters not resolved by the batched count request', fakeAsync(() => {
const notifications$ = new Subject<FilterCountersNotification>();
getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable());
component.appName = 'my-app-1';
fixture.detectChanges();
component.counters = { ...component.counters, 'fake-my-task1': 3 };
notifications$.next({ events: [], counters: { TASK: { ASSIGNED: 7 } } });
expect(component.counters['fake-my-task1']).toBe(3);
flush();
}));
it('should emit the events of the batch that triggered the count request', fakeAsync(() => {
const notifications$ = new Subject<FilterCountersNotification>();
getFilterCountersNotificationsSpy.and.returnValue(notifications$.asObservable());
it('should emit the events of the debounced batch', fakeAsync(() => {
const events$ = new Subject<any>();
getEngineEventsSpy.and.returnValue(events$.asObservable());
const filterCounterUpdatedSpy = spyOn(component.filterCounterUpdated, 'emit');
component.appName = 'my-app-1';
fixture.detectChanges();
notifications$.next(filterCountersNotificationMock);
events$.next(taskNotifications);
expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications);
flush();
@@ -479,7 +452,7 @@ describe('TaskFiltersCloudComponent', () => {
expect(updatedFilterCounters.length).toBe(0);
});
it('should update filter counter when filter is selected', async () => {
it('should refresh the filter counters when a filter is selected', async () => {
await bindAppName();
const filterButton = await loader.getHarness(
@@ -487,6 +460,14 @@ describe('TaskFiltersCloudComponent', () => {
);
await filterButton.click();
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
it('should resolve the counters with the POST method when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
expect(getTaskListCountSpy).toHaveBeenCalledWith(new TaskFilterCloudAdapter(fakeGlobalFilter[0]));
});
});
@@ -699,104 +680,68 @@ describe('TaskFiltersCloudComponent', () => {
expect(component.updatedCountersSet.has(fakeFilterKey)).toBe(true);
});
it('should call fetchTaskFilterCounter only if filter.showCounter is true', () => {
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true });
const filterWithoutCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false });
const fetchSpy = spyOn<any>(component, 'fetchTaskFilterCounter').and.returnValue(of(42));
getTaskFilterCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(filterWithCounter);
});
describe('Batched counters registration', () => {
let registerFiltersSpy: jasmine.Spy;
describe('Batched counters', () => {
it('should read the counters of the task filters of the bound app', async () => {
await bindAppName();
beforeEach(() => {
registerFiltersSpy = spyOn(filterCountersService, 'registerFilters');
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK);
});
const registeredQueries = () => registerFiltersSpy.calls.mostRecent().args[1];
it('should register every filter with a counter enabled', async () => {
getTaskListFiltersSpy.and.returnValue(
of([
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }),
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' }),
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[2], showCounter: true, sort: 'createdDate', order: 'DESC' })
])
);
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
await bindAppName();
expect(registerFiltersSpy).toHaveBeenCalledWith('TASK', jasmine.any(Array));
expect(registeredQueries().length).toBe(3);
expect(registeredQueries().map((query: any) => query.status)).toEqual([['CREATED'], ['ASSIGNED'], ['COMPLETED']]);
expect(component.counters['fake-involved-tasks']).toBe(9);
});
it('should not register a filter without a counter enabled', async () => {
getTaskListFiltersSpy.and.returnValue(
of([
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: 'createdDate', order: 'DESC' }),
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false, sort: 'createdDate', order: 'DESC' })
])
);
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName();
expect(registeredQueries().length).toBe(1);
expect(registeredQueries()[0].status).toEqual(['CREATED']);
expect(updatedFilterSpy).toHaveBeenCalledWith('fake-involved-tasks');
});
it('should register the full criteria of a filter', async () => {
getTaskListFiltersSpy.and.returnValue(
of([
new TaskFilterCloudModel({
...defaultTaskFiltersMock[1],
showCounter: true,
sort: 'createdDate',
order: 'DESC',
assignee: 'mock-user',
priority: 4
})
])
);
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
const query = registeredQueries()[0];
expect(query.status).toEqual(['ASSIGNED']);
expect(query.assignee).toEqual(['mock-user']);
expect(query.priority).toEqual(['4']);
expect(query.sort).toEqual({ field: 'createdDate', direction: 'desc', isProcessVariable: false });
expect(getTaskFilterCounterSpy).toHaveBeenCalled();
expect(component.counters['fake-involved-tasks']).toBe(11);
});
it('should not register a filter targeting every status', async () => {
getTaskListFiltersSpy.and.returnValue(
of([new TaskFilterCloudModel({ ...fakeAllTaskFilter, showCounter: true, sort: 'createdDate', order: 'DESC' })])
);
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName();
expect(registeredQueries().length).toBe(0);
component.onFilterClick(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
it('should not break the filter list when the query of a filter cannot be built', async () => {
getTaskListFiltersSpy.and.returnValue(
of([
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true, sort: undefined, order: undefined }),
new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: true, sort: 'createdDate', order: 'DESC' })
])
);
it('should refresh the counter of the clicked filter alone when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
getTaskFilterCounterSpy.calls.reset();
expect(component.filters.length).toBe(2);
expect(registeredQueries().length).toBe(1);
expect(registeredQueries()[0].status).toEqual(['ASSIGNED']);
component.onFilterClick(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).not.toHaveBeenCalled();
expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
});
});
@@ -16,7 +16,7 @@
*/
import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { Observable, of } from 'rxjs';
import { 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';
@@ -28,7 +28,6 @@ import { TaskListCloudService } from '../../../task-list/services/task-list-clou
import { TaskFilterCloudAdapter } from '../../../../models/filter-cloud-model';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { FilterCountersManager, FilterCounterAdapter } from '../../../../services/filter-counters-manager';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
@@ -45,7 +44,13 @@ import { AsyncPipe } from '@angular/common';
export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent implements OnInit, OnChanges {
protected readonly TASKS_ROUTE = '/task-list-cloud';
/** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count. */
/**
* (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,
* along with the 'GET' method, in ADF 10.0.0.
*/
@Input()
searchApiMethod: 'GET' | 'POST' = 'GET';
@@ -70,8 +75,10 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
currentFilter: TaskFilterCloudModel;
enableNotifications = true;
notificationDebounceTime = 3000;
currentFiltersValues: { [key: string]: number } = {};
private filtersLoadedFor?: string;
private countersManager: FilterCountersManager<TaskFilterCloudModel>;
private countersSubscription?: Subscription;
private batchedCounters = true;
private readonly taskFilterCloudService = inject(TaskFilterCloudService);
private readonly taskListCloudService = inject(TaskListCloudService);
@@ -85,49 +92,17 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
if (!this.countersManager) {
this.initCountersManager();
}
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
this.initFilterCounterNotifications();
this.countersManager.subscribeToExternalRefresh(this.taskFilterCloudService.filterKeyToBeRefreshed$);
}
private initCountersManager(): void {
const counterAdapter: FilterCounterAdapter<TaskFilterCloudModel> = {
getFilterCounter: (filter) =>
this.searchApiMethod === 'POST'
? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
: this.taskFilterCloudService.getTaskFilterCounter(filter)
};
this.countersManager = new FilterCountersManager(
FilterCounterEntityType.TASK,
this.filterCountersCloudService,
counterAdapter,
this.destroyRef,
{
onFilterUpdated: (filterKey) => {
this.updatedFilter.emit(filterKey);
this.counters = this.countersManager.counters;
},
onCountersUpdated: () => {
this.counters = this.countersManager.counters;
}
}
);
this.getFilterKeysAfterExternalRefreshing();
}
ngOnChanges(changes: SimpleChanges) {
const appName = changes['appName'];
const filter = changes['filterParam'];
if (appName && appName.currentValue !== appName.previousValue) {
if (!this.countersManager) {
this.initCountersManager();
}
this.getFilters(appName.currentValue);
} else if (filter && filter.currentValue !== filter.previousValue) {
this.selectFilterAndEmit(filter.currentValue);
@@ -141,18 +116,16 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
const filters$ = this.filterCountersCloudService
.getFilters(appName)
.pipe(map((filters) => filters[FilterCounterEntityType.TASK] as TaskFilterCloudModel[]));
const filters$ = this.filterCountersCloudService.getTaskFilters(appName);
this.filters$ = filters$.pipe(catchError(() => of([])));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
this.resetFilter();
this.filters = res || [];
this.countersManager.initCounters(this.filters);
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
this.countersManager.loadCounters(appName);
this.loadFilterCounters(appName);
this.success.emit(res);
},
error: (err) => {
@@ -161,40 +134,59 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
});
}
/**
* Initialize counter collection for filters
*/
initFilterCounters(): void {
this.filters.forEach((filter) => (this.counters[filter.key] = 0));
}
/**
* Iterate over filters and update counters
*
* @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.
*/
updateFilterCounters(): void {
this.countersManager.updateAllCounters();
this.filters.forEach((filter) => this.updateFilterCounter(filter));
}
/**
* 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.
*/
updateFilterCounter(filter: TaskFilterCloudModel): void {
this.countersManager.updateSingleCounter(filter);
this.counters = this.countersManager.counters;
if (!filter?.showCounter) {
return;
}
this.fetchTaskFilterCounter(filter)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((counter) => {
this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
this.counters = { ...this.counters, [filter.key]: counter };
});
}
initFilterCounterNotifications() {
if (!this.appName || !this.enableNotifications) {
if (!this.appName) {
return;
}
if (!this.enableNotifications) {
this.counters = {};
return;
}
this.filterCountersCloudService
.getFilterCountersNotifications(this.appName)
.getEngineEvents(this.appName)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ events }) => {
events.forEach((taskEvent) => {
this.checkFilterCounter(taskEvent.entity);
});
.subscribe((events) => {
events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity));
this.filterCounterUpdated.emit(events);
});
this.countersManager.subscribeToNotifications(this.appName);
}
checkFilterCounter(filterNotification: TaskDetailsCloudModel) {
@@ -235,7 +227,6 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.selectFilter(newParamFilter);
if (this.currentFilter) {
this.countersManager.resetFilterUpdate(this.currentFilter.key);
this.resetFilterCounter(this.currentFilter.key);
this.filterSelected.emit(this.currentFilter);
}
@@ -252,9 +243,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
onFilterClick(filter: FilterParamsModel) {
if (filter) {
this.selectFilter(filter);
this.updateFilterCounter(this.currentFilter);
this.refreshFilterCounter(this.currentFilter);
this.filterClicked.emit(this.currentFilter);
this.countersManager.resetFilterUpdate(filter.key);
this.updatedCountersSet.delete(filter.key);
} else {
this.currentFilter = undefined;
@@ -280,6 +270,62 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
return this.filters === undefined || (this.filters && this.filters.length === 0);
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue };
this.updatedFilter.emit(filterKey);
this.updatedCountersSet.add(filterKey);
}
}
/**
* Flags the counter of a filter as read whenever the filter is refreshed by an external action
*/
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 {
this.countersSubscription?.unsubscribe();
this.countersSubscription = this.filterCountersCloudService
.getFilterCounters(appName, FilterCounterEntityType.TASK)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ counters, batched }) => {
this.batchedCounters = batched;
if (batched) {
this.applyFilterCounters(counters);
} else {
this.updateFilterCounters();
}
});
}
/**
* Holds the counters resolved by the batched count request, which are keyed by filter key.
*
* @param counters counters keyed by filter key
*/
private applyFilterCounters(counters: { [filterKey: string]: number }): void {
Object.entries(counters).forEach(([filterKey, counter]) => {
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
}
private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable<number> {
return this.searchApiMethod === 'POST'
? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
: this.taskFilterCloudService.getTaskFilterCounter(filter);
}
/**
* Reset the filters properties
*/
@@ -288,13 +334,17 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.currentFilter = undefined;
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) {
if (
this.countersManager.currentFiltersValues[filterKey] === undefined ||
this.countersManager.currentFiltersValues[filterKey] !== filterValue
) {
this.updatedFilter.emit(filterKey);
this.updatedCountersSet.add(filterKey);
/**
* 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);
} else {
this.updateFilterCounter(filter);
}
}
}
@@ -362,7 +362,7 @@ export class TaskFilterCloudService extends BaseCloudService {
}
/**
* @deprecated use FilterCountersCloudService.getFilterCountersNotifications instead, which shares a single
* @deprecated use FilterCountersCloudService.getEngineEvents instead, which shares a single
* subscription with the process filters and resolves the counters with a single request.
* @param appName Name of the target app
* @returns Task engine events