-
-
- {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate : { userName: validateUsersMessage } }}
-
-
-
- {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate : { pattern: getValidationPattern() } }}
-
-
-
-
- {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate : { requiredLength: getValidationMaxLength() } }}
-
-
-
-
-
- {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate : { requiredLength: getValidationMinLength() } }}
-
-
-
-
- {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}
-
-
-
- {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate : { userName: searchedValue } }}
-
+
+ @if (validationLoading) {
+
+ }
+ @if (showErrors) {
+ @if (hasPreselectError() && !isValidationLoading()) {
+
+
+ {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: validateUsersMessage } }}
+
+ }
+ @if (searchUserCtrl.hasError('pattern')) {
+
+
+
+ {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
+
+
+ }
+ @if (searchUserCtrl.hasError('maxlength')) {
+
+
+
+ {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
+
+
+ }
+ @if (searchUserCtrl.hasError('minlength')) {
+
+
+
+ {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}
+
+
+ }
+ @if ((searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()) {
+
+
+ {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}
+
+ }
+ @if (searchUserCtrl.hasError('searchTypingError') && !this.isFocused) {
+
+
+ {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: searchedValue } }}
+
+ }
+ }
diff --git a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.scss b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.scss
index 668683bfc0..a4ed4e3cdb 100644
--- a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.scss
+++ b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.scss
@@ -66,6 +66,10 @@
@include mixins.adf-error-icon;
}
+ .adf-error {
+ padding-top: 3px;
+ }
+
.adf-error-animate {
animation: adf-people-cloud-slide-in-down 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
}
diff --git a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.spec.ts
index 3b99609dd0..98381d0c34 100644
--- a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.spec.ts
@@ -27,6 +27,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormFieldHarness } from '@angular/material/form-field/testing';
+import { MatProgressBar } from '@angular/material/progress-bar';
import { IdentityUserService } from '../services/identity-user.service';
describe('PeopleCloudComponent', () => {
@@ -99,6 +100,19 @@ describe('PeopleCloudComponent', () => {
expect(await inputField.getLabel()).toEqual('TITLE_KEY');
});
+ it('should use dynamic form field subscript sizing by default', () => {
+ expect(component.formFieldSubscriptSizing).toBe('dynamic');
+ });
+
+ it('should render validation progress inside the reserved status area', () => {
+ component.validationLoading = true;
+ fixture.detectChanges();
+
+ const progressBar = fixture.debugElement.query(By.directive(MatProgressBar));
+
+ expect(progressBar.parent.classes['adf-error-messages-container']).toBeTrue();
+ });
+
describe('Search user', () => {
beforeEach(() => {
fixture.detectChanges();
diff --git a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.ts b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.ts
index 5a11dd42e7..62a47a8af9 100644
--- a/lib/process-services-cloud/src/lib/people/components/people-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/people/components/people-cloud.component.ts
@@ -169,7 +169,7 @@ export class PeopleCloudComponent implements OnInit, OnChanges, AfterViewInit {
* Material form field subscript sizing (fixed / dynamic)
*/
@Input()
- formFieldSubscriptSizing: SubscriptSizing = 'fixed';
+ formFieldSubscriptSizing: SubscriptSizing = 'dynamic';
/**
* Show errors under the form field
diff --git a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts
index 8c90e5f42a..b3ca4b78f7 100644
--- a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.spec.ts
@@ -16,15 +16,15 @@
*/
import { Component, SimpleChange } from '@angular/core';
-import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
+import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
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 } from '@alfresco/adf-core';
+import { AppConfigService, AppConfigServiceMock, NoopAuthModule } from '@alfresco/adf-core';
import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service';
import { ApolloTestingModule } from 'apollo-angular/testing';
import { HarnessLoader } from '@angular/cdk/testing';
@@ -32,39 +32,39 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatIconHarness } from '@angular/material/icon/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
-import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
+import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
+import { FilterCounterEntityType, FilterCountersResult } from '../../../../models/filter-counters-cloud.model';
+import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
class DummyComponent {}
const ProcessFilterCloudServiceMock = {
getProcessFilters: () => of(mockProcessFilters),
- getProcessNotificationSubscription: () => of([]),
filterKeyToBeRefreshed$: of(mockProcessFilters[0].key)
};
describe('ProcessFiltersCloudComponent', () => {
let processFilterService: ProcessFilterCloudService;
+ let filterCountersService: FilterCountersCloudService;
+ let processListService: ProcessListCloudService;
let component: ProcessFiltersCloudComponent;
let fixture: ComponentFixture
;
let getProcessFiltersSpy: jasmine.Spy;
- let getProcessNotificationSubscriptionSpy: jasmine.Spy;
+ let getFilterCountersSpy: jasmine.Spy;
+ let refreshFilterCountersSpy: jasmine.Spy;
+ let getProcessCounterSpy: jasmine.Spy;
let loader: HarnessLoader;
let router: Router;
const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => {
TestBed.configureTestingModule({
- imports: [ProcessFiltersCloudComponent, ApolloTestingModule],
+ imports: [NoopAuthModule, ProcessFiltersCloudComponent, ApolloTestingModule],
providers: [
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
+ { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: AppConfigService, useClass: AppConfigServiceMock },
- {
- provide: ProcessListCloudService,
- useValue: {
- getProcessCounter: () => of(10),
- getProcessListCount: () => of(10)
- }
- },
+ ProcessListCloudService,
{ provide: ProcessFilterCloudService, useValue: ProcessFilterCloudServiceMock },
provideRouter([{ path: 'process-list-cloud', component: DummyComponent }]),
{
@@ -88,11 +88,16 @@ describe('ProcessFiltersCloudComponent', () => {
component.searchApiMethod = searchApiMethod;
processFilterService = TestBed.inject(ProcessFilterCloudService);
+ filterCountersService = TestBed.inject(FilterCountersCloudService);
+ processListService = TestBed.inject(ProcessListCloudService);
TestBed.inject(ActivatedRoute);
router = TestBed.inject(Router);
await RouterTestingHarness.create();
- getProcessFiltersSpy = spyOn(processFilterService, 'getProcessFilters').and.returnValue(of(mockProcessFilters));
- getProcessNotificationSubscriptionSpy = spyOn(processFilterService, 'getProcessNotificationSubscription').and.returnValue(of([]));
+ 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));
};
const bindAppName = async (appName = 'my-app-1') => {
@@ -463,17 +468,98 @@ 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(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', () => {
+ 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, false);
+ });
+
+ it('should not ask for the batched count endpoint by default', async () => {
+ await bindAppName('mock-app-name');
+
+ expect(component.useBatchedCounters).toBeFalse();
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
+ });
+
+ it('should ask for the batched count endpoint when the input is set', async () => {
+ fixture.componentRef.setInput('useBatchedCounters', true);
+
+ await bindAppName('mock-app-name');
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true);
+ });
+
+ 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 resolve the counters of the filters the batch left out on their own', async () => {
+ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
+
+ await bindAppName('mock-app-name');
+
+ expect(component.counters['FakeRunningProcesses']).toBe(9);
+ expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']);
+ });
+
+ it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
+ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
+ getProcessCounterSpy.and.throwError('the query of the filter cannot be built');
+
+ await bindAppName('mock-app-name');
+
+ expect(component.counters['FakeRunningProcesses']).toBe(9);
+ expect(component.counters['completed-processes']).toBe(0);
+ });
+
+ 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', () => {
@@ -507,39 +593,436 @@ describe('ProcessFiltersCloudComponent', () => {
expect(component.notificationDebounceTime).toBe(5000);
});
- it('should debounce notification subscription using the configured debounce time', fakeAsync(() => {
- const notifications$ = new Subject();
- getProcessNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable());
+ it('should keep the counters in sync with the counters stream', fakeAsync(() => {
+ const counters$ = new Subject();
+ getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
+ component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
- const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters');
-
- notifications$.next([]);
- tick(1000);
- expect(updateFilterCountersSpy).not.toHaveBeenCalled();
-
- tick(2000);
- expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1);
+ counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true });
+ expect(component.counters['FakeRunningProcesses']).toBe(7);
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(getProcessNotificationSubscriptionSpy).toHaveBeenCalled();
+
+ 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(getProcessNotificationSubscriptionSpy).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, false);
+ });
+
+ it('should not ask for the batched count endpoint by default', async () => {
+ await bindAppName('mock-app-name');
+
+ expect(component.useBatchedCounters).toBeFalse();
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
+ });
+
+ it('should ask for the batched count endpoint when the input is set', async () => {
+ fixture.componentRef.setInput('useBatchedCounters', true);
+
+ await bindAppName('mock-app-name');
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true);
+ });
+
+ 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 resolve the counters of the filters the batch left out on their own', async () => {
+ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
+
+ await bindAppName('mock-app-name');
+
+ expect(component.counters['FakeRunningProcesses']).toBe(9);
+ expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']);
+ });
+
+ it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
+ getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
+ getProcessCounterSpy.and.throwError('the query of the filter cannot be built');
+
+ await bindAppName('mock-app-name');
+
+ expect(component.counters['FakeRunningProcesses']).toBe(9);
+ expect(component.counters['completed-processes']).toBe(0);
+ });
+
+ 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();
+ getFilterCountersSpy.and.returnValue(counters$.asObservable());
+ component.appName = 'mock-app-name';
+
+ fixture.detectChanges();
+ component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
+
+ 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();
+ getFilterCountersSpy.and.returnValue(counters$.asObservable());
+ component.appName = 'mock-app-name';
+
+ fixture.detectChanges();
+ component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
+ 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, false);
});
it('should emit filter key when filter counter is set for first time', () => {
diff --git a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts
index 9e7fc7ba5f..d7001220d4 100644
--- a/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/process/process-filters/components/process-filters/process-filters-cloud.component.ts
@@ -16,14 +16,16 @@
*/
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
-import { EMPTY, Observable } from 'rxjs';
+import { combineLatest, defer, EMPTY, Observable, of, 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';
import { FilterParamsModel } from '../../../../task/task-filters/models/filter-cloud.model';
-import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators';
+import { catchError, map } from 'rxjs/operators';
import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service';
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 { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe } from '@ngx-translate/core';
import { AsyncPipe } from '@angular/common';
@@ -43,10 +45,21 @@ 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.
+ *
+ */
@Input()
searchApiMethod: 'GET' | 'POST' = 'GET';
+ /**
+ * (optional) Resolves the counters of the task and the process filters with a single call to
+ * `POST /query/v1/count`. Both filter components have to
+ * ask for it, otherwise the counters are resolved one filter at a time.
+ */
+ @Input()
+ useBatchedCounters = false;
+
/** (optional) The filter to be selected by default */
@Input()
filterParam: FilterParamsModel;
@@ -79,27 +92,31 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
currentFilter?: ProcessFilterCloudModel;
filters: ProcessFilterCloudModel[] = [];
counters: { [key: string]: number } = {};
- enableNotifications = true;
- notificationDebounceTime = 3000;
currentFiltersValues: { [key: string]: number } = {};
updatedFiltersSet = new Set();
+ enableNotifications = true;
+ notificationDebounceTime = 3000;
private filtersLoadedFor?: string;
+ private countersSubscription?: Subscription;
+ private countersFilters$?: Observable;
+ private batchedCounters = true;
private readonly destroyRef = inject(DestroyRef);
private readonly processFilterCloudService = inject(ProcessFilterCloudService);
private readonly translationService = inject(TranslationService);
private readonly appConfigService = inject(AppConfigService);
private readonly processListCloudService = inject(ProcessListCloudService);
+ private readonly filterCountersCloudService = inject(FilterCountersCloudService);
private readonly activatedRoute = inject(ActivatedRoute);
protected readonly currentRouteFilterId = toSignal(this.activatedRoute.queryParamMap.pipe(map((params) => params.get('filterId'))));
ngOnInit() {
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
+
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
- this.initProcessNotification();
this.getFilterKeysAfterExternalRefreshing();
}
@@ -110,6 +127,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.getFilters(appName.currentValue);
} else if (filter && filter.currentValue !== filter.previousValue) {
this.selectFilterAndEmit(filter.currentValue);
+ } else if (changes['useBatchedCounters'] && !changes['useBatchedCounters'].firstChange && this.filtersLoadedFor) {
+ this.loadFilterCounters(this.filtersLoadedFor);
}
}
@@ -120,8 +139,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
- const filters$ = this.processFilterCloudService.getProcessFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true }));
- this.filters$ = filters$.pipe(catchError(() => EMPTY));
+ const filters$ = this.filterCountersCloudService.getProcessFilters(appName);
+ this.filters$ = filters$.pipe(catchError(() => of([])));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
@@ -130,19 +149,25 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
this.success.emit(res);
- this.updateFilterCounters();
},
- error: (err: any) => {
+ error: (err: unknown) => {
this.error.emit(err);
}
});
+
+ this.countersFilters$ = filters$;
+ this.loadFilterCounters(appName);
}
/**
* Initialize counter collection for filters
*/
- initFilterCounters() {
- this.filters.forEach((filter) => (this.counters[filter.key] = 0));
+ initFilterCounters(): void {
+ this.filters.forEach((filter) => {
+ if (filter.key) {
+ this.counters[filter.key] = 0;
+ }
+ });
}
/**
@@ -167,20 +192,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
*
@@ -213,7 +224,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
if (filter) {
this.selectFilter(filter);
this.filterClicked.emit(this.currentFilter);
- this.updateFilterCounter(this.currentFilter);
+ this.refreshFilterCounter(this.currentFilter);
this.updatedFiltersSet.delete(filter.key);
} else {
this.currentFilter = undefined;
@@ -247,6 +258,83 @@ 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;
+ }
+
+ /**
+ * @deprecated does nothing: the counters keep themselves in sync. Removed in ADF 10.0.0.
+ */
+ initProcessNotification(): void {}
+
+ /**
+ * Iterate over filters and update counters
+ *
+ * @deprecated counts one filter at a time. Removed in ADF 10.0.0.
+ */
+ updateFilterCounters(): void {
+ this.filters.forEach((filter) => this.updateFilterCounter(filter));
+ }
+
+ /**
+ * Get current value for filter and check if value has changed
+ *
+ * @param filter filter
+ * @deprecated counts one filter at a time. Removed in ADF 10.0.0.
+ */
+ updateFilterCounter(filter: ProcessFilterCloudModel): void {
+ const filterKey = filter?.showCounter ? filter.key : undefined;
+ if (!filterKey) {
+ return;
+ }
+
+ defer(() => this.fetchProcessFilterCounter(filter))
+ .pipe(
+ catchError(() => EMPTY),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe((counter) => {
+ this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
+ this.counters = { ...this.counters, [filterKey]: 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);
+ }
+ }
+
+ /**
+ * Get filer key when filter was refreshed by 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
*/
@@ -255,76 +343,53 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.currentFilter = undefined;
}
- isActiveFilter(filter: ProcessFilterCloudModel): boolean {
- return this.currentFilter.name === filter.name;
- }
-
- initProcessNotification(): void {
- if (this.appName && this.enableNotifications) {
- this.processFilterCloudService
- .getProcessNotificationSubscription(this.appName)
- .pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef))
- .subscribe(() => {
- this.updateFilterCounters();
- });
- }
- }
-
- /**
- * Iterate over filters and update counters
- */
- updateFilterCounters(): void {
- this.filters.forEach((filter: ProcessFilterCloudModel) => {
- this.updateFilterCounter(filter);
- });
- }
-
- /**
- * Get current value for filter and check if value has changed
- *
- * @param filter filter
- */
- updateFilterCounter(filter: ProcessFilterCloudModel): void {
- if (!filter?.showCounter) {
+ private loadFilterCounters(appName: string): void {
+ if (!this.countersFilters$) {
return;
}
- this.fetchProcessFilterCounter(filter)
- .pipe(
- tap((filterCounter) => {
- this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter);
- })
- )
- .subscribe((data) => {
- this.counters = {
- ...this.counters,
- [filter.key]: data
- };
+ this.countersSubscription?.unsubscribe();
+ this.countersSubscription = combineLatest([
+ this.countersFilters$.pipe(catchError(() => of([]))),
+ this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, this.useBatchedCounters)
+ ])
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(([, { counters, batched }]) => {
+ this.batchedCounters = batched;
+ if (batched) {
+ this.applyFilterCounters(counters);
+ } else {
+ this.updateFilterCounters();
+ }
});
}
- checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void {
- if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
- this.currentFiltersValues[filterKey] = filterValue;
- this.updatedFilter.emit(filterKey);
- this.updatedFiltersSet.add(filterKey);
- }
- }
+ private applyFilterCounters(counters: { [filterKey: string]: number }): void {
+ this.filters.forEach((filter) => {
+ const filterKey = filter?.showCounter ? filter.key : undefined;
+ if (!filterKey) {
+ return;
+ }
- isFilterUpdated(filterName: string): boolean {
- return this.updatedFiltersSet.has(filterName);
- }
+ const counter = counters[filterKey];
+ if (counter === undefined) {
+ this.updateFilterCounter(filter);
+ return;
+ }
- /**
- * Get filer key when filter was refreshed by external action
- *
- */
- getFilterKeysAfterExternalRefreshing(): void {
- this.processFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => {
- this.updatedFiltersSet.delete(filterKey);
+ this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
+ this.counters = { ...this.counters, [filterKey]: counter };
});
}
+ private refreshFilterCounter(filter?: ProcessFilterCloudModel): void {
+ if (this.batchedCounters) {
+ this.filterCountersCloudService.refreshFilterCounters(this.appName);
+ } else if (filter) {
+ this.updateFilterCounter(filter);
+ }
+ }
+
private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable {
return this.searchApiMethod === 'POST'
? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter))
diff --git a/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts b/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts
index 316933cba6..9839470ea2 100644
--- a/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts
+++ b/lib/process-services-cloud/src/lib/process/process-filters/services/process-filter-cloud.service.ts
@@ -404,6 +404,12 @@ export class ProcessFilterCloudService {
];
}
+ /**
+ * @deprecated use FilterCountersCloudService.getEngineEvents instead.
+ *
+ * @param appName Name of the target app
+ * @returns Process engine events
+ */
getProcessNotificationSubscription(appName: string): Observable {
return this.notificationCloudService
.makeGQLQuery(appName, PROCESS_EVENT_SUBSCRIPTION_QUERY)
diff --git a/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts b/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts
index 84c5a97cfb..8316bf1f09 100644
--- a/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts
+++ b/lib/process-services-cloud/src/lib/process/process-list/services/process-list-cloud.service.ts
@@ -100,7 +100,7 @@ export class ProcessListCloudService extends BaseCloudService {
);
}
- protected buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } {
+ buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } {
const queryData: { [key: string]: any } = {
name: requestNode.name,
id: requestNode.id,
diff --git a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts
index 71136e5c5b..0f9559bc03 100755
--- a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.spec.ts
@@ -17,8 +17,8 @@
import { DebugElement, SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
-import { FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
-import { of, throwError } from 'rxjs';
+import { ADF_DISPLAY_TEXT_SETTINGS, FormFieldTypes, FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
+import { Subject, of, throwError } from 'rxjs';
import { StartProcessCloudService } from '../services/start-process-cloud.service';
import { FormCloudService } from '../../../form/services/form-cloud.service';
import { FormCloudComponent } from '../../../form/components/form-cloud.component';
@@ -47,7 +47,7 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
-import { FormCloudDisplayMode } from '../../../services/form-fields.interfaces';
+import { FormCloudDisplayMode, FormContent } from '../../../services/form-fields.interfaces';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatDialog } from '@angular/material/dialog';
import { ReactiveFormsModule } from '@angular/forms';
@@ -92,7 +92,10 @@ describe('StartProcessCloudComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [StartProcessCloudComponent, ReactiveFormsModule, StartProcessScreenCloudComponent],
- providers: [provideScreen(screenId, MockedTaskScreenCloudComponent)]
+ providers: [
+ provideScreen(screenId, MockedTaskScreenCloudComponent),
+ { provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }
+ ]
});
processService = TestBed.inject(StartProcessCloudService);
formCloudService = TestBed.inject(FormCloudService);
@@ -417,6 +420,54 @@ describe('StartProcessCloudComponent', () => {
expect(startBtn.disabled).toBe(false);
});
+ it('should keep the start action unavailable while the form definition is loading', async () => {
+ const formDefinition = new Subject();
+ formDefinitionSpy.and.returnValue(formDefinition);
+ typeValueInto('[data-automation-id="adf-inplace-input"]', 'My new process with form');
+ await selectOptionByName('processwithform');
+
+ const startButton = fixture.nativeElement.querySelector('#button-start');
+ expect(startButton).toBeNull();
+ expect(startProcessWithFormSpy).not.toHaveBeenCalled();
+ });
+
+ it('should materialize unrendered rich text expressions when starting a process', async () => {
+ formDefinitionSpy.and.returnValue(of(fakeStartForm));
+ component.processDefinitionCurrent = fakeProcessDefinitions[2];
+ component.processPayloadCloud.processDefinitionKey = fakeProcessDefinitions[2].key;
+ component.processInstanceName.setValue('My process');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const form = new FormModel({
+ fields: [
+ {
+ id: 'richText',
+ type: FormFieldTypes.DISPLAY_RICH_TEXT,
+ value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] }
+ },
+ { id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
+ ]
+ });
+ const formElement = fixture.debugElement.query(By.css('adf-cloud-form'));
+ const startButton = fixture.debugElement.query(By.css('#button-start'));
+
+ formElement.triggerEventHandler('formLoaded', form);
+ fixture.detectChanges();
+ startButton.triggerEventHandler('click', null);
+
+ expect(startProcessWithFormSpy).toHaveBeenCalledWith(
+ component.appName,
+ fakeProcessDefinitions[2].formKey,
+ fakeProcessDefinitions[2].version,
+ jasmine.objectContaining({
+ values: jasmine.objectContaining({
+ richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] }
+ })
+ })
+ );
+ });
+
it('should be able to start a process with form full display mode', async () => {
component.displayModeConfigurations = [
{
diff --git a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts
index c06299d703..40126c5722 100755
--- a/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/process/start-process/components/start-process-cloud.component.ts
@@ -30,10 +30,14 @@ import {
ViewEncapsulation
} from '@angular/core';
import {
+ ADF_DISPLAY_TEXT_SETTINGS,
ConfirmDialogComponent,
ContentLinkModel,
+ DisplayTextWidgetSettings,
+ FormExpressionService,
FormModel,
FormOutcomeEvent,
+ FormValues,
IconModule,
InplaceFormInputComponent,
LocalizedDatePipe,
@@ -65,6 +69,7 @@ import { FormCustomOutcomesComponent } from '../../../form/components/form-cloud
import { MatDialog } from '@angular/material/dialog';
import { StartProcessScreenCloudComponent } from '../../../screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component';
import { TaskTypeResolverService } from '../../../services/task-type-resolver/task-type-resolver.service';
+import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../../../form/services/form-cloud-submission-values';
const MAX_NAME_LENGTH: number = 255;
const PROCESS_DEFINITION_DEBOUNCE: number = 300;
@@ -211,6 +216,11 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
private readonly hasVisibleOutcomesSubject = new BehaviorSubject(false);
private readonly dialog = inject(MatDialog);
private readonly taskTypeResolverService = inject(TaskTypeResolverService);
+ private readonly expressions = inject(FormExpressionService);
+ private readonly displayTextSettings = inject | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, {
+ optional: true
+ });
+ private enableExpressionEvaluation = false;
private screenSubmitPayload: unknown;
@@ -218,8 +228,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
showCompleteButton = false;
get isProcessFormValid(): boolean {
- if (this.hasForm && this.isFormCloudLoaded) {
- return (this.formCloud ? !Object.keys(this.formCloud.values).length : false) || this.formCloud?.isValid || this.isProcessStarting;
+ if (this.hasForm) {
+ if (!this.isFormCloudLoaded || !this.formCloud) {
+ return false;
+ }
+
+ return !Object.keys(this.formCloud.values).length || this.formCloud.isValid || this.isProcessStarting;
} else if (this.hasScreen) {
return true;
} else {
@@ -268,6 +282,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
constructor() {
this.startProcessButtonLabel = this.defaultStartProcessButtonLabel;
this.cancelButtonLabel = this.defaultCancelProcessButtonLabel;
+
+ getExpressionEvaluationEnabled$(this.displayTextSettings)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((enabled) => {
+ this.enableExpressionEvaluation = enabled;
+ });
}
ngOnInit() {
@@ -482,6 +502,14 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
}
startProcessWithoutConfirmation() {
+ let submissionValues = this.screenSubmitPayload;
+ if (this.hasForm) {
+ if (!this.formCloud) {
+ return;
+ }
+ submissionValues = this.getFormSubmissionValues(this.formCloud);
+ }
+
this.isProcessStarting = true;
let action: Observable;
@@ -495,7 +523,7 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
processName: this.processInstanceName.value,
processDefinitionKey: this.processPayloadCloud.processDefinitionKey,
variables: this.variables ?? {},
- values: this.hasForm ? this.formCloud.values : this.screenSubmitPayload,
+ values: submissionValues,
outcome: this.customOutcomeName
})
);
@@ -524,6 +552,10 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
});
}
+ private getFormSubmissionValues(form: FormModel): FormValues {
+ return materializeSubmissionValues(form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions);
+ }
+
startProcess() {
if (!this.formCloud?.confirmMessage?.show) {
this.startProcessWithoutConfirmation();
diff --git a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.spec.ts
new file mode 100644
index 0000000000..f1135da80e
--- /dev/null
+++ b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.spec.ts
@@ -0,0 +1,198 @@
+/*!
+ * @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 { Component, ComponentRef, OnDestroy } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { BaseScreenCloudComponent } from './base-screen-cloud.component';
+import { provideScreen } from '../../../services/provide-screen';
+import { ScreenRenderingService } from '../../../services/screen-rendering.service';
+
+@Component({
+ selector: 'adf-cloud-test-dynamic-screen',
+ template: `dynamic screen
`
+})
+class TestDynamicScreenComponent implements OnDestroy {
+ destroyed = false;
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ }
+}
+
+@Component({
+ selector: 'adf-cloud-test-host-screen',
+ template: ``
+})
+class TestHostScreenComponent extends BaseScreenCloudComponent {
+ setInputsCalls: ComponentRef[] = [];
+ subscribeToOutputsCalls: ComponentRef[] = [];
+
+ get dynamicComponentRef(): ComponentRef | undefined {
+ return this.componentRef;
+ }
+
+ get dynamicComponentRefSignalValue(): ComponentRef | undefined {
+ return this.componentRefChanged();
+ }
+
+ protected override setInputsForDynamicComponent(componentRef: ComponentRef): void {
+ this.setInputsCalls.push(componentRef);
+ }
+
+ protected subscribeToOutputs(componentRef: ComponentRef): void {
+ this.subscribeToOutputsCalls.push(componentRef);
+ }
+}
+
+/** Same host component, but without the `#container` anchor in its template. */
+@Component({
+ selector: 'adf-cloud-test-host-screen-without-container',
+ template: ``
+})
+class TestHostScreenWithoutContainerComponent extends TestHostScreenComponent {}
+
+describe('BaseScreenCloudComponent', () => {
+ const screenId = 'test-screen';
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestHostScreenComponent, TestHostScreenWithoutContainerComponent, TestDynamicScreenComponent],
+ providers: [provideScreen(screenId, TestDynamicScreenComponent)]
+ });
+ });
+
+ describe('when a screenId is provided', () => {
+ let fixture: ComponentFixture;
+ let component: TestHostScreenComponent;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestHostScreenComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('screenId', screenId);
+ fixture.detectChanges();
+ });
+
+ it('should create the dynamic component and expose it through the signal', () => {
+ expect(component.dynamicComponentRef).toBeDefined();
+ expect(component.dynamicComponentRefSignalValue).toBe(component.dynamicComponentRef);
+ expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeTruthy();
+ });
+
+ it('should wire inputs and outputs once, passing the created component reference', () => {
+ expect(component.setInputsCalls).toEqual([component.dynamicComponentRef!]);
+ expect(component.subscribeToOutputsCalls).toEqual([component.dynamicComponentRef!]);
+ });
+
+ it('should destroy the dynamic component reference on destroy', () => {
+ const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy').and.callThrough();
+
+ fixture.destroy();
+
+ expect(destroySpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should run the ngOnDestroy hook of the dynamic component on destroy', () => {
+ const dynamicComponentInstance = component.dynamicComponentRef?.instance;
+ expect(dynamicComponentInstance?.destroyed).toBeFalse();
+
+ fixture.destroy();
+
+ expect(dynamicComponentInstance?.destroyed).toBeTrue();
+ });
+
+ it('should clear the dynamic component reference and the signal on destroy', () => {
+ fixture.destroy();
+
+ expect(component.dynamicComponentRef).toBeUndefined();
+ expect(component.dynamicComponentRefSignalValue).toBeUndefined();
+ });
+
+ it('should destroy the dynamic component reference only once when ngOnDestroy runs again', () => {
+ const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy');
+
+ component.ngOnDestroy();
+ component.ngOnDestroy();
+
+ expect(destroySpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when no screenId is provided', () => {
+ let fixture: ComponentFixture;
+ let component: TestHostScreenComponent;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestHostScreenComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should not create any dynamic component nor wire inputs and outputs', () => {
+ expect(component.dynamicComponentRef).toBeUndefined();
+ expect(component.dynamicComponentRefSignalValue).toBeUndefined();
+ expect(component.setInputsCalls).toEqual([]);
+ expect(component.subscribeToOutputsCalls).toEqual([]);
+ expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeNull();
+ });
+
+ it('should not throw on destroy', () => {
+ expect(() => fixture.destroy()).not.toThrow();
+ expect(component.dynamicComponentRef).toBeUndefined();
+ });
+ });
+
+ describe('when the container anchor is missing', () => {
+ let fixture: ComponentFixture;
+ let component: TestHostScreenWithoutContainerComponent;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestHostScreenWithoutContainerComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('screenId', screenId);
+ });
+
+ it('should not throw and should not create any dynamic component', () => {
+ expect(() => fixture.detectChanges()).not.toThrow();
+
+ expect(component.container).toBeUndefined();
+ expect(component.dynamicComponentRef).toBeUndefined();
+ expect(component.dynamicComponentRefSignalValue).toBeUndefined();
+ });
+
+ it('should not wire inputs and outputs when no dynamic component was created', () => {
+ fixture.detectChanges();
+
+ expect(component.setInputsCalls).toEqual([]);
+ expect(component.subscribeToOutputsCalls).toEqual([]);
+ });
+
+ it('should not resolve any component type', () => {
+ const resolveComponentTypeSpy = spyOn(TestBed.inject(ScreenRenderingService), 'resolveComponentType').and.callThrough();
+
+ fixture.detectChanges();
+
+ expect(resolveComponentTypeSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not throw on destroy', () => {
+ fixture.detectChanges();
+
+ expect(() => fixture.destroy()).not.toThrow();
+ });
+ });
+});
diff --git a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.ts b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.ts
index b00f85c4da..7da72468dc 100644
--- a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/base-screen/base-screen-cloud.component.ts
@@ -15,20 +15,20 @@
* limitations under the License.
*/
-import { Component, ComponentRef, inject, Input, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
+import { Component, ComponentRef, inject, Input, OnDestroy, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
@Component({
template: ''
})
-export abstract class BaseScreenCloudComponent implements OnInit {
+export abstract class BaseScreenCloudComponent implements OnInit, OnDestroy {
@Input()
screenId: string = '';
@ViewChild('container', { read: ViewContainerRef, static: true })
- container: ViewContainerRef;
+ container: ViewContainerRef | undefined;
- protected componentRef: ComponentRef;
+ protected componentRef: ComponentRef | undefined;
private readonly _componentRefChanged = signal | undefined>(undefined);
protected readonly componentRefChanged = this._componentRefChanged.asReadonly();
protected readonly screenRenderingService = inject(ScreenRenderingService);
@@ -37,17 +37,27 @@ export abstract class BaseScreenCloudComponent imple
this.createDynamicComponent();
}
- private createDynamicComponent(): void {
- if (this.screenId) {
- const componentType = this.screenRenderingService.resolveComponentType({ type: this.screenId });
- this.componentRef = this.container.createComponent(componentType);
- this._componentRefChanged.set(this.componentRef);
- this.setInputsForDynamicComponent();
- this.subscribeToOutputs();
- }
+ ngOnDestroy(): void {
+ this.componentRef?.destroy();
+ this.componentRef = undefined;
+ this._componentRefChanged.set(undefined);
}
- protected setInputsForDynamicComponent(): void {}
+ private createDynamicComponent(): void {
+ if (!this.screenId || !this.container) {
+ return;
+ }
- protected abstract subscribeToOutputs(): void;
+ const componentType = this.screenRenderingService.resolveComponentType({ type: this.screenId });
+ const componentRef: ComponentRef = this.container.createComponent(componentType);
+
+ this.componentRef = componentRef;
+ this._componentRefChanged.set(componentRef);
+ this.setInputsForDynamicComponent(componentRef);
+ this.subscribeToOutputs(componentRef);
+ }
+
+ protected setInputsForDynamicComponent(_componentRef: ComponentRef): void {}
+
+ protected abstract subscribeToOutputs(componentRef: ComponentRef): void;
}
diff --git a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.spec.ts
index 937d1ad536..da37f04b9e 100644
--- a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.spec.ts
@@ -16,6 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component, Input, OnDestroy, input, output } from '@angular/core';
import { StartProcessScreenCloudComponent } from './start-process-screen-cloud.component';
import { MockedTaskScreenCloudComponent } from '../../../../testing/start-process-screen-mock.component';
import { provideScreen } from '../../../services/provide-screen';
@@ -66,11 +67,11 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set appName', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
- expect(screenInstance.appName()).toEqual('');
+ expect(screenInstance.appName?.()).toEqual('');
const newValue = 'new-app-name';
fixture.componentRef.setInput('appName', newValue);
fixture.detectChanges();
- expect(screenInstance.appName()).toEqual(newValue);
+ expect(screenInstance.appName?.()).toEqual(newValue);
});
it('should set process definition id', () => {
@@ -84,10 +85,125 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set resolvedValues', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
- expect(screenInstance.resolvedValues()).toBeUndefined();
+ expect(screenInstance.resolvedValues?.()).toBeUndefined();
const newValues = [new TaskVariableCloud({ id: 'new-id', name: 'new-name' })];
fixture.componentRef.setInput('resolvedValues', newValues);
fixture.detectChanges();
- expect(screenInstance.resolvedValues()).toEqual(newValues);
+ expect(screenInstance.resolvedValues?.()).toEqual(newValues);
+ });
+});
+
+@Component({
+ selector: 'adf-cloud-destroy-tracking-screen',
+ template: `screen
`
+})
+class DestroyTrackingScreenComponent implements StartProcessScreenCloud, OnDestroy {
+ readonly appName = input('');
+ processDefinitionId = input('');
+ readonly resolvedValues = input();
+ defaultStartProcessButtonsConfigurationChange = output();
+ startProcessPayloadChanged = output();
+
+ destroyed = false;
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ }
+}
+
+@Component({
+ selector: 'adf-cloud-test-start-process-wrapper',
+ template: `
+ @if (showScreen) {
+
+ }
+ `,
+ imports: [StartProcessScreenCloudComponent]
+})
+class TestStartProcessWrapperComponent {
+ @Input() screenId = '';
+ showScreen = true;
+}
+
+describe('StartProcessScreenCloudComponent - destroy', () => {
+ let fixture: ComponentFixture;
+ let component: TestStartProcessWrapperComponent;
+ const screenId = 'screen-1234-5678-121212-123456';
+
+ const getScreenInstance = (): DestroyTrackingScreenComponent =>
+ fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent)).componentInstance;
+
+ const destroyScreen = () => {
+ component.showScreen = false;
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestStartProcessWrapperComponent],
+ providers: [provideScreen(screenId, DestroyTrackingScreenComponent)]
+ });
+
+ fixture = TestBed.createComponent(TestStartProcessWrapperComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('screenId', screenId);
+ fixture.detectChanges();
+ });
+
+ it('should destroy the screen component when the host is destroyed', () => {
+ const screenInstance = getScreenInstance();
+ expect(screenInstance.destroyed).toBeFalse();
+
+ destroyScreen();
+
+ expect(screenInstance.destroyed).toBeTrue();
+ });
+
+ it('should remove the screen component from the DOM when the host is destroyed', () => {
+ expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeTruthy();
+
+ destroyScreen();
+
+ expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeNull();
+ });
+
+ it('should create a new screen component instance when the host is re-created', () => {
+ const firstInstance = getScreenInstance();
+
+ destroyScreen();
+ component.showScreen = true;
+ fixture.detectChanges();
+
+ const secondInstance = getScreenInstance();
+ expect(secondInstance).not.toBe(firstInstance);
+ expect(secondInstance.destroyed).toBeFalse();
+ expect(secondInstance.processDefinitionId()).toBe('definition-id');
+ });
+});
+
+describe('StartProcessScreenCloudComponent - without screenId', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [StartProcessScreenCloudComponent]
+ });
+ fixture = TestBed.createComponent(StartProcessScreenCloudComponent);
+ });
+
+ it('should not create any screen component and should not throw', () => {
+ expect(() => fixture.detectChanges()).not.toThrow();
+ expect(fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent))).toBeNull();
+ });
+
+ it('should not throw when inputs change or on destroy', () => {
+ fixture.detectChanges();
+
+ expect(() => {
+ fixture.componentRef.setInput('appName', 'new-app-name');
+ fixture.componentRef.setInput('resolvedValues', [new TaskVariableCloud({ id: 'id', name: 'name' })]);
+ fixture.detectChanges();
+ }).not.toThrow();
+ expect(() => fixture.destroy()).not.toThrow();
});
});
diff --git a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.ts b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.ts
index 0f9c94dc81..ce069b6cdb 100644
--- a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, ComponentRef, effect, input, output, signal } from '@angular/core';
import { BaseScreenCloudComponent } from '../base-screen/base-screen-cloud.component';
import { MatCardModule } from '@angular/material/card';
import { CommonModule } from '@angular/common';
@@ -43,7 +43,7 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent {
const componentRef = this.componentRefChanged();
- if (componentRef.instance && 'appName' in componentRef.instance) {
+ if (componentRef?.instance && 'appName' in componentRef.instance) {
componentRef.setInput('appName', this.appName());
}
});
@@ -56,9 +56,9 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent this.screenStartProcessPayloadChange.emit(payload));
- this.componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
+ protected subscribeToOutputs(componentRef: ComponentRef): void {
+ componentRef.instance.startProcessPayloadChanged.subscribe((payload) => this.screenStartProcessPayloadChange.emit(payload));
+ componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
this.showStartProcessButtons.set(config.show);
this.disableStartProcessButton.emit(config.disable);
});
diff --git a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/user-task-screen/screen-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/user-task-screen/screen-cloud.component.spec.ts
index 3bf74e8816..e271a8a6e6 100644
--- a/lib/process-services-cloud/src/lib/screen/components/screen-cloud/user-task-screen/screen-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/screen/components/screen-cloud/user-task-screen/screen-cloud.component.spec.ts
@@ -16,7 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
+import { Component, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
import { TaskScreenCloudComponent } from './screen-cloud.component';
@@ -32,18 +32,22 @@ import { TaskScreenCloudComponent } from './screen-cloud.component';
`
})
-class TestComponent {
+class TestComponent implements OnDestroy {
@Input() taskId = '';
@Input() screenId = '';
@Input() rootProcessInstanceId = '';
@Output() taskCompleted = new EventEmitter();
- displayMode: string;
+ displayMode: string | undefined;
+ destroyed = false;
onComplete() {
this.taskCompleted.emit();
}
switchToDisplayMode(newDisplayMode?: string) {
this.displayMode = newDisplayMode;
}
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ }
}
@Component({
@@ -61,7 +65,7 @@ class TestComponent {
})
class TestWrapperComponent {
@Input() screenId = '';
- @ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent;
+ @ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent | undefined;
onTaskCompleted() {}
switchToDisplayMode(newDisplayMode?: string): void {
if (this.adfCloudTaskScreen) {
@@ -118,6 +122,118 @@ describe('TaskScreenCloudComponent', () => {
component.switchToDisplayMode();
fixture.detectChanges();
- expect(component.adfCloudTaskScreen.switchToDisplayMode).toHaveBeenCalled();
+ expect(component.adfCloudTaskScreen?.switchToDisplayMode).toHaveBeenCalled();
+ });
+});
+
+@Component({
+ selector: 'adf-cloud-test-conditional-component',
+ template: `
+ @if (showTaskScreen) {
+