Merge branch 'develop' into acs-10264-add-fix-for-visible-labels-to-be-announced

This commit is contained in:
Shivangi Shree
2026-09-08 12:56:50 +05:30
committed by GitHub
76 changed files with 3841 additions and 1071 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ runs:
- name: get latest tag sha
if: ${{ inputs.full-setup == 'true' }}
id: tag-sha
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@567a9393acca033008ce34eafcb200469e4ea2f5 # v18.27.0
- name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag
+3 -13
View File
@@ -1,9 +1,9 @@
{
"entries": {
"github/gh-aw-actions/setup@v0.85.4": {
"github/gh-aw-actions/setup@v0.87.10": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.85.4",
"sha": "2709137ea6c5b0e19aa621454dc643ea8dc526b1"
"version": "v0.87.10",
"sha": "bc8c008a419c5b7a29df6f5641edd35fd1c6ea85"
}
},
"containers": {
@@ -22,20 +22,10 @@
"digest": "sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.8": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.8",
"digest": "sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"
},
"ghcr.io/github/github-mcp-server:v1.8.0": {
"image": "ghcr.io/github/github-mcp-server:v1.8.0",
"digest": "sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"
}
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3.29.5
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3.29.5
# Override language selection by uncommenting this and choosing your languages
with:
languages: javascript
@@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3.29.5
uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3.29.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -53,4 +53,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3.29.5
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3.29.5
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
- name: Send Teams notification
if: steps.check_label_timing.outputs.should_notify == 'true'
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@567a9393acca033008ce34eafcb200469e4ea2f5 # v18.27.0
with:
webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }}
skip_checkout: true
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: develop
token: ${{ steps.app-token.outputs.token }}
- name: Pull translations from Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
skip_ref_checkout: true
upload_sources: false
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Push Source Files to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_sources_args: --delete-obsolete
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@567a9393acca033008ce34eafcb200469e4ea2f5 # v18.27.0
@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@567a9393acca033008ce34eafcb200469e4ea2f5 # v18.27.0
with:
comment-identifier: supply-chain-review-instructions
comment-body: |
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -8,10 +8,9 @@ permissions:
contents: read
pull-requests: read
model: gpt-5-mini
engine:
id: copilot
model: gpt-5-mini
tools:
github:
toolsets: [context, pull_requests, repos]
@@ -30,11 +29,12 @@ safe-outputs:
hide-older-comments: true
add-labels:
allowed: [security:low, security:medium, security:high]
issue-intent: false
remove-labels:
allowed: [security:low, security:medium, security:high]
submit-pull-request-review:
source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7bc0fc6f4f11df6c065b57d4a6aa90d7ea362b2f
source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@e35840d877477896b1f0aa05d05371cb3b31ce9f
---
# Supply Chain Review
+3 -3
View File
@@ -91,7 +91,7 @@ jobs:
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
@@ -117,7 +117,7 @@ jobs:
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
path: coverage-reports
@@ -138,7 +138,7 @@ jobs:
echo "Coverage files found:"
find coverage -name 'lcov.info' -type f
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
@@ -27,6 +27,7 @@ Lists all available process filters and allows to select a filter.
| appName | `string` | "" | (required) The application name |
| filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default |
| showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
@@ -36,6 +36,7 @@ Shows all available filters.
| appName | `string` | "" | Display filters available to the current user for the application with the specified name. |
| filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. |
| showIcons | `boolean` | false | Toggles display of the filter's icons. |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
@@ -32,11 +32,13 @@ export interface QaFeaturesHelperConfig {
helperExposeKeyOnDocument?: string;
}
export interface FlagChangesetValues {
current: any;
previous: any;
}
export interface FlagChangeset {
[key: string]: {
current: any;
previous: any;
};
[key: string]: FlagChangesetValues;
}
export interface WritableFlagChangeset {
@@ -0,0 +1,133 @@
/*!
* @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 { TestBed } from '@angular/core/testing';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface';
import { MockFeatureFlags, provideMockFeatureFlags } from './features-service-mock.factory';
describe('provideMockFeatureFlags', () => {
let featureValue$: BehaviorSubject<boolean>;
const feature1 = 'feature1';
const setupFeatureService = (featureFlags: MockFeatureFlags | string | string[]): IFeaturesService => {
TestBed.configureTestingModule({
providers: [provideMockFeatureFlags(featureFlags)]
});
return TestBed.inject(FeaturesServiceToken);
};
beforeEach(() => {
featureValue$ = new BehaviorSubject(false);
});
it('should emit the updated value when an observable feature flag changes', async () => {
const featuresService = setupFeatureService({ [feature1]: featureValue$ });
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
featureValue$.next(true);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(false);
featureValue$.next(false);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
});
it('should not let a consumer change the feature flag value through the observable returned by isOn$', async () => {
const originalValue$ = new BehaviorSubject(true);
const service = setupFeatureService({ [feature1]: originalValue$ });
const isOn$ = service.isOn$(feature1) as Subject<boolean>;
expect(isOn$).not.toBe(originalValue$);
expect(() => isOn$.next(false)).toThrow();
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(originalValue$)).toBe(true);
});
it('should mock a string feature flag as enabled', async () => {
const service = setupFeatureService(feature1);
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should mock every feature flag in an array as enabled', async () => {
const feature2 = 'feature2';
const service = setupFeatureService([feature1, feature2]);
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null },
[feature2]: { current: true, previous: null }
});
});
it('should emit an empty changeset when no feature flags are provided', async () => {
const service = setupFeatureService({});
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should emit an empty changeset when an empty feature flag array is provided', async () => {
const service = setupFeatureService([]);
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should return the expected state when the feature flag is true', async () => {
const service = setupFeatureService({ [feature1]: true });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: true, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should return the expected state when the feature flag is false', async () => {
const service = setupFeatureService({ [feature1]: false });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: false, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(true);
});
it('should resolve observable values in the complete feature flags result', async () => {
const service = setupFeatureService({ [feature1]: of(true) });
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null }
});
});
it('should throw when a feature flag has not been mocked', () => {
const featuresService = setupFeatureService({ [feature1]: true });
expect(() => featuresService.isOn$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
expect(() => featuresService.isOff$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
});
});
@@ -15,11 +15,27 @@
* limitations under the License.
*/
import { of } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
import { of, Observable, map, combineLatest, take, defer } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, FlagChangesetValues, IFeaturesService } from '../interfaces/features.interface';
/**
* Feature flags to mock. A boolean sets a fixed value, an observable lets the test change the value over time.
*
* Observable flags must have a current value, use 'BehaviorSubject' or 'of(...)'.
* A bare 'Subject' has no value until it emits, and 'getFlags$()'/'init()' withhold the whole changeset
* until every observable flag has emitted at least once.
*/
export interface MockFeatureFlags {
[key: string]: boolean;
[key: string]: boolean | Observable<boolean>;
}
interface MockFlagChangesetValues extends FlagChangesetValues {
current: boolean | Observable<boolean>;
previous: null;
}
interface MockFlagChangeset extends FlagChangeset {
[key: string]: MockFlagChangesetValues;
}
const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
@@ -32,19 +48,72 @@ const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
}
};
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({
init: () => of(flagChangeset),
/**
* Calling 'pipe' on a 'Subject' returns an 'AnonymousSubject' that still writes through to the original 'Subject',
* so piping alone does not stop a consumer from pushing values into the mocked flag.
* 'defer' breaks that chain and gives back a plain, read only observable.
*
* @param value$ Observable feature flag value provided by the test
* @returns Observable that cannot be used to change the mocked value
*/
const toReadOnly = (value$: Observable<boolean>): Observable<boolean> => defer(() => value$);
const mockFeaturesService = (flagChangeset: MockFlagChangeset): IFeaturesService => ({
init: () => resolveFeatureFlagValues(flagChangeset).pipe(take(1)),
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
// In case of an observable, we do not want to return the original observable, so a consumer cannot 'next', 'error' or 'complete' it
return typeof featureFlagValue === 'boolean' ? of(featureFlagValue) : toReadOnly(featureFlagValue).pipe(map(Boolean));
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
return typeof featureFlagValue === 'boolean' ? of(!featureFlagValue) : toReadOnly(featureFlagValue).pipe(map((value) => !value));
},
getFlags$: () => of(flagChangeset)
getFlags$: () => resolveFeatureFlagValues(flagChangeset)
});
/**
* 'provideMockFeatureFlags' can receive observables, therefore we need to resolve these values
*
* @param mockFlagChangeset Mocked flag changeset
* @returns FlagChangeset
*/
const resolveFeatureFlagValues = (mockFlagChangeset: MockFlagChangeset): Observable<FlagChangeset> => {
// No FF provided, just return empty object
if (Object.keys(mockFlagChangeset).length === 0) {
return of({});
}
const resolveFeatureFlagValues$ = Object.entries(mockFlagChangeset).map(([featureKey, values]) => {
if (typeof values.current === 'boolean') {
return of([featureKey, { ...values }] as const);
}
// Value is observable, we need to resolve it
const observableValue$ = values.current;
const resolveFlagValue$ = observableValue$.pipe(map((resolvedValue) => [featureKey, { current: resolvedValue, previous: null }] as const));
return resolveFlagValue$;
});
return combineLatest(resolveFeatureFlagValues$).pipe(
map((resolvedFlags) => {
const resolvedFeatureFlag: FlagChangeset = {};
resolvedFlags.forEach(([key, values]) => {
resolvedFeatureFlag[key] = values;
});
return resolvedFeatureFlag;
})
);
};
const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
const flagChangeset: FlagChangeset = {};
featureFlags.forEach((featureFlag) => {
@@ -62,6 +131,26 @@ const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) =>
return flagChangeset;
};
/**
* Mock the FeaturesService with the provided feature flags.
* A string or string[] sets every listed feature to true, a MockFeatureFlags object sets each value explicitly.
* Every flag the code under test asks for has to be mocked, otherwise 'isOn$'/'isOff$' throw.
*
* Use a 'BehaviorSubject' for a flag that changes during the test. A bare 'Subject' has no current value,
* so the flag stays silent and 'getFlags$()'/'init()' withhold the whole changeset, until it emits.
*
* @example
*
* const featureA$ = new BehaviorSubject(false);
*
* providers: [provideMockFeatureFlags('featureA')]
* providers: [provideMockFeatureFlags(['featureA', 'featureB'])]
* providers: [provideMockFeatureFlags({ featureA: true, featureB: false })]
* providers: [provideMockFeatureFlags({ featureA: featureA$ })]
*
* @param featureFlag The feature flag(s) to mock. Can be a single feature flag string, an array of feature flag strings, or a MockFeatureFlags object.
* @returns A provider object for the FeaturesServiceToken with the mocked feature flags.
*/
export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => {
if (typeof featureFlag === 'string') {
featureFlag = [featureFlag];
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-with-rule copy</title><desc>Created with Sketch.</desc><g id="folder-with-rule-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-with-rules-active"><g id="folder-with-rules"><g id="Group"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#D9E021" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><polygon id="Fill-1-Copy" fill="#FFF" points="14.862 9 16.436 10.574 13.495 13.515 10.55 10.574 12.125 9 8 9 8 13.125 9.575 11.551 12.811 14.789 12.811 18 14.186 18 14.186 14.776 17.412 11.551 18.986 13.125 18.986 9"/></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-with-rule copy</title><desc>Created with Sketch.</desc><g id="folder-with-rule-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-with-rules-active"><g id="folder-with-rules"><g id="Group"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#2A7DE1" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><polygon id="Fill-1-Copy" fill="#FFF" points="14.862 9 16.436 10.574 13.495 13.515 10.55 10.574 12.125 9 8 9 8 13.125 9.575 11.551 12.811 14.789 12.811 18 14.186 18 14.186 14.776 17.412 11.551 18.986 13.125 18.986 9"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 861 B

After

Width:  |  Height:  |  Size: 861 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-shortcut-link copy</title><desc>Created with Sketch.</desc><g id="folder-shortcut-link-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-shortcut-link-active"><g id="folder-shortcut-link"><g id="Group-9"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Path-2-Copy" fill="#D9E021" d="M2.70933755,4.54327602 L2,5.924926 L2,18.8121399 L2.69466158,19.5150573 C2.88252616,19.7051546 3.13866895,19.8121399 3.40593302,19.8121399 L20.6045166,19.8121399 L21.3116234,19.1050331 C21.4991598,18.9174967 21.6045166,18.6631428 21.6045166,18.3979263 L21.6045166,7.00924449 L20.9015261,6.24697884 C20.7122055,6.04169503 20.4456711,5.924926 20.1664154,5.924926 L11.1733882,5.924926 C10.8510886,5.924926 10.548546,5.76958526 10.3607292,5.50766556 L9.57882415,4.41726045 C9.39100739,4.15534074 9.08846483,4 8.76616517,4 L3.59894601,4 C3.2240117,4 2.88057904,4.20973129 2.70933755,4.54327602 Z"/><path id="Fill-2" fill="#FFF" d="M15.75,10 L13.15,10 L13.15,11.235 L15.75,11.235 C16.8615,11.235 17.765,12.1385 17.765,13.25 C17.765,14.3615 16.8615,15.265 15.75,15.265 L13.15,15.265 L13.15,16.5 L15.75,16.5 C17.544,16.5 19,15.044 19,13.25 C19,11.456 17.544,10 15.75,10 L15.75,10 Z M9.9,13.9 L15.1,13.9 L15.1,12.6 L9.9,12.6 L9.9,13.9 Z M7.235,13.25 C7.235,12.1385 8.1385,11.235 9.25,11.235 L11.85,11.235 L11.85,10 L9.25,10 C7.456,10 6,11.456 6,13.25 C6,15.044 7.456,16.5 9.25,16.5 L11.85,16.5 L11.85,15.265 L9.25,15.265 C8.1385,15.265 7.235,14.3615 7.235,13.25 L7.235,13.25 Z"/></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-shortcut-link copy</title><desc>Created with Sketch.</desc><g id="folder-shortcut-link-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-shortcut-link-active"><g id="folder-shortcut-link"><g id="Group-9"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Path-2-Copy" fill="#2A7DE1" d="M2.70933755,4.54327602 L2,5.924926 L2,18.8121399 L2.69466158,19.5150573 C2.88252616,19.7051546 3.13866895,19.8121399 3.40593302,19.8121399 L20.6045166,19.8121399 L21.3116234,19.1050331 C21.4991598,18.9174967 21.6045166,18.6631428 21.6045166,18.3979263 L21.6045166,7.00924449 L20.9015261,6.24697884 C20.7122055,6.04169503 20.4456711,5.924926 20.1664154,5.924926 L11.1733882,5.924926 C10.8510886,5.924926 10.548546,5.76958526 10.3607292,5.50766556 L9.57882415,4.41726045 C9.39100739,4.15534074 9.08846483,4 8.76616517,4 L3.59894601,4 C3.2240117,4 2.88057904,4.20973129 2.70933755,4.54327602 Z"/><path id="Fill-2" fill="#FFF" d="M15.75,10 L13.15,10 L13.15,11.235 L15.75,11.235 C16.8615,11.235 17.765,12.1385 17.765,13.25 C17.765,14.3615 16.8615,15.265 15.75,15.265 L13.15,15.265 L13.15,16.5 L15.75,16.5 C17.544,16.5 19,15.044 19,13.25 C19,11.456 17.544,10 15.75,10 L15.75,10 Z M9.9,13.9 L15.1,13.9 L15.1,12.6 L9.9,12.6 L9.9,13.9 Z M7.235,13.25 C7.235,12.1385 8.1385,11.235 9.25,11.235 L11.85,11.235 L11.85,10 L9.25,10 C7.456,10 6,11.456 6,13.25 C6,15.044 7.456,16.5 9.25,16.5 L11.85,16.5 L11.85,15.265 L9.25,15.265 C8.1385,15.265 7.235,14.3615 7.235,13.25 L7.235,13.25 Z"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>filetype/folder</title><desc>Created with Sketch.</desc><g id="Symbols" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="filetype/folder"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#D9E021" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><g id="cog" fill="#FFF" fill-rule="nonzero" transform="translate(10.000000, 8.000000)"><path id="Shape" d="M8.97602103,5.70765953 L9.87354689,6.20360587 C9.97604967,6.2602388 10.0236259,6.37735544 9.98851764,6.48556081 C9.7553749,7.20414773 9.3573263,7.85333966 8.83672508,8.39253187 C8.75648164,8.47568691 8.62677806,8.49455592 8.52435171,8.4379753 L7.6274998,7.9421096 C7.25015532,8.25161622 6.81633302,8.49173291 6.34786835,8.65037397 L6.34786835,9.64194408 C6.34787008,9.75522045 6.26583319,9.85335946 6.15038149,9.87819349 C5.41418114,10.0364592 4.62349658,10.0446447 3.85014503,9.87833462 C3.73452139,9.85347581 3.65211059,9.7554317 3.65211059,9.6419844 L3.65211059,8.65037397 C3.18364144,8.49174219 2.74981745,8.25162456 2.37247914,7.9421096 L1.47562723,8.4379753 C1.37320088,8.49455592 1.24349729,8.47568691 1.16325386,8.39253187 C0.642673695,7.85333966 0.244604043,7.20414773 0.011461297,6.48556081 C-0.02364701,6.3773756 0.023929274,6.26025896 0.126432051,6.20360587 L1.02397897,5.70765953 C0.933828729,5.23983992 0.933828729,4.76018024 1.02397897,4.29236063 L0.126453112,3.79641429 C0.0239503348,3.73978137 -0.0236259492,3.62266472 0.0114823578,3.51445935 C0.244625104,2.79587243 0.642673695,2.1466805 1.16327492,1.60748829 C1.24351836,1.52433325 1.37322194,1.50546424 1.47564829,1.56204486 L2.3725002,2.05791056 C2.74984468,1.74840395 3.18366698,1.50828725 3.65213165,1.34964619 L3.65213165,0.358055922 C3.65212992,0.244779553 3.73416681,0.146640538 3.84961851,0.12180651 C4.58581886,-0.0364592244 5.37650342,-0.0446446879 6.14985497,0.121665382 C6.26547861,0.146524191 6.34788941,0.244568301 6.34788941,0.358015599 L6.34788941,1.34962603 C6.81635856,1.50825781 7.25018255,1.74837544 7.62752086,2.0578904 L8.52437277,1.5620247 C8.62679912,1.50544408 8.75650271,1.52431309 8.83674614,1.60746813 C9.3573263,2.14666034 9.75539596,2.79585227 9.9885387,3.51443919 C10.023647,3.6226244 9.97607073,3.73974104 9.87356795,3.79639413 L8.97602103,4.29234047 C9.06617149,4.76016684 9.06617149,5.23983316 8.97602103,5.70765953 Z M6.68486176,5 C6.68486176,4.11064738 5.92903278,3.38710079 5,3.38710079 C4.07096722,3.38710079 3.31513824,4.11064738 3.31513824,5 C3.31513824,5.88935262 4.07096722,6.61289921 5,6.61289921 C5.92903278,6.61289921 6.68486176,5.88935262 6.68486176,5 Z"/></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>filetype/folder</title><desc>Created with Sketch.</desc><g id="Symbols" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="filetype/folder"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#2A7DE1" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><g id="cog" fill="#FFF" fill-rule="nonzero" transform="translate(10.000000, 8.000000)"><path id="Shape" d="M8.97602103,5.70765953 L9.87354689,6.20360587 C9.97604967,6.2602388 10.0236259,6.37735544 9.98851764,6.48556081 C9.7553749,7.20414773 9.3573263,7.85333966 8.83672508,8.39253187 C8.75648164,8.47568691 8.62677806,8.49455592 8.52435171,8.4379753 L7.6274998,7.9421096 C7.25015532,8.25161622 6.81633302,8.49173291 6.34786835,8.65037397 L6.34786835,9.64194408 C6.34787008,9.75522045 6.26583319,9.85335946 6.15038149,9.87819349 C5.41418114,10.0364592 4.62349658,10.0446447 3.85014503,9.87833462 C3.73452139,9.85347581 3.65211059,9.7554317 3.65211059,9.6419844 L3.65211059,8.65037397 C3.18364144,8.49174219 2.74981745,8.25162456 2.37247914,7.9421096 L1.47562723,8.4379753 C1.37320088,8.49455592 1.24349729,8.47568691 1.16325386,8.39253187 C0.642673695,7.85333966 0.244604043,7.20414773 0.011461297,6.48556081 C-0.02364701,6.3773756 0.023929274,6.26025896 0.126432051,6.20360587 L1.02397897,5.70765953 C0.933828729,5.23983992 0.933828729,4.76018024 1.02397897,4.29236063 L0.126453112,3.79641429 C0.0239503348,3.73978137 -0.0236259492,3.62266472 0.0114823578,3.51445935 C0.244625104,2.79587243 0.642673695,2.1466805 1.16327492,1.60748829 C1.24351836,1.52433325 1.37322194,1.50546424 1.47564829,1.56204486 L2.3725002,2.05791056 C2.74984468,1.74840395 3.18366698,1.50828725 3.65213165,1.34964619 L3.65213165,0.358055922 C3.65212992,0.244779553 3.73416681,0.146640538 3.84961851,0.12180651 C4.58581886,-0.0364592244 5.37650342,-0.0446446879 6.14985497,0.121665382 C6.26547861,0.146524191 6.34788941,0.244568301 6.34788941,0.358015599 L6.34788941,1.34962603 C6.81635856,1.50825781 7.25018255,1.74837544 7.62752086,2.0578904 L8.52437277,1.5620247 C8.62679912,1.50544408 8.75650271,1.52431309 8.83674614,1.60746813 C9.3573263,2.14666034 9.75539596,2.79585227 9.9885387,3.51443919 C10.023647,3.6226244 9.97607073,3.73974104 9.87356795,3.79639413 L8.97602103,4.29234047 C9.06617149,4.76016684 9.06617149,5.23983316 8.97602103,5.70765953 Z M6.68486176,5 C6.68486176,4.11064738 5.92903278,3.38710079 5,3.38710079 C4.07096722,3.38710079 3.31513824,4.11064738 3.31513824,5 C3.31513824,5.88935262 4.07096722,6.61289921 5,6.61289921 C5.92903278,6.61289921 6.68486176,5.88935262 6.68486176,5 Z"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -67,19 +67,25 @@ const renderDateCell = (dateConfig: DateConfig, value: number | string | Date, t
};
const checkDisplayedDate = (expectedDate: string) => {
const displayedDate = testingUtils.getByCSS('span').nativeElement.textContent.trim();
const displayedDate = testingUtils.getByCSS('time').nativeElement.textContent.trim();
expect(displayedDate).toBeTruthy();
expect(displayedDate).toBe(expectedDate);
};
const checkDisplayedTooltip = (expectedTooltip: string) => {
const displayedTooltip = testingUtils.getByCSS('span').nativeElement.title;
const displayedTooltip = testingUtils.getByCSS('time').nativeElement.title;
expect(displayedTooltip).toBeTruthy();
expect(displayedTooltip).toBe(expectedTooltip);
};
const checkDatetimeAttribute = (expectedIso: string) => {
const datetime = testingUtils.getByCSS('time').nativeElement.getAttribute('datetime');
expect(datetime).toBe(expectedIso);
};
const configureTestingModule = (providers: any[]) => {
TestBed.configureTestingModule({
imports: [DateCellComponent],
@@ -141,7 +147,7 @@ describe('DateCellComponent', () => {
expect(component.config().locale).toEqual('en-US');
});
it('should display date and tooltip with defaules values if NO dateConfig or appConfig is provided', () => {
it('should display date and tooltip with default values if NO dateConfig or appConfig is provided', () => {
appConfigService.config = {
dateValues: {}
};
@@ -232,6 +238,16 @@ describe('DateCellComponent', () => {
renderDateCell(mockDateConfig, mockTimestamp);
checkDisplayedDate(expectedDate);
});
it('should render a datetime attribute with the full ISO date value', () => {
const mockDateConfig: DateConfig = {
format: 'short',
tooltipFormat: 'short'
};
renderDateCell(mockDateConfig, mockDate);
checkDatetimeAttribute(mockDate.toISOString());
});
});
describe('DateCellComponent locale', () => {
@@ -26,7 +26,9 @@ import { toSignal } from '@angular/core/rxjs-interop';
selector: 'adf-date-cell',
template: `
@if (formattedDate()) {
<span [title]="title()" class="adf-datatable-cell-value">{{ formattedDate() }}</span>
<time [attr.datetime]="isoDate()" [attr.aria-label]="title() || null" [title]="title()" class="adf-datatable-cell-value"
>{{ formattedDate() }}
</time>
}
`,
encapsulation: ViewEncapsulation.None,
@@ -55,6 +57,14 @@ export class DateCellComponent extends DataTableCellComponent implements OnInit
// Convert value$ observable to signal for reactive computation
private readonly dateValue = toSignal(this.value$);
// Computed signal that automatically formats the date to ISO string for datetime attribute
protected readonly isoDate = computed(() => {
const date = this.dateValue();
const parsed = new Date(date);
return isNaN(parsed.getTime()) ? null : parsed.toISOString();
});
// Computed signal that automatically formats the date based on value and config
protected readonly formattedDate = computed(() => {
const date = this.dateValue();
@@ -0,0 +1,76 @@
/*!
* @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 { TestBed } from '@angular/core/testing';
import { ElementRef } from '@angular/core';
import { DropZoneDirective } from './drop-zone.directive';
describe('DropZoneDirective', () => {
let directive: DropZoneDirective;
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
TestBed.configureTestingModule({
providers: [{ provide: ElementRef, useValue: new ElementRef(element) }]
});
directive = TestBed.runInInjectionContext(() => new DropZoneDirective());
directive.dropTarget = 'cell';
directive.ngOnInit();
});
it('should dispatch a namespaced custom event on dragenter while attached', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.dispatchEvent(new DragEvent('dragenter'));
expect(dispatched).toContain('cell-dragenter');
});
it('should not handle drag events after the directive is destroyed', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.addEventListener('cell-dragover', () => dispatched.push('cell-dragover'));
element.addEventListener('cell-drop', () => dispatched.push('cell-drop'));
directive.ngOnDestroy();
element.dispatchEvent(new DragEvent('dragenter'));
element.dispatchEvent(new DragEvent('dragover'));
element.dispatchEvent(new DragEvent('drop'));
expect(dispatched).toEqual([]);
});
it('should remove listeners using the same references that were added', () => {
const addSpy = spyOn(element, 'addEventListener').and.callThrough();
const removeSpy = spyOn(element, 'removeEventListener').and.callThrough();
directive.ngOnInit();
directive.ngOnDestroy();
const addedByEvent = new Map<string, EventListenerOrEventListenerObject>();
addSpy.calls.allArgs().forEach(([evt, fn]) => addedByEvent.set(evt as string, fn as EventListenerOrEventListenerObject));
removeSpy.calls.allArgs().forEach(([evt, fn]) => {
expect(fn).toBe(addedByEvent.get(evt as string));
});
});
});
@@ -36,6 +36,10 @@ export class DropZoneDirective implements OnInit, OnDestroy {
@Input()
dropColumn: DataColumn;
private readonly onDragEnterHandler = this.onDragEnter.bind(this);
private readonly onDragOverHandler = this.onDragOver.bind(this);
private readonly onDropHandler = this.onDrop.bind(this);
constructor() {
const elementRef = inject(ElementRef);
@@ -44,16 +48,16 @@ export class DropZoneDirective implements OnInit, OnDestroy {
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
this.element.addEventListener('dragenter', this.onDragEnter.bind(this));
this.element.addEventListener('dragover', this.onDragOver.bind(this));
this.element.addEventListener('drop', this.onDrop.bind(this));
this.element.addEventListener('dragenter', this.onDragEnterHandler);
this.element.addEventListener('dragover', this.onDragOverHandler);
this.element.addEventListener('drop', this.onDropHandler);
});
}
ngOnDestroy() {
this.element.removeEventListener('dragenter', this.onDragEnter);
this.element.removeEventListener('dragover', this.onDragOver);
this.element.removeEventListener('drop', this.onDrop);
this.element.removeEventListener('dragenter', this.onDragEnterHandler);
this.element.removeEventListener('dragover', this.onDragOverHandler);
this.element.removeEventListener('drop', this.onDropHandler);
}
onDragEnter(event: DragEvent) {
@@ -98,7 +98,7 @@
[hidden]="!currentRootElement?.isVisible"
>
<adf-repeat-widget [element]="currentRootElement" [isEditor]="false">
@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;
<div
class="adf-grid-list-container"
@@ -125,11 +125,11 @@
}
</div>
<section class="adf-grid-list-column-view">
@for (column of row.columns; track column; let columnIndex = $index) {
@for (column of row.columns; track column.id; let columnIndex = $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)">
@for (field of column?.fields; track field) {
@for (field of column?.fields; track field.id) {
@if (field.type === 'section') {
<adf-form-section [field]="field"/>
} @else {
@@ -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 {
@@ -10,7 +10,11 @@
>
</div>
<div class="adf-amount-widget-container">
<mat-form-field class="adf-amount-widget__input adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-amount-widget__input adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</mat-label> }
@if(!enableDisplayBasedOnLocale) {
<span matTextPrefix class="adf-amount-widget__prefix-spacing">{{ currency }}&nbsp;</span>
@@ -32,12 +36,13 @@
(blur)="amountWidgetOnBlur()"
/>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -20,4 +20,5 @@ export interface FormVariableModel {
name: string;
type: string;
value?: any;
runtimeSet?: boolean;
}
@@ -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', () => {
@@ -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];
}
@@ -10,6 +10,7 @@
</div>
<div class="adf-date-time-widget-container">
<mat-form-field class="adf-date-time-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
[floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) {
@@ -38,11 +39,12 @@
[timeInterval]="5"
[disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -1,7 +1,7 @@
<div class="{{ field.className }} date-widget-container" id="data-widget"
[class.adf-invalid]="dateInputControl.invalid && dateInputControl.touched"
[class.adf-readonly]="field.readOnly">
<mat-form-field class="adf-date-widget adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-date-widget adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
<mat-label class="adf-label"
[id]="field.id + '-label'"
[attr.for]="field.id">
@@ -22,11 +22,12 @@
[startAt]="startAt"
[disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@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 } }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
@@ -9,7 +9,7 @@
</div>
<div class="adf-decimal-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> }
<input matInput
class="adf-input"
@@ -25,12 +25,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()" />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -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);
@@ -7,6 +7,7 @@
<mat-form-field
floatPlaceholder="never"
class="adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-has-counter]="field.maxLength > 0"
[floatLabel]="field.placeholder ? 'always' : null"
>
@@ -31,14 +32,16 @@
>
</textarea>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
@if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>}
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
} @else if (field.maxLength > 0) {
<mat-hint class="adf-multiline-hint">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
<mat-hint class="adf-multiline-hint adf-form-field-status-slot">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
} @else {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
</div>
@@ -9,7 +9,7 @@
</label>
</div>
<div class="adf-number-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
@@ -30,12 +30,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()">
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -8,7 +8,11 @@
</label>
</div>
<div class="adf-text-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || this.field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
</mat-label>
@@ -30,7 +34,7 @@
(paste)="onPaste($event)"
(blur)="onBlur()">
@if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<span class="adf-error-text">
@if (maxLengthPasteError.isActive()) {
@@ -43,6 +47,9 @@
</span>
</mat-error>
}
@if (!fieldStatusTemplate) {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
<ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" />
<ng-template #maxLengthPasteErrorTemplate>
@@ -18,5 +18,5 @@
export class TaskProcessVariableModel {
id?: string;
type?: string;
value: string;
value: any;
}
@@ -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);
});
});
});
@@ -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;
}
@@ -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';
+3 -3
View File
@@ -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",
@@ -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,
@@ -2575,3 +2575,126 @@ describe('FormCloudComponent — runtime state preservation on data refresh', ()
});
});
});
describe('FormCloudComponent — form variable visibility on data refresh', () => {
let fixture: ComponentFixture<FormCloudComponent>;
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();
});
});
@@ -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<string, TaskVariableCloud>();
for (const variable of [...existing, ...updates]) {
if (variable?.name) {
byName.set(variable.name, variable);
}
}
this.formCloudRepresentationJSON.processVariables = Array.from(byName.values());
}
private snapshotRuntimeState(): Map<string, FormFieldRuntimeState> {
const snapshot = new Map<string, FormFieldRuntimeState>();
if (!this.form) {
@@ -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;
}
}
@@ -15,7 +15,11 @@
>
</div>
<div class="adf-date-widget-container">
<mat-form-field class="adf-date-widget adf-form-field-input" [class.adf-left-label-input-datepicker]="field.leftLabels">
<mat-form-field
class="adf-date-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
>
@if ( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">
{{field.name | translate }} ({{field.dateDisplayFormat}})
@@ -36,12 +40,13 @@
<mat-datepicker-toggle matSuffix [for]="datePicker" [disabled]="field.readOnly" />
<mat-datepicker #datePicker [startAt]="startAt" [disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@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 } }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -11,7 +11,7 @@
</div>
<div>
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id"> {{ field.name | translate }} </mat-label>
}
@@ -32,8 +32,9 @@
</span>
</ng-container>
@if (propertyLoadFailed && !previewState) {
<mat-error><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error>
<mat-error class="adf-form-field-status-slot"><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -12,7 +12,7 @@
</div>
}
<div class="adf-dropdown-widget-container">
<mat-form-field class="adf-form-field-input">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic">
@if ( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label>
}
@@ -49,12 +49,13 @@
}
</mat-select>
@if ((dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) || (!previewState && !field.readOnly && (isRestApiFailed || variableOptionsFailed))) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@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 }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -45,8 +45,4 @@
word-break: break-word;
}
}
&-radio-group-error-message .adf-error-container {
margin-top: 5px;
}
}
@@ -63,6 +63,7 @@
}
.adf-error {
padding-top: 3px;
animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
}
}
@@ -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;
}
@@ -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);
}
@@ -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<ProcessFiltersCloudComponent>;
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<any>(component, 'fetchProcessFilterCounter').and.returnValue(of(42));
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true });
const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false });
getProcessCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getProcessCounterSpy).toHaveBeenCalledTimes(1);
expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status);
});
describe('Batched counters', () => {
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<TaskCloudEngineEvent[]>();
getProcessNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable());
it('should keep the counters in sync with the counters stream', fakeAsync(() => {
const counters$ = new Subject<FilterCountersResult>();
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<FilterCountersResult>();
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<FilterCountersResult>();
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', () => {
@@ -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<string>();
enableNotifications = true;
notificationDebounceTime = 3000;
private filtersLoadedFor?: string;
private countersSubscription?: Subscription;
private countersFilters$?: Observable<ProcessFilterCloudModel[]>;
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<number> {
return this.searchApiMethod === 'POST'
? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter))
@@ -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<TaskCloudEngineEvent[]> {
return this.notificationCloudService
.makeGQLQuery(appName, PROCESS_EVENT_SUBSCRIPTION_QUERY)
@@ -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,
@@ -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<FilterCounters>;
}
describe('FilterCountersCloudService', () => {
let service: FilterCountersCloudService;
let notificationCloudService: NotificationCloudService;
let appConfigService: AppConfigService;
let taskEvents$: Subject<EngineEventsResult>;
let processEvents$: Subject<EngineEventsResult>;
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<TaskFilterCloudModel>) =>
new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter });
const processFilter = (filter: Partial<ProcessFilterCloudModel>) =>
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<EngineEventsResult>();
processEvents$ = new Subject<EngineEventsResult>();
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<EngineEventsResult>().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);
}));
});
});
@@ -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, string> = {
[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<string, Observable<TaskCloudEngineEvent[]>>();
private readonly rawEventsPerEntityType = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly recountPerApp = new Map<string, Subject<void>>();
private readonly eventRecountPerApp = new Map<string, Subject<void>>();
private readonly activeEntityTypesPerApp = new Map<string, Set<FilterCounterEntityType>>();
private readonly subscribersPerEntityType = new Map<string, number>();
private readonly batchedCountersPerEntityType = new Map<string, boolean>();
private readonly eventSubscriptionsPerEntityType = new Map<string, Subscription>();
private readonly appsWithoutBatchedCounters = new Set<string>();
private readonly taskFiltersPerApp = new Map<string, Observable<TaskFilterCloudModel[]>>();
private readonly processFiltersPerApp = new Map<string, Observable<ProcessFilterCloudModel[]>>();
private readonly countersPerApp = new Map<string, Observable<{ counters: FilterCounters; batched: boolean }>>();
get notificationDebounceTime(): number {
return this.appConfigService.get('notificationDebounceTime', 3000);
}
getTaskFilters(appName: string): Observable<TaskFilterCloudModel[]> {
return this.shareFilters(this.taskFiltersPerApp, appName, () => this.injector.get(TaskFilterCloudService).getTaskListFilters(appName));
}
getProcessFilters(appName: string): Observable<ProcessFilterCloudModel[]> {
return this.shareFilters(this.processFiltersPerApp, appName, () => this.injector.get(ProcessFilterCloudService).getProcessFilters(appName));
}
getFilterCounters(appName: string, entityType: FilterCounterEntityType, batchedCounters = false): Observable<FilterCountersResult> {
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<TaskCloudEngineEvent[]> {
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<TaskCloudEngineEvent[]> {
const key = this.entityTypeKey(appName, entityType);
let events$ = this.rawEventsPerEntityType.get(key);
if (!events$) {
events$ = defer(() =>
this.notificationCloudService.makeGQLQuery<EngineEventsData>(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<FilterCounterEntityType> {
let activeEntityTypes = this.activeEntityTypesPerApp.get(appName);
if (!activeEntityTypes) {
activeEntityTypes = new Set<FilterCounterEntityType>();
this.activeEntityTypesPerApp.set(appName, activeEntityTypes);
}
return activeEntityTypes;
}
private entityTypeKey(appName: string, entityType: FilterCounterEntityType): string {
return `${appName}|${entityType}`;
}
private recount(appName: string): void {
this.recountTrigger(appName).next();
}
private getFiltersForCounters(appName: string): Observable<FilterCountersFilters> {
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<T>(cache: Map<string, Observable<T[]>>, appName: string, loadFilters: () => Observable<T[]>): Observable<T[]> {
let filters$ = cache.get(appName);
if (!filters$) {
filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: 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<unknown> {
return merge(
merge(of(undefined), this.recountTrigger(appName)).pipe(debounceTime(0, asapScheduler)),
this.eventRecountTrigger(appName).pipe(debounceTime(this.notificationDebounceTime))
);
}
private recountTrigger(appName: string): Subject<void> {
let recount$ = this.recountPerApp.get(appName);
if (!recount$) {
recount$ = new Subject<void>();
this.recountPerApp.set(appName, recount$);
}
return recount$;
}
private eventRecountTrigger(appName: string): Subject<void> {
let eventRecount$ = this.eventRecountPerApp.get(appName);
if (!eventRecount$) {
eventRecount$ = new Subject<void>();
this.eventRecountPerApp.set(appName, eventRecount$);
}
return 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<T extends FilterCounterCandidate>(
filters: T[],
buildQuery: (filter: T) => Omit<FilterCountersQuery, 'requestId'>
): FilterCountersQuery[] {
return (filters ?? [])
.filter((filter) => filter?.showCounter && this.isCounterBatched(filter))
.map((filter) => {
try {
return { ...buildQuery(filter), requestId: filter.key as string };
} catch {
return undefined;
}
})
.filter((query): query is FilterCountersQuery => !!query);
}
private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable<FilterCounters> {
if (!Object.keys(request).length) {
return of({});
}
const queryUrl = `${this.getBasePath(appName)}/query/v1/count`;
return this.post<FilterCountersRequest, FilterCounters>(queryUrl, request).pipe(map((counters) => counters || {}));
}
private isCounterBatched(filter: FilterCounterCandidate): boolean {
return !!filter?.key;
}
}
@@ -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<T = unknown>(appName: string, gqlQuery: string): Observable<FetchResult<T>> {
return this.webSocketService.getSubscription<T>({
apolloClientName: appName,
wsUrl: `${appName}/notifications`,
httpUrl: `${appName}/notifications/v2/ws/graphql`,
@@ -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';
@@ -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<void> = new Subject<void>();
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<FetchResult>((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<FetchResult>((resolve, reject) => {
execute(createdLink!, { query: gql(`query { testQuery }`) }).subscribe({
next: resolve,
error: reject
});
});
expect(requestCount).toBe(2);
expect(result).toEqual(expectedResult);
});
});
@@ -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<T>(options: serviceOptions): Observable<FetchResult<T>> {
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({
@@ -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<typeof taskNotifications>();
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<TaskCloudEngineEvent[]>();
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<any>(component, 'fetchTaskFilterCounter').and.returnValue(of(42));
getTaskFilterCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(filterWithCounter);
});
describe('Batched counters', () => {
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<TaskFilterCloudModel[]>();
getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
await bindAppName();
expect(component.counters['fake-involved-tasks']).toBeUndefined();
filters$.next(fakeGlobalFilter);
fixture.detectChanges();
expect(component.counters['fake-involved-tasks']).toBe(9);
});
it('should read the counters of the task filters of the bound app', async () => {
await bindAppName();
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', () => {
@@ -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<TaskFilterCloudModel>();
@@ -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<TaskFilterCloudModel[]>;
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<number> {
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<number> {
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);
}
}
}
@@ -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<TaskCloudEngineEvent[]> {
return this.notificationCloudService
.makeGQLQuery(appName, TASK_EVENT_SUBSCRIPTION_QUERY)
@@ -135,7 +135,7 @@ export class TaskListCloudService extends BaseCloudService implements TaskListCl
return this.post<object, number>(queryUrl, queryData).pipe(map((response) => response || 0));
}
protected buildQueryData(requestNode: TaskListRequestModel) {
buildQueryData(requestNode: TaskListRequestModel) {
const queryData: any = {
id: requestNode.id,
parentId: requestNode.parentId,
@@ -33,6 +33,7 @@ export * from './lib/models/application-version.model';
export * from './lib/models/engine-event-cloud.model';
export * from './lib/models/task-cloud.model';
export * from './lib/models/filter-cloud-model';
export * from './lib/models/filter-counters-cloud.model';
export * from './lib/models/task-list-sorting.model';
export * from './lib/models/process-instance-variable.model';
export * from './lib/models/variable-definition';
@@ -6,17 +6,18 @@
<label class="adf-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
<mat-form-field class="adf-form-field-input">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic">
<mat-select class="adf-select" [id]="field.id" [formControl]="dropdownControl">
<mat-option *ngFor="let opt of field.options" [value]="opt" [id]="opt.id">{{opt.name}}</mat-option>
<mat-option id="readonlyOption" *ngIf="dropdownControl.disabled" [value]="field.value">{{field.value}}</mat-option>
</mat-select>
@if (!isReadOnlyField && dropdownControl.touched && (field.validationSummary?.message || dropdownControl.hasError('required'))) {
<mat-error data-automation-id="adf-dropdown-error">
<mat-error class="adf-form-field-status-slot" data-automation-id="adf-dropdown-error">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
@@ -4,7 +4,7 @@
[class.adf-invalid]="!field.isValid"
[class.adf-readonly]="field.readOnly"
id="typehead-div">
<mat-form-field class="adf-form-field-input">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic">
<label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</label>
<input matInput class="adf-input"
type="text"
@@ -23,12 +23,13 @@
</mat-option>
</mat-autocomplete>
@if (field.validationSummary?.message || isInvalidFieldRequired()) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
+7 -7
View File
@@ -52,12 +52,12 @@
"@angular/platform-browser-dynamic": "20.3.27",
"@angular/router": "20.3.27",
"@apollo/client": "3.13.1",
"@cspell/eslint-plugin": "10.0.0",
"@cspell/eslint-plugin": "10.0.1",
"@mat-datetimepicker/core": "16.0.1",
"@ngx-translate/core": "17.0.0",
"angular-oauth2-oidc": "19.0.0",
"apollo-angular": "11.0.0",
"chart.js": "4.4.4",
"chart.js": "4.5.1",
"cropperjs": "1.6.2",
"date-fns": "2.30.0",
"dotenv-expand": "5.1.0",
@@ -66,7 +66,7 @@
"graphql-ws": "6.0.8",
"material-icons": "1.13.14",
"minimatch": "10.2.6",
"ng2-charts": "4.1.1",
"ng2-charts": "9.0.0",
"pdfjs-dist": "5.1.91",
"raphael": "2.3.0",
"rxjs": "7.8.2",
@@ -98,10 +98,10 @@
"@types/jasminewd2": "2.0.13",
"@types/node": "26.1.1",
"@types/sinon": "22.0.0",
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/eslint-plugin": "8.69.0",
"@typescript-eslint/parser": "8.69.0",
"@typescript-eslint/typescript-estree": "8.69.0",
"@typescript-eslint/utils": "8.69.0",
"ajv": "8.20.0",
"dotenv": "16.4.7",
"eslint": "10.8.0",
+313 -389
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ overrides:
"brace-expansion@5.0.8": "5.0.9"
"brace-expansion@<1.1.18": "1.1.18"
"socket.io-parser@4.2.6": 4.2.7
"browserslist@>=4.28.0 <4.28.8": "4.28.8"
svgo: 4.0.2
shell-quote: 1.9.0
adm-zip: 0.6.0