Merge branch 'develop' into acs-10246-make-dates-semantically-conveyed

This commit is contained in:
Shivangi Shree
2026-08-21 16:45:51 +05:30
committed by GitHub
97 changed files with 3064 additions and 835 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@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
- name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # 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@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # 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@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v3.29.5
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check if label was added after PR creation (with time threshold)
id: check_label_timing
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const prCreatedAt = new Date('${{ github.event.pull_request.created_at }}');
@@ -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@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
with:
webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }}
skip_checkout: true
+2
View File
@@ -254,6 +254,8 @@ jobs:
name: "Unit Tests"
needs: [setup]
uses: ./.github/workflows/unit-test-workflow.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
base_ref: ${{ github.base_ref || 'develop' }}
+22
View File
@@ -0,0 +1,22 @@
name: "SonarCloud Full Scan (develop)"
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
full-unit-tests-and-sonar-scan:
name: "Full Unit Tests + SonarCloud Scan"
uses: ./.github/workflows/unit-test-workflow.yml
secrets: inherit
with:
full: true
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@98bcfbe06aafffdc0e9a790f352602316f82303b # v18.23.0
with:
comment-identifier: supply-chain-review-instructions
comment-body: |
+7 -7
View File
@@ -41,7 +41,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
# - github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -494,7 +494,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1128,7 +1128,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1404,7 +1404,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1653,7 +1653,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1732,7 +1732,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@6aab9e5b5c91c615506061f09bedd81a23babe3c # v0.86.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+63
View File
@@ -2,12 +2,21 @@ name: "Unit Tests Workflow"
on:
workflow_call:
secrets:
SONAR_TOKEN:
description: 'Token for SonarCloud analysis'
required: false
inputs:
base_ref:
description: 'Base branch for affected calculation'
required: false
type: string
default: 'develop'
full:
description: 'Run the full (non-affected) test suite for every project instead of only affected ones'
required: false
type: boolean
default: false
jobs:
generate-affected-matrix:
@@ -30,9 +39,15 @@ jobs:
id: set-matrix
env:
BASE_REF: ${{ inputs.base_ref }}
FULL_RUN: ${{ inputs.full }}
run: |
if [ "$FULL_RUN" == "true" ]; then
echo "Running full (non-affected) test suite"
AFFECTED_UNIT=$(pnpm nx show projects --target=test --select=projects --plain --exclude=cli,stories,eslint-angular)
else
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
fi
echo "Affected projects for UNIT: $AFFECTED_UNIT"
if [ -z "$AFFECTED_UNIT" ]; then
@@ -74,8 +89,56 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=5120"
run: |
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
if-no-files-found: ignore
retention-days: 1
- name: Save nx cache
if: ${{ success() }}
uses: ./.github/actions/save-nx-cache
with:
cache-suffix: test-${{ matrix.project }}
sonarcloud:
name: "SonarCloud Scan"
runs-on: ubuntu-latest
needs: [generate-affected-matrix, unit-tests]
if: ${{ needs.generate-affected-matrix.outputs.hasProjects == 'true' && always() && needs.unit-tests.result != 'cancelled' }}
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: coverage-*
path: coverage-reports
- name: Merge coverage reports
run: |
mkdir -p coverage
echo "Artifact structure:"
find coverage-reports -type f -name 'lcov.info' 2>/dev/null || true
for dir in coverage-reports/coverage-*/; do
project_name=$(basename "$dir" | sed 's/^coverage-//')
lcov_file=$(find "$dir" -name 'lcov.info' -type f | head -1)
if [ -n "$lcov_file" ]; then
mkdir -p "coverage/${project_name}"
cp "$lcov_file" "coverage/${project_name}/lcov.info"
echo "Copied coverage for ${project_name}"
fi
done
echo "Coverage files found:"
find coverage -name 'lcov.info' -type f
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
+2 -1
View File
@@ -52,7 +52,8 @@ Contains the value and metadata for a field of a [`Form`](../../../lib/process-s
| columns | [`ContainerColumnModel`](../../../lib/core/src/lib/form/components/widgets/core/container-column.model.ts)\[] | \[] | Column definitions for a container field |
| rows | [`ContainerRowModel`](../../../lib/core/src/lib/form/components/widgets/core/container-row.model.ts)\[] | \[] | Row definitions for a repeatable section field |
| emptyOption | [`FormFieldOption`](../../../lib/core/src/lib/form/components/widgets/core/form-field-option.ts) | | Dropdown menu item to use when no option is chosen |
| validationSummary | string | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummary | [`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts) | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummaryChanges$ | Observable<[`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts)> | | Replays the current validation summary to subscribers and emits the completed summary after each validation |
## Details
+1 -1
View File
@@ -56,7 +56,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/content-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/adf-core": ">=9.0.0"
@@ -1,6 +1,8 @@
<div class="adf-search-filter-menu-card">
<div class="adf-search-filter-title">
<h2 class="adf-search-filter-title-heading">
<ng-content select="filter-title" />
</h2>
<button mat-icon-button
class="adf-search-filter-title-action"
aria-hidden="false"
@@ -7,6 +7,11 @@
height: 32px;
font: var(--mat-sys-body-medium);
&-heading {
margin: 0;
font: inherit;
}
&-action {
float: right;
}
@@ -38,4 +38,11 @@ describe('SearchFilterMenuComponent', () => {
closeButton.click();
expect(spyCloseEvent).toHaveBeenCalled();
});
it('should expose the title as a heading', () => {
const heading = fixture.debugElement.nativeElement.querySelector('.adf-search-filter-title-heading');
expect(heading).not.toBeNull();
expect(heading.tagName).toBe('H2');
});
});
+1 -1
View File
@@ -69,7 +69,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/core'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+5 -5
View File
@@ -23,13 +23,13 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/material-date-fns-adapter": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@mat-datetimepicker/core": ">=12.0.1",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/js-api": ">=10.0.0",
@@ -131,7 +131,10 @@ export class ResizableDirective implements OnInit, OnDestroy {
.pipe(filter(() => !!this.currentRect));
mouseDrag
.pipe(map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)))
.pipe(
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rectangle: BoundingRectangle) => {
if (this.resizing.observers.length > 0) {
this.zone.run(() => {
@@ -131,6 +131,28 @@ describe('ResizeHandleDirective', () => {
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function));
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function));
});
it('should unregister previous mouseup listener before registering a new one on repeated mousedown', () => {
const firstUnlistenMouseUp = jasmine.createSpy('firstUnlistenMouseUp');
const secondUnlistenMouseUp = jasmine.createSpy('secondUnlistenMouseUp');
let mouseUpCallCount = 0;
renderer.listen.and.callFake((_target: any, eventName: string, _callback: (event: MouseEvent) => void) => {
if (eventName === 'mouseup') {
mouseUpCallCount++;
return mouseUpCallCount === 1 ? firstUnlistenMouseUp : secondUnlistenMouseUp;
}
return () => {};
});
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).not.toHaveBeenCalled();
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).toHaveBeenCalled();
});
});
describe('keyboard resizing', () => {
@@ -86,6 +86,7 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
});
}
this.unlistenMouseUp?.();
this.unlistenMouseUp = this.renderer.listen('document', 'mouseup', (mouseUpEvent: MouseEvent) => {
this.onMouseup(mouseUpEvent);
});
@@ -96,7 +97,8 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
private onMouseup(event: MouseEvent): void {
this.unlistenMouseMove?.();
this.unlistenMouseMove = undefined;
this.unlistenMouseUp();
this.unlistenMouseUp?.();
this.unlistenMouseUp = undefined;
this.resizableContainer.mouseup.next(event);
}
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix {
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;
}
}
}
.alfresco-tabs-widget {
@@ -40,7 +46,7 @@
.adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
margin-bottom: 35px;
margin-bottom: 0;
}
.adf-grid-list {
@@ -265,7 +271,7 @@
}
&-error-messages-container {
min-height: 35px;
height: 40px;
}
&-error-messages-container-visible {
@@ -5,7 +5,7 @@
&-single-column {
display: flex;
flex-wrap: inherit;
align-items: center;
align-items: flex-start;
gap: 1%;
@include flex.layout-bp(lt-md) {
@@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { of } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { FormService } from '../../../services/form.service';
import { FormFieldEvent } from '../../../events/form-field.event';
import { TranslationService } from '../../../../translation/translation.service';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { TranslateService } from '@ngx-translate/core';
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => {
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
});
describe('when validation runs without amount interaction', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}"
}
}
}
};
let amountField: FormFieldModel;
let form: FormModel;
beforeEach(async () => {
const translateService = TestBed.inject(TranslateService);
translateService.setTranslation('en', validatorTranslations);
await firstValueFrom(translateService.use('en'));
form = new FormModel({ taskId: '<id>' }, undefined, false, formService);
amountField = new FormFieldModel(form, {
id: 'amount-id',
type: FormFieldTypes.AMOUNT,
value: 1,
minValue: '10'
});
form.fieldsCache = [amountField];
amountField.validate();
fixture.componentRef.setInput('field', amountField);
fixture.detectChanges();
});
it('should render updated parameters after direct revalidation', async () => {
const formField = await testingUtils.formField.get();
let errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be less than 10");
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
amountField.validate();
fixture.detectChanges();
errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
it('should render updated parameters after sibling field revalidation', async () => {
const siblingField = new FormFieldModel(form, {
id: 'sibling-id',
type: FormFieldTypes.TEXT,
value: 'before'
});
form.fieldsCache = [amountField, siblingField];
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
siblingField.value = 'after';
form.onFormFieldChanged(siblingField);
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -25,6 +25,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.subscribeToFieldChanges();
this.setInitialValues();
this.initErrorStateMatcher();
this.updateTranslateParameters();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
}
@@ -143,7 +146,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
}
}
this.markAsTouched();
this.updateTranslateParameters();
}
amountWidgetOnFocus(): void {
@@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field);
this.markAsTouched();
this.updateTranslateParameters();
}
setInitialValues(): void {
@@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value;
}
this.updateTranslateParameters();
});
}
@@ -208,12 +208,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field?.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -16,10 +16,9 @@
<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[error]="field.validationSummary"
[required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
</div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common';
import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf],
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -17,24 +17,36 @@
export class ErrorMessageModel {
message: string = '';
attributes: Map<string, string> = null;
attributes: Map<string, string> = new Map();
constructor(obj?: any) {
this.message = obj?.message || '';
this.attributes = obj?.attributes || new Map();
if (obj?.attributes) {
this.attributes = obj.attributes;
}
}
isActive(): boolean {
return !!this.message;
}
getAttributesAsJsonObj() {
const result = {};
getAttributesAsJsonObj(): Record<string, string> {
const result: Record<string, string> = {};
if (this.attributes.size > 0) {
this.attributes.forEach((value, key) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
});
}
return result;
}
}
export const getValidationSummaryTranslationParameters = (validationSummary?: ErrorMessageModel): Record<string, string> => {
if (validationSummary?.isActive()) {
return validationSummary.getAttributesAsJsonObj();
}
return {};
};
@@ -17,9 +17,10 @@
import { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event';
import { firstValueFrom, map, Subject, take, timeout } from 'rxjs';
import { firstValueFrom, map, skip, Subject, take, timeout } from 'rxjs';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from './error-message.model';
import { FormFieldTypes } from './form-field-types';
import { RequiredFieldValidator } from './form-field-validator';
import { MinValueFieldValidator, RequiredFieldValidator } from './form-field-validator';
import { FormFieldModel } from './form-field.model';
import { FormModel } from './form.model';
@@ -36,6 +37,58 @@ describe('FormFieldModel', () => {
expect(model.json).toBe(json);
});
it('should return an isolated authored value snapshot', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const model = new FormFieldModel(new FormModel(), { id: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue });
const snapshot = model.authoredValue as typeof authoredValue;
snapshot.blocks[0].data.text = 'changed';
expect((model.authoredValue as typeof authoredValue).blocks[0].data.text).toBe('${field.name}');
expect(authoredValue.blocks[0].data.text).toBe('${field.name}');
});
it('should not capture authored values for other field types', () => {
const model = new FormFieldModel(new FormModel(), { id: 'json', type: FormFieldTypes.JSON, value: { content: 'value' } });
expect(model.authoredValue).toBeUndefined();
});
it('should return undefined for authored values that cannot be cloned', () => {
const circularValue: { self?: unknown } = {};
circularValue.self = circularValue;
const circularValueModel = new FormFieldModel(new FormModel(), {
id: 'circular',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: circularValue
});
const bigintValueModel = new FormFieldModel(new FormModel(), {
id: 'bigint',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: BigInt(1)
});
expect(circularValueModel.authoredValue).toBeUndefined();
expect(bigintValueModel.authoredValue).toBeUndefined();
});
it('should preserve authored value when form data overrides the field value', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const savedValue = { blocks: [{ data: { text: 'John' } }] };
const form = new FormModel(
{
fields: [{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }]
},
{ richText: savedValue }
);
const model = form.getFieldById('richText');
expect(model.value).toEqual(savedValue);
expect(model.authoredValue).toEqual(authoredValue);
expect(model.authoredValue).not.toBe(authoredValue);
});
it('should setup with json config', () => {
const json = {
fieldType: '<fieldType>',
@@ -1222,6 +1275,57 @@ describe('FormFieldModel', () => {
});
});
describe('validation summary changes', () => {
const createField = (): FormFieldModel => {
const form = new FormModel();
form.fieldValidators = [new MinValueFieldValidator()];
return new FormFieldModel(form, {
id: 'number-field',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: '10'
});
};
it('should replay an inactive validation summary before the first validation', async () => {
const field = new FormFieldModel(new FormModel());
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary).toEqual(jasmine.any(ErrorMessageModel));
expect(validationSummary.isActive()).toBe(false);
});
it('should replay the completed validation summary when subscribing after validation', async () => {
const field = createField();
field.validate();
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(validationSummary.attributes.get('minValue')).toBe('10');
});
it('should emit completed summaries when validation state changes', async () => {
const field = createField();
const invalidSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const invalidSummary = await invalidSummaryPromise;
field.value = 10;
const validSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const validSummary = await validSummaryPromise;
expect(invalidSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(invalidSummary.attributes.get('minValue')).toBe('10');
expect(validSummary.isActive()).toBe(false);
expect(validSummary.attributes.size).toBe(0);
});
});
it('should fail validation for readOnly required display-external-property field with null value', () => {
const form = new FormModel();
const field = new FormFieldModel(form, {
@@ -2056,3 +2160,35 @@ describe('FormFieldTypes', () => {
});
});
});
describe('ErrorMessageModel', () => {
it('should initialize empty attributes when attributes are omitted', () => {
const errorMessage = new ErrorMessageModel();
expect(errorMessage.attributes).toEqual(new Map());
});
it('should retain provided attributes', () => {
const attributes = new Map([['minValue', '10']]);
const errorMessage = new ErrorMessageModel({ attributes });
expect(errorMessage.attributes).toBe(attributes);
});
});
describe('getValidationSummaryTranslationParameters', () => {
it('should return validation attributes when the summary is active', () => {
const validationSummary = new ErrorMessageModel({
message: 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN',
attributes: new Map([['minValue', '10']])
});
expect(getValidationSummaryTranslationParameters(validationSummary)).toEqual({ minValue: '10' });
});
it('should return empty parameters when the summary is inactive or omitted', () => {
expect(getValidationSummaryTranslationParameters(new ErrorMessageModel())).toEqual({});
expect(getValidationSummaryTranslationParameters()).toEqual({});
});
});
@@ -29,6 +29,7 @@ import { VariableConfig } from './form-field-variable-options';
import { DataColumn } from '../../../../datatable/data/data-column.model';
import { DateFnsUtils } from '../../../../common';
import { isValid as isValidDate } from 'date-fns';
import { Observable, ReplaySubject } from 'rxjs';
import { ContainerRowModel } from './container-row.model';
import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model';
import { formFieldRuleHandler } from './handlers/form-field-rule.handler';
@@ -38,12 +39,35 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable';
export type FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal';
interface ValidationSummaryChangesState {
subject: ReplaySubject<ErrorMessageModel>;
observable: Observable<ErrorMessageModel>;
}
const validationSummaryChangesByField = new WeakMap<FormFieldModel, ValidationSummaryChangesState>();
const isJsonPrimitive = (value: unknown): value is null | string | number | boolean =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
const cloneJsonCompatibleValue = (value: unknown): unknown => {
if (value === undefined || isJsonPrimitive(value)) {
return value;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
};
// Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel {
private _value: string;
private _readOnly: boolean = false;
private _isValid: boolean = true;
private _required: boolean = false;
private readonly _authoredValue: unknown;
readonly defaultDateFormat: string = 'D-M-YYYY';
readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A';
@@ -110,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel {
// util members
emptyOption: FormFieldOption;
validationSummary: ErrorMessageModel;
validationSummary: ErrorMessageModel = new ErrorMessageModel();
get validationSummaryChanges$(): Observable<ErrorMessageModel> {
const existingState = validationSummaryChangesByField.get(this);
if (existingState) {
return existingState.observable;
}
const subject = new ReplaySubject<ErrorMessageModel>(1);
const observable = subject.asObservable();
validationSummaryChangesByField.set(this, { subject, observable });
subject.next(this.validationSummary);
return observable;
}
get value(): any {
return this._value;
@@ -123,6 +161,10 @@ export class FormFieldModel extends FormWidgetModel {
}
}
get authoredValue(): unknown {
return cloneJsonCompatibleValue(this._authoredValue);
}
get readOnly(): boolean {
if (this.form?.readOnly) {
return true;
@@ -173,16 +215,19 @@ export class FormFieldModel extends FormWidgetModel {
for (const validator of validators) {
if (!validator.validate(this)) {
this._isValid = false;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
}
this._isValid = true;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
super(form, json);
this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined;
if (json) {
this.fieldType = json.fieldType;
this.id = this.getId(json.id, parent);
@@ -221,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel {
this.enableFractions = json.enableFractions;
this.currency = json.currency;
this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json);
this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip || '';
this.selectionType = json.selectionType;
this.alignmentType = json.alignmentType;
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormService } from '../../../services/form.service';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { DecimalWidgetComponent } from './decimal.component';
import { TranslateService } from '@ngx-translate/core';
describe('DecimalComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
INVALID_DECIMAL_PRECISION: 'Precision {{ precision }}'
}
}
}
};
let loader: HarnessLoader;
let widget: DecimalWidgetComponent;
let fixture: ComponentFixture<DecimalWidgetComponent>;
@@ -107,6 +119,62 @@ describe('DecimalComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'decimal-id',
type: FormFieldTypes.DECIMAL,
value: 1,
minValue: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render decimal precision when programmatic revalidation fails', async () => {
field.value = 1.234;
field.minValue = '1';
field.precision = 2;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Precision 2');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -16,13 +16,15 @@
*/
import { NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component';
export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {};
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onDecimalFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -10,12 +10,16 @@
}
}
error-widget {
display: block;
}
.adf-error {
display: flex;
align-items: center;
&-widget-container {
height: auto;
height: 40px;
}
&-animate {
@@ -1,6 +1,7 @@
.adf-hyperlink-widget {
padding: 0.4375em 0;
border-top: 0.8438em solid transparent;
margin-bottom: 20px;
a {
color: var(--mat-sys-primary);
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon;
}
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
margin-bottom: 44px;
.adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 20px;
}
@@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('MultilineTextWidgetComponentComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: MultilineTextWidgetComponentComponent;
let fixture: ComponentFixture<MultilineTextWidgetComponentComponent>;
@@ -109,6 +120,48 @@ describe('MultilineTextWidgetComponentComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'multiline-text-id',
type: FormFieldTypes.MULTILINE_TEXT,
value: 'text',
minLength: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when is required', () => {
beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
@@ -27,6 +27,7 @@ import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { isObservable } from 'rxjs';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
if (this.enableCustomMessage != null) {
if (isObservable(this.enableCustomMessage)) {
this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => {
@@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onMultilineTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { NumberWidgetComponent } from './number.widget';
import { DecimalNumberPipe } from '../../../../pipes';
import { TranslateService } from '@ngx-translate/core';
describe('NumberWidgetComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
NO_LONGER_THAN: 'Maximum length {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: NumberWidgetComponent;
let fixture: ComponentFixture<NumberWidgetComponent>;
@@ -175,6 +187,60 @@ describe('NumberWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'number-id',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render the maximum length when programmatic revalidation fails', async () => {
field.value = 12345678901;
field.minValue = '1';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum length 10');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -18,7 +18,8 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common';
import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { DecimalNumberPipe } from '../../../../pipes';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
translateParameters: Record<string, string> = {};
private readonly decimalNumberPipe = inject(DecimalNumberPipe);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
if (this.field.readOnly) {
@@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
this.displayValue = this.field.value;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
protected onNumberChange(value: string) {
@@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
}
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -9,7 +9,8 @@
}
&-row-action {
margin-left: 10px;
margin-inline-start: 10px;
margin-block-end: 35px;
}
&-row-limit {
@@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('TextWidgetComponent', () => {
const form = new FormModel({ taskId: 'fake-task-id' });
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: TextWidgetComponent;
@@ -64,6 +75,47 @@ describe('TextWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(form, {
id: 'text-id',
type: FormFieldTypes.TEXT,
value: 'text',
minLength: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when template is ready', () => {
describe('and no mask is configured on text element', () => {
it('should raise ngModelChange event', async () => {
@@ -19,6 +19,7 @@
import { NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { WidgetComponent } from '../widget.component';
import { ErrorMessageModel } from '../core/error-message.model';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { FormattableTextWidgetComponent } from '../core/formattable-text.widget';
import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator';
import { InputMaskDirective } from './text-mask.component';
@@ -97,6 +98,9 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
this.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onPaste(event: ClipboardEvent): void {
@@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -143,14 +145,6 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number {
const value = input.value ?? '';
const selectionStart = input.selectionStart ?? value.length;
@@ -16,6 +16,7 @@
*/
import { Subject } from 'rxjs';
import { FormEvent } from '../events/form.event';
import { FormFieldEvent } from '../events/form-field.event';
import { FormRulesEvent } from '../events/form-rules.event';
import { ValidateFormFieldEvent } from '../events/validate-form-field.event';
@@ -26,4 +27,5 @@ export interface FormValidationService {
validateForm: Subject<ValidateFormEvent>;
validateFormField: Subject<ValidateFormFieldEvent>;
formRulesEvent?: Subject<FormRulesEvent>;
formVisibilityRefreshed?: Subject<FormEvent>;
}
@@ -62,6 +62,12 @@ export class FormService implements FormValidationService {
formRulesEvent = new Subject<FormRulesEvent>();
/**
* Emitted after form field/outcome visibility has been re-evaluated via WidgetVisibilityService.refreshVisibility.
* Internal ADF form-rendering event — not part of the FormValidationService contract.
*/
formVisibilityRefreshed = new Subject<FormEvent>();
constructor() {
const injectedFieldValidators = inject(FORM_SERVICE_FIELD_VALIDATORS_TOKEN, { optional: true });
@@ -19,6 +19,7 @@ import { TestBed } from '@angular/core/testing';
import { ContainerModel, FormFieldModel, FormFieldTypes, FormModel, TabModel } from '../components/widgets/core';
import { WidgetVisibilityModel } from '../models/widget-visibility.model';
import { WidgetVisibilityService } from './widget-visibility.service';
import { FormService } from './form.service';
import {
fakeFormJson,
formTest,
@@ -50,6 +51,30 @@ describe('WidgetVisibilityService', () => {
service = TestBed.inject(WidgetVisibilityService);
});
it('should emit formVisibilityRefreshed when visibility is refreshed', () => {
const formService = TestBed.inject(FormService);
let emittedForm: FormModel | undefined;
formService.formVisibilityRefreshed.subscribe((event) => {
emittedForm = event.form;
});
service.refreshVisibility(stubFormWithFields);
expect(emittedForm).toBe(stubFormWithFields);
});
it('should not emit formVisibilityRefreshed when form is null', () => {
const formService = TestBed.inject(FormService);
let emitCount = 0;
formService.formVisibilityRefreshed.subscribe(() => emitCount++);
service.refreshVisibility(null);
expect(emitCount).toBe(0);
});
describe('should be able to evaluate next condition operations', () => {
it('using == and return true', () => {
const resultsArray = evaluateConditions(
@@ -15,16 +15,20 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { FormFieldModel, FormModel, TabModel, ContainerModel, FormOutcomeModel } from '../components/widgets/core';
import { FormEvent } from '../events/form.event';
import { TaskProcessVariableModel } from '../models/task-process-variable.model';
import { WidgetVisibilityModel, WidgetTypeEnum } from '../models/widget-visibility.model';
import { format, isValid, parse } from 'date-fns';
import { FormService } from './form.service';
@Injectable({
providedIn: 'root'
})
export class WidgetVisibilityService {
private readonly formService = inject(FormService);
private processVarList: TaskProcessVariableModel[];
private form: FormModel;
@@ -45,6 +49,8 @@ export class WidgetVisibilityService {
}
form.getFormFields().map((field) => this.refreshEntityVisibility(field));
this.formService.formVisibilityRefreshed.next(new FormEvent(form));
}
}
@@ -17,6 +17,7 @@ $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';
+1 -1
View File
@@ -23,7 +23,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/extensions'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+2 -2
View File
@@ -12,8 +12,8 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
},
"peerDependencies": {
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0"
},
"keywords": [
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/insights'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+4 -4
View File
@@ -16,10 +16,10 @@
"raphael": ">=2.3.0"
},
"peerDependencies": {
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0",
@@ -0,0 +1,148 @@
/*!
* @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 { Chart } from './chart.model';
describe('Chart Model', () => {
describe('constructor', () => {
it('should create with default values when no argument is provided', () => {
const chart = new Chart();
expect(chart.labels).toEqual([]);
expect(chart.data).toEqual([]);
expect(chart.datasets).toEqual([]);
expect(chart.showDetails).toBe(false);
});
it('should populate properties from input object', () => {
const chart = new Chart({
id: '1',
title: 'Test Chart',
titleKey: 'KEY',
labels: ['a', 'b'],
data: [1, 2],
datasets: [{ data: [1] }],
showDetails: true,
detailsTable: { key: 'value' },
options: { responsive: true }
});
expect(chart.id).toBe('1');
expect(chart.title).toBe('Test Chart');
expect(chart.titleKey).toBe('KEY');
expect(chart.labels).toEqual(['a', 'b']);
expect(chart.data).toEqual([1, 2]);
expect(chart.datasets).toEqual([{ data: [1] }]);
expect(chart.showDetails).toBe(true);
expect(chart.detailsTable).toEqual({ key: 'value' });
expect(chart.options).toEqual({ responsive: true });
});
it('should convert type and set icon for pieChart', () => {
const chart = new Chart({ type: 'pieChart' });
expect(chart.type).toBe('pie');
expect(chart.icon).toBe('pie_chart');
});
it('should convert type and set icon for barChart', () => {
const chart = new Chart({ type: 'barChart' });
expect(chart.type).toBe('bar');
expect(chart.icon).toBe('equalizer');
});
it('should convert type and set icon for line', () => {
const chart = new Chart({ type: 'line' });
expect(chart.type).toBe('line');
expect(chart.icon).toBe('show_chart');
});
it('should convert type and set icon for table', () => {
const chart = new Chart({ type: 'table' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
it('should convert type and set icon for multiBarChart', () => {
const chart = new Chart({ type: 'multiBarChart' });
expect(chart.type).toBe('multiBar');
expect(chart.icon).toBe('poll');
});
it('should convert type and set icon for processDefinitionHeatMap', () => {
const chart = new Chart({ type: 'processDefinitionHeatMap' });
expect(chart.type).toBe('HeatMap');
expect(chart.icon).toBe('share');
});
it('should convert type and set icon for masterDetailTable', () => {
const chart = new Chart({ type: 'masterDetailTable' });
expect(chart.type).toBe('masterDetailTable');
expect(chart.icon).toBe('subtitles');
});
it('should default to table type for unknown types', () => {
const chart = new Chart({ type: 'unknown' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
});
describe('hasData', () => {
it('should return true when data is not empty', () => {
const chart = new Chart({ data: [1, 2, 3] });
expect(chart.hasData()).toBe(true);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasData()).toBe(false);
});
it('should return false when no data is provided', () => {
const chart = new Chart();
expect(chart.hasData()).toBe(false);
});
});
describe('hasDatasets', () => {
it('should return true when datasets is not empty', () => {
const chart = new Chart({ datasets: [{ data: [1] }] });
expect(chart.hasDatasets()).toBe(true);
});
it('should return false when datasets is empty', () => {
const chart = new Chart({ datasets: [] });
expect(chart.hasDatasets()).toBe(false);
});
});
describe('hasZeroValues', () => {
it('should return true when all data values are zero', () => {
const chart = new Chart({ data: [0, 0, 0] });
expect(chart.hasZeroValues()).toBe(true);
});
it('should return false when at least one value is non-zero', () => {
const chart = new Chart({ data: [0, 1, 0] });
expect(chart.hasZeroValues()).toBe(false);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasZeroValues()).toBe(false);
});
});
});
+1 -1
View File
@@ -48,7 +48,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/process-services-cloud'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0",
@@ -17,6 +17,7 @@
import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services';
import {
ADF_DISPLAY_TEXT_SETTINGS,
ContentLinkModel,
CoreModule,
FormFieldModel,
@@ -31,6 +32,8 @@ import {
provideTranslations,
AuthModule,
FormFieldEvent,
FormEvent,
FormRulesEvent,
NoopTranslateModule,
NoopAuthModule,
FORM_FIELD_VALIDATORS
@@ -117,7 +120,8 @@ describe('FormCloudComponent', () => {
useValue: {}
},
{ provide: FormRenderingService, useClass: CloudFormRenderingService },
{ provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] }
{ provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] },
{ provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }
]
});
const apiService = TestBed.inject(AlfrescoApiService);
@@ -874,6 +878,39 @@ describe('FormCloudComponent', () => {
expect(savedForm).toEqual(formModel);
});
it('should materialize unrendered rich text expressions when saving a task form', () => {
spyOn(formCloudService, 'saveTaskForm').and.returnValue(of(undefined));
const formModel = new FormModel({
id: '23',
taskId: '123-223',
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const originalValues = JSON.parse(JSON.stringify(formModel.values));
formComponent.form = formModel;
formComponent.taskId = formModel.taskId;
formComponent.appName = 'test-app';
formComponent.saveTaskForm();
expect(formCloudService.saveTaskForm).toHaveBeenCalledWith(
'test-app',
formModel.taskId,
undefined,
formModel.id,
jasmine.objectContaining({
richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] }
})
);
expect(formModel.values).toEqual(originalValues);
});
it('should handle error during form save', () => {
const error = 'Error';
spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error));
@@ -981,6 +1018,39 @@ describe('FormCloudComponent', () => {
expect(completedForm).toBe(formComponent.form);
});
it('should materialize unrendered rich text expressions when completing a task form', () => {
spyOn(formCloudService, 'completeTaskForm').and.returnValue(of(undefined));
const formModel = new FormModel({
id: '23',
taskId: '123-223',
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
formComponent.form = formModel;
formComponent.taskId = formModel.taskId;
formComponent.appName = 'test-app';
formComponent.completeTaskForm('complete');
expect(formCloudService.completeTaskForm).toHaveBeenCalledWith(
'test-app',
formModel.taskId,
undefined,
formModel.id,
jasmine.objectContaining({
richText: { blocks: [{ type: 'paragraph', data: { text: 'John' } }] }
}),
'complete',
undefined
);
});
it('should open confirmation dialog on complete task', async () => {
formComponent.form = new FormModel({
confirmMessage: {
@@ -1231,6 +1301,38 @@ describe('FormCloudComponent', () => {
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when form visibility is refreshed', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formVisibilityRefreshed.next(new FormEvent(formModel));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when fieldValueChanged rule event fires', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formRulesEvent.next(new FormRulesEvent('fieldValueChanged', new FormEvent(formModel)));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should raise [executeOutcome] event for formService', async () => {
spyOn(formComponent.executeOutcome, 'emit');
@@ -30,14 +30,17 @@ import {
SimpleChanges,
ViewChild
} from '@angular/core';
import { forkJoin, isObservable, Observable, of, Subscription } from 'rxjs';
import { forkJoin, isObservable, merge, Observable, of, Subscription } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import {
ConfirmDialogComponent,
ContentLinkModel,
ADF_DISPLAY_TEXT_SETTINGS,
DisplayTextWidgetSettings,
FormatSpacePipe,
FormBaseComponent,
FormEvent,
FormExpressionService,
FormFieldModel,
FormRulesEvent,
FormFieldValidator,
@@ -67,6 +70,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { A11yModule } from '@angular/cdk/a11y';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../services/form-cloud-submission-values';
interface FormFieldRuntimeState {
value: any;
@@ -228,6 +232,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
protected changeDetector = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly expressions = inject(FormExpressionService);
private enableExpressionEvaluation = false;
private get currentForm(): FormModel | undefined {
return super.form;
@@ -252,6 +258,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
constructor() {
const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true });
const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { optional: true });
const displayTextSettings = inject<Observable<DisplayTextWidgetSettings> | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, {
optional: true
});
super();
this.loadInjectedFieldValidators(injectedFieldValidators);
@@ -270,6 +279,12 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
}
getExpressionEvaluationEnabled$(displayTextSettings)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((enabled) => {
this.enableExpressionEvaluation = enabled;
});
this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => {
if (content instanceof UploadWidgetContentLinkModel) {
this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);
@@ -291,11 +306,11 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
});
this.formService.formRulesEvent
.pipe(
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id),
takeUntilDestroyed()
merge(
this.formService.formVisibilityRefreshed.pipe(filter((event) => event.form?.id === this.form?.id)),
this.formService.formRulesEvent.pipe(filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id))
)
.pipe(takeUntilDestroyed())
.subscribe(() => this.recomputeVisibleOutcomes());
}
@@ -482,7 +497,7 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
saveTaskForm() {
if (this.form && this.appName && this.taskId) {
this.formCloudService
.saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values)
.saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.getSubmissionValues())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
@@ -523,7 +538,15 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
private completeForm(outcome?: string, outcomeId?: string) {
if (this.form && this.appName && this.taskId) {
this.formCloudService
.completeTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values, outcome, this.appVersion)
.completeTaskForm(
this.appName,
this.taskId,
this.processInstanceId,
`${this.form.id}`,
this.getSubmissionValues(),
outcome,
this.appVersion
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
@@ -536,6 +559,10 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
}
private getSubmissionValues(): FormValues {
return materializeSubmissionValues(this.form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions);
}
parseForm(formCloudRepresentationJSON?: any): FormModel | null {
if (formCloudRepresentationJSON) {
const formValues: FormValues = {};
@@ -568,7 +595,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
checkVisibility(field: FormFieldModel) {
if (field?.form) {
this.visibilityService.refreshVisibility(field.form);
this.recomputeVisibleOutcomes();
}
}
@@ -586,7 +612,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.setCheckParentVisibilityForValidationOnFields();
this.visibilityService.refreshVisibility(this.form);
this.form.validateForm();
this.recomputeVisibleOutcomes();
this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form);
@@ -1,21 +1,26 @@
<div class="adf-attach-file-widget-container">
<div class="adf-attach-widget {{field.className}}"
[class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'">{{field.name}}
<span class="adf-asterisk" *ngIf="isRequired()">*</span>
<div class="adf-attach-widget {{ field.className }}" [class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'"
>{{ field.name }}
@if (isRequired()) {
<span class="adf-asterisk">*</span>
}
</label>
<div class="adf-attach-widget-container" (focusout)="markAsTouched()">
<div class="adf-attach-widget__menu-upload" *ngIf="isUploadButtonVisible()">
@if (isUploadButtonVisible()) {
<div class="adf-attach-widget__menu-upload">
<button
(click)="openSelectDialog()"
mat-raised-button
class="adf-attach-widget__menu-upload__button"
[id]="field.id"
[title]="field.tooltip">
[title]="field.tooltip"
>
{{ 'FORM.FIELD.ATTACH' | translate }}
<mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" />
</button>
</div>
}
</div>
</div>
@@ -34,12 +39,15 @@
(contentModelFileHandler)="contentModelFormFileHandler($event)"
(removeAttachFile)="onRemoveAttachFile($event)"
/>
<div *ngIf="!hasFile && field.readOnly" id="{{'adf-attach-empty-list-'+field.id}}">
@if (!hasFile && field.readOnly) {
<div id="{{ 'adf-attach-empty-list-' + field.id }}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="!field.isValid && isTouched() && !isSelected()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget
[error]="field.validationSummary"
[required]="!field.isValid && isTouched() && !isSelected() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
@@ -1,31 +1,23 @@
<div class="adf-data-table-widget-container">
<div class="adf-data-table-widget-label">
<label
class="adf-label"
[class.adf-left-label]="field.leftLabels"
[attr.for]="field.id">
{{field.name | translate }}
</label>
<label class="adf-label" [class.adf-left-label]="field.leftLabels" [attr.for]="field.id"> {{field.name | translate }} </label>
</div>
<ng-container *ngIf="!previewState; else previewTemplate">
@if (!previewState) {
<adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource">
<adf-no-content-template>
<ng-template>
<adf-empty-content
icon="border_all"
[title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
<adf-empty-content icon="border_all" [title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
</ng-template>
</adf-no-content-template>
</adf-datatable>
<error-widget *ngIf="dataTableLoadFailed"
<error-widget
class="adf-data-table-widget-failed-message"
[required]="'FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate" />
</ng-container>
<ng-template #previewTemplate>
[required]="dataTableLoadFailed ? ('FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate) : ''"
/>
} @else {
<adf-datatable data-automation-id="adf-data-table-widget-preview" />
<div class="adf-preview-placeholder"></div>
</ng-template>
}
</div>
@@ -1,4 +1,5 @@
.adf-data-table-widget-failed-message {
display: block;
margin: 10px;
}
@@ -283,7 +283,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, []);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
it('path points to single object with appropriate schema definition', () => {
@@ -294,7 +295,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
});
@@ -27,7 +27,6 @@ import {
NoContentTemplateDirective,
EmptyContentComponent
} from '@alfresco/adf-core';
import { NgIf } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { FormCloudService } from '../../../services/form-cloud.service';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
@@ -36,7 +35,7 @@ import { DataTablePathParserHelper } from './helpers/data-table-path-parser.help
@Component({
standalone: true,
imports: [NgIf, TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
imports: [TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
selector: 'data-table',
templateUrl: './data-table.widget.html',
styleUrls: ['./data-table.widget.scss'],
@@ -200,6 +200,51 @@ describe('DisplayRichTextWidgetComponent', () => {
expect(widget.field.value.blocks[0].data.text).toBe('Hello John');
});
it('should resolve from authored value after saved data rehydrates the field', () => {
const form = new FormModel(
{
fields: [
{
id: 'richText1',
name: 'richText1',
type: 'display-rich-text',
value: {
blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }]
}
},
{ id: 'name', name: 'name', type: 'text', value: 'John' }
]
},
{
richText1: {
blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }]
},
name: 'Jane'
}
);
widget.field = form.getFieldById('richText1');
fixture.detectChanges();
expect(widget.field.value.blocks[0].data.text).toBe('Hello Jane');
});
it('should preserve the current value when the authored value is unavailable', () => {
const form = new FormModel({
fields: [{ id: 'richText1', type: 'display-rich-text' }]
});
const currentValue = {
blocks: [{ type: 'paragraph', data: { text: 'Current value' } }]
};
const field = form.getFieldById('richText1');
field.value = currentValue;
widget.field = field;
fixture.detectChanges();
expect(widget.field.value).toBe(currentValue);
});
it('should resolve expressions in multiple blocks', () => {
const form = new FormModel({
fields: [
@@ -22,6 +22,7 @@ import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core';
import { DomSanitizer } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { RichTextParserService } from '../../../services/rich-text-parser.service';
import { resolveRichTextExpressions } from './rich-text-expression-resolver';
export const RICH_TEXT_PARSER_TOKEN = new InjectionToken<RichTextParserService>('RichTextParserService', {
factory: () => new RichTextParserService()
@@ -66,7 +67,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
protected storeOriginalValue(): void {
if (this.field) {
this.originalFieldValue = JSON.stringify(this.field.value);
const authoredValue = this.field.authoredValue;
if (authoredValue !== undefined) {
this.originalFieldValue = JSON.stringify(authoredValue);
}
}
}
@@ -75,8 +79,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
return;
}
const value = JSON.parse(JSON.stringify(this.field.value));
this.applyExpressionsToBlocks(value);
const authoredValue = this.field.authoredValue;
if (authoredValue !== undefined) {
this.applyExpressionsToBlocks(authoredValue);
}
}
protected reevaluateExpressions(): void {
@@ -89,16 +95,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
}
private applyExpressionsToBlocks(value: any): void {
for (const block of value.blocks) {
if (block.type === 'list') {
for (const item of block.data.items) {
item.content = this.resolveExpressions(item.content, true);
}
} else {
block.data.text = this.resolveExpressions(block.data.text, true);
}
}
this.field.value = value;
this.field.value = resolveRichTextExpressions(value, (content) => this.resolveExpressions(content, true));
}
private parseAndSanitize(): void {
@@ -0,0 +1,137 @@
/*!
* @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 { resolveRichTextExpressions } from './rich-text-expression-resolver';
describe('resolveRichTextExpressions', () => {
const resolve = (value: string) => value.replaceAll('${field.name}', 'John').replaceAll('${variable.status}', 'Active');
it('should resolve supported rich text content without mutating the input', () => {
const value = {
time: 1,
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello ${field.name}',
caption: 'Status: ${variable.status}',
content: [
['Cell ${field.name}'],
{
label: '${variable.status}'
}
]
}
},
{
type: 'list',
data: {
items: [
{
content: '${field.name}',
items: [{ content: '${variable.status}' }]
}
]
}
}
],
version: '2.30.0'
};
const originalValue = JSON.parse(JSON.stringify(value));
const result = resolveRichTextExpressions(value, resolve);
expect(result).toEqual({
time: 1,
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello John',
caption: 'Status: Active',
content: [['Cell John'], { label: 'Active' }]
}
},
{
type: 'list',
data: {
items: [{ content: 'John', items: [{ content: 'Active' }] }]
}
}
],
version: '2.30.0'
});
expect(value).toEqual(originalValue);
expect(result).not.toBe(value);
});
it('should resolve a caller-owned clone without cloning it again', () => {
const value = {
blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }]
};
const result = resolveRichTextExpressions(value, resolve, { cloneValue: false }) as typeof value;
expect(result).toBe(value);
expect(result.blocks[0].data.text).toBe('Hello John');
});
it('should preserve unknown blocks and properties', () => {
const value = {
blocks: [
{
type: 'custom',
data: {
label: '${field.name}'
},
metadata: '${variable.status}'
}
]
};
expect(resolveRichTextExpressions(value, resolve)).toEqual(value);
});
it('should not introduce missing content properties', () => {
const result = resolveRichTextExpressions({ blocks: [{ type: 'paragraph', data: {} }] }, resolve) as {
blocks: Array<{ data: Record<string, unknown> }>;
};
expect(result.blocks[0].data).toEqual({});
});
it('should return malformed values unchanged', () => {
const malformedValues = [null, undefined, 'text', [], {}, { blocks: null }];
malformedValues.forEach((value) => {
expect(resolveRichTextExpressions(value, resolve)).toBe(value);
});
});
it('should return non-cloneable values without mutating them', () => {
const value: {
blocks: Array<{ data: { text: string } }>;
self?: unknown;
} = {
blocks: [{ data: { text: '${field.name}' } }]
};
value.self = value;
expect(resolveRichTextExpressions(value, resolve)).toBe(value);
expect(value.blocks[0].data.text).toBe('${field.name}');
});
});
@@ -0,0 +1,111 @@
/*!
* @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.
*/
type JsonObject = Record<string, unknown>;
export type RichTextExpressionResolver = (value: string) => string;
export interface RichTextExpressionResolverOptions {
cloneValue?: boolean;
}
const isJsonObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value);
const cloneJsonValue = (value: unknown): unknown => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
};
const resolveNestedContent = (value: unknown, resolve: RichTextExpressionResolver): unknown => {
if (typeof value === 'string') {
return resolve(value);
}
if (Array.isArray(value)) {
return value.map((entry) => resolveNestedContent(entry, resolve));
}
if (isJsonObject(value)) {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveNestedContent(entry, resolve)]));
}
return value;
};
const resolveListItems = (items: unknown, resolve: RichTextExpressionResolver): unknown => {
if (!Array.isArray(items)) {
return items;
}
return items.map((item) => {
if (!isJsonObject(item)) {
return item;
}
if (Object.hasOwn(item, 'content')) {
item.content = resolveNestedContent(item.content, resolve);
}
if (Object.hasOwn(item, 'items')) {
item.items = resolveListItems(item.items, resolve);
}
return item;
});
};
export const resolveRichTextExpressions = (
value: unknown,
resolve: RichTextExpressionResolver,
options: RichTextExpressionResolverOptions = {}
): unknown => {
if (!isJsonObject(value) || !Array.isArray(value.blocks)) {
return value;
}
const resolvedValue = options.cloneValue === false ? value : cloneJsonValue(value);
if (!isJsonObject(resolvedValue) || !Array.isArray(resolvedValue.blocks)) {
return value;
}
resolvedValue.blocks.forEach((block) => {
if (!isJsonObject(block) || !isJsonObject(block.data)) {
return;
}
if (typeof block.data.text === 'string') {
block.data.text = resolve(block.data.text);
}
if (typeof block.data.caption === 'string') {
block.data.caption = resolve(block.data.caption);
}
if (Object.hasOwn(block.data, 'content')) {
block.data.content = resolveNestedContent(block.data.content, resolve);
}
if (block.type === 'list' && Object.hasOwn(block.data, 'items')) {
block.data.items = resolveListItems(block.data.items, resolve);
}
});
return resolvedValue;
};
@@ -4,11 +4,13 @@
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels"
>
<div *ngIf="field.leftLabels">
@if (field.leftLabels) {
<div>
<label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
</div>
}
<div>
<adf-cloud-group
[mode]="mode"
@@ -24,11 +26,5 @@
[attr.title]="field.tooltip"
[label]="field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
/>
</div>
</div>
@@ -141,8 +141,9 @@ describe('GroupCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
});
});
@@ -16,7 +16,7 @@
*/
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -31,7 +31,7 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
@Component({
selector: 'group-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, GroupCloudComponent],
imports: [CommonModule, TranslatePipe, GroupCloudComponent],
templateUrl: './group-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -1,10 +1,16 @@
<div class="adf-dropdown-widget {{field.className}}"
<div
class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels">
<div *ngIf="field.leftLabels">
<label class="adf-label adf-left-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label>
[class.adf-left-label-input-container]="field.leftLabels"
>
@if (field.leftLabels) {
<div>
<label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
</div>
}
<div>
<adf-cloud-people
[preSelectUsers]="preSelectUsers"
@@ -21,11 +27,5 @@
[attr.title]="field.tooltip"
[label]="field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
</div>
</div>
@@ -171,8 +171,9 @@ describe('PeopleCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
});
});
@@ -16,7 +16,7 @@
*/
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -27,13 +27,12 @@ import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
import { MatFormFieldModule } from '@angular/material/form-field';
/* eslint-disable @angular-eslint/component-selector */
@Component({
selector: 'people-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, PeopleCloudComponent, MatFormFieldModule],
imports: [CommonModule, TranslatePipe, PeopleCloudComponent],
templateUrl: './people-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -11,7 +11,6 @@
}
&-radio-button-container-horizontal {
margin-bottom: 15px;
display: flex;
flex-flow: column wrap;
align-items: flex-start;
@@ -4,7 +4,8 @@
>
<div class="adf-cloud-upload-widget-container">
<div>
<mat-list *ngIf="hasFile">
@if (hasFile) {
<mat-list>
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img
matListItemLine
@@ -27,28 +28,26 @@
tabindex="0"
>{{file.name}}</span
>
<button
*ngIf="!field.readOnly"
mat-icon-button
[id]="'file-'+file.id+'-remove'"
(click)="removeFile(file);"
(keyup.enter)="removeFile(file);"
>
@if (!field.readOnly) {
<button mat-icon-button [id]="'file-'+file.id+'-remove'" (click)="removeFile(file);" (keyup.enter)="removeFile(file);">
<mat-icon class="mat-24" adf-icon="highlight_off" />
</button>
}
</mat-list-item>
</mat-list>
}
</div>
<div *ngIf="(!hasFile || multipleOption) && !field.readOnly">
@if ((!hasFile || multipleOption) && !field.readOnly) {
<div>
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button>
</div>
<div *ngIf="!hasFile && field.readOnly">{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
} @if (!hasFile && field.readOnly) {
<div>{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="isInvalidFieldRequired()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget [error]="field.validationSummary" [required]="isInvalidFieldRequired() ? ('FORM.FIELD.REQUIRED' | translate) : ''" />
</div>
@@ -55,4 +55,12 @@ describe('UploadCloudWidgetComponent', () => {
expect(eventSpy).toHaveBeenCalledWith(clickEvent);
});
});
it('should render one reserved form field status area', () => {
widget.field = new FormFieldModel(new FormModel(), {});
fixture.detectChanges();
const statusAreas = fixture.nativeElement.querySelectorAll('error-widget');
expect(statusAreas.length).toBe(1);
});
});
@@ -43,5 +43,6 @@ export * from './services/form-cloud.service';
export * from './services/content-cloud-node-selector.service';
export * from './services/process-cloud-content.service';
export * from './services/display-mode.service';
export * from './services/form-cloud-submission-values';
export * from './form-cloud.module';
@@ -0,0 +1,185 @@
/*!
* @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 { FormExpressionService, FormFieldModel, FormFieldTypes, FormModel } from '@alfresco/adf-core';
import { firstValueFrom, of } from 'rxjs';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from './form-cloud-submission-values';
describe('getExpressionEvaluationEnabled$', () => {
it('should return the configured static value', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$({ enableExpressionEvaluation: true }));
expect(enabled).toBe(true);
});
it('should return values emitted by observable settings', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(of({ enableExpressionEvaluation: true })));
expect(enabled).toBe(true);
});
it('should return false when settings are unavailable', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(undefined));
expect(enabled).toBe(false);
});
});
describe('materializeSubmissionValues', () => {
let expressions: FormExpressionService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [FormExpressionService]
});
expressions = TestBed.inject(FormExpressionService);
});
it('should resolve root rich text values from the authored template without mutating the form', () => {
const authoredValue = {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello ${field.name} - ${variable.status} - ${field.missing} - ${field.unsafe}'
}
}
]
};
const form = new FormModel({
fields: [
{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue },
{ id: 'name', name: 'name', type: FormFieldTypes.TEXT, value: 'John' },
{ id: 'unsafe', name: 'unsafe', type: FormFieldTypes.TEXT, value: '<b>John</b>' }
],
variables: [{ id: 'status', name: 'status', type: 'string', value: 'Active' }]
});
const richTextField = form.getFieldById('richText');
richTextField.value = { blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] };
const originalValues = JSON.parse(JSON.stringify(form.values));
const originalDefinition = JSON.parse(JSON.stringify(form.json));
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(values.richText).toEqual({
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello John - Active - - &lt;b&gt;John&lt;/b&gt;'
}
}
]
});
expect(form.values).toEqual(originalValues);
expect(form.json).toEqual(originalDefinition);
expect(richTextField.value).toEqual({ blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] });
});
it('should produce stable values across repeated materialization', () => {
const form = new FormModel({
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const firstValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
const secondValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(secondValues).toEqual(firstValues);
});
it('should return a shallow clone without resolving expressions when evaluation is disabled', () => {
const form = new FormModel({
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: false }, expressions);
expect(values).toEqual(form.values);
expect(values).not.toBe(form.values);
expect(values.richText).toBe(form.values.richText);
});
it('should isolate materialized repeatable section rows', () => {
const form = new FormModel();
form.values = {
section: [
{ richText: 'saved row one', untouched: 'one' },
{ richText: 'saved row two', untouched: 'two' }
],
name: 'John'
};
const nameField = new FormFieldModel(form, { id: 'name', type: FormFieldTypes.TEXT, value: 'John' });
const firstField = new FormFieldModel(
form,
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'First ${field.name}' } }] }
},
{ id: 'section', uid: 'richText-Row1', fields: {}, rowIndex: 0 }
);
const secondField = new FormFieldModel(
form,
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'Second ${field.name}' } }] }
},
{ id: 'section', uid: 'richText-Row2', fields: {}, rowIndex: 1 }
);
form.fieldsCache = [nameField, firstField, secondField];
form.values.section = [
{ richText: 'saved row one', untouched: 'one' },
{ richText: 'saved row two', untouched: 'two' }
];
const originalSection = form.values.section;
const originalFirstRow = form.values.section[0];
const originalSecondRow = form.values.section[1];
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(values.section).toEqual([
{
richText: { blocks: [{ type: 'paragraph', data: { text: 'First John' } }] },
untouched: 'one'
},
{
richText: { blocks: [{ type: 'paragraph', data: { text: 'Second John' } }] },
untouched: 'two'
}
]);
expect(values.section).not.toBe(originalSection);
expect(values.section[0]).not.toBe(originalFirstRow);
expect(values.section[1]).not.toBe(originalSecondRow);
expect(form.values.section).toBe(originalSection);
});
});
@@ -0,0 +1,80 @@
/*!
* @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 { DisplayTextWidgetSettings, FormExpressionService, FormFieldTypes, FormModel, FormValues, ROW_ID_PREFIX } from '@alfresco/adf-core';
import { isObservable, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { resolveRichTextExpressions } from '../components/widgets/display-rich-text/rich-text-expression-resolver';
type SubmissionRow = Record<string, unknown>;
const isSubmissionRow = (value: unknown): value is SubmissionRow => typeof value === 'object' && value !== null && !Array.isArray(value);
export interface FormCloudSubmissionValuesOptions {
enableExpressionEvaluation: boolean;
}
export const getExpressionEvaluationEnabled$ = (
settings: Observable<DisplayTextWidgetSettings> | DisplayTextWidgetSettings | null | undefined
): Observable<boolean> =>
isObservable(settings)
? settings.pipe(map((value) => value?.enableExpressionEvaluation ?? false))
: of(settings?.enableExpressionEvaluation ?? false);
export const materializeSubmissionValues = (
form: FormModel,
options: FormCloudSubmissionValuesOptions,
expressions: FormExpressionService
): FormValues => {
const values = { ...form.values };
if (!options.enableExpressionEvaluation) {
return values;
}
for (const field of form.getFormFields([FormFieldTypes.DISPLAY_RICH_TEXT])) {
const { authoredValue, parent } = field;
if (authoredValue === undefined || parent?.isTemplate) {
continue;
}
const materializedValue = resolveRichTextExpressions(authoredValue, (content) => expressions.resolveExpressions(form, content, true), {
cloneValue: false
});
if (!parent) {
values[field.id] = materializedValue;
continue;
}
const sectionValues = values[parent.id];
const sectionRow = Array.isArray(sectionValues) ? sectionValues[parent.rowIndex] : undefined;
if (!isSubmissionRow(sectionRow)) {
continue;
}
const materializedRows = [...sectionValues];
const fieldId = field.id.split(ROW_ID_PREFIX)[0];
materializedRows[parent.rowIndex] = {
...sectionRow,
[fieldId]: materializedValue
};
values[parent.id] = materializedRows;
}
return values;
};
@@ -1,18 +1,26 @@
<form>
<mat-form-field class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) { <mat-label><span>{{label}}</span></mat-label> }
<mat-form-field subscriptSizing="dynamic" class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) {
<mat-label
><span>{{ label }}</span></mat-label
>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list">
<mat-chip-row
*ngFor="let group of selectedGroups"
[removable]="!(group.readonly)"
[removable]="!group.readonly"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
(removed)="onRemove(group)"
[disabled]="readOnly || isValidationLoading()"
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}">
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
>
{{ group.name }}
<mat-icon *ngIf="!(group.readonly || readOnly)" matChipRemove [attr.data-automation-id]="'adf-cloud-group-chip-remove-icon-' + group.name" adf-icon="cancel" />
@if (!(group.readonly || readOnly)) {
<mat-icon matChipRemove [attr.data-automation-id]="'adf-cloud-group-chip-remove-icon-' + group.name" adf-icon="cancel" />
}
</mat-chip-row>
<input matInput
<input
matInput
[formControl]="searchGroupsControl"
[matAutocomplete]="auto"
[matChipInputFor]="groupChipList"
@@ -21,7 +29,9 @@
(focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()"
class="adf-group-input"
data-automation-id="adf-cloud-group-search-input" #groupInput>
data-automation-id="adf-cloud-group-search-input"
#groupInput
/>
</mat-chip-grid>
<mat-autocomplete
@@ -30,57 +40,77 @@
class="adf-cloud-group-list"
(optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName"
data-automation-id="adf-cloud-group-autocomplete">
<ng-container *ngIf="(searchGroups$ | async)?.length else noResults">
<mat-option *ngFor="let group of searchGroups$ | async; let i = index" [value]="group"
data-automation-id="adf-cloud-group-autocomplete"
>
@if ((searchGroups$ | async)?.length) {
<mat-option
*ngFor="let group of searchGroups$ | async; let i = index"
[value]="group"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
class="adf-cloud-group-option-active">
<div
class="adf-cloud-group-row"
id="adf-group-{{i}}"
data-automation-id="adf-cloud-group-row">
class="adf-cloud-group-option-active"
>
<div class="adf-cloud-group-row" id="adf-group-{{ i }}" data-automation-id="adf-cloud-group-row">
<button class="adf-group-short-name" mat-fab>{{ getGroupNameInitials(group) }}</button>
<span>{{ group.name }}</span>
</div>
</mat-option>
</ng-container>
} @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults>
<mat-option *ngIf="searchGroupsControl.hasError('searchTypingError') && !searchLoading" disabled
class="adf-cloud-group-option-not-active"
data-automation-id="adf-cloud-group-no-results">
@if (searchGroupsControl.hasError('searchTypingError') && !searchLoading) {
<mat-option disabled class="adf-cloud-group-option-not-active" data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option>
}
</ng-template>
</mat-autocomplete>
</mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error">
@if (validationLoading) {
<mat-progress-bar mode="indeterminate" />
}
@if (hasPreselectError() && !isValidationLoading()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('pattern')" class="adf-error">
}
@if (searchGroupsControl.hasError('pattern')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}</div>
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('maxlength')" class="adf-error">
}
@if (searchGroupsControl.hasError('maxlength')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}</div>
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('minlength')" class="adf-error">
}
@if (searchGroupsControl.hasError('minlength')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}</div>
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}
</div>
</mat-error>
<mat-error *ngIf="(searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()"
class="adf-error">
}
@if ((searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('searchTypingError') && !this.isFocused"
data-automation-id="invalid-groups-typing-error" class="adf-error">
}
@if (searchGroupsControl.hasError('searchTypingError') && !this.isFocused) {
<mat-error data-automation-id="invalid-groups-typing-error" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
}
</div>
</form>
@@ -57,10 +57,12 @@
}
}
.adf-error-messages-container .adf-error-icon {
.adf-error-messages-container {
.adf-error-icon {
@include mixins.adf-error-icon;
}
.adf-error-messages-container .adf-error {
.adf-error {
animation: slide-down-fade-in 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
}
}
@@ -27,6 +27,8 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing';
import { MatIconHarness } from '@angular/material/icon/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormField } from '@angular/material/form-field';
import { MatProgressBar } from '@angular/material/progress-bar';
describe('GroupCloudComponent', () => {
let loader: HarnessLoader;
@@ -98,6 +100,22 @@ describe('GroupCloudComponent', () => {
expect(await inputElement.getPlaceholder()).toEqual('');
});
it('should use dynamic form field subscript sizing', () => {
fixture.detectChanges();
const formField = fixture.debugElement.query(By.directive(MatFormField)).componentInstance as MatFormField;
expect(formField.subscriptSizing).toBe('dynamic');
});
it('should render validation progress inside the reserved status area', () => {
component.validationLoading = true;
fixture.detectChanges();
const progressBar = fixture.debugElement.query(By.directive(MatProgressBar));
expect(progressBar.parent.classes['adf-error-messages-container']).toBeTrue();
});
describe('Search group', () => {
beforeEach(() => {
fixture.detectChanges();
@@ -4,10 +4,14 @@
class="adf-people-cloud adf-form-field-input"
[class.adf-invalid]="hasError() && isDirty()"
>
<mat-label *ngIf="!title">
@if (!title) {
<mat-label>
<span>{{ label }}</span>
</mat-label>
<mat-label *ngIf="title">{{ title | translate }}</mat-label>
}
@if (title) {
<mat-label>{{ title | translate }}</mat-label>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #userMultipleChipList data-automation-id="adf-cloud-people-chip-list">
<mat-chip-row
@@ -20,7 +24,9 @@
[matTooltip]="showFullNameOnHover ? (user | fullName: true) : user.email"
>
{{ user | fullName }}
<mat-icon matChipRemove *ngIf="!(user.readonly || readOnly)" [attr.data-automation-id]="'adf-people-cloud-chip-remove-icon-' + user.username" adf-icon="cancel" />
@if (!(user.readonly || readOnly)) {
<mat-icon matChipRemove [attr.data-automation-id]="'adf-people-cloud-chip-remove-icon-' + user.username" adf-icon="cancel" />
}
</mat-chip-row>
<input
matInput
@@ -44,64 +50,73 @@
(optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName"
>
<ng-container *ngIf="(searchUsers$ | async)?.length; else noResults">
@if ((searchUsers$ | async)?.length) {
<mat-option *ngFor="let user of searchUsers$ | async; let i = index" [value]="user" class="adf-people-cloud-option-active">
<div class="adf-people-cloud-row" id="adf-people-cloud-user-{{ user.username }}" data-automation-id="adf-people-cloud-row">
<div [outerHTML]="user | usernameInitials: 'adf-people-cloud-pic'"></div>
<span class="adf-people-label-name"> {{ user | fullName: true }}</span>
</div>
</mat-option>
</ng-container>
} @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults>
<mat-option
*ngIf="searchUserCtrl.hasError('searchTypingError') && !searchLoading"
disabled
class="adf-people-cloud-option-not-active"
data-automation-id="adf-people-cloud-no-results"
>
@if (searchUserCtrl.hasError('searchTypingError') && !searchLoading) {
<mat-option disabled class="adf-people-cloud-option-not-active" data-automation-id="adf-people-cloud-no-results">
<span> {{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: searchedValue } }}</span>
</mat-option>
}
</ng-template>
</mat-autocomplete>
</mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container" *ngIf="showErrors">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error adf-error-animate">
<div class="adf-error-container adf-error-messages-container">
@if (validationLoading) {
<mat-progress-bar mode="indeterminate" />
}
@if (showErrors) {
@if (hasPreselectError() && !isValidationLoading()) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: validateUsersMessage } }}</div>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('pattern')" class="adf-error adf-error-animate">
}
@if (searchUserCtrl.hasError('pattern')) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate : { pattern: getValidationPattern() } }}</div>
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('maxlength')" class="adf-error adf-error-animate">
}
@if (searchUserCtrl.hasError('maxlength')) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div>
</mat-error>
<mat-error *ngIf="searchUserCtrl.hasError('minlength')" class="adf-error adf-error-animate">
}
@if (searchUserCtrl.hasError('minlength')) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}
</div>
</mat-error>
<mat-error
*ngIf="(searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()"
class="adf-error adf-error-animate"
>
}
@if ((searchUserCtrl.hasError('required') || userChipsCtrl.hasError('required')) && isDirty()) {
<mat-error class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }}</div>
</mat-error>
<mat-error
*ngIf="searchUserCtrl.hasError('searchTypingError') && !this.isFocused"
data-automation-id="invalid-users-typing-error"
class="adf-error adf-error-animate"
>
}
@if (searchUserCtrl.hasError('searchTypingError') && !this.isFocused) {
<mat-error data-automation-id="invalid-users-typing-error" class="adf-error adf-error-animate">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_USERS.ERROR.NOT_FOUND' | translate: { userName: searchedValue } }}</div>
</mat-error>
}
}
</div>
</form>
@@ -27,6 +27,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness } from '@angular/material/chips/testing';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatFormFieldHarness } from '@angular/material/form-field/testing';
import { MatProgressBar } from '@angular/material/progress-bar';
import { IdentityUserService } from '../services/identity-user.service';
describe('PeopleCloudComponent', () => {
@@ -99,6 +100,19 @@ describe('PeopleCloudComponent', () => {
expect(await inputField.getLabel()).toEqual('TITLE_KEY');
});
it('should use dynamic form field subscript sizing by default', () => {
expect(component.formFieldSubscriptSizing).toBe('dynamic');
});
it('should render validation progress inside the reserved status area', () => {
component.validationLoading = true;
fixture.detectChanges();
const progressBar = fixture.debugElement.query(By.directive(MatProgressBar));
expect(progressBar.parent.classes['adf-error-messages-container']).toBeTrue();
});
describe('Search user', () => {
beforeEach(() => {
fixture.detectChanges();
@@ -169,7 +169,7 @@ export class PeopleCloudComponent implements OnInit, OnChanges, AfterViewInit {
* Material form field subscript sizing (fixed / dynamic)
*/
@Input()
formFieldSubscriptSizing: SubscriptSizing = 'fixed';
formFieldSubscriptSizing: SubscriptSizing = 'dynamic';
/**
* Show errors under the form field
@@ -17,8 +17,8 @@
import { DebugElement, SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
import { of, throwError } from 'rxjs';
import { ADF_DISPLAY_TEXT_SETTINGS, FormFieldTypes, FormModel, FormOutcomeEvent, FormOutcomeModel } from '@alfresco/adf-core';
import { Subject, of, throwError } from 'rxjs';
import { StartProcessCloudService } from '../services/start-process-cloud.service';
import { FormCloudService } from '../../../form/services/form-cloud.service';
import { FormCloudComponent } from '../../../form/components/form-cloud.component';
@@ -47,7 +47,7 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { FormCloudDisplayMode } from '../../../services/form-fields.interfaces';
import { FormCloudDisplayMode, FormContent } from '../../../services/form-fields.interfaces';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatDialog } from '@angular/material/dialog';
import { ReactiveFormsModule } from '@angular/forms';
@@ -92,7 +92,10 @@ describe('StartProcessCloudComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [StartProcessCloudComponent, ReactiveFormsModule, StartProcessScreenCloudComponent],
providers: [provideScreen(screenId, MockedTaskScreenCloudComponent)]
providers: [
provideScreen(screenId, MockedTaskScreenCloudComponent),
{ provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }
]
});
processService = TestBed.inject(StartProcessCloudService);
formCloudService = TestBed.inject(FormCloudService);
@@ -417,6 +420,54 @@ describe('StartProcessCloudComponent', () => {
expect(startBtn.disabled).toBe(false);
});
it('should keep the start action unavailable while the form definition is loading', async () => {
const formDefinition = new Subject<FormContent>();
formDefinitionSpy.and.returnValue(formDefinition);
typeValueInto('[data-automation-id="adf-inplace-input"]', 'My new process with form');
await selectOptionByName('processwithform');
const startButton = fixture.nativeElement.querySelector('#button-start');
expect(startButton).toBeNull();
expect(startProcessWithFormSpy).not.toHaveBeenCalled();
});
it('should materialize unrendered rich text expressions when starting a process', async () => {
formDefinitionSpy.and.returnValue(of(fakeStartForm));
component.processDefinitionCurrent = fakeProcessDefinitions[2];
component.processPayloadCloud.processDefinitionKey = fakeProcessDefinitions[2].key;
component.processInstanceName.setValue('My process');
fixture.detectChanges();
await fixture.whenStable();
const form = new FormModel({
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const formElement = fixture.debugElement.query(By.css('adf-cloud-form'));
const startButton = fixture.debugElement.query(By.css('#button-start'));
formElement.triggerEventHandler('formLoaded', form);
fixture.detectChanges();
startButton.triggerEventHandler('click', null);
expect(startProcessWithFormSpy).toHaveBeenCalledWith(
component.appName,
fakeProcessDefinitions[2].formKey,
fakeProcessDefinitions[2].version,
jasmine.objectContaining({
values: jasmine.objectContaining({
richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] }
})
})
);
});
it('should be able to start a process with form full display mode', async () => {
component.displayModeConfigurations = [
{
@@ -30,10 +30,14 @@ import {
ViewEncapsulation
} from '@angular/core';
import {
ADF_DISPLAY_TEXT_SETTINGS,
ConfirmDialogComponent,
ContentLinkModel,
DisplayTextWidgetSettings,
FormExpressionService,
FormModel,
FormOutcomeEvent,
FormValues,
IconModule,
InplaceFormInputComponent,
LocalizedDatePipe,
@@ -65,6 +69,7 @@ import { FormCustomOutcomesComponent } from '../../../form/components/form-cloud
import { MatDialog } from '@angular/material/dialog';
import { StartProcessScreenCloudComponent } from '../../../screen/components/screen-cloud/start-process-event-screen/start-process-screen-cloud.component';
import { TaskTypeResolverService } from '../../../services/task-type-resolver/task-type-resolver.service';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../../../form/services/form-cloud-submission-values';
const MAX_NAME_LENGTH: number = 255;
const PROCESS_DEFINITION_DEBOUNCE: number = 300;
@@ -211,6 +216,11 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
private readonly hasVisibleOutcomesSubject = new BehaviorSubject<boolean>(false);
private readonly dialog = inject(MatDialog);
private readonly taskTypeResolverService = inject(TaskTypeResolverService);
private readonly expressions = inject(FormExpressionService);
private readonly displayTextSettings = inject<Observable<DisplayTextWidgetSettings> | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, {
optional: true
});
private enableExpressionEvaluation = false;
private screenSubmitPayload: unknown;
@@ -218,8 +228,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
showCompleteButton = false;
get isProcessFormValid(): boolean {
if (this.hasForm && this.isFormCloudLoaded) {
return (this.formCloud ? !Object.keys(this.formCloud.values).length : false) || this.formCloud?.isValid || this.isProcessStarting;
if (this.hasForm) {
if (!this.isFormCloudLoaded || !this.formCloud) {
return false;
}
return !Object.keys(this.formCloud.values).length || this.formCloud.isValid || this.isProcessStarting;
} else if (this.hasScreen) {
return true;
} else {
@@ -268,6 +282,12 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
constructor() {
this.startProcessButtonLabel = this.defaultStartProcessButtonLabel;
this.cancelButtonLabel = this.defaultCancelProcessButtonLabel;
getExpressionEvaluationEnabled$(this.displayTextSettings)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((enabled) => {
this.enableExpressionEvaluation = enabled;
});
}
ngOnInit() {
@@ -482,6 +502,14 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
}
startProcessWithoutConfirmation() {
let submissionValues = this.screenSubmitPayload;
if (this.hasForm) {
if (!this.formCloud) {
return;
}
submissionValues = this.getFormSubmissionValues(this.formCloud);
}
this.isProcessStarting = true;
let action: Observable<ProcessInstanceCloud>;
@@ -495,7 +523,7 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
processName: this.processInstanceName.value,
processDefinitionKey: this.processPayloadCloud.processDefinitionKey,
variables: this.variables ?? {},
values: this.hasForm ? this.formCloud.values : this.screenSubmitPayload,
values: submissionValues,
outcome: this.customOutcomeName
})
);
@@ -524,6 +552,10 @@ export class StartProcessCloudComponent implements OnChanges, OnInit {
});
}
private getFormSubmissionValues(form: FormModel): FormValues {
return materializeSubmissionValues(form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions);
}
startProcess() {
if (!this.formCloud?.confirmMessage?.show) {
this.startProcessWithoutConfirmation();
@@ -0,0 +1,198 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ComponentRef, OnDestroy } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { BaseScreenCloudComponent } from './base-screen-cloud.component';
import { provideScreen } from '../../../services/provide-screen';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
@Component({
selector: 'adf-cloud-test-dynamic-screen',
template: `<div class="adf-cloud-test-dynamic-screen">dynamic screen</div>`
})
class TestDynamicScreenComponent implements OnDestroy {
destroyed = false;
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
selector: 'adf-cloud-test-host-screen',
template: `<ng-container #container />`
})
class TestHostScreenComponent extends BaseScreenCloudComponent<TestDynamicScreenComponent> {
setInputsCalls: ComponentRef<TestDynamicScreenComponent>[] = [];
subscribeToOutputsCalls: ComponentRef<TestDynamicScreenComponent>[] = [];
get dynamicComponentRef(): ComponentRef<TestDynamicScreenComponent> | undefined {
return this.componentRef;
}
get dynamicComponentRefSignalValue(): ComponentRef<TestDynamicScreenComponent> | undefined {
return this.componentRefChanged();
}
protected override setInputsForDynamicComponent(componentRef: ComponentRef<TestDynamicScreenComponent>): void {
this.setInputsCalls.push(componentRef);
}
protected subscribeToOutputs(componentRef: ComponentRef<TestDynamicScreenComponent>): void {
this.subscribeToOutputsCalls.push(componentRef);
}
}
/** Same host component, but without the `#container` anchor in its template. */
@Component({
selector: 'adf-cloud-test-host-screen-without-container',
template: `<div class="adf-cloud-no-container"></div>`
})
class TestHostScreenWithoutContainerComponent extends TestHostScreenComponent {}
describe('BaseScreenCloudComponent', () => {
const screenId = 'test-screen';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestHostScreenComponent, TestHostScreenWithoutContainerComponent, TestDynamicScreenComponent],
providers: [provideScreen(screenId, TestDynamicScreenComponent)]
});
});
describe('when a screenId is provided', () => {
let fixture: ComponentFixture<TestHostScreenComponent>;
let component: TestHostScreenComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
fixture.detectChanges();
});
it('should create the dynamic component and expose it through the signal', () => {
expect(component.dynamicComponentRef).toBeDefined();
expect(component.dynamicComponentRefSignalValue).toBe(component.dynamicComponentRef);
expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeTruthy();
});
it('should wire inputs and outputs once, passing the created component reference', () => {
expect(component.setInputsCalls).toEqual([component.dynamicComponentRef!]);
expect(component.subscribeToOutputsCalls).toEqual([component.dynamicComponentRef!]);
});
it('should destroy the dynamic component reference on destroy', () => {
const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy').and.callThrough();
fixture.destroy();
expect(destroySpy).toHaveBeenCalledTimes(1);
});
it('should run the ngOnDestroy hook of the dynamic component on destroy', () => {
const dynamicComponentInstance = component.dynamicComponentRef?.instance;
expect(dynamicComponentInstance?.destroyed).toBeFalse();
fixture.destroy();
expect(dynamicComponentInstance?.destroyed).toBeTrue();
});
it('should clear the dynamic component reference and the signal on destroy', () => {
fixture.destroy();
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
});
it('should destroy the dynamic component reference only once when ngOnDestroy runs again', () => {
const destroySpy = spyOn(component.dynamicComponentRef!, 'destroy');
component.ngOnDestroy();
component.ngOnDestroy();
expect(destroySpy).toHaveBeenCalledTimes(1);
});
});
describe('when no screenId is provided', () => {
let fixture: ComponentFixture<TestHostScreenComponent>;
let component: TestHostScreenComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should not create any dynamic component nor wire inputs and outputs', () => {
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
expect(component.setInputsCalls).toEqual([]);
expect(component.subscribeToOutputsCalls).toEqual([]);
expect(fixture.debugElement.query(By.css('.adf-cloud-test-dynamic-screen'))).toBeNull();
});
it('should not throw on destroy', () => {
expect(() => fixture.destroy()).not.toThrow();
expect(component.dynamicComponentRef).toBeUndefined();
});
});
describe('when the container anchor is missing', () => {
let fixture: ComponentFixture<TestHostScreenWithoutContainerComponent>;
let component: TestHostScreenWithoutContainerComponent;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostScreenWithoutContainerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
});
it('should not throw and should not create any dynamic component', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(component.container).toBeUndefined();
expect(component.dynamicComponentRef).toBeUndefined();
expect(component.dynamicComponentRefSignalValue).toBeUndefined();
});
it('should not wire inputs and outputs when no dynamic component was created', () => {
fixture.detectChanges();
expect(component.setInputsCalls).toEqual([]);
expect(component.subscribeToOutputsCalls).toEqual([]);
});
it('should not resolve any component type', () => {
const resolveComponentTypeSpy = spyOn(TestBed.inject(ScreenRenderingService), 'resolveComponentType').and.callThrough();
fixture.detectChanges();
expect(resolveComponentTypeSpy).not.toHaveBeenCalled();
});
it('should not throw on destroy', () => {
fixture.detectChanges();
expect(() => fixture.destroy()).not.toThrow();
});
});
});
@@ -15,20 +15,20 @@
* limitations under the License.
*/
import { Component, ComponentRef, inject, Input, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, ComponentRef, inject, Input, OnDestroy, OnInit, signal, ViewChild, ViewContainerRef } from '@angular/core';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
@Component({
template: ''
})
export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> implements OnInit {
export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> implements OnInit, OnDestroy {
@Input()
screenId: string = '';
@ViewChild('container', { read: ViewContainerRef, static: true })
container: ViewContainerRef;
container: ViewContainerRef | undefined;
protected componentRef: ComponentRef<TScreenComponent>;
protected componentRef: ComponentRef<TScreenComponent> | undefined;
private readonly _componentRefChanged = signal<ComponentRef<TScreenComponent> | undefined>(undefined);
protected readonly componentRefChanged = this._componentRefChanged.asReadonly();
protected readonly screenRenderingService = inject(ScreenRenderingService);
@@ -37,17 +37,27 @@ export abstract class BaseScreenCloudComponent<TScreenComponent = unknown> imple
this.createDynamicComponent();
}
ngOnDestroy(): void {
this.componentRef?.destroy();
this.componentRef = undefined;
this._componentRefChanged.set(undefined);
}
private createDynamicComponent(): void {
if (this.screenId) {
if (!this.screenId || !this.container) {
return;
}
const componentType = this.screenRenderingService.resolveComponentType({ type: this.screenId });
this.componentRef = this.container.createComponent(componentType);
this._componentRefChanged.set(this.componentRef);
this.setInputsForDynamicComponent();
this.subscribeToOutputs();
}
const componentRef: ComponentRef<TScreenComponent> = this.container.createComponent(componentType);
this.componentRef = componentRef;
this._componentRefChanged.set(componentRef);
this.setInputsForDynamicComponent(componentRef);
this.subscribeToOutputs(componentRef);
}
protected setInputsForDynamicComponent(): void {}
protected setInputsForDynamicComponent(_componentRef: ComponentRef<TScreenComponent>): void {}
protected abstract subscribeToOutputs(): void;
protected abstract subscribeToOutputs(componentRef: ComponentRef<TScreenComponent>): void;
}
@@ -16,6 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, Input, OnDestroy, input, output } from '@angular/core';
import { StartProcessScreenCloudComponent } from './start-process-screen-cloud.component';
import { MockedTaskScreenCloudComponent } from '../../../../testing/start-process-screen-mock.component';
import { provideScreen } from '../../../services/provide-screen';
@@ -66,11 +67,11 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set appName', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
expect(screenInstance.appName()).toEqual('');
expect(screenInstance.appName?.()).toEqual('');
const newValue = 'new-app-name';
fixture.componentRef.setInput('appName', newValue);
fixture.detectChanges();
expect(screenInstance.appName()).toEqual(newValue);
expect(screenInstance.appName?.()).toEqual(newValue);
});
it('should set process definition id', () => {
@@ -84,10 +85,125 @@ describe('StartProcessScreenCloudComponent', () => {
it('should set resolvedValues', () => {
const screenInstance: StartProcessScreenCloud = fixture.debugElement.query(By.directive(MockedTaskScreenCloudComponent)).componentInstance;
expect(screenInstance.resolvedValues()).toBeUndefined();
expect(screenInstance.resolvedValues?.()).toBeUndefined();
const newValues = [new TaskVariableCloud({ id: 'new-id', name: 'new-name' })];
fixture.componentRef.setInput('resolvedValues', newValues);
fixture.detectChanges();
expect(screenInstance.resolvedValues()).toEqual(newValues);
expect(screenInstance.resolvedValues?.()).toEqual(newValues);
});
});
@Component({
selector: 'adf-cloud-destroy-tracking-screen',
template: `<div class="adf-cloud-destroy-tracking-screen">screen</div>`
})
class DestroyTrackingScreenComponent implements StartProcessScreenCloud, OnDestroy {
readonly appName = input('');
processDefinitionId = input('');
readonly resolvedValues = input<TaskVariableCloud[] | undefined>();
defaultStartProcessButtonsConfigurationChange = output<StartProcessScreenDefaultButtons>();
startProcessPayloadChanged = output<unknown>();
destroyed = false;
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
selector: 'adf-cloud-test-start-process-wrapper',
template: `
@if (showScreen) {
<adf-cloud-start-process-screen-cloud [screenId]="screenId" [processDefinitionId]="'definition-id'" />
}
`,
imports: [StartProcessScreenCloudComponent]
})
class TestStartProcessWrapperComponent {
@Input() screenId = '';
showScreen = true;
}
describe('StartProcessScreenCloudComponent - destroy', () => {
let fixture: ComponentFixture<TestStartProcessWrapperComponent>;
let component: TestStartProcessWrapperComponent;
const screenId = 'screen-1234-5678-121212-123456';
const getScreenInstance = (): DestroyTrackingScreenComponent =>
fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent)).componentInstance;
const destroyScreen = () => {
component.showScreen = false;
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestStartProcessWrapperComponent],
providers: [provideScreen(screenId, DestroyTrackingScreenComponent)]
});
fixture = TestBed.createComponent(TestStartProcessWrapperComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('screenId', screenId);
fixture.detectChanges();
});
it('should destroy the screen component when the host is destroyed', () => {
const screenInstance = getScreenInstance();
expect(screenInstance.destroyed).toBeFalse();
destroyScreen();
expect(screenInstance.destroyed).toBeTrue();
});
it('should remove the screen component from the DOM when the host is destroyed', () => {
expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeTruthy();
destroyScreen();
expect(fixture.debugElement.query(By.css('.adf-cloud-destroy-tracking-screen'))).toBeNull();
});
it('should create a new screen component instance when the host is re-created', () => {
const firstInstance = getScreenInstance();
destroyScreen();
component.showScreen = true;
fixture.detectChanges();
const secondInstance = getScreenInstance();
expect(secondInstance).not.toBe(firstInstance);
expect(secondInstance.destroyed).toBeFalse();
expect(secondInstance.processDefinitionId()).toBe('definition-id');
});
});
describe('StartProcessScreenCloudComponent - without screenId', () => {
let fixture: ComponentFixture<StartProcessScreenCloudComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [StartProcessScreenCloudComponent]
});
fixture = TestBed.createComponent(StartProcessScreenCloudComponent);
});
it('should not create any screen component and should not throw', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(fixture.debugElement.query(By.directive(DestroyTrackingScreenComponent))).toBeNull();
});
it('should not throw when inputs change or on destroy', () => {
fixture.detectChanges();
expect(() => {
fixture.componentRef.setInput('appName', 'new-app-name');
fixture.componentRef.setInput('resolvedValues', [new TaskVariableCloud({ id: 'id', name: 'name' })]);
fixture.detectChanges();
}).not.toThrow();
expect(() => fixture.destroy()).not.toThrow();
});
});
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { ChangeDetectionStrategy, Component, effect, input, output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, ComponentRef, effect, input, output, signal } from '@angular/core';
import { BaseScreenCloudComponent } from '../base-screen/base-screen-cloud.component';
import { MatCardModule } from '@angular/material/card';
import { CommonModule } from '@angular/common';
@@ -43,7 +43,7 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent<S
super();
effect(() => {
const componentRef = this.componentRefChanged();
if (componentRef.instance && 'appName' in componentRef.instance) {
if (componentRef?.instance && 'appName' in componentRef.instance) {
componentRef.setInput('appName', this.appName());
}
});
@@ -56,9 +56,9 @@ export class StartProcessScreenCloudComponent extends BaseScreenCloudComponent<S
});
}
protected subscribeToOutputs(): void {
this.componentRef.instance.startProcessPayloadChanged.subscribe((payload) => this.screenStartProcessPayloadChange.emit(payload));
this.componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
protected subscribeToOutputs(componentRef: ComponentRef<StartProcessScreenCloud>): void {
componentRef.instance.startProcessPayloadChanged.subscribe((payload) => this.screenStartProcessPayloadChange.emit(payload));
componentRef.instance.defaultStartProcessButtonsConfigurationChange.subscribe((config) => {
this.showStartProcessButtons.set(config.show);
this.disableStartProcessButton.emit(config.disable);
});
@@ -16,7 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ScreenRenderingService } from '../../../services/screen-rendering.service';
import { TaskScreenCloudComponent } from './screen-cloud.component';
@@ -32,18 +32,22 @@ import { TaskScreenCloudComponent } from './screen-cloud.component';
</div>
`
})
class TestComponent {
class TestComponent implements OnDestroy {
@Input() taskId = '';
@Input() screenId = '';
@Input() rootProcessInstanceId = '';
@Output() taskCompleted = new EventEmitter();
displayMode: string;
displayMode: string | undefined;
destroyed = false;
onComplete() {
this.taskCompleted.emit();
}
switchToDisplayMode(newDisplayMode?: string) {
this.displayMode = newDisplayMode;
}
ngOnDestroy(): void {
this.destroyed = true;
}
}
@Component({
@@ -61,7 +65,7 @@ class TestComponent {
})
class TestWrapperComponent {
@Input() screenId = '';
@ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent;
@ViewChild('adfCloudTaskScreen') adfCloudTaskScreen: TaskScreenCloudComponent | undefined;
onTaskCompleted() {}
switchToDisplayMode(newDisplayMode?: string): void {
if (this.adfCloudTaskScreen) {
@@ -118,6 +122,118 @@ describe('TaskScreenCloudComponent', () => {
component.switchToDisplayMode();
fixture.detectChanges();
expect(component.adfCloudTaskScreen.switchToDisplayMode).toHaveBeenCalled();
expect(component.adfCloudTaskScreen?.switchToDisplayMode).toHaveBeenCalled();
});
});
@Component({
selector: 'adf-cloud-test-conditional-component',
template: `
@if (showTaskScreen) {
<adf-cloud-task-screen [taskId]="'1'" [appName]="'app-name-test'" [screenId]="'test'" (taskCompleted)="onTaskCompleted()" />
}
`,
imports: [TaskScreenCloudComponent]
})
class TestConditionalWrapperComponent {
showTaskScreen = true;
onTaskCompleted() {}
}
describe('TaskScreenCloudComponent - destroy', () => {
let fixture: ComponentFixture<TestConditionalWrapperComponent>;
let component: TestConditionalWrapperComponent;
const getDynamicComponentInstance = (): TestComponent => fixture.debugElement.query(By.directive(TestComponent)).componentInstance;
const getTaskScreen = (): TaskScreenCloudComponent => fixture.debugElement.query(By.directive(TaskScreenCloudComponent)).componentInstance;
const destroyTaskScreen = () => {
component.showTaskScreen = false;
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TaskScreenCloudComponent, TestComponent, TestConditionalWrapperComponent]
});
TestBed.inject(ScreenRenderingService).register({ ['test']: () => TestComponent });
fixture = TestBed.createComponent(TestConditionalWrapperComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should destroy the dynamic component when the task screen is destroyed', () => {
const dynamicComponentInstance = getDynamicComponentInstance();
expect(dynamicComponentInstance.destroyed).toBeFalse();
destroyTaskScreen();
expect(dynamicComponentInstance.destroyed).toBeTrue();
});
it('should remove the dynamic component from the DOM when the task screen is destroyed', () => {
expect(fixture.debugElement.query(By.css('.adf-cloud-test-container'))).toBeTruthy();
destroyTaskScreen();
expect(fixture.debugElement.query(By.css('.adf-cloud-test-container'))).toBeNull();
});
it('should not emit outputs of the dynamic component after the task screen is destroyed', () => {
const onTaskCompletedSpy = spyOn(component, 'onTaskCompleted');
const dynamicComponentInstance = getDynamicComponentInstance();
destroyTaskScreen();
dynamicComponentInstance.taskCompleted.emit();
expect(onTaskCompletedSpy).not.toHaveBeenCalled();
});
it('should not call the dynamic component when switching display mode after destroy', () => {
const taskScreen = getTaskScreen();
const switchToDisplayModeSpy = spyOn(getDynamicComponentInstance(), 'switchToDisplayMode');
destroyTaskScreen();
expect(() => taskScreen.switchToDisplayMode('mode')).not.toThrow();
expect(switchToDisplayModeSpy).not.toHaveBeenCalled();
});
it('should create a new dynamic component instance when the task screen is re-created', () => {
const firstInstance = getDynamicComponentInstance();
destroyTaskScreen();
component.showTaskScreen = true;
fixture.detectChanges();
const secondInstance = getDynamicComponentInstance();
expect(secondInstance).not.toBe(firstInstance);
expect(secondInstance.destroyed).toBeFalse();
expect(secondInstance.taskId).toBe('1');
});
});
describe('TaskScreenCloudComponent - without screenId', () => {
let fixture: ComponentFixture<TaskScreenCloudComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TaskScreenCloudComponent]
});
fixture = TestBed.createComponent(TaskScreenCloudComponent);
});
it('should not create any dynamic component and should not throw', () => {
expect(() => fixture.detectChanges()).not.toThrow();
expect(fixture.debugElement.query(By.directive(TestComponent))).toBeNull();
});
it('should not throw when switching display mode or destroying', () => {
fixture.detectChanges();
expect(() => fixture.componentInstance.switchToDisplayMode('mode')).not.toThrow();
expect(() => fixture.destroy()).not.toThrow();
});
});
@@ -16,7 +16,7 @@
*/
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, EventEmitter, inject, Input, Output } from '@angular/core';
import { Component, ComponentRef, DestroyRef, EventEmitter, inject, Input, Output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { UserTaskCustomUi } from './screen-cloud.model';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -102,65 +102,65 @@ export class TaskScreenCloudComponent extends BaseScreenCloudComponent<UserTaskC
private readonly destroyRef = inject(DestroyRef);
protected override setInputsForDynamicComponent(): void {
if (this.taskId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'taskId')) {
this.componentRef.setInput('taskId', this.taskId);
protected override setInputsForDynamicComponent(componentRef: ComponentRef<UserTaskCustomUi>): void {
if (this.taskId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'taskId')) {
componentRef.setInput('taskId', this.taskId);
}
if (this.appName && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'appName')) {
this.componentRef.setInput('appName', this.appName);
if (this.appName && Object.prototype.hasOwnProperty.call(componentRef.instance, 'appName')) {
componentRef.setInput('appName', this.appName);
}
if (this.screenId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'screenId')) {
this.componentRef.setInput('screenId', this.screenId);
if (this.screenId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'screenId')) {
componentRef.setInput('screenId', this.screenId);
}
if (this.processInstanceId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'processInstanceId')) {
this.componentRef.setInput('processInstanceId', this.processInstanceId);
if (this.processInstanceId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'processInstanceId')) {
componentRef.setInput('processInstanceId', this.processInstanceId);
}
if (this.taskName && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'taskName')) {
this.componentRef.setInput('taskName', this.taskName);
if (this.taskName && Object.prototype.hasOwnProperty.call(componentRef.instance, 'taskName')) {
componentRef.setInput('taskName', this.taskName);
}
if (this.canClaimTask && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'canClaimTask')) {
this.componentRef.setInput('canClaimTask', this.canClaimTask);
if (this.canClaimTask && Object.prototype.hasOwnProperty.call(componentRef.instance, 'canClaimTask')) {
componentRef.setInput('canClaimTask', this.canClaimTask);
}
if (this.canUnclaimTask && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'canUnclaimTask')) {
this.componentRef.setInput('canUnclaimTask', this.canUnclaimTask);
if (this.canUnclaimTask && Object.prototype.hasOwnProperty.call(componentRef.instance, 'canUnclaimTask')) {
componentRef.setInput('canUnclaimTask', this.canUnclaimTask);
}
if (this.showCancelButton && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'showCancelButton')) {
this.componentRef.setInput('showCancelButton', this.showCancelButton);
if (this.showCancelButton && Object.prototype.hasOwnProperty.call(componentRef.instance, 'showCancelButton')) {
componentRef.setInput('showCancelButton', this.showCancelButton);
}
if (this.rootProcessInstanceId && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'rootProcessInstanceId')) {
this.componentRef.setInput('rootProcessInstanceId', this.rootProcessInstanceId);
if (this.rootProcessInstanceId && Object.prototype.hasOwnProperty.call(componentRef.instance, 'rootProcessInstanceId')) {
componentRef.setInput('rootProcessInstanceId', this.rootProcessInstanceId);
}
if (this.showNextTaskCheckbox && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'showNextTaskCheckbox')) {
this.componentRef.setInput('showNextTaskCheckbox', this.showNextTaskCheckbox);
if (this.showNextTaskCheckbox && Object.prototype.hasOwnProperty.call(componentRef.instance, 'showNextTaskCheckbox')) {
componentRef.setInput('showNextTaskCheckbox', this.showNextTaskCheckbox);
}
if (this.isNextTaskCheckboxChecked && Object.prototype.hasOwnProperty.call(this.componentRef.instance, 'isNextTaskCheckboxChecked')) {
this.componentRef.setInput('isNextTaskCheckboxChecked', this.isNextTaskCheckboxChecked);
if (this.isNextTaskCheckboxChecked && Object.prototype.hasOwnProperty.call(componentRef.instance, 'isNextTaskCheckboxChecked')) {
componentRef.setInput('isNextTaskCheckboxChecked', this.isNextTaskCheckboxChecked);
}
}
protected override subscribeToOutputs(): void {
if (this.componentRef.instance?.taskSaved) {
this.componentRef.instance.taskSaved.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.taskSaved.emit());
protected override subscribeToOutputs(componentRef: ComponentRef<UserTaskCustomUi>): void {
if (componentRef.instance?.taskSaved) {
componentRef.instance.taskSaved.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.taskSaved.emit());
}
if (this.componentRef.instance?.taskCompleted) {
this.componentRef.instance.taskCompleted
if (componentRef.instance?.taskCompleted) {
componentRef.instance.taskCompleted
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((openNextTask) => this.taskCompleted.emit(openNextTask));
}
if (this.componentRef.instance?.error) {
this.componentRef.instance.error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.error.emit(data));
if (componentRef.instance?.error) {
componentRef.instance.error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.error.emit(data));
}
if (this.componentRef.instance?.claimTask) {
this.componentRef.instance.claimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.claimTask.emit(data));
if (componentRef.instance?.claimTask) {
componentRef.instance.claimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.claimTask.emit(data));
}
if (this.componentRef.instance?.unclaimTask) {
this.componentRef.instance.unclaimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.unclaimTask.emit(data));
if (componentRef.instance?.unclaimTask) {
componentRef.instance.unclaimTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.unclaimTask.emit(data));
}
if (this.componentRef.instance?.cancelTask) {
this.componentRef.instance.cancelTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.cancelTask.emit(data));
if (componentRef.instance?.cancelTask) {
componentRef.instance.cancelTask.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((data) => this.cancelTask.emit(data));
}
if (this.componentRef.instance?.nextTaskCheckboxCheckedChanged) {
this.componentRef.instance.nextTaskCheckboxCheckedChanged
if (componentRef.instance?.nextTaskCheckboxCheckedChanged) {
componentRef.instance.nextTaskCheckboxCheckedChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => this.nextTaskCheckboxCheckedChanged.emit(data));
}
+1 -1
View File
@@ -43,7 +43,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/process-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0",
+2 -1
View File
@@ -10,7 +10,8 @@
"cache": true
},
"test": {
"cache": true
"cache": true,
"outputs": ["{workspaceRoot}/coverage/{projectName}"]
},
"stylelint": {
"cache": true
+12 -12
View File
@@ -40,17 +40,17 @@
],
"dependencies": {
"@angular-eslint/utils": "20.7.0",
"@angular/animations": "20.3.26",
"@angular/animations": "20.3.27",
"@angular/cdk": "20.2.14",
"@angular/common": "20.3.27",
"@angular/compiler": "20.3.27",
"@angular/core": "20.3.27",
"@angular/forms": "20.3.26",
"@angular/forms": "20.3.27",
"@angular/material": "20.2.14",
"@angular/material-date-fns-adapter": "20.2.14",
"@angular/platform-browser": "20.3.26",
"@angular/platform-browser-dynamic": "20.3.26",
"@angular/router": "20.3.26",
"@angular/platform-browser": "20.3.27",
"@angular/platform-browser-dynamic": "20.3.27",
"@angular/router": "20.3.27",
"@apollo/client": "3.13.1",
"@cspell/eslint-plugin": "10.0.0",
"@mat-datetimepicker/core": "16.0.1",
@@ -83,7 +83,7 @@
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/template-parser": "22.1.0",
"@angular/build": "20.3.32",
"@angular/compiler-cli": "20.3.26",
"@angular/compiler-cli": "20.3.27",
"@chromatic-com/storybook": "4.1.3",
"@eslint/compat": "^2.1.0",
"@nx/angular": "23.1.1",
@@ -98,10 +98,10 @@
"@types/jasminewd2": "2.0.13",
"@types/node": "26.1.1",
"@types/sinon": "22.0.0",
"@typescript-eslint/eslint-plugin": "8.66.0",
"@typescript-eslint/parser": "8.66.0",
"@typescript-eslint/typescript-estree": "8.66.0",
"@typescript-eslint/utils": "8.66.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",
"ajv": "8.20.0",
"dotenv": "16.4.7",
"eslint": "10.8.0",
@@ -115,7 +115,7 @@
"eslint-plugin-unicorn": "61.0.2",
"graphql": "16.14.0",
"husky": "9.1.7",
"jasmine-core": "5.13.0",
"jasmine-core": "6.3.0",
"jasmine-reporters": "2.5.2",
"jsonc-eslint-parser": "2.4.2",
"karma": "6.4.4",
@@ -140,7 +140,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"undici": "8.9.0",
"webpack": "5.109.0"
"webpack": "5.109.2"
},
"license": "Apache-2.0",
"engines": {
+471 -347
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,9 +8,10 @@ minimumReleaseAgeStrict: true
overrides:
# Security fixes
fast-uri: 3.1.5
"js-yaml@4.2.0": "4.3.0"
"js-yaml@4.2.0": "4.3.1"
"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
svgo: 4.0.2
shell-quote: 1.9.0
adm-zip: 0.6.0
+11
View File
@@ -0,0 +1,11 @@
sonar.organization=alfresco
sonar.projectKey=Alfresco_alfresco-ng2-components
sonar.sources=lib
sonar.tests=lib
sonar.test.inclusions=**/*.spec.ts
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.ts,**/*.mock.ts,**/mock/**,**/mocks/**,**/testing/**,**/stories/**
sonar.javascript.lcov.reportPaths=coverage/core/lcov.info,coverage/content-services/lcov.info,coverage/extensions/lcov.info,coverage/insights/lcov.info,coverage/process-services/lcov.info,coverage/process-services-cloud/lcov.info
sonar.sourceEncoding=UTF-8