diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 69a6feef8b..436e1fa0aa 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 88c32e79f5..9a2c2a18d4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -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 diff --git a/.github/workflows/notify-on-an-bdu-label.yml b/.github/workflows/notify-on-an-bdu-label.yml index 2248248c4e..cafc0dff62 100644 --- a/.github/workflows/notify-on-an-bdu-label.yml +++ b/.github/workflows/notify-on-an-bdu-label.yml @@ -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 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 424b51941b..371fe5e0fa 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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' }} diff --git a/.github/workflows/sonar-develop.yml b/.github/workflows/sonar-develop.yml new file mode 100644 index 0000000000..83bd79b0e7 --- /dev/null +++ b/.github/workflows/sonar-develop.yml @@ -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 diff --git a/.github/workflows/stale-pr-cleanup.yml b/.github/workflows/stale-pr-cleanup.yml index c50211b485..e14f9155c7 100644 --- a/.github/workflows/stale-pr-cleanup.yml +++ b/.github/workflows/stale-pr-cleanup.yml @@ -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 diff --git a/.github/workflows/supply-chain-pr-instructions.yml b/.github/workflows/supply-chain-pr-instructions.yml index 9ebc9bee49..5c5166f57f 100644 --- a/.github/workflows/supply-chain-pr-instructions.yml +++ b/.github/workflows/supply-chain-pr-instructions.yml @@ -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: | diff --git a/.github/workflows/supply-chain-review.lock.yml b/.github/workflows/supply-chain-review.lock.yml index 8b1a7db227..8a114a76b5 100644 --- a/.github/workflows/supply-chain-review.lock.yml +++ b/.github/workflows/supply-chain-review.lock.yml @@ -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 }} diff --git a/.github/workflows/unit-test-workflow.yml b/.github/workflows/unit-test-workflow.yml index 8759f8b863..2b52941aec 100644 --- a/.github/workflows/unit-test-workflow.yml +++ b/.github/workflows/unit-test-workflow.yml @@ -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: | - 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) + 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 diff --git a/docs/core/models/form-field.model.md b/docs/core/models/form-field.model.md index ed98e18f8d..ab88e0ab5a 100644 --- a/docs/core/models/form-field.model.md +++ b/docs/core/models/form-field.model.md @@ -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 diff --git a/lib/content-services/karma.conf.js b/lib/content-services/karma.conf.js index a37f38f5e1..6880782c7c 100644 --- a/lib/content-services/karma.conf.js +++ b/lib/content-services/karma.conf.js @@ -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, diff --git a/lib/content-services/package.json b/lib/content-services/package.json index abce4fea81..7e17e7c9ea 100644 --- a/lib/content-services/package.json +++ b/lib/content-services/package.json @@ -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" diff --git a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component.html b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component.html index 8167e8b14e..dd4ca6f210 100644 --- a/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component.html +++ b/lib/content-services/src/lib/search/components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component.html @@ -1,6 +1,8 @@
diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts index 15ef00254b..6d6afa7d45 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.spec.ts @@ -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'); }); }); diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts index f6555a01a7..c4c6a58a85 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/group/group-cloud.widget.ts @@ -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)', diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.html b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.html index 399f01aca0..457aad503a 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.html +++ b/lib/process-services-cloud/src/lib/form/components/widgets/people/people-cloud.widget.html @@ -1,10 +1,16 @@ - diff --git a/lib/process-services-cloud/src/lib/form/components/widgets/upload/upload-cloud.widget.spec.ts b/lib/process-services-cloud/src/lib/form/components/widgets/upload/upload-cloud.widget.spec.ts index 61e4537d8d..39f922fcfd 100644 --- a/lib/process-services-cloud/src/lib/form/components/widgets/upload/upload-cloud.widget.spec.ts +++ b/lib/process-services-cloud/src/lib/form/components/widgets/upload/upload-cloud.widget.spec.ts @@ -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); + }); }); diff --git a/lib/process-services-cloud/src/lib/form/public-api.ts b/lib/process-services-cloud/src/lib/form/public-api.ts index 758c688f72..10256da84f 100644 --- a/lib/process-services-cloud/src/lib/form/public-api.ts +++ b/lib/process-services-cloud/src/lib/form/public-api.ts @@ -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'; diff --git a/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts new file mode 100644 index 0000000000..c5df7f6b1e --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.spec.ts @@ -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: 'John' } + ], + 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 - - <b>John</b>' + } + } + ] + }); + 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); + }); +}); diff --git a/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts new file mode 100644 index 0000000000..a43709e4e2 --- /dev/null +++ b/lib/process-services-cloud/src/lib/form/services/form-cloud-submission-values.ts @@ -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