- @for (row of currentRootElement.field.rows; track row; let rowIndex = $index) {
+ @for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1;
- @for (column of row.columns; track column; let columnIndex = $index) {
+ @for (column of row.columns; track column.id; let columnIndex = $index) {
- @for (field of column?.fields; track field) {
+ @for (field of column?.fields; track field.id) {
@if (field.type === 'section') {
} @else {
diff --git a/lib/core/src/lib/form/components/form-renderer.component.scss b/lib/core/src/lib/form/components/form-renderer.component.scss
index 956c09af4d..0e906f4d26 100644
--- a/lib/core/src/lib/form/components/form-renderer.component.scss
+++ b/lib/core/src/lib/form/components/form-renderer.component.scss
@@ -21,10 +21,10 @@
width: auto;
}
- .adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
- #{ms.$mat-form-field-subscript-wrapper} {
- height: 40px;
- }
+ .adf-form-field-status-slot {
+ display: block;
+ height: 40px;
+ box-sizing: border-box;
}
}
@@ -33,6 +33,7 @@
.adf-form-tab-content {
margin-top: 1em;
+ padding-bottom: 3px;
}
.adf-form-tab-group {
diff --git a/lib/core/src/lib/form/components/widgets/amount/amount.widget.html b/lib/core/src/lib/form/components/widgets/amount/amount.widget.html
index 00fefd8e34..76104bba6d 100644
--- a/lib/core/src/lib/form/components/widgets/amount/amount.widget.html
+++ b/lib/core/src/lib/form/components/widgets/amount/amount.widget.html
@@ -10,7 +10,11 @@
>
-
+
@if ( (field.name || field?.required) && !field.leftLabels) { {{field.name | translate }} }
@if(!enableDisplayBasedOnLocale) {
{{ currency }}
@@ -32,12 +36,13 @@
(blur)="amountWidgetOnBlur()"
/>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
-
+
error_outline
@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}
}
+
diff --git a/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts b/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts
index 0bac1bb08a..3efe973bbe 100644
--- a/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts
+++ b/lib/core/src/lib/form/components/widgets/core/form-variable.model.ts
@@ -20,4 +20,5 @@ export interface FormVariableModel {
name: string;
type: string;
value?: any;
+ runtimeSet?: boolean;
}
diff --git a/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts b/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts
index f2a834bb54..90d8fcd047 100644
--- a/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts
+++ b/lib/core/src/lib/form/components/widgets/core/form.model.spec.ts
@@ -600,6 +600,33 @@ describe('FormModel', () => {
const missing = form.getProcessVariableValue('missing');
expect(missing).toBeUndefined();
});
+
+ it('should return zero process variable value instead of the form default', () => {
+ const formWithZero = new FormModel({
+ variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }],
+ processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }]
+ });
+
+ expect(formWithZero.getProcessVariableValue('amount')).toBe(0);
+ });
+
+ it('should return false process variable value instead of the form default', () => {
+ const formWithFalse = new FormModel({
+ variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }],
+ processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }]
+ });
+
+ expect(formWithFalse.getProcessVariableValue('flag')).toBe(false);
+ });
+
+ it('should return empty string process variable value instead of the form default', () => {
+ const formWithEmpty = new FormModel({
+ variables: [{ id: 'text-var', name: 'text', type: 'string', value: 'default' }],
+ processVariables: [{ name: 'variables.text', value: '', type: 'string' }]
+ });
+
+ expect(formWithEmpty.getProcessVariableValue('text')).toBe('');
+ });
});
describe('add values not present', () => {
diff --git a/lib/core/src/lib/form/components/widgets/core/form.model.ts b/lib/core/src/lib/form/components/widgets/core/form.model.ts
index 2fea8ce8dd..1d53f2f2d1 100644
--- a/lib/core/src/lib/form/components/widgets/core/form.model.ts
+++ b/lib/core/src/lib/form/components/widgets/core/form.model.ts
@@ -307,22 +307,17 @@ export class FormModel implements ProcessFormModel {
* @returns process variable value
*/
getProcessVariableValue(name: string): any {
- let value;
if (this.processVariables?.length) {
const names = [`variables.${name}`, name];
const processVariable = this.processVariables.find((entry) => names.includes(entry.name));
if (processVariable) {
- value = this.parseValue(processVariable.type, processVariable.value);
+ return this.parseValue(processVariable.type, processVariable.value);
}
}
- if (!value) {
- value = this.getDefaultFormVariableValue(name);
- }
-
- return value;
+ return this.getDefaultFormVariableValue(name);
}
protected parseValue(type: string, value: any): any {
@@ -535,9 +530,20 @@ export class FormModel implements ProcessFormModel {
const variable = this.getFormVariable(variableId);
if (variable) {
variable.value = value;
+ variable.runtimeSet = true;
}
}
+ /**
+ * Checks whether a form variable has been given a value at runtime, for example by a form rule.
+ *
+ * @param identifier The `name` or `id` value
+ * @returns `true` when the value was set at runtime rather than coming from the form definition
+ */
+ isVariableSetAtRuntime(identifier: string): boolean {
+ return !!this.getFormVariable(identifier)?.runtimeSet;
+ }
+
private loadInjectedFieldValidators(injectedFieldValidators: FormFieldValidator[]): void {
this.fieldValidators = injectedFieldValidators ? [...FORM_FIELD_VALIDATORS, ...injectedFieldValidators] : [...FORM_FIELD_VALIDATORS];
}
diff --git a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.html b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.html
index d1c5a7b33b..1457221133 100644
--- a/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.html
+++ b/lib/core/src/lib/form/components/widgets/date-time/date-time.widget.html
@@ -10,6 +10,7 @@
@if( (field.name || field?.required) && !field.leftLabels) {
@@ -38,11 +39,12 @@
[timeInterval]="5"
[disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
-
+
error_outline
{{ field.validationSummary.message | translate:translateParameters }}
}
+
diff --git a/lib/core/src/lib/form/components/widgets/date/date.widget.html b/lib/core/src/lib/form/components/widgets/date/date.widget.html
index f36c6ae24e..8633b8db24 100644
--- a/lib/core/src/lib/form/components/widgets/date/date.widget.html
+++ b/lib/core/src/lib/form/components/widgets/date/date.widget.html
@@ -1,7 +1,7 @@
-
+
@@ -22,11 +22,12 @@
[startAt]="startAt"
[disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
-
+
error_outline
@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}
}
+
diff --git a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.html b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.html
index e2c5be4965..c531e78288 100644
--- a/lib/core/src/lib/form/components/widgets/decimal/decimal.component.html
+++ b/lib/core/src/lib/form/components/widgets/decimal/decimal.component.html
@@ -9,7 +9,7 @@
-
+
@if ( (field.name || field?.required) && !field.leftLabels) { {{ field.name | translate }} }
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
-
+
error_outline
@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}
}
+
diff --git a/lib/core/src/lib/form/components/widgets/hyperlink/hyperlink.widget.scss b/lib/core/src/lib/form/components/widgets/hyperlink/hyperlink.widget.scss
index 91025303f0..4dedad8795 100644
--- a/lib/core/src/lib/form/components/widgets/hyperlink/hyperlink.widget.scss
+++ b/lib/core/src/lib/form/components/widgets/hyperlink/hyperlink.widget.scss
@@ -1,7 +1,6 @@
.adf-hyperlink-widget {
padding: 0.4375em 0;
- border-top: 0.8438em solid transparent;
- margin-bottom: 20px;
+ margin-bottom: 40px;
a {
color: var(--mat-sys-primary);
diff --git a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html
index cea1673327..73d7d5036a 100644
--- a/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html
+++ b/lib/core/src/lib/form/components/widgets/multiline-text/multiline-text.widget.html
@@ -7,6 +7,7 @@
0"
[floatLabel]="field.placeholder ? 'always' : null"
>
@@ -31,14 +32,16 @@
>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
-
+
@if (field.maxLength > 0) {{{ field?.value?.length || 0 }}/{{ field.maxLength }}}
error_outline
@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}
} @else if (field.maxLength > 0) {
- {{ field?.value?.length || 0 }}/{{ field.maxLength }}
+ {{ field?.value?.length || 0 }}/{{ field.maxLength }}
+ } @else {
+
}
diff --git a/lib/core/src/lib/form/components/widgets/number/number.widget.html b/lib/core/src/lib/form/components/widgets/number/number.widget.html
index 11999ea2b1..344d5c9abd 100644
--- a/lib/core/src/lib/form/components/widgets/number/number.widget.html
+++ b/lib/core/src/lib/form/components/widgets/number/number.widget.html
@@ -9,7 +9,7 @@
-
+
@if( (field.name || this.field?.required) && !field.leftLabels) {
{{ field.name | translate }}
@@ -30,12 +30,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()">
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
-
+
error_outline
@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}
}
+
diff --git a/lib/core/src/lib/form/components/widgets/text/text.widget.html b/lib/core/src/lib/form/components/widgets/text/text.widget.html
index e1fc6d47e0..e9dee6e3b9 100644
--- a/lib/core/src/lib/form/components/widgets/text/text.widget.html
+++ b/lib/core/src/lib/form/components/widgets/text/text.widget.html
@@ -8,7 +8,11 @@
-
+
@if ( (field.name || this.field?.required) && !field.leftLabels) {
{{ field.name | translate }}
@@ -30,7 +34,7 @@
(paste)="onPaste($event)"
(blur)="onBlur()">
@if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) {
-
+
@if (maxLengthPasteError.isActive()) {
@@ -43,6 +47,9 @@
}
+ @if (!fieldStatusTemplate) {
+
+ }
diff --git a/lib/core/src/lib/form/models/task-process-variable.model.ts b/lib/core/src/lib/form/models/task-process-variable.model.ts
index 1bfc188caa..f2f764f366 100644
--- a/lib/core/src/lib/form/models/task-process-variable.model.ts
+++ b/lib/core/src/lib/form/models/task-process-variable.model.ts
@@ -18,5 +18,5 @@
export class TaskProcessVariableModel {
id?: string;
type?: string;
- value: string;
+ value: any;
}
diff --git a/lib/core/src/lib/form/services/widget-visibility.service.spec.ts b/lib/core/src/lib/form/services/widget-visibility.service.spec.ts
index a75f9fa768..ce761cace6 100644
--- a/lib/core/src/lib/form/services/widget-visibility.service.spec.ts
+++ b/lib/core/src/lib/form/services/widget-visibility.service.spec.ts
@@ -1002,4 +1002,169 @@ describe('WidgetVisibilityService', () => {
expect(textField.isVisible).toBe(true);
});
});
+
+ describe('Visibility calculation from form variables', () => {
+ const hiddenWhileRequestor = new WidgetVisibilityModel({
+ leftType: 'variable',
+ leftValue: 'person_type',
+ operator: '!=',
+ rightType: 'value',
+ rightValue: 'Requestor',
+ nextConditionOperator: '',
+ nextCondition: null
+ });
+
+ const requestorProcessVariables = [{ id: 'variables.person_type', value: 'Requestor' }];
+
+ let formJson: any;
+ let form: FormModel;
+
+ beforeEach(() => {
+ formJson = {
+ id: 'person-type-form',
+ variables: [{ id: 'person-type-var', name: 'person_type', value: null }],
+ processVariables: [{ name: 'variables.person_type', value: 'Requestor' }]
+ };
+ form = new FormModel(formJson);
+ service.cleanProcessVariable();
+ });
+
+ it('should resolve a variable from the form when the cached process variables no longer hold it', () => {
+ service.refreshVisibility(form, requestorProcessVariables);
+
+ service.refreshVisibility(new FormModel({ id: 'another-form' }), [{ id: 'variables.other', value: 'other' }]);
+
+ expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false);
+ });
+
+ it('should resolve a variable from the form when the refreshed data omits it', () => {
+ service.refreshVisibility(form, requestorProcessVariables);
+ service.refreshVisibility(form, [{ id: 'processOutput', value: 'result' }]);
+
+ expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false);
+ });
+
+ it('should prefer a process variable over the value defined in the form definition', () => {
+ const formWithDefault = new FormModel({
+ id: 'person-type-form-with-default',
+ variables: [{ id: 'person-type-var', name: 'person_type', value: 'Approver' }],
+ processVariables: [{ name: 'variables.person_type', value: 'Requestor' }]
+ });
+
+ expect(service.getVariableValue(formWithDefault, 'person_type', requestorProcessVariables)).toBe('Requestor');
+ });
+
+ it('should prefer a variable changed at runtime over a process variable of the same name', () => {
+ service.refreshVisibility(form, requestorProcessVariables);
+
+ form.changeVariableValue('person-type-var', 'Approver');
+ service.refreshVisibility(form, requestorProcessVariables);
+
+ expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true);
+ });
+
+ it('should prefer a variable cleared at runtime over a process variable of the same name', () => {
+ service.refreshVisibility(form, requestorProcessVariables);
+
+ form.changeVariableValue('person-type-var', '');
+ service.refreshVisibility(form, requestorProcessVariables);
+
+ expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true);
+ });
+
+ it('should keep a variable changed at runtime when the form is rebuilt from the same definition', () => {
+ service.refreshVisibility(form, requestorProcessVariables);
+ form.changeVariableValue('person-type-var', 'Approver');
+
+ const rebuiltForm = new FormModel(formJson);
+ service.refreshVisibility(rebuiltForm, [{ id: 'processOutput', value: 'result' }]);
+
+ expect(service.evaluateVisibility(rebuiltForm, hiddenWhileRequestor)).toBe(true);
+ });
+
+ it('should not convert the type of a variable changed at runtime', () => {
+ const numericForm = new FormModel({ id: 'numeric-form', variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }] });
+ const booleanForm = new FormModel({ id: 'boolean-form', variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }] });
+
+ service.refreshVisibility(numericForm);
+ service.refreshVisibility(booleanForm);
+
+ numericForm.changeVariableValue('amount-var', 5);
+ booleanForm.changeVariableValue('flag-var', false);
+
+ service.refreshVisibility(numericForm);
+ service.refreshVisibility(booleanForm);
+
+ expect(service.getVariableValue(numericForm, 'amount')).toBe(5);
+ expect(service.getVariableValue(booleanForm, 'flag', [])).toBe(false);
+ });
+
+ it('should evaluate a numeric variable changed at runtime without string comparison', () => {
+ const numericForm = new FormModel({
+ id: 'numeric-visibility-form',
+ variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }]
+ });
+ const amountOverTen = new WidgetVisibilityModel({
+ leftType: 'variable',
+ leftValue: 'amount',
+ operator: '>',
+ rightType: 'value',
+ rightValue: '10',
+ nextConditionOperator: '',
+ nextCondition: null
+ });
+
+ service.refreshVisibility(numericForm);
+ numericForm.changeVariableValue('amount-var', 5);
+ service.refreshVisibility(numericForm);
+
+ expect(service.evaluateVisibility(numericForm, amountOverTen)).toBe(false);
+ });
+
+ it('should resolve a zero process variable from the form when the refreshed data omits it', () => {
+ const zeroForm = new FormModel({
+ id: 'zero-form',
+ variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }],
+ processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }]
+ });
+ const amountIsZero = new WidgetVisibilityModel({
+ leftType: 'variable',
+ leftValue: 'amount',
+ operator: '==',
+ rightType: 'value',
+ rightValue: '0',
+ nextConditionOperator: '',
+ nextCondition: null
+ });
+
+ service.refreshVisibility(zeroForm, [{ id: 'variables.amount', value: 0 }]);
+ service.refreshVisibility(zeroForm, [{ id: 'processOutput', value: 'result' }]);
+
+ expect(service.getVariableValue(zeroForm, 'amount', [])).toBe(0);
+ expect(service.evaluateVisibility(zeroForm, amountIsZero)).toBe(true);
+ });
+
+ it('should resolve a false process variable from the form when the refreshed data omits it', () => {
+ const falseForm = new FormModel({
+ id: 'false-form',
+ variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }],
+ processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }]
+ });
+ const flagIsFalse = new WidgetVisibilityModel({
+ leftType: 'variable',
+ leftValue: 'flag',
+ operator: '==',
+ rightType: 'value',
+ rightValue: 'false',
+ nextConditionOperator: '',
+ nextCondition: null
+ });
+
+ service.refreshVisibility(falseForm, [{ id: 'variables.flag', value: false }]);
+ service.refreshVisibility(falseForm, [{ id: 'processOutput', value: 'result' }]);
+
+ expect(service.getVariableValue(falseForm, 'flag', [])).toBe(false);
+ expect(service.evaluateVisibility(falseForm, flagIsFalse)).toBe(true);
+ });
+ });
});
diff --git a/lib/core/src/lib/form/services/widget-visibility.service.ts b/lib/core/src/lib/form/services/widget-visibility.service.ts
index a7d1ea039d..618a08d2e1 100644
--- a/lib/core/src/lib/form/services/widget-visibility.service.ts
+++ b/lib/core/src/lib/form/services/widget-visibility.service.ts
@@ -29,7 +29,7 @@ import { FormService } from './form.service';
export class WidgetVisibilityService {
private readonly formService = inject(FormService);
- private processVarList: TaskProcessVariableModel[];
+ private processVarList: TaskProcessVariableModel[] = [];
private form: FormModel;
public refreshVisibility(form: FormModel, processVarList?: TaskProcessVariableModel[]) {
@@ -110,7 +110,7 @@ export class WidgetVisibilityService {
}
}
- getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string {
+ getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any {
let leftValue = '';
if (visibilityObj.leftType === WidgetTypeEnum.variable) {
leftValue = this.getVariableValue(form, visibilityObj.leftValue, this.processVarList);
@@ -124,7 +124,7 @@ export class WidgetVisibilityService {
return leftValue;
}
- getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string {
+ getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any {
let valueFound = '';
if (visibilityObj.rightType === WidgetTypeEnum.variable) {
@@ -270,20 +270,21 @@ export class WidgetVisibilityService {
return field.id && fieldToFind ? field.id.toUpperCase() === fieldToFind.toUpperCase() : false;
}
- public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[]): string {
- const processVariableValue = this.getProcessVariableValue(name, processVarList);
- const variableDefaultValue = form.getDefaultFormVariableValue(name);
+ public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[] = []): any {
+ if (form.isVariableSetAtRuntime(name)) {
+ return form.getDefaultFormVariableValue(name);
+ }
- return processVariableValue === undefined ? variableDefaultValue : processVariableValue;
+ const processVariableValue = this.getProcessVariableValue(name, processVarList);
+
+ return processVariableValue === undefined ? form.getProcessVariableValue(name) : processVariableValue;
}
- private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): string {
- if (processVarList) {
- const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`);
+ private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): any {
+ const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`);
- if (processVariable) {
- return processVariable.value;
- }
+ if (processVariable) {
+ return processVariable.value;
}
return undefined;
}
diff --git a/lib/core/src/lib/styles/_mat-selectors.scss b/lib/core/src/lib/styles/_mat-selectors.scss
index dba941cafa..31fa2b1208 100644
--- a/lib/core/src/lib/styles/_mat-selectors.scss
+++ b/lib/core/src/lib/styles/_mat-selectors.scss
@@ -17,7 +17,6 @@ $mat-button: '.mat-mdc-button';
$mat-button-label: '.mdc-button__label';
$mat-form-field: '.mat-mdc-form-field';
$mat-form-field-wrapper: '.mat-mdc-text-field-wrapper';
-$mat-form-field-subscript-wrapper: '.mat-mdc-form-field-subscript-wrapper';
$mat-line-ripple: '.mdc-line-ripple';
$mat-form-field-prefix: '.mat-mdc-form-field-text-prefix';
$mat-form-field-suffix: '.mat-mdc-form-field-text-suffix';
diff --git a/lib/insights/package.json b/lib/insights/package.json
index bf0225cff7..1571dd03d8 100644
--- a/lib/insights/package.json
+++ b/lib/insights/package.json
@@ -11,9 +11,9 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
},
"dependencies": {
- "chart.js": "^4.3.0",
- "ng2-charts": "^4.1.1",
- "raphael": ">=2.3.0"
+ "chart.js": "4.5.1",
+ "ng2-charts": "9.0.0",
+ "raphael": "2.3.0"
},
"peerDependencies": {
"@angular/common": ">=20.3.27",
diff --git a/lib/insights/src/lib/analytics-process/components/analytics-generator.component.ts b/lib/insights/src/lib/analytics-process/components/analytics-generator.component.ts
index 2679c765da..d84aff0d29 100644
--- a/lib/insights/src/lib/analytics-process/components/analytics-generator.component.ts
+++ b/lib/insights/src/lib/analytics-process/components/analytics-generator.component.ts
@@ -22,7 +22,7 @@ import { AnalyticsService } from '../services/analytics.service';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
-import { NgChartsModule } from 'ng2-charts';
+import { BaseChartDirective } from 'ng2-charts';
import { TranslatePipe } from '@ngx-translate/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
@@ -34,7 +34,7 @@ import { AnalyticsReportHeatMapComponent } from './analytics-report-heat-map.com
CommonModule,
MatButtonModule,
MatIconModule,
- NgChartsModule,
+ BaseChartDirective,
TranslatePipe,
MatCheckboxModule,
FormsModule,
diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts
index 221f4dedb5..8467382568 100644
--- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.spec.ts
@@ -2575,3 +2575,126 @@ describe('FormCloudComponent â runtime state preservation on data refresh', ()
});
});
});
+
+describe('FormCloudComponent â form variable visibility on data refresh', () => {
+ let fixture: ComponentFixture;
+ let formComponent: FormCloudComponent;
+ let visibilityService: WidgetVisibilityService;
+
+ /** field is hidden while person_type is Requestor */
+ const personTypeFormJson = {
+ id: 'person-type-form',
+ name: 'Person Type Form',
+ variables: [{ id: 'person-type-var', name: 'person_type', value: null }],
+ fields: [
+ {
+ fieldType: 'ContainerRepresentation',
+ id: 'container1',
+ name: 'Container',
+ type: 'container',
+ tab: null,
+ numberOfColumns: 1,
+ fields: {
+ 1: [
+ {
+ fieldType: 'FormFieldRepresentation',
+ id: 'conditionalField',
+ name: 'Conditional Field',
+ type: 'multiline-text',
+ value: null,
+ required: false,
+ readOnly: false,
+ visibilityCondition: {
+ leftType: 'variable',
+ leftValue: 'person_type',
+ operator: '!=',
+ rightType: 'value',
+ rightValue: 'Requestor',
+ nextConditionOperator: '',
+ nextCondition: null
+ },
+ params: { existingColspan: 1, maxColspan: 1 }
+ }
+ ]
+ }
+ }
+ ]
+ };
+
+ const requestorVariables = () => [new TaskVariableCloud({ name: 'variables.person_type', value: 'Requestor' })];
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent],
+ providers: [
+ { provide: VersionCompatibilityService, useValue: {} },
+ { provide: FormRenderingService, useClass: CloudFormRenderingService }
+ ]
+ });
+
+ const apiService = TestBed.inject(AlfrescoApiService);
+ spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth);
+
+ visibilityService = TestBed.inject(WidgetVisibilityService);
+ visibilityService.cleanProcessVariable();
+
+ fixture = TestBed.createComponent(FormCloudComponent);
+ formComponent = fixture.componentInstance;
+
+ formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(personTypeFormJson)));
+ formComponent.formCloudRepresentationJSON.processVariables = requestorVariables();
+ formComponent.data = requestorVariables();
+ formComponent.form = formComponent.parseForm(formComponent.formCloudRepresentationJSON);
+ visibilityService.refreshVisibility(formComponent.form, formComponent.data);
+
+ fixture.detectChanges();
+ });
+
+ it('should keep the field hidden when the refreshed data omits the variable', () => {
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse();
+
+ const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
+ const change = new SimpleChange(formComponent.data, partialData, false);
+ formComponent.data = partialData;
+
+ formComponent.ngOnChanges({ data: change });
+
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse();
+ });
+
+ it('should show the field when the refreshed data changes the variable', () => {
+ const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })];
+ const change = new SimpleChange(formComponent.data, approverData, false);
+ formComponent.data = approverData;
+
+ formComponent.ngOnChanges({ data: change });
+
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
+ });
+
+ it('should keep a variable changed by a form rule when the refreshed data omits it', () => {
+ formComponent.form.changeVariableValue('person-type-var', 'Approver');
+
+ const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
+ const change = new SimpleChange(formComponent.data, partialData, false);
+ formComponent.data = partialData;
+
+ formComponent.ngOnChanges({ data: change });
+
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
+ });
+
+ it('should keep the latest received variable value across a following partial refresh', () => {
+ const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })];
+ formComponent.data = approverData;
+ formComponent.ngOnChanges({ data: new SimpleChange(requestorVariables(), approverData, false) });
+
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
+
+ const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
+ formComponent.data = partialData;
+ formComponent.ngOnChanges({ data: new SimpleChange(approverData, partialData, false) });
+
+ expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
+ });
+});
diff --git a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts
index c2ab42da40..66f91cc602 100644
--- a/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/form/components/form-cloud.component.ts
@@ -601,6 +601,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
private refreshFormData(previousData: TaskVariableCloud[] = []) {
const snapshot = this.snapshotRuntimeState();
+ this.mergeProcessVariables(this.data ?? []);
+
this.form = this.parseForm(this.formCloudRepresentationJSON);
if (!this.form) {
return;
@@ -610,13 +612,36 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.restoreRuntimeState(this.form, snapshot, changedFieldIds);
this.setCheckParentVisibilityForValidationOnFields();
- this.visibilityService.refreshVisibility(this.form);
+ this.visibilityService.refreshVisibility(this.form, this.data);
this.form.validateForm();
this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form);
}
+ /**
+ * Keeps the process variables on the stored representation up to date with the latest data, so that a
+ * variable omitted by a later partial refresh still resolves to the most recent value received.
+ *
+ * @param updates Variables received on the latest data refresh
+ */
+ private mergeProcessVariables(updates: TaskVariableCloud[]): void {
+ if (!this.formCloudRepresentationJSON) {
+ return;
+ }
+
+ const existing: TaskVariableCloud[] = this.formCloudRepresentationJSON.processVariables ?? [];
+ const byName = new Map();
+
+ for (const variable of [...existing, ...updates]) {
+ if (variable?.name) {
+ byName.set(variable.name, variable);
+ }
+ }
+
+ this.formCloudRepresentationJSON.processVariables = Array.from(byName.values());
+ }
+
private snapshotRuntimeState(): Map {
const snapshot = new Map();
if (!this.form) {
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/data-table/data-table.widget.scss b/lib/process-services-cloud/src/lib/form/components/widgets/data-table/data-table.widget.scss
index ed9398c207..8fcb2550ac 100644
--- a/lib/process-services-cloud/src/lib/form/components/widgets/data-table/data-table.widget.scss
+++ b/lib/process-services-cloud/src/lib/form/components/widgets/data-table/data-table.widget.scss
@@ -1,11 +1,12 @@
.adf-data-table-widget-failed-message {
display: block;
- margin: 10px;
}
-.adf-preview-placeholder {
- height: 100%;
- width: 100%;
- min-height: 100px;
- margin-bottom: 10px;
+.adf-data-table-widget-container {
+ .adf-preview-placeholder {
+ height: 100%;
+ width: 100%;
+ min-height: 100px;
+ margin-bottom: 10px;
+ }
}
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.html
index 1cc7d8e98b..bde9f411f9 100644
--- a/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.html
+++ b/lib/process-services-cloud/src/lib/form/components/widgets/date/date-cloud.widget.html
@@ -15,7 +15,11 @@
>
-
+
@if ( (field.name || field?.required) && !field.leftLabels) {
{{field.name | translate }} ({{field.dateDisplayFormat}})
@@ -36,12 +40,13 @@
@if (dateInputControl.invalid && dateInputControl.touched) {
-
+
error_outline
@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}
}
+
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.html
index 39a356ac8d..709d994c44 100644
--- a/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.html
+++ b/lib/process-services-cloud/src/lib/form/components/widgets/display-external-property/display-external-property.widget.html
@@ -11,7 +11,7 @@
-
+
@if( (field.name || field?.required) && !field.leftLabels) {
{{ field.name | translate }}
}
@@ -32,8 +32,9 @@
@if (propertyLoadFailed && !previewState) {
- error_outline{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}
+ error_outline{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}
}
+
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html
index eedec326ff..e994d83967 100644
--- a/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html
+++ b/lib/process-services-cloud/src/lib/form/components/widgets/dropdown/dropdown-cloud.widget.html
@@ -12,7 +12,7 @@
}
-
+
@if ( (field.name || this.field?.required) && !field.leftLabels) {
{{ field.name | translate }}
}
@@ -49,12 +49,13 @@
}
@if ((dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) || (!previewState && !field.readOnly && (isRestApiFailed || variableOptionsFailed))) {
-
+
error_outline
@if (dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (isRestApiFailed) {{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}} @else if (variableOptionsFailed) {{{ 'FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED' | translate }}}
}
+
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/radio-buttons/radio-buttons-cloud.widget.scss b/lib/process-services-cloud/src/lib/form/components/widgets/radio-buttons/radio-buttons-cloud.widget.scss
index ae2d2c54f7..ca6a8fae94 100644
--- a/lib/process-services-cloud/src/lib/form/components/widgets/radio-buttons/radio-buttons-cloud.widget.scss
+++ b/lib/process-services-cloud/src/lib/form/components/widgets/radio-buttons/radio-buttons-cloud.widget.scss
@@ -45,8 +45,4 @@
word-break: break-word;
}
}
-
- &-radio-group-error-message .adf-error-container {
- margin-top: 5px;
- }
}
diff --git a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss
index 7cca3876c9..d7e1aa5a21 100644
--- a/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss
+++ b/lib/process-services-cloud/src/lib/group/components/group-cloud.component.scss
@@ -63,6 +63,7 @@
}
.adf-error {
+ padding-top: 3px;
animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
}
}
diff --git a/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts
new file mode 100644
index 0000000000..5725360d1a
--- /dev/null
+++ b/lib/process-services-cloud/src/lib/models/filter-counters-cloud.model.ts
@@ -0,0 +1,55 @@
+/*!
+ * @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.
+ */
+
+export const FilterCounterEntityType = {
+ TASK: 'TASK',
+ PROCESS_INSTANCE: 'PROCESS_INSTANCE'
+} as const;
+
+export type FilterCounterEntityType = (typeof FilterCounterEntityType)[keyof typeof FilterCounterEntityType];
+
+export interface FilterCountersQuerySort {
+ field: string;
+ direction: string;
+ isProcessVariable: boolean;
+}
+
+export interface FilterCountersQuery {
+ requestId: string;
+ status?: string[];
+ assignee?: string[];
+ sort?: FilterCountersQuerySort;
+ [criteria: string]: unknown;
+}
+
+export type FilterCountersRequest = {
+ [entityType in FilterCounterEntityType]?: FilterCountersQuery[];
+};
+
+export interface FilterCounterCandidate {
+ key?: string | null;
+ showCounter?: boolean;
+}
+
+export type FilterCounters = {
+ [entityType in FilterCounterEntityType]?: { [requestId: string]: number };
+};
+
+export interface FilterCountersResult {
+ counters: { [filterKey: string]: number };
+ batched: boolean;
+}
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/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/services/filter-counters-cloud.service.spec.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts
new file mode 100644
index 0000000000..c0846418f6
--- /dev/null
+++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.spec.ts
@@ -0,0 +1,651 @@
+/*!
+ * @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 { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core';
+import { BehaviorSubject, combineLatest, firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
+import { ApolloTestingModule } from 'apollo-angular/testing';
+import { FilterCountersCloudService } from './filter-counters-cloud.service';
+import { NotificationCloudService } from './notification-cloud.service';
+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,
+ FilterCountersQuery,
+ FilterCountersRequest,
+ FilterCountersResult
+} from '../models/filter-counters-cloud.model';
+import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model';
+import { FetchResult } from '@apollo/client/core';
+
+type EngineEventsResult = FetchResult<{ engineEvents?: TaskCloudEngineEvent[] }>;
+
+interface CountEndpoint {
+ post: (url: string, request: FilterCountersRequest) => Observable;
+}
+
+describe('FilterCountersCloudService', () => {
+ let service: FilterCountersCloudService;
+ let notificationCloudService: NotificationCloudService;
+ let appConfigService: AppConfigService;
+ let taskEvents$: Subject;
+ let processEvents$: Subject;
+ let makeGQLQuerySpy: jasmine.Spy;
+ let postSpy: jasmine.Spy;
+ let getTaskListFiltersSpy: jasmine.Spy;
+ let getProcessFiltersSpy: jasmine.Spy;
+
+ const countRequest = (): FilterCountersRequest => postSpy.calls.mostRecent().args[1];
+ const countUrl = (): string => postSpy.calls.mostRecent().args[0];
+ const countQueries = (entityType: FilterCounterEntityType): FilterCountersQuery[] => countRequest()[entityType] ?? [];
+ const countRequestIds = (entityType: FilterCounterEntityType): string[] => countQueries(entityType).map((query) => query.requestId);
+
+ const countersMock: FilterCounters = {
+ TASK: { 'my-tasks': 5, 'queued-tasks': 0 },
+ PROCESS_INSTANCE: { 'running-processes': 5 }
+ };
+
+ const taskFilter = (filter: Partial) =>
+ new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter });
+ const processFilter = (filter: Partial) =>
+ 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 engineEvents = (eventType: string): EngineEventsResult => ({
+ data: { engineEvents: [{ eventType, entity: {} } as TaskCloudEngineEvent] }
+ });
+ const emitTaskEvent = (eventType = 'TASK_CREATED') => taskEvents$.next(engineEvents(eventType));
+ const emitProcessEvent = (eventType = 'PROCESS_STARTED') => processEvents$.next(engineEvents(eventType));
+
+ const counters = (entityType: FilterCounterEntityType, appName = 'mock-app') =>
+ firstValueFrom(service.getFilterCounters(appName, entityType, true));
+ const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName);
+ const processCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.PROCESS_INSTANCE, appName);
+ const bothCounters = (appName = 'mock-app') =>
+ firstValueFrom(
+ combineLatest([
+ service.getFilterCounters(appName, FilterCounterEntityType.TASK, true),
+ service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, true)
+ ])
+ );
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [NoopAuthModule, ApolloTestingModule],
+ providers: [
+ { provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
+ { provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }
+ ]
+ });
+
+ service = TestBed.inject(FilterCountersCloudService);
+ notificationCloudService = TestBed.inject(NotificationCloudService);
+ appConfigService = TestBed.inject(AppConfigService);
+ appConfigService.config.bpmHost = 'https://fake-bpm-host.com';
+
+ taskEvents$ = new Subject();
+ processEvents$ = new Subject();
+ makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery');
+ makeGQLQuerySpy.and.callFake((_appName: string, query: string) =>
+ (query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable()
+ );
+ postSpy = spyOn(service as unknown as CountEndpoint, 'post').and.returnValue(of(countersMock));
+ getTaskListFiltersSpy = spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock));
+ getProcessFiltersSpy = spyOn(TestBed.inject(ProcessFilterCloudService), 'getProcessFilters').and.returnValue(of(processFiltersMock));
+ });
+
+ 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 load the filters of an app once for concurrent subscribers', async () => {
+ await firstValueFrom(combineLatest([service.getTaskFilters('mock-app'), service.getTaskFilters('mock-app')]));
+ await firstValueFrom(combineLatest([service.getProcessFilters('mock-app'), service.getProcessFilters('mock-app')]));
+
+ 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 () => {
+ const subscription = service.getTaskFilters('mock-app').subscribe();
+ await taskCounters();
+ subscription.unsubscribe();
+
+ 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', () => {
+ 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', async () => {
+ expect(await bothCounters()).toEqual([
+ { counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true },
+ { counters: { 'running-processes': 5 }, batched: true }
+ ]);
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call the batched count endpoint of the app', async () => {
+ await taskCounters();
+
+ expect(countUrl()).toBe('https://fake-bpm-host.com/mock-app/query/v1/count');
+ });
+
+ it('should identify the query of every filter by the key of the filter', async () => {
+ await bothCounters();
+
+ expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
+ expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['running-processes']);
+ });
+
+ it('should send the criteria of every filter along with its request id', async () => {
+ await taskCounters();
+
+ expect(countQueries(FilterCounterEntityType.TASK)[0]).toEqual({
+ requestId: 'my-tasks',
+ status: ['ASSIGNED'],
+ assignee: ['mock-user'],
+ sort: { field: 'createdDate', direction: 'desc', isProcessVariable: false }
+ });
+ });
+
+ it('should not send the filters without a counter enabled', async () => {
+ await taskCounters();
+
+ expect(countRequestIds(FilterCounterEntityType.TASK)).not.toContain('completed-tasks');
+ });
+
+ it('should send the query of a filter targeting every status', async () => {
+ getProcessFiltersSpy.and.returnValue(of([processFilter({ key: 'all-processes', status: '', showCounter: true })]));
+
+ await processCounters();
+
+ expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['all-processes']);
+ });
+
+ it('should omit an entity type without filters with a counter enabled', async () => {
+ getProcessFiltersSpy.and.returnValue(of([]));
+
+ await bothCounters();
+
+ expect(countRequest().PROCESS_INSTANCE).toBeUndefined();
+ });
+
+ 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]])
+ );
+
+ await taskCounters();
+
+ expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['queued-tasks']);
+ });
+
+ it('should leave out a filter without a key, since it holds no request id', async () => {
+ getProcessFiltersSpy.and.returnValue(of([processFilter({ key: null, status: 'RUNNING', showCounter: true })]));
+
+ expect(await processCounters()).toEqual({ counters: {}, batched: true });
+ expect(postSpy).not.toHaveBeenCalled();
+ });
+
+ it('should resolve the counters of an entity type when the filters of the other one fail to load', async () => {
+ getProcessFiltersSpy.and.returnValue(throwError(() => new Error('filters failed')));
+
+ await bothCounters();
+
+ expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
+ expect(countRequest().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();
+ });
+
+ describe('when the batched count endpoint is not available', () => {
+ it('should report the counters as not batched', async () => {
+ postSpy.and.returnValue(throwError(() => ({ status: 404 })));
+
+ expect(await taskCounters()).toEqual({ counters: {}, batched: false });
+ });
+
+ it('should not call the endpoint again for the same app', async () => {
+ postSpy.and.returnValue(throwError(() => ({ status: 404 })));
+
+ await taskCounters();
+ expect(await processCounters()).toEqual({ counters: {}, batched: false });
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should keep calling the endpoint of the apps that do hold it', async () => {
+ postSpy.and.returnValue(throwError(() => ({ status: 404 })));
+ await taskCounters();
+
+ 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));
+ expect(await taskCounters()).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
+ expect(postSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+
+ describe('batched counters opted in by the filter components', () => {
+ it('should not call the batched count endpoint when it was not asked for', async () => {
+ const result = await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK));
+
+ expect(result).toEqual({ counters: {}, batched: false });
+ expect(postSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not load the filters when the batched count endpoint was not asked for', async () => {
+ await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK));
+
+ expect(getTaskListFiltersSpy).not.toHaveBeenCalled();
+ });
+
+ it('should call the batched count endpoint when every entity type on screen asked for it', async () => {
+ await bothCounters();
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call the batched count endpoint when one entity type on screen did not ask for it', fakeAsync(() => {
+ const results: FilterCountersResult[] = [];
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe();
+ tick(0);
+
+ expect(postSpy).not.toHaveBeenCalled();
+ expect(results).toEqual([{ counters: {}, batched: false }]);
+ }));
+
+ it('should call the batched count endpoint once the entity type that opted out leaves the screen', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ const processSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe();
+ tick(0);
+
+ processSubscription.unsubscribe();
+ service.refreshFilterCounters('mock-app');
+ tick(0);
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
+ }));
+ });
+
+ describe('counters scoped to the entity types on screen', () => {
+ it('should send the queries of the entity type on screen alone', async () => {
+ await taskCounters();
+
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
+ });
+
+ it('should not load the filters of an entity type that is not on screen', async () => {
+ await taskCounters();
+
+ expect(getTaskListFiltersSpy).toHaveBeenCalled();
+ expect(getProcessFiltersSpy).not.toHaveBeenCalled();
+ });
+
+ it('should send the queries of both entity types when both are on screen', async () => {
+ await bothCounters();
+
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]);
+ });
+
+ it('should resolve the counters again when an entity type joins the ones on screen', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
+
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+
+ expect(postSpy).toHaveBeenCalledTimes(2);
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]);
+ }));
+
+ it('should stop covering an entity type once its counters hold no subscriber', fakeAsync(() => {
+ const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+
+ taskSubscription.unsubscribe();
+ service.refreshFilterCounters('mock-app');
+ tick(0);
+
+ expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.PROCESS_INSTANCE]);
+ }));
+ });
+
+ describe('teardown', () => {
+ it('should close the engine event subscription once the counters hold no subscriber', fakeAsync(() => {
+ const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
+
+ subscription.unsubscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should keep the engine event subscription while another subscriber holds the same entity type', fakeAsync(() => {
+ const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+
+ subscription.unsubscribe();
+ emitTaskEvent();
+ tick(3000);
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
+ expect(postSpy).toHaveBeenCalledTimes(2);
+ }));
+
+ it('should release the filters subscription once nothing reads them', fakeAsync(() => {
+ const filters$ = new BehaviorSubject(taskFiltersMock);
+ getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
+
+ const subscriptions = [
+ service.getTaskFilters('mock-app').subscribe(),
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe()
+ ];
+ tick(0);
+ expect(filters$.observed).toBeTrue();
+
+ subscriptions.forEach((subscription) => subscription.unsubscribe());
+
+ expect(filters$.observed).toBeFalse();
+ }));
+
+ it('should resolve the counters again for a subscriber that comes after a full teardown', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe().unsubscribe();
+ tick(0);
+ postSpy.calls.reset();
+
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ }));
+ });
+
+ describe('refreshFilterCounters', () => {
+ it('should resolve the counters again with a single request', fakeAsync(() => {
+ const results: FilterCountersResult[] = [];
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+
+ service.refreshFilterCounters('mock-app');
+ tick(0);
+
+ expect(postSpy).toHaveBeenCalledTimes(2);
+ expect(results.length).toBe(2);
+ }));
+
+ it('should not resolve the counters of an app without subscribers', fakeAsync(() => {
+ service.refreshFilterCounters('mock-app');
+ tick(0);
+
+ expect(postSpy).not.toHaveBeenCalled();
+ }));
+ });
+
+ describe('when only one of the two filter families is wired', () => {
+ const configureTasksOnly = () => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ imports: [NoopAuthModule, ApolloTestingModule],
+ providers: [{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }]
+ });
+
+ const tasksOnlyService = TestBed.inject(FilterCountersCloudService);
+ TestBed.inject(AppConfigService).config.bpmHost = 'https://fake-bpm-host.com';
+ spyOn(TestBed.inject(NotificationCloudService), 'makeGQLQuery').and.returnValue(new Subject().asObservable());
+ spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock));
+ postSpy = spyOn(tasksOnlyService as unknown as CountEndpoint, 'post').and.returnValue(of(countersMock));
+
+ return tasksOnlyService;
+ };
+
+ it('should resolve the counters of the wired family', async () => {
+ const tasksOnlyService = configureTasksOnly();
+
+ const result = await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true));
+
+ expect(result).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
+ });
+
+ it('should leave the filters of the family that is not wired out of the request', async () => {
+ const tasksOnlyService = configureTasksOnly();
+
+ await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true));
+
+ expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
+ expect(countRequest().PROCESS_INSTANCE).toBeUndefined();
+ });
+ });
+
+ describe('getEngineEvents', () => {
+ it('should return EMPTY when appName is not set', () => {
+ let completed = false;
+ service.getEngineEvents('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) });
+
+ expect(completed).toBeTrue();
+ expect(makeGQLQuerySpy).not.toHaveBeenCalled();
+ });
+
+ it('should subscribe to the events of the task entity type alone', () => {
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
+
+ const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args;
+ expect(appName).toBe('mock-app');
+ expect(query).toContain('TASK_CREATED');
+ expect(query).not.toContain('PROCESS_STARTED');
+ });
+
+ it('should subscribe to the events of the process entity type alone', () => {
+ service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
+
+ const [, query] = makeGQLQuerySpy.calls.mostRecent().args;
+ expect(query).toContain('PROCESS_STARTED');
+ expect(query).not.toContain('TASK_CREATED');
+ });
+
+ it('should open a single subscription for multiple subscribers of the same entity type', () => {
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should open a separate subscription per entity type', () => {
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
+ service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should open a separate subscription per app', () => {
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
+ service.getEngineEvents('other-app', FilterCounterEntityType.TASK).subscribe();
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should emit the debounced batch of events', fakeAsync(() => {
+ const batches: TaskCloudEngineEvent[][] = [];
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe((events) => batches.push(events));
+
+ emitTaskEvent('TASK_CREATED');
+ emitTaskEvent('TASK_ASSIGNED');
+ tick(3000);
+
+ expect(batches.length).toBe(1);
+ expect(batches[0][0].eventType).toBe('TASK_ASSIGNED');
+ }));
+
+ it('should debounce the events using the configured debounce time', fakeAsync(() => {
+ spyOnProperty(service, 'notificationDebounceTime', 'get').and.returnValue(5000);
+ let emitted = false;
+ service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(() => (emitted = true));
+
+ emitTaskEvent();
+ tick(3000);
+ expect(emitted).toBeFalse();
+
+ tick(2000);
+ expect(emitted).toBeTrue();
+ }));
+ });
+
+ describe('counters driven by the engine events', () => {
+ it('should make a single count request for a batch of events of both entity types', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+ postSpy.calls.reset();
+
+ emitTaskEvent();
+ emitProcessEvent();
+ tick(3000);
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should make a single count request for the events of both entity types arriving apart', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+ postSpy.calls.reset();
+
+ emitTaskEvent();
+ tick(1000);
+ emitProcessEvent();
+ tick(3000);
+
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should emit the counters resolved for the batch of events', fakeAsync(() => {
+ const results: FilterCountersResult[] = [];
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
+ tick(0);
+
+ postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } }));
+ emitTaskEvent();
+ tick(3000);
+
+ expect(results.length).toBe(2);
+ expect(results[1]).toEqual({ counters: { 'my-tasks': 9 }, batched: true });
+ }));
+
+ it('should not subscribe to the events of an entity type that is not on screen', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+
+ expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
+ expect(makeGQLQuerySpy.calls.mostRecent().args[1]).toContain('TASK_CREATED');
+ }));
+
+ it('should not resolve the counters again on the events of an entity type that is not on screen', fakeAsync(() => {
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(0);
+ postSpy.calls.reset();
+
+ emitProcessEvent();
+ tick(3000);
+
+ expect(postSpy).not.toHaveBeenCalled();
+ }));
+
+ it('should stop resolving the counters on the events of an entity type that left the screen', fakeAsync(() => {
+ const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
+ tick(0);
+
+ taskSubscription.unsubscribe();
+ postSpy.calls.reset();
+ emitTaskEvent();
+ tick(3000);
+
+ expect(postSpy).not.toHaveBeenCalled();
+ }));
+
+ it('should not subscribe to the engine events when notifications are disabled', fakeAsync(() => {
+ appConfigService.config.notifications = false;
+
+ service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
+ tick(3000);
+
+ expect(makeGQLQuerySpy).not.toHaveBeenCalled();
+ expect(postSpy).toHaveBeenCalledTimes(1);
+ }));
+ });
+});
diff --git a/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts
new file mode 100644
index 0000000000..0d0efb20dc
--- /dev/null
+++ b/lib/process-services-cloud/src/lib/services/filter-counters-cloud.service.ts
@@ -0,0 +1,375 @@
+/*!
+ * @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 { inject, Injectable, Injector } from '@angular/core';
+import { asapScheduler, combineLatest, defer, EMPTY, merge, Observable, of, Subject, Subscription } from 'rxjs';
+import { catchError, debounceTime, finalize, map, shareReplay, switchMap, take } from 'rxjs/operators';
+import { BaseCloudService } from './base-cloud.service';
+import { NotificationCloudService } from './notification-cloud.service';
+import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model';
+import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service';
+import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service';
+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,
+ FilterCountersQuery,
+ FilterCountersRequest,
+ FilterCountersResult
+} from '../models/filter-counters-cloud.model';
+const BATCHED_COUNTERS_UNAVAILABLE_STATUSES = [404, 501];
+
+interface FilterCountersFilters {
+ [FilterCounterEntityType.TASK]: TaskFilterCloudModel[];
+ [FilterCounterEntityType.PROCESS_INSTANCE]: ProcessFilterCloudModel[];
+}
+
+interface EngineEventsData {
+ engineEvents?: TaskCloudEngineEvent[];
+}
+
+const ENGINE_EVENTS_SUBSCRIPTION_QUERIES: Record = {
+ [FilterCounterEntityType.TASK]: `
+ subscription {
+ engineEvents(eventType: [
+ TASK_COMPLETED
+ TASK_ASSIGNED
+ TASK_ACTIVATED
+ TASK_SUSPENDED
+ TASK_CANCELLED
+ TASK_CREATED
+ ]) {
+ eventType
+ entity
+ }
+ }
+`,
+ [FilterCounterEntityType.PROCESS_INSTANCE]: `
+ subscription {
+ engineEvents(eventType: [
+ PROCESS_CANCELLED
+ PROCESS_COMPLETED
+ PROCESS_CREATED
+ PROCESS_RESUMED
+ PROCESS_SUSPENDED
+ PROCESS_STARTED
+ ]) {
+ eventType
+ entity
+ }
+ }
+`
+};
+
+@Injectable({ providedIn: 'root' })
+export class FilterCountersCloudService extends BaseCloudService {
+ private readonly notificationCloudService = inject(NotificationCloudService);
+ private readonly taskListCloudService = inject(TaskListCloudService);
+ private readonly processListCloudService = inject(ProcessListCloudService);
+ private readonly injector = inject(Injector);
+
+ private readonly eventsPerEntityType = new Map>();
+ private readonly rawEventsPerEntityType = new Map>();
+ private readonly recountPerApp = new Map>();
+ private readonly eventRecountPerApp = new Map>();
+ private readonly activeEntityTypesPerApp = new Map>();
+ private readonly subscribersPerEntityType = new Map();
+ private readonly batchedCountersPerEntityType = new Map();
+ private readonly eventSubscriptionsPerEntityType = new Map();
+ private readonly appsWithoutBatchedCounters = new Set();
+ private readonly taskFiltersPerApp = new Map>();
+ private readonly processFiltersPerApp = new Map>();
+ private readonly countersPerApp = new Map>();
+
+ get notificationDebounceTime(): number {
+ return this.appConfigService.get('notificationDebounceTime', 3000);
+ }
+
+ getTaskFilters(appName: string): Observable {
+ return this.shareFilters(this.taskFiltersPerApp, appName, () => this.injector.get(TaskFilterCloudService).getTaskListFilters(appName));
+ }
+
+ getProcessFilters(appName: string): Observable {
+ return this.shareFilters(this.processFiltersPerApp, appName, () => this.injector.get(ProcessFilterCloudService).getProcessFilters(appName));
+ }
+
+ getFilterCounters(appName: string, entityType: FilterCounterEntityType, batchedCounters = false): Observable {
+ if (!appName) {
+ return EMPTY;
+ }
+
+ return defer(() => {
+ this.activateEntityType(appName, entityType, batchedCounters);
+
+ return this.getCounters(appName);
+ }).pipe(
+ map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched })),
+ finalize(() => this.deactivateEntityType(appName, entityType))
+ );
+ }
+
+ refreshFilterCounters(appName: string): void {
+ this.recount(appName);
+ }
+
+ getEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable {
+ if (!appName) {
+ return EMPTY;
+ }
+
+ const key = this.entityTypeKey(appName, entityType);
+ let events$ = this.eventsPerEntityType.get(key);
+ if (!events$) {
+ events$ = this.rawEngineEvents(appName, entityType).pipe(
+ debounceTime(this.notificationDebounceTime),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+ this.eventsPerEntityType.set(key, events$);
+ }
+
+ return events$;
+ }
+
+ private rawEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable {
+ const key = this.entityTypeKey(appName, entityType);
+ let events$ = this.rawEventsPerEntityType.get(key);
+ if (!events$) {
+ events$ = defer(() =>
+ this.notificationCloudService.makeGQLQuery(appName, ENGINE_EVENTS_SUBSCRIPTION_QUERIES[entityType])
+ ).pipe(
+ map((result) => result.data?.engineEvents ?? []),
+ catchError(() => EMPTY),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+ this.rawEventsPerEntityType.set(key, events$);
+ }
+
+ return events$;
+ }
+
+ private get notificationsEnabled(): boolean {
+ return this.appConfigService.get('notifications', true);
+ }
+
+ private activateEntityType(appName: string, entityType: FilterCounterEntityType, batchedCounters: boolean): void {
+ const key = this.entityTypeKey(appName, entityType);
+ const subscribers = (this.subscribersPerEntityType.get(key) ?? 0) + 1;
+ this.subscribersPerEntityType.set(key, subscribers);
+
+ if (subscribers > 1) {
+ return;
+ }
+
+ this.batchedCountersPerEntityType.set(key, batchedCounters);
+
+ const activeEntityTypes = this.activeEntityTypes(appName);
+ const joinsResolvedCounters = activeEntityTypes.size > 0;
+ activeEntityTypes.add(entityType);
+
+ if (this.notificationsEnabled) {
+ this.eventSubscriptionsPerEntityType.set(
+ key,
+ this.rawEngineEvents(appName, entityType).subscribe(() => this.eventRecountTrigger(appName).next())
+ );
+ }
+
+ if (joinsResolvedCounters) {
+ this.recount(appName);
+ }
+ }
+
+ private deactivateEntityType(appName: string, entityType: FilterCounterEntityType): void {
+ const key = this.entityTypeKey(appName, entityType);
+ const subscribers = (this.subscribersPerEntityType.get(key) ?? 1) - 1;
+
+ if (subscribers > 0) {
+ this.subscribersPerEntityType.set(key, subscribers);
+ return;
+ }
+
+ this.subscribersPerEntityType.delete(key);
+ this.batchedCountersPerEntityType.delete(key);
+ this.activeEntityTypes(appName).delete(entityType);
+ this.eventSubscriptionsPerEntityType.get(key)?.unsubscribe();
+ this.eventSubscriptionsPerEntityType.delete(key);
+ }
+
+ private activeEntityTypes(appName: string): Set {
+ let activeEntityTypes = this.activeEntityTypesPerApp.get(appName);
+ if (!activeEntityTypes) {
+ activeEntityTypes = new Set();
+ this.activeEntityTypesPerApp.set(appName, activeEntityTypes);
+ }
+
+ return activeEntityTypes;
+ }
+
+ private entityTypeKey(appName: string, entityType: FilterCounterEntityType): string {
+ return `${appName}|${entityType}`;
+ }
+
+ private recount(appName: string): void {
+ this.recountTrigger(appName).next();
+ }
+
+ private getFiltersForCounters(appName: string): Observable {
+ const activeEntityTypes = this.activeEntityTypes(appName);
+
+ return combineLatest({
+ [FilterCounterEntityType.TASK]: activeEntityTypes.has(FilterCounterEntityType.TASK)
+ ? this.getTaskFilters(appName).pipe(catchError(() => of([])))
+ : of([]),
+ [FilterCounterEntityType.PROCESS_INSTANCE]: activeEntityTypes.has(FilterCounterEntityType.PROCESS_INSTANCE)
+ ? this.getProcessFilters(appName).pipe(catchError(() => of([])))
+ : of([])
+ });
+ }
+
+ private shareFilters(cache: Map>, appName: string, loadFilters: () => Observable): Observable {
+ let filters$ = cache.get(appName);
+ if (!filters$) {
+ filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ cache.set(appName, filters$);
+ }
+
+ return filters$;
+ }
+
+ private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> {
+ let counters$ = this.countersPerApp.get(appName);
+ if (!counters$) {
+ counters$ = this.recounts(appName).pipe(
+ switchMap(() => this.resolveCounters(appName)),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+ this.countersPerApp.set(appName, counters$);
+ }
+
+ return counters$;
+ }
+
+ private resolveCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> {
+ if (!this.batchedCountersEnabled(appName) || 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)) {
+ this.appsWithoutBatchedCounters.add(appName);
+ }
+
+ return of({ counters: {}, batched: false });
+ })
+ );
+ }
+
+ private batchedCountersEnabled(appName: string): boolean {
+ const activeEntityTypes = [...this.activeEntityTypes(appName)];
+
+ return (
+ activeEntityTypes.length > 0 &&
+ activeEntityTypes.every((entityType) => this.batchedCountersPerEntityType.get(this.entityTypeKey(appName, entityType)))
+ );
+ }
+
+ private recounts(appName: string): Observable {
+ return merge(
+ merge(of(undefined), this.recountTrigger(appName)).pipe(debounceTime(0, asapScheduler)),
+ this.eventRecountTrigger(appName).pipe(debounceTime(this.notificationDebounceTime))
+ );
+ }
+
+ private recountTrigger(appName: string): Subject {
+ let recount$ = this.recountPerApp.get(appName);
+ if (!recount$) {
+ recount$ = new Subject();
+ this.recountPerApp.set(appName, recount$);
+ }
+
+ return recount$;
+ }
+
+ private eventRecountTrigger(appName: string): Subject {
+ let eventRecount$ = this.eventRecountPerApp.get(appName);
+ if (!eventRecount$) {
+ eventRecount$ = new Subject();
+ this.eventRecountPerApp.set(appName, eventRecount$);
+ }
+
+ return eventRecount$;
+ }
+
+ private buildRequest(filters: FilterCountersFilters): FilterCountersRequest {
+ const request: FilterCountersRequest = {};
+
+ const taskQueries = this.buildQueries(filters[FilterCounterEntityType.TASK], (filter) =>
+ this.taskListCloudService.buildQueryData(new TaskFilterCloudAdapter(filter))
+ );
+ if (taskQueries.length) {
+ request[FilterCounterEntityType.TASK] = taskQueries;
+ }
+
+ const processQueries = this.buildQueries(filters[FilterCounterEntityType.PROCESS_INSTANCE], (filter) =>
+ this.processListCloudService.buildQueryData(new ProcessFilterCloudAdapter(filter))
+ );
+ if (processQueries.length) {
+ request[FilterCounterEntityType.PROCESS_INSTANCE] = processQueries;
+ }
+
+ return request;
+ }
+
+ private buildQueries(
+ filters: T[],
+ buildQuery: (filter: T) => Omit
+ ): FilterCountersQuery[] {
+ return (filters ?? [])
+ .filter((filter) => filter?.showCounter && this.isCounterBatched(filter))
+ .map((filter) => {
+ try {
+ return { ...buildQuery(filter), requestId: filter.key as string };
+ } catch {
+ return undefined;
+ }
+ })
+ .filter((query): query is FilterCountersQuery => !!query);
+ }
+
+ private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable {
+ if (!Object.keys(request).length) {
+ return of({});
+ }
+
+ const queryUrl = `${this.getBasePath(appName)}/query/v1/count`;
+
+ return this.post(queryUrl, request).pipe(map((counters) => counters || {}));
+ }
+
+ private isCounterBatched(filter: FilterCounterCandidate): boolean {
+ return !!filter?.key;
+ }
+}
diff --git a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts
index ae0cfe68fc..7b5a46571a 100644
--- a/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts
+++ b/lib/process-services-cloud/src/lib/services/notification-cloud.service.ts
@@ -15,8 +15,9 @@
* limitations under the License.
*/
-import { gql } from '@apollo/client/core';
+import { FetchResult, gql } from '@apollo/client/core';
import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
import { WebSocketService } from './web-socket.service';
@Injectable({
providedIn: 'root'
@@ -24,8 +25,8 @@ import { WebSocketService } from './web-socket.service';
export class NotificationCloudService {
private readonly webSocketService = inject(WebSocketService);
- makeGQLQuery(appName: string, gqlQuery: string) {
- return this.webSocketService.getSubscription({
+ makeGQLQuery(appName: string, gqlQuery: string): Observable> {
+ return this.webSocketService.getSubscription({
apolloClientName: appName,
wsUrl: `${appName}/notifications`,
httpUrl: `${appName}/notifications/v2/ws/graphql`,
diff --git a/lib/process-services-cloud/src/lib/services/public-api.ts b/lib/process-services-cloud/src/lib/services/public-api.ts
index e3ab7c3b05..e300e35a83 100644
--- a/lib/process-services-cloud/src/lib/services/public-api.ts
+++ b/lib/process-services-cloud/src/lib/services/public-api.ts
@@ -17,6 +17,7 @@
export * from './base-cloud.service';
export * from './cloud-token.service';
+export * from './filter-counters-cloud.service';
export * from './form-fields.interfaces';
export * from './local-preference-cloud.service';
export * from './notification-cloud.service';
diff --git a/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts b/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts
index 9f47740424..402b1ae380 100644
--- a/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts
+++ b/lib/process-services-cloud/src/lib/services/web-socket.service.spec.ts
@@ -16,18 +16,39 @@
*/
import { TestBed } from '@angular/core/testing';
+import { Injectable } from '@angular/core';
import { Apollo, gql } from 'apollo-angular';
import { lastValueFrom, of, Subject } from 'rxjs';
import { WebSocketService } from './web-socket.service';
-import { SubscriptionOptions } from '@apollo/client/core';
+import { ApolloLink, execute, FetchResult, Observable as ApolloObservable, SubscriptionOptions } from '@apollo/client/core';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthenticationService, AppConfigService } from '@alfresco/adf-core';
+import { Client, ClientOptions, Sink, SubscribePayload } from 'graphql-ws';
+import { HttpLink } from 'apollo-angular/http';
+
+@Injectable()
+class TestWebSocketService extends WebSocketService {
+ public capturedOnError: (() => void) | undefined;
+
+ protected override createWsClient(clientOptions: ClientOptions): Client {
+ this.capturedOnError = clientOptions.on?.error as (() => void) | undefined;
+
+ return {
+ on: () => () => undefined,
+ subscribe: (_payload: SubscribePayload, _sink: Sink) => () => undefined,
+ async *iterate() {},
+ terminate: () => undefined,
+ dispose: () => undefined
+ };
+ }
+}
describe('WebSocketService', () => {
- let service: WebSocketService;
+ let service: TestWebSocketService;
const onLogoutSubject: Subject = new Subject();
- const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed']);
+ const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed', 'removeClient']);
+ const httpLinkMock = jasmine.createSpyObj('HttpLink', ['create']);
beforeEach(() => {
TestBed.configureTestingModule({
@@ -37,6 +58,14 @@ describe('WebSocketService', () => {
provide: Apollo,
useValue: apolloMock
},
+ {
+ provide: WebSocketService,
+ useClass: TestWebSocketService
+ },
+ {
+ provide: HttpLink,
+ useValue: httpLinkMock
+ },
{
provide: AppConfigService,
useValue: {
@@ -52,13 +81,15 @@ describe('WebSocketService', () => {
}
]
});
- service = TestBed.inject(WebSocketService);
+ service = TestBed.inject(WebSocketService) as TestWebSocketService;
apolloMock.use.and.returnValues(undefined, { subscribe: () => of({}) });
});
afterEach(() => {
apolloMock.use.calls.reset();
apolloMock.createNamed.calls.reset();
+ apolloMock.removeClient.calls.reset();
+ httpLinkMock.create.calls.reset();
});
it('should not create a new Apollo client if it is already in use', async () => {
@@ -95,7 +126,7 @@ describe('WebSocketService', () => {
const apolloClientName = 'testClient';
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
- apolloMock.createNamed.and.callFake((_, options) => {
+ apolloMock.createNamed.and.callFake((_: any, options: { headers: {} }) => {
headers = options.headers;
});
@@ -105,4 +136,74 @@ describe('WebSocketService', () => {
expect(apolloMock.createNamed).toHaveBeenCalled();
expect(headers).toEqual(expectedHeaders);
});
+
+ it('should recreate the subscription client when the websocket connection errors', async () => {
+ const apolloClientName = 'testClient';
+ const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
+ const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
+
+ await lastValueFrom(service.getSubscription(wsOptions));
+
+ expect(apolloMock.createNamed).toHaveBeenCalledTimes(1);
+ expect(apolloMock.removeClient).not.toHaveBeenCalled();
+
+ if (!service.capturedOnError) {
+ fail('Expected websocket error handler to be registered');
+ return;
+ }
+
+ service.capturedOnError();
+
+ expect(apolloMock.removeClient).toHaveBeenCalledWith(apolloClientName);
+ expect(apolloMock.createNamed).toHaveBeenCalledTimes(2);
+ expect(apolloMock.createNamed).toHaveBeenCalledWith(apolloClientName, jasmine.any(Object));
+ });
+
+ it('should retry the operation when a GraphQL error is unauthenticated', async () => {
+ const apolloClientName = 'testClient';
+ const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
+ const wsOptions = { apolloClientName, wsUrl: 'testUrl', httpUrl: 'testHttpUrl', subscriptionOptions };
+ const expectedResult: FetchResult = { data: { retried: true } };
+ let createdLink: ApolloLink | undefined;
+ let requestCount = 0;
+
+ httpLinkMock.create.and.returnValue(
+ new ApolloLink(
+ () =>
+ new ApolloObservable((observer) => {
+ requestCount++;
+
+ if (requestCount === 1) {
+ observer.next({
+ errors: [{ message: 'Unauthorized', extensions: { code: 'UNAUTHENTICATED' } }]
+ });
+ } else {
+ observer.next(expectedResult);
+ }
+
+ observer.complete();
+ })
+ )
+ );
+ apolloMock.createNamed.and.callFake((_clientName: any, options: { link: ApolloLink | undefined }) => {
+ createdLink = options.link;
+ });
+
+ await lastValueFrom(service.getSubscription(wsOptions));
+
+ if (!createdLink) {
+ fail('Expected Apollo link to be created');
+ return;
+ }
+
+ const result = await new Promise((resolve, reject) => {
+ execute(createdLink!, { query: gql(`query { testQuery }`) }).subscribe({
+ next: resolve,
+ error: reject
+ });
+ });
+
+ expect(requestCount).toBe(2);
+ expect(result).toEqual(expectedResult);
+ });
});
diff --git a/lib/process-services-cloud/src/lib/services/web-socket.service.ts b/lib/process-services-cloud/src/lib/services/web-socket.service.ts
index 4b9ebf95a9..c9e0e36076 100644
--- a/lib/process-services-cloud/src/lib/services/web-socket.service.ts
+++ b/lib/process-services-cloud/src/lib/services/web-socket.service.ts
@@ -15,10 +15,9 @@
* limitations under the License.
*/
-import { createClient } from 'graphql-ws';
+import { Client, ClientOptions, createClient } from 'graphql-ws';
import { inject, Injectable } from '@angular/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
-import { WebSocketLink } from '@apollo/client/link/ws';
import {
DefaultContext,
FetchResult,
@@ -56,9 +55,8 @@ export class WebSocketService {
private readonly authService = inject(AuthenticationService);
private readonly appConfigService = inject(AppConfigService);
- private readonly subscriptionProtocol: 'graphql-ws' | 'transport-ws' = 'graphql-ws';
- private wsLink: GraphQLWsLink | WebSocketLink;
- private httpLinkHandler: HttpLinkHandler;
+ private wsLink!: GraphQLWsLink;
+ private httpLinkHandler: HttpLinkHandler | undefined;
public getSubscription(options: serviceOptions): Observable> {
const { apolloClientName, subscriptionOptions } = options;
@@ -110,8 +108,7 @@ export class WebSocketService {
operation.setContext(({ headers }: DefaultContext) => ({
headers: {
...headers,
- ...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
- ...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
+ Authorization: `Bearer ${this.authService.getToken()}`
}
}));
return forward(operation);
@@ -120,8 +117,8 @@ export class WebSocketService {
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const error of graphQLErrors) {
- if (error.extensions && error.extensions['code'] === 'UNAUTHENTICATED') {
- authLink(operation, forward);
+ if (error.extensions?.['code'] === 'UNAUTHENTICATED') {
+ return authLink(operation, forward);
}
}
}
@@ -129,6 +126,8 @@ export class WebSocketService {
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
+
+ return undefined;
});
const retryLink = new RetryLink({
@@ -145,8 +144,7 @@ export class WebSocketService {
this.apollo.createNamed(options.apolloClientName, {
headers: {
- ...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
- ...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
+ Authorization: `Bearer ${this.authService.getToken()}`
},
link: from([authLink, retryLink, errorLink, link]),
cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig)
@@ -155,22 +153,28 @@ export class WebSocketService {
private createGraphQLWsLink(options: serviceOptions): void {
this.wsLink = new GraphQLWsLink(
- createClient({
+ this.createWsClient({
url: this.createWsUrl(options.wsUrl) + '/v2/ws/graphql',
connectionParams: () => ({
Authorization: 'Bearer ' + this.authService.getToken()
}),
on: {
- error: () => {
- this.apollo.removeClient(options.apolloClientName);
- this.initSubscriptions(options);
- }
+ error: () => this.reconnect(options)
},
lazy: true
})
);
}
+ protected createWsClient(clientOptions: ClientOptions): Client {
+ return createClient(clientOptions);
+ }
+
+ private reconnect(options: serviceOptions): void {
+ this.apollo.removeClient(options.apolloClientName);
+ this.initSubscriptions(options);
+ }
+
private createHttpLinkHandler(options: serviceOptions): void {
this.httpLinkHandler = options.httpUrl
? this.httpLink.create({
diff --git a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts
index 5fb1f21daf..6fda86d7a0 100644
--- a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts
+++ b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.spec.ts
@@ -17,10 +17,10 @@
import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core';
import { Component, SimpleChange } from '@angular/core';
-import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing';
+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 { first, NEVER, of, Subject, throwError } from 'rxjs';
+import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service';
import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock';
import { TaskFilterCloudService } from '../../services/task-filter-cloud.service';
@@ -35,6 +35,9 @@ import { TaskFilterCloudModel } from '../../models/filter-cloud.model';
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 { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
+import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
class DummyComponent {}
@@ -50,7 +53,10 @@ describe('TaskFiltersCloudComponent', () => {
let getTaskFilterCounterSpy: jasmine.Spy;
let getTaskListFiltersSpy: jasmine.Spy;
let getTaskListCountSpy: jasmine.Spy;
- let getTaskNotificationSubscriptionSpy: jasmine.Spy;
+ let getEngineEventsSpy: jasmine.Spy;
+ let filterCountersService: FilterCountersCloudService;
+ let getFilterCountersSpy: jasmine.Spy;
+ let refreshFilterCountersSpy: jasmine.Spy;
let router: Router;
const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => {
@@ -58,6 +64,7 @@ describe('TaskFiltersCloudComponent', () => {
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,
@@ -76,10 +83,15 @@ describe('TaskFiltersCloudComponent', () => {
});
taskFilterService = TestBed.inject(TaskFilterCloudService);
taskListService = TestBed.inject(TaskListCloudService);
+ filterCountersService = TestBed.inject(FilterCountersCloudService);
getTaskFilterCounterSpy = spyOn(taskFilterService, 'getTaskFilterCounter').and.returnValue(of(11));
getTaskListCountSpy = spyOn(taskListService, 'getTaskListCount').and.returnValue(of(11));
- getTaskNotificationSubscriptionSpy = spyOn(taskFilterService, 'getTaskNotificationSubscription').and.returnValue(of(taskNotifications));
- getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter));
+ 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 })
+ );
+ refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters');
appConfigService = TestBed.inject(AppConfigService);
@@ -261,7 +273,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();
@@ -269,7 +281,7 @@ describe('TaskFiltersCloudComponent', () => {
filterButton.click();
fixture.detectChanges();
- expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]);
+ expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
describe('Notifications config', () => {
@@ -306,30 +318,33 @@ describe('TaskFiltersCloudComponent', () => {
});
it('should not subscribe to notifications when appName is missing', () => {
- getTaskNotificationSubscriptionSpy.calls.reset();
+ getEngineEventsSpy.calls.reset();
component.appName = '';
fixture.detectChanges();
- expect(getTaskNotificationSubscriptionSpy).not.toHaveBeenCalled();
+ expect(getEngineEventsSpy).not.toHaveBeenCalled();
});
- it('should debounce notification subscription using the configured debounce time', fakeAsync(() => {
- const notifications$ = new Subject();
- getTaskNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable());
+ it('should subscribe to the notifications of the bound app', () => {
component.appName = 'my-app-1';
fixture.detectChanges();
- const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters');
+ expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK);
+ });
- notifications$.next(taskNotifications);
- tick(1000);
- expect(updateFilterCountersSpy).not.toHaveBeenCalled();
+ it('should emit the events of the debounced batch', fakeAsync(() => {
+ const events$ = new Subject();
+ getEngineEventsSpy.and.returnValue(events$.asObservable());
+ const filterCounterUpdatedSpy = spyOn(component.filterCounterUpdated, 'emit');
+ component.appName = 'my-app-1';
- tick(2000);
- expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1);
+ fixture.detectChanges();
+ events$.next(taskNotifications);
+
+ expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications);
flush();
}));
});
@@ -438,7 +453,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(
@@ -446,6 +461,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]));
});
});
@@ -658,17 +681,137 @@ 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(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', () => {
+ it('should read the counters without waiting for the filters', async () => {
+ getTaskListFiltersSpy.and.returnValue(NEVER);
+
+ await bindAppName();
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
+ });
+
+ it('should hold the counters until the filters they belong to arrive', async () => {
+ const filters$ = new Subject();
+ getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
+ getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
+
+ await bindAppName();
+ expect(component.counters['fake-involved-tasks']).toBeUndefined();
+
+ filters$.next(fakeGlobalFilter);
+ fixture.detectChanges();
+
+ expect(component.counters['fake-involved-tasks']).toBe(9);
+ });
+
+ it('should read the counters of the task filters of the bound app', async () => {
+ await bindAppName();
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
+ });
+
+ it('should not ask for the batched count endpoint by default', async () => {
+ await bindAppName();
+
+ expect(component.useBatchedCounters).toBeFalse();
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
+ });
+
+ it('should ask for the batched count endpoint when the input is set', async () => {
+ fixture.componentRef.setInput('useBatchedCounters', true);
+
+ await bindAppName();
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true);
+ });
+
+ it('should read the counters again when the input changes', async () => {
+ await bindAppName();
+ getFilterCountersSpy.calls.reset();
+
+ fixture.componentRef.setInput('useBatchedCounters', true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true);
+ });
+
+ 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(component.counters['fake-involved-tasks']).toBe(9);
+ });
+
+ 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(updatedFilterSpy).toHaveBeenCalledWith('fake-involved-tasks');
+ });
+
+ it('should resolve the counter of a filter the batch left out on its own', async () => {
+ getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: true }));
+
+ await bindAppName();
+
+ expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]);
+ expect(component.counters['fake-involved-tasks']).toBe(11);
+ });
+
+ it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
+ getTaskListFiltersSpy.and.returnValue(of([fakeGlobalFilter[0], { ...fakeGlobalFilter[1], showCounter: true }]));
+ getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 4 }, batched: true }));
+ getTaskFilterCounterSpy.and.throwError('the query of the filter cannot be built');
+
+ await bindAppName();
+
+ expect(component.counters['fake-involved-tasks']).toBe(4);
+ expect(component.counters['fake-my-task1']).toBe(0);
+ });
+
+ 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();
+
+ expect(getTaskFilterCounterSpy).toHaveBeenCalled();
+ expect(component.counters['fake-involved-tasks']).toBe(11);
+ });
+
+ it('should refresh the counters of every filter when a filter is clicked', async () => {
+ await bindAppName();
+
+ component.onFilterClick(fakeGlobalFilter[0]);
+
+ expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
+ });
+
+ 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();
+
+ component.onFilterClick(fakeGlobalFilter[0]);
+
+ expect(refreshFilterCountersSpy).not.toHaveBeenCalled();
+ expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
+ });
});
describe('Highlight Selected Filter', () => {
diff --git a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts
index 3c553aa7c2..54f2891877 100644
--- a/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts
+++ b/lib/process-services-cloud/src/lib/task/task-filters/components/task-filters/task-filters-cloud.component.ts
@@ -16,16 +16,18 @@
*/
import { Component, 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 { TaskFilterCloudService } from '../../services/task-filter-cloud.service';
import { FilterParamsModel, TaskFilterCloudModel } from '../../models/filter-cloud.model';
import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core';
-import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators';
+import { catchError, map } from 'rxjs/operators';
import { BaseTaskFiltersCloudComponent } from '../base-task-filters-cloud.component';
import { TaskDetailsCloudModel } from '../../../models/task-details-cloud.model';
import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service';
import { TaskFilterCloudAdapter } from '../../../../models/filter-cloud-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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
@@ -42,10 +44,21 @@ 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.
+ *
+ */
@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;
+
/** Emitted when a filter is being selected based on the filterParam input. */
@Output()
filterSelected = new EventEmitter();
@@ -69,9 +82,13 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
notificationDebounceTime = 3000;
currentFiltersValues: { [key: string]: number } = {};
private filtersLoadedFor?: string;
+ private countersSubscription?: Subscription;
+ private countersFilters$?: Observable;
+ private batchedCounters = true;
private readonly taskFilterCloudService = inject(TaskFilterCloudService);
private readonly taskListCloudService = inject(TaskListCloudService);
+ private readonly filterCountersCloudService = inject(FilterCountersCloudService);
private readonly translationService = inject(TranslationService);
private readonly appConfigService = inject(AppConfigService);
private readonly activatedRoute = inject(ActivatedRoute);
@@ -80,6 +97,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
ngOnInit() {
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
+
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
@@ -94,6 +112,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
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);
}
}
@@ -104,8 +124,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
- const filters$ = this.taskFilterCloudService.getTaskListFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true }));
- this.filters$ = filters$.pipe(catchError(() => EMPTY));
+ const filters$ = this.filterCountersCloudService.getTaskFilters(appName);
+ this.filters$ = filters$.pipe(catchError(() => of([])));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
@@ -113,13 +133,15 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.filters = res || [];
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
- this.updateFilterCounters();
this.success.emit(res);
},
error: (err) => {
this.error.emit(err);
}
});
+
+ this.countersFilters$ = filters$;
+ this.loadFilterCounters(appName);
}
/**
@@ -131,55 +153,47 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
/**
* 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: TaskFilterCloudModel) => this.updateFilterCounter(filter));
+ 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: TaskFilterCloudModel): void {
if (!filter?.showCounter) {
return;
}
- this.fetchTaskFilterCounter(filter)
+
+ defer(() => this.fetchTaskFilterCounter(filter))
.pipe(
- tap((filterCounter) => {
- this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter);
- })
+ catchError(() => EMPTY),
+ takeUntilDestroyed(this.destroyRef)
)
- .subscribe((data) => {
- this.counters = {
- ...this.counters,
- [filter.key]: data
- };
+ .subscribe((counter) => {
+ this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
+ this.counters = { ...this.counters, [filter.key]: counter };
});
}
- private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable {
- return this.searchApiMethod === 'POST'
- ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
- : this.taskFilterCloudService.getTaskFilterCounter(filter);
- }
-
- initFilterCounterNotifications() {
+ initFilterCounterNotifications(): void {
if (!this.appName) {
return;
}
- if (this.enableNotifications) {
- this.taskFilterCloudService
- .getTaskNotificationSubscription(this.appName)
- .pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef))
- .subscribe((result) => {
- result.forEach((taskEvent) => {
- this.checkFilterCounter(taskEvent.entity);
- });
- this.updateFilterCounters();
- this.filterCounterUpdated.emit(result);
+ if (this.enableNotifications) {
+ this.filterCountersCloudService
+ .getEngineEvents(this.appName, FilterCounterEntityType.TASK)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((events) => {
+ events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity));
+ this.filterCounterUpdated.emit(events);
});
} else {
this.counters = {};
@@ -240,7 +254,7 @@ 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.updatedCountersSet.delete(filter.key);
} else {
@@ -267,17 +281,9 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
return this.filters === undefined || (this.filters && this.filters.length === 0);
}
- /**
- * Reset the filters properties
- */
- private resetFilter() {
- this.filters = [];
- this.currentFilter = undefined;
- }
-
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
- this.currentFiltersValues[filterKey] = filterValue;
+ this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue };
this.updatedFilter.emit(filterKey);
this.updatedCountersSet.add(filterKey);
}
@@ -288,8 +294,69 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
*
*/
getFilterKeysAfterExternalRefreshing(): void {
- this.taskFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => {
- this.updatedCountersSet.delete(filterKey);
+ this.taskFilterCloudService.filterKeyToBeRefreshed$
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((filterKey: string) => this.updatedCountersSet.delete(filterKey));
+ }
+
+ private loadFilterCounters(appName: string): void {
+ if (!this.countersFilters$) {
+ return;
+ }
+
+ this.countersSubscription?.unsubscribe();
+ this.countersSubscription = combineLatest([
+ this.countersFilters$.pipe(catchError(() => of([]))),
+ this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK, this.useBatchedCounters)
+ ])
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(([, { counters, batched }]) => {
+ this.batchedCounters = batched;
+ if (batched) {
+ this.applyFilterCounters(counters);
+ } else {
+ this.updateFilterCounters();
+ }
+ });
+ }
+
+ private applyFilterCounters(counters: { [filterKey: string]: number }): void {
+ this.filters.forEach((filter) => {
+ const filterKey = filter?.showCounter ? filter.key : undefined;
+ if (!filterKey) {
+ return;
+ }
+
+ const counter = counters[filterKey];
+ if (counter === undefined) {
+ this.updateFilterCounter(filter);
+ return;
+ }
+
+ this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
+ this.counters = { ...this.counters, [filterKey]: counter };
});
}
+
+ private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable {
+ return this.searchApiMethod === 'POST'
+ ? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
+ : this.taskFilterCloudService.getTaskFilterCounter(filter);
+ }
+
+ /**
+ * Reset the filters properties
+ */
+ private resetFilter() {
+ this.filters = [];
+ this.currentFilter = undefined;
+ }
+
+ private refreshFilterCounter(filter: TaskFilterCloudModel): void {
+ if (this.batchedCounters) {
+ this.filterCountersCloudService.refreshFilterCounters(this.appName);
+ } else {
+ this.updateFilterCounter(filter);
+ }
+ }
}
diff --git a/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts b/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts
index 0018f4231f..e8a38a776b 100644
--- a/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts
+++ b/lib/process-services-cloud/src/lib/task/task-filters/services/task-filter-cloud.service.ts
@@ -361,6 +361,11 @@ export class TaskFilterCloudService extends BaseCloudService {
];
}
+ /**
+ * @deprecated use FilterCountersCloudService.getEngineEvents instead.
+ * @param appName Name of the target app
+ * @returns Task engine events
+ */
getTaskNotificationSubscription(appName: string): Observable {
return this.notificationCloudService
.makeGQLQuery(appName, TASK_EVENT_SUBSCRIPTION_QUERY)
diff --git a/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts b/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts
index 7d6f6ce04c..c46f7f7ef0 100644
--- a/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts
+++ b/lib/process-services-cloud/src/lib/task/task-list/services/task-list-cloud.service.ts
@@ -135,7 +135,7 @@ export class TaskListCloudService extends BaseCloudService implements TaskListCl
return this.post