Merge branch 'develop' into acs-10221-change-folder-icon-to-maintain-contrast-ratio

This commit is contained in:
Shivangi Shree
2026-09-02 10:42:51 +05:30
committed by GitHub
137 changed files with 5711 additions and 1250 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ runs:
- name: get latest tag sha - name: get latest tag sha
if: ${{ inputs.full-setup == 'true' }} if: ${{ inputs.full-setup == 'true' }}
id: tag-sha 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@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
- name: load "NPM TAG" - name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }} if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag id: set-npm-tag
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5 uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
# Override language selection by uncommenting this and choosing your languages # Override language selection by uncommenting this and choosing your languages
with: with:
languages: javascript languages: javascript
@@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5 uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
# ️ Command-line programs to run using the OS shell. # ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -53,4 +53,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5 uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
+35 -1
View File
@@ -7,6 +7,7 @@ on:
permissions: permissions:
pull-requests: read pull-requests: read
issues: read
jobs: jobs:
notify-bdu: notify-bdu:
@@ -18,8 +19,41 @@ jobs:
github.event.label.name == 'A/N BDU' && github.event.label.name == 'A/N BDU' &&
github.event.pull_request.state == 'open' github.event.pull_request.state == 'open'
steps: steps:
- name: Check if label was added after PR creation (with time threshold)
id: check_label_timing
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const prCreatedAt = new Date('${{ github.event.pull_request.created_at }}');
const timeline = await github.rest.issues.listEvents({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const labelEvent = timeline.data.reverse().find(event =>
event.event === 'labeled' &&
event.label.name === 'A/N BDU'
);
if (!labelEvent) {
core.setOutput('should_notify', 'false');
return;
}
const labelAddedAt = new Date(labelEvent.created_at);
const timeDiffSeconds = (labelAddedAt - prCreatedAt) / 1000;
if (timeDiffSeconds > 15) {
core.setOutput('should_notify', 'true');
} else {
core.setOutput('should_notify', 'false');
}
- name: Send Teams notification - name: Send Teams notification
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3 if: steps.check_label_timing.outputs.should_notify == 'true'
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
with: with:
webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }} webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }}
skip_checkout: true skip_checkout: true
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: develop ref: develop
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Pull translations from Crowdin - name: Pull translations from Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with: with:
skip_ref_checkout: true skip_ref_checkout: true
upload_sources: false upload_sources: false
+2
View File
@@ -254,6 +254,8 @@ jobs:
name: "Unit Tests" name: "Unit Tests"
needs: [setup] needs: [setup]
uses: ./.github/workflows/unit-test-workflow.yml uses: ./.github/workflows/unit-test-workflow.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with: with:
base_ref: ${{ github.base_ref || 'develop' }} base_ref: ${{ github.base_ref || 'develop' }}
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Push Source Files to Crowdin - name: Push Source Files to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0 uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with: with:
upload_sources: true upload_sources: true
upload_sources_args: --delete-obsolete upload_sources_args: --delete-obsolete
+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: jobs:
stale-pr-cleanup: 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@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
@@ -16,7 +16,7 @@ jobs:
permissions: permissions:
pull-requests: write pull-requests: write
steps: 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@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
with: with:
comment-identifier: supply-chain-review-instructions comment-identifier: supply-chain-review-instructions
comment-body: | comment-body: |
+7 -7
View File
@@ -41,7 +41,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 # - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 # - github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
# #
# Container images used: # Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 # - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4
@@ -125,7 +125,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
@@ -494,7 +494,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
@@ -1128,7 +1128,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
@@ -1404,7 +1404,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
@@ -1653,7 +1653,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
@@ -1732,7 +1732,7 @@ jobs:
steps: steps:
- name: Setup Scripts - name: Setup Scripts
id: setup id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1 uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with: with:
destination: ${{ runner.temp }}/gh-aw/actions destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }} job-name: ${{ github.job }}
+65 -2
View File
@@ -2,12 +2,21 @@ name: "Unit Tests Workflow"
on: on:
workflow_call: workflow_call:
secrets:
SONAR_TOKEN:
description: 'Token for SonarCloud analysis'
required: false
inputs: inputs:
base_ref: base_ref:
description: 'Base branch for affected calculation' description: 'Base branch for affected calculation'
required: false required: false
type: string type: string
default: 'develop' 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: jobs:
generate-affected-matrix: generate-affected-matrix:
@@ -30,9 +39,15 @@ jobs:
id: set-matrix id: set-matrix
env: env:
BASE_REF: ${{ inputs.base_ref }} BASE_REF: ${{ inputs.base_ref }}
FULL_RUN: ${{ inputs.full }}
run: | run: |
echo "Base ref is $BASE_REF" if [ "$FULL_RUN" == "true" ]; then
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular) 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" echo "Affected projects for UNIT: $AFFECTED_UNIT"
if [ -z "$AFFECTED_UNIT" ]; then if [ -z "$AFFECTED_UNIT" ]; then
@@ -74,8 +89,56 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=5120" NODE_OPTIONS: "--max-old-space-size=5120"
run: | run: |
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
if-no-files-found: ignore
retention-days: 1
- name: Save nx cache - name: Save nx cache
if: ${{ success() }} if: ${{ success() }}
uses: ./.github/actions/save-nx-cache uses: ./.github/actions/save-nx-cache
with: with:
cache-suffix: test-${{ matrix.project }} 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
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@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
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 | | 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 | | 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 | | 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 ## Details
+17
View File
@@ -18,6 +18,7 @@ Accesses and manipulates ACS document nodes using their node IDs.
- [Getting folder node contents](#getting-folder-node-contents) - [Getting folder node contents](#getting-folder-node-contents)
- [Creating and updating nodes](#creating-and-updating-nodes) - [Creating and updating nodes](#creating-and-updating-nodes)
- [Deleting and restoring nodes](#deleting-and-restoring-nodes) - [Deleting and restoring nodes](#deleting-and-restoring-nodes)
- [Checking out nodes](#checking-out-nodes)
- [See also](#see-also) - [See also](#see-also)
## Class members ## Class members
@@ -103,6 +104,16 @@ Accesses and manipulates ACS document nodes using their node IDs.
- _nodeId:_ `string` - ID of the target node - _nodeId:_ `string` - ID of the target node
- _opts:_ `{ where?: string; includeSource?: boolean;} & NodesIncludeQuery & ContentPagingQuery` - additional options that API can take - _opts:_ `{ where?: string; includeSource?: boolean;} & NodesIncludeQuery & ContentPagingQuery` - additional options that API can take
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeAssociationPaging`](../../../lib/js-api/src/api/content-rest-api/docs/NodesApi.md#NodeAssociationPaging)`>` - List of node's parents. - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeAssociationPaging`](../../../lib/js-api/src/api/content-rest-api/docs/NodesApi.md#NodeAssociationPaging)`>` - List of node's parents.
- **checkoutNode**(nodeId: `string`, opts?: `NodesIncludeQuery`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>`<br/>
Checks out a file node for offline editing. Creates a private working copy and locks the original.
- _nodeId:_ `string` - ID of the file node to check out
- _opts:_ `NodesIncludeQuery` - (Optional) Additional query parameters (`include`, `fields`)
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>` - The working copy node
- **cancelCheckoutNode**(nodeId: `string`, opts?: `NodesIncludeQuery`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>`<br/>
Cancels a checkout. Accepts either the working copy or the original checked-out node. Deletes the working copy and unlocks the original.
- _nodeId:_ `string` - ID of the working copy or original checked-out node
- _opts:_ `NodesIncludeQuery` - (Optional) Additional query parameters (`include`, `fields`)
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>` - The original (unlocked) node
## Details ## Details
@@ -160,6 +171,12 @@ and
pages in the Alfresco JS API for further details and options. Note that you can also use the pages in the Alfresco JS API for further details and options. Note that you can also use the
[Deleted Nodes Api service](deleted-nodes-api.service.md) get a list of all items currently in the trashcan. [Deleted Nodes Api service](deleted-nodes-api.service.md) get a list of all items currently in the trashcan.
### Checking out nodes
Use `checkoutNode` to lock a file node for offline editing. This creates a private working copy and locks the original node so other users cannot modify it. On success, the Observable emits the working copy `NodeEntry`.
Use `cancelCheckoutNode` to discard the working copy and unlock the original. You can pass either the working copy node ID or the original node ID. On success, the Observable emits the original (unlocked) `NodeEntry`.
## See also ## See also
- [Deleted nodes api service](deleted-nodes-api.service.md) - [Deleted nodes api service](deleted-nodes-api.service.md)
@@ -27,6 +27,7 @@ Lists all available process filters and allows to select a filter.
| appName | `string` | "" | (required) The application name | | appName | `string` | "" | (required) The application name |
| filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default | | filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default |
| showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter | | showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events ### Events
@@ -36,6 +36,7 @@ Shows all available filters.
| appName | `string` | "" | Display filters available to the current user for the application with the specified name. | | appName | `string` | "" | Display filters available to the current user for the application with the specified name. |
| filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. | | filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. |
| showIcons | `boolean` | false | Toggles display of the filter's icons. | | showIcons | `boolean` | false | Toggles display of the filter's icons. |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events ### Events
+1 -1
View File
@@ -56,7 +56,7 @@ module.exports = function (config) {
coverageReporter: { coverageReporter: {
dir: join(__dirname, '../../coverage/content-services'), dir: join(__dirname, '../../coverage/content-services'),
subdir: '.', 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: { check: {
global: { global: {
statements: 75, statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
}, },
"peerDependencies": { "peerDependencies": {
"@angular/cdk": ">=20.2.14", "@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25", "@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.25", "@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.25", "@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.25", "@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14", "@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25", "@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.25", "@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.25", "@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0", "@alfresco/js-api": ">=10.0.0",
"@ngx-translate/core": ">=17.0.0", "@ngx-translate/core": ">=17.0.0",
"@alfresco/adf-core": ">=9.0.0" "@alfresco/adf-core": ">=9.0.0"
@@ -18,11 +18,19 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { RedirectAuthService } from '@alfresco/adf-core'; import { RedirectAuthService } from '@alfresco/adf-core';
import { EMPTY, firstValueFrom, of } from 'rxjs'; import { EMPTY, firstValueFrom, of } from 'rxjs';
import { JobIdBodyEntry, SizeDetails, SizeDetailsEntry } from '@alfresco/js-api'; import { JobIdBodyEntry, NodeEntry, SizeDetails, SizeDetailsEntry } from '@alfresco/js-api';
import { NodesApiService } from './nodes-api.service'; import { NodesApiService } from './nodes-api.service';
import { AlfrescoApiService } from '../../services/alfresco-api.service'; import { AlfrescoApiService } from '../../services/alfresco-api.service';
import { AlfrescoApiServiceMock } from '../../mock/alfresco-api.service.mock'; import { AlfrescoApiServiceMock } from '../../mock/alfresco-api.service.mock';
const fakeNodeEntry = {
entry: {
id: 'fake-node-id',
name: 'fake-file.txt',
nodeType: 'cm:content'
}
} as NodeEntry;
const fakeInitiateFolderSizeResponse: JobIdBodyEntry = { const fakeInitiateFolderSizeResponse: JobIdBodyEntry = {
entry: { entry: {
jobId: 'fake-job-id' jobId: 'fake-job-id'
@@ -75,4 +83,20 @@ describe('NodesApiService', () => {
expect(nodesApiService.nodesApi.listParents).toHaveBeenCalledWith('fake-node-id', { include: ['path'], where: 'isPrimary=true' }); expect(nodesApiService.nodesApi.listParents).toHaveBeenCalledWith('fake-node-id', { include: ['path'], where: 'isPrimary=true' });
}); });
it('should call nodesApi.checkoutNode with the node ID and optional query params', async () => {
const opts = { include: ['path', 'allowableOperations'], fields: ['id', 'name'] };
spyOn(nodesApiService.nodesApi, 'checkoutNode').and.returnValue(Promise.resolve(fakeNodeEntry));
await firstValueFrom(nodesApiService.checkoutNode(fakeNodeEntry.entry.id, opts));
expect(nodesApiService.nodesApi.checkoutNode).toHaveBeenCalledWith(fakeNodeEntry.entry.id, opts);
});
it('should call nodesApi.cancelCheckoutNode with the node ID and optional query params', async () => {
const opts = { include: ['path'], fields: ['id'] };
spyOn(nodesApiService.nodesApi, 'cancelCheckoutNode').and.returnValue(Promise.resolve(fakeNodeEntry));
await firstValueFrom(nodesApiService.cancelCheckoutNode(fakeNodeEntry.entry.id, opts));
expect(nodesApiService.nodesApi.cancelCheckoutNode).toHaveBeenCalledWith(fakeNodeEntry.entry.id, opts);
});
}); });
@@ -293,6 +293,30 @@ export class NodesApiService {
return from(this.nodesApi.listParents(nodeId, opts)); return from(this.nodesApi.listParents(nodeId, opts));
} }
/**
* Checks out a file node for offline editing.
* Creates a private working copy and locks the original.
*
* @param nodeId ID of the node to check out
* @param opts Optional query parameters (`include`, `fields`)
* @returns Observable emitting the working copy node
*/
checkoutNode(nodeId: string, opts?: NodesIncludeQuery): Observable<NodeEntry> {
return from(this.nodesApi.checkoutNode(nodeId, opts));
}
/**
* Cancels a checkout. Accepts either the working copy or the original node.
* Deletes the working copy and unlocks the original.
*
* @param nodeId ID of the working copy or the original checked-out node
* @param opts Optional query parameters (`include`, `fields`)
* @returns Observable emitting the original (unlocked) node
*/
cancelCheckoutNode(nodeId: string, opts?: NodesIncludeQuery): Observable<NodeEntry> {
return from(this.nodesApi.cancelCheckoutNode(nodeId, opts));
}
private randomNodeName(): string { private randomNodeName(): string {
return `node_${Date.now()}`; return `node_${Date.now()}`;
} }
@@ -1,6 +1,8 @@
<div class="adf-search-filter-menu-card"> <div class="adf-search-filter-menu-card">
<div class="adf-search-filter-title"> <div class="adf-search-filter-title">
<ng-content select="filter-title" /> <h2 class="adf-search-filter-title-heading">
<ng-content select="filter-title" />
</h2>
<button mat-icon-button <button mat-icon-button
class="adf-search-filter-title-action" class="adf-search-filter-title-action"
aria-hidden="false" aria-hidden="false"
@@ -7,6 +7,11 @@
height: 32px; height: 32px;
font: var(--mat-sys-body-medium); font: var(--mat-sys-body-medium);
&-heading {
margin: 0;
font: inherit;
}
&-action { &-action {
float: right; float: right;
} }
@@ -38,4 +38,11 @@ describe('SearchFilterMenuComponent', () => {
closeButton.click(); closeButton.click();
expect(spyCloseEvent).toHaveBeenCalled(); 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: { coverageReporter: {
dir: join(__dirname, '../../coverage/core'), dir: join(__dirname, '../../coverage/core'),
subdir: '.', 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: { check: {
global: { global: {
statements: 75, statements: 75,
+5 -5
View File
@@ -23,13 +23,13 @@
}, },
"peerDependencies": { "peerDependencies": {
"@angular/cdk": ">=20.2.14", "@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25", "@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.25", "@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.25", "@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14", "@angular/material": ">=20.2.14",
"@angular/material-date-fns-adapter": ">=20.2.14", "@angular/material-date-fns-adapter": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25", "@angular/platform-browser": ">=20.3.27",
"@angular/router": ">=20.3.25", "@angular/router": ">=20.3.27",
"@mat-datetimepicker/core": ">=12.0.1", "@mat-datetimepicker/core": ">=12.0.1",
"@ngx-translate/core": ">=17.0.0", "@ngx-translate/core": ">=17.0.0",
"@alfresco/js-api": ">=10.0.0", "@alfresco/js-api": ">=10.0.0",
@@ -0,0 +1,76 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { ElementRef } from '@angular/core';
import { DropZoneDirective } from './drop-zone.directive';
describe('DropZoneDirective', () => {
let directive: DropZoneDirective;
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
TestBed.configureTestingModule({
providers: [{ provide: ElementRef, useValue: new ElementRef(element) }]
});
directive = TestBed.runInInjectionContext(() => new DropZoneDirective());
directive.dropTarget = 'cell';
directive.ngOnInit();
});
it('should dispatch a namespaced custom event on dragenter while attached', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.dispatchEvent(new DragEvent('dragenter'));
expect(dispatched).toContain('cell-dragenter');
});
it('should not handle drag events after the directive is destroyed', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.addEventListener('cell-dragover', () => dispatched.push('cell-dragover'));
element.addEventListener('cell-drop', () => dispatched.push('cell-drop'));
directive.ngOnDestroy();
element.dispatchEvent(new DragEvent('dragenter'));
element.dispatchEvent(new DragEvent('dragover'));
element.dispatchEvent(new DragEvent('drop'));
expect(dispatched).toEqual([]);
});
it('should remove listeners using the same references that were added', () => {
const addSpy = spyOn(element, 'addEventListener').and.callThrough();
const removeSpy = spyOn(element, 'removeEventListener').and.callThrough();
directive.ngOnInit();
directive.ngOnDestroy();
const addedByEvent = new Map<string, EventListenerOrEventListenerObject>();
addSpy.calls.allArgs().forEach(([evt, fn]) => addedByEvent.set(evt as string, fn as EventListenerOrEventListenerObject));
removeSpy.calls.allArgs().forEach(([evt, fn]) => {
expect(fn).toBe(addedByEvent.get(evt as string));
});
});
});
@@ -36,6 +36,10 @@ export class DropZoneDirective implements OnInit, OnDestroy {
@Input() @Input()
dropColumn: DataColumn; dropColumn: DataColumn;
private readonly onDragEnterHandler = this.onDragEnter.bind(this);
private readonly onDragOverHandler = this.onDragOver.bind(this);
private readonly onDropHandler = this.onDrop.bind(this);
constructor() { constructor() {
const elementRef = inject(ElementRef); const elementRef = inject(ElementRef);
@@ -44,16 +48,16 @@ export class DropZoneDirective implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {
this.element.addEventListener('dragenter', this.onDragEnter.bind(this)); this.element.addEventListener('dragenter', this.onDragEnterHandler);
this.element.addEventListener('dragover', this.onDragOver.bind(this)); this.element.addEventListener('dragover', this.onDragOverHandler);
this.element.addEventListener('drop', this.onDrop.bind(this)); this.element.addEventListener('drop', this.onDropHandler);
}); });
} }
ngOnDestroy() { ngOnDestroy() {
this.element.removeEventListener('dragenter', this.onDragEnter); this.element.removeEventListener('dragenter', this.onDragEnterHandler);
this.element.removeEventListener('dragover', this.onDragOver); this.element.removeEventListener('dragover', this.onDragOverHandler);
this.element.removeEventListener('drop', this.onDrop); this.element.removeEventListener('drop', this.onDropHandler);
} }
onDragEnter(event: DragEvent) { onDragEnter(event: DragEvent) {
@@ -131,7 +131,10 @@ export class ResizableDirective implements OnInit, OnDestroy {
.pipe(filter(() => !!this.currentRect)); .pipe(filter(() => !!this.currentRect));
mouseDrag 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) => { .subscribe((rectangle: BoundingRectangle) => {
if (this.resizing.observers.length > 0) { if (this.resizing.observers.length > 0) {
this.zone.run(() => { this.zone.run(() => {
@@ -131,6 +131,28 @@ describe('ResizeHandleDirective', () => {
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function)); expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function));
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', 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', () => { 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.unlistenMouseUp = this.renderer.listen('document', 'mouseup', (mouseUpEvent: MouseEvent) => {
this.onMouseup(mouseUpEvent); this.onMouseup(mouseUpEvent);
}); });
@@ -96,7 +97,8 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
private onMouseup(event: MouseEvent): void { private onMouseup(event: MouseEvent): void {
this.unlistenMouseMove?.(); this.unlistenMouseMove?.();
this.unlistenMouseMove = undefined; this.unlistenMouseMove = undefined;
this.unlistenMouseUp(); this.unlistenMouseUp?.();
this.unlistenMouseUp = undefined;
this.resizableContainer.mouseup.next(event); this.resizableContainer.mouseup.next(event);
} }
@@ -98,7 +98,7 @@
[hidden]="!currentRootElement?.isVisible" [hidden]="!currentRootElement?.isVisible"
> >
<adf-repeat-widget [element]="currentRootElement" [isEditor]="false"> <adf-repeat-widget [element]="currentRootElement" [isEditor]="false">
@for (row of currentRootElement.field.rows; track row; let rowIndex = $index) { @for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1; @let hasMultipleRows = currentRootElement.field.rows.length > 1;
<div <div
class="adf-grid-list-container" class="adf-grid-list-container"
@@ -125,11 +125,11 @@
} }
</div> </div>
<section class="adf-grid-list-column-view"> <section class="adf-grid-list-column-view">
@for (column of row.columns; track column; let columnIndex = $index) { @for (column of row.columns; track column.id; let columnIndex = $index) {
<div <div
class="adf-grid-list-single-column" class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)"> [style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)">
@for (field of column?.fields; track field) { @for (field of column?.fields; track field.id) {
@if (field.type === 'section') { @if (field.type === 'section') {
<adf-form-section [field]="field"/> <adf-form-section [field]="field"/>
} @else { } @else {
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix { .mat-mdc-form-field-infix {
width: auto; width: auto;
} }
.adf-form-field-status-slot {
display: block;
height: 40px;
box-sizing: border-box;
}
} }
.alfresco-tabs-widget { .alfresco-tabs-widget {
@@ -27,6 +33,7 @@
.adf-form-tab-content { .adf-form-tab-content {
margin-top: 1em; margin-top: 1em;
padding-bottom: 3px;
} }
.adf-form-tab-group { .adf-form-tab-group {
@@ -40,7 +47,7 @@
.adf-container-widget { .adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) { .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 { .adf-grid-list {
@@ -265,7 +272,7 @@
} }
&-error-messages-container { &-error-messages-container {
min-height: 35px; height: 40px;
} }
&-error-messages-container-visible { &-error-messages-container-visible {
@@ -5,7 +5,7 @@
&-single-column { &-single-column {
display: flex; display: flex;
flex-wrap: inherit; flex-wrap: inherit;
align-items: center; align-items: flex-start;
gap: 1%; gap: 1%;
@include flex.layout-bp(lt-md) { @include flex.layout-bp(lt-md) {
@@ -10,7 +10,11 @@
> >
</div> </div>
<div class="adf-amount-widget-container"> <div class="adf-amount-widget-container">
<mat-form-field class="adf-amount-widget__input adf-form-field-input" [floatLabel]="placeholder ? 'always' : null"> <mat-form-field
class="adf-amount-widget__input adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</mat-label> } @if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</mat-label> }
@if(!enableDisplayBasedOnLocale) { @if(!enableDisplayBasedOnLocale) {
<span matTextPrefix class="adf-amount-widget__prefix-spacing">{{ currency }}&nbsp;</span> <span matTextPrefix class="adf-amount-widget__prefix-spacing">{{ currency }}&nbsp;</span>
@@ -32,12 +36,13 @@
(blur)="amountWidgetOnBlur()" (blur)="amountWidgetOnBlur()"
/> />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) { @if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span> >@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model';
import { HarnessLoader } from '@angular/cdk/testing'; import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { of } from 'rxjs'; import { firstValueFrom, of } from 'rxjs';
import { FormService } from '../../../services/form.service'; import { FormService } from '../../../services/form.service';
import { FormFieldEvent } from '../../../events/form-field.event'; import { FormFieldEvent } from '../../../events/form-field.event';
import { TranslationService } from '../../../../translation/translation.service'; import { TranslationService } from '../../../../translation/translation.service';
import { registerLocaleData } from '@angular/common'; import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de'; import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de'; import localeDeExtra from '@angular/common/locales/extra/de';
import { TranslateService } from '@ngx-translate/core';
registerLocaleData(localeDe, 'de-DE', localeDeExtra); registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => {
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER'); 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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { 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 { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs'; import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.subscribeToFieldChanges(); this.subscribeToFieldChanges();
this.setInitialValues(); this.setInitialValues();
this.initErrorStateMatcher(); 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.markAsTouched();
this.updateTranslateParameters();
} }
amountWidgetOnFocus(): void { amountWidgetOnFocus(): void {
@@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.field.value = this.amountWidgetValue; this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field); super.onFieldChanged(this.field);
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
setInitialValues(): void { setInitialValues(): void {
@@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) { } else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value; 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()) !!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> <span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox> </mat-checkbox>
<div class="adf-error-messages-container"> <div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget <error-widget
*ngIf="isInvalidFieldRequired() && isTouched()" [error]="field.validationSummary"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}" [required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/> />
</div> </div>
</div> </div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common'; import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core'; import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)', '(invalid)': 'event($event)',
'(select)': 'event($event)' '(select)': 'event($event)'
}, },
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf], imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None encapsulation: ViewEncapsulation.None
}) })
export class CheckboxWidgetComponent extends WidgetComponent {} export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -17,24 +17,36 @@
export class ErrorMessageModel { export class ErrorMessageModel {
message: string = ''; message: string = '';
attributes: Map<string, string> = null; attributes: Map<string, string> = new Map();
constructor(obj?: any) { constructor(obj?: any) {
this.message = obj?.message || ''; this.message = obj?.message || '';
this.attributes = obj?.attributes || new Map();
if (obj?.attributes) {
this.attributes = obj.attributes;
}
} }
isActive(): boolean { isActive(): boolean {
return !!this.message; return !!this.message;
} }
getAttributesAsJsonObj() { getAttributesAsJsonObj(): Record<string, string> {
const result = {}; const result: Record<string, string> = {};
if (this.attributes.size > 0) { if (this.attributes.size > 0) {
this.attributes.forEach((value, key) => { this.attributes.forEach((value, key) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value); result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}); });
} }
return result; 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 { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event'; 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 { 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 { FormFieldModel } from './form-field.model';
import { FormModel } from './form.model'; import { FormModel } from './form.model';
@@ -36,6 +37,58 @@ describe('FormFieldModel', () => {
expect(model.json).toBe(json); 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', () => { it('should setup with json config', () => {
const json = { const json = {
fieldType: '<fieldType>', 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', () => { it('should fail validation for readOnly required display-external-property field with null value', () => {
const form = new FormModel(); const form = new FormModel();
const field = new FormFieldModel(form, { 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 { DataColumn } from '../../../../datatable/data/data-column.model';
import { DateFnsUtils } from '../../../../common'; import { DateFnsUtils } from '../../../../common';
import { isValid as isValidDate } from 'date-fns'; import { isValid as isValidDate } from 'date-fns';
import { Observable, ReplaySubject } from 'rxjs';
import { ContainerRowModel } from './container-row.model'; import { ContainerRowModel } from './container-row.model';
import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model'; import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model';
import { formFieldRuleHandler } from './handlers/form-field-rule.handler'; 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 FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal'; 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 // Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel { export class FormFieldModel extends FormWidgetModel {
private _value: string; private _value: string;
private _readOnly: boolean = false; private _readOnly: boolean = false;
private _isValid: boolean = true; private _isValid: boolean = true;
private _required: boolean = false; private _required: boolean = false;
private readonly _authoredValue: unknown;
readonly defaultDateFormat: string = 'D-M-YYYY'; readonly defaultDateFormat: string = 'D-M-YYYY';
readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A'; readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A';
@@ -110,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel {
// util members // util members
emptyOption: FormFieldOption; 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 { get value(): any {
return this._value; return this._value;
@@ -123,6 +161,10 @@ export class FormFieldModel extends FormWidgetModel {
} }
} }
get authoredValue(): unknown {
return cloneJsonCompatibleValue(this._authoredValue);
}
get readOnly(): boolean { get readOnly(): boolean {
if (this.form?.readOnly) { if (this.form?.readOnly) {
return true; return true;
@@ -173,16 +215,19 @@ export class FormFieldModel extends FormWidgetModel {
for (const validator of validators) { for (const validator of validators) {
if (!validator.validate(this)) { if (!validator.validate(this)) {
this._isValid = false; this._isValid = false;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid; return this._isValid;
} }
} }
this._isValid = true; this._isValid = true;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid; return this._isValid;
} }
constructor(form: any, json?: any, parent?: RepeatableSectionModel) { constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
super(form, json); super(form, json);
this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined;
if (json) { if (json) {
this.fieldType = json.fieldType; this.fieldType = json.fieldType;
this.id = this.getId(json.id, parent); this.id = this.getId(json.id, parent);
@@ -221,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel {
this.enableFractions = json.enableFractions; this.enableFractions = json.enableFractions;
this.currency = json.currency; this.currency = json.currency;
this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json); this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json);
this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip || ''; this.tooltip = json.tooltip || '';
this.selectionType = json.selectionType; this.selectionType = json.selectionType;
this.alignmentType = json.alignmentType; this.alignmentType = json.alignmentType;
@@ -10,6 +10,7 @@
</div> </div>
<div class="adf-date-time-widget-container"> <div class="adf-date-time-widget-container">
<mat-form-field class="adf-date-time-widget adf-form-field-input" <mat-form-field class="adf-date-time-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels" [class.adf-left-label-input-datepicker]="field.leftLabels"
[floatLabel]="field.placeholder ? 'always' : null"> [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) { @if( (field.name || field?.required) && !field.leftLabels) {
@@ -38,11 +39,12 @@
[timeInterval]="5" [timeInterval]="5"
[disabled]="field.readOnly" /> [disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) { @if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span> <span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
<div class="{{ field.className }} date-widget-container" id="data-widget" <div class="{{ field.className }} date-widget-container" id="data-widget"
[class.adf-invalid]="dateInputControl.invalid && dateInputControl.touched" [class.adf-invalid]="dateInputControl.invalid && dateInputControl.touched"
[class.adf-readonly]="field.readOnly"> [class.adf-readonly]="field.readOnly">
<mat-form-field class="adf-date-widget adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null"> <mat-form-field class="adf-date-widget adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
<mat-label class="adf-label" <mat-label class="adf-label"
[id]="field.id + '-label'" [id]="field.id + '-label'"
[attr.for]="field.id"> [attr.for]="field.id">
@@ -22,11 +22,12 @@
[startAt]="startAt" [startAt]="startAt"
[disabled]="field.readOnly" /> [disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) { @if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span> >@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
@@ -9,7 +9,7 @@
</div> </div>
<div class="adf-decimal-widget-container"> <div class="adf-decimal-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null"> <mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> } @if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> }
<input matInput <input matInput
class="adf-input" class="adf-input"
@@ -25,12 +25,13 @@
[errorStateMatcher]="errorStateMatcher" [errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()" /> (blur)="onBlur()" />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) { @if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span> >@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormService } from '../../../services/form.service'; import { FormService } from '../../../services/form.service';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { DecimalWidgetComponent } from './decimal.component'; import { DecimalWidgetComponent } from './decimal.component';
import { TranslateService } from '@ngx-translate/core';
describe('DecimalComponent', () => { 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 loader: HarnessLoader;
let widget: DecimalWidgetComponent; let widget: DecimalWidgetComponent;
let fixture: ComponentFixture<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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -16,13 +16,15 @@
*/ */
import { NgIf } from '@angular/common'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component';
export class DecimalWidgetComponent extends WidgetComponent implements OnInit { export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher; errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {}; translateParameters: Record<string, string> = {};
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void { ngOnInit(): void {
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onDecimalFieldChanged(): void { onDecimalFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
!this.field.isValid && this.isTouched() !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 { .adf-error {
display: flex; display: flex;
align-items: center; align-items: center;
&-widget-container { &-widget-container {
height: auto; height: 40px;
} }
&-animate { &-animate {
@@ -1,6 +1,6 @@
.adf-hyperlink-widget { .adf-hyperlink-widget {
padding: 0.4375em 0; padding: 0.4375em 0;
border-top: 0.8438em solid transparent; margin-bottom: 40px;
a { a {
color: var(--mat-sys-primary); color: var(--mat-sys-primary);
@@ -7,6 +7,7 @@
<mat-form-field <mat-form-field
floatPlaceholder="never" floatPlaceholder="never"
class="adf-form-field-input" class="adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-has-counter]="field.maxLength > 0" [class.adf-has-counter]="field.maxLength > 0"
[floatLabel]="field.placeholder ? 'always' : null" [floatLabel]="field.placeholder ? 'always' : null"
> >
@@ -31,14 +32,16 @@
> >
</textarea> </textarea>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) { @if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error> <mat-error class="adf-form-field-status-slot">
@if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>} @if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>}
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span> >@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error> </mat-error>
} @else if (field.maxLength > 0) { } @else if (field.maxLength > 0) {
<mat-hint class="adf-multiline-hint">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint> <mat-hint class="adf-multiline-hint adf-form-field-status-slot">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
} @else {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
} }
</mat-form-field> </mat-form-field>
</div> </div>
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon; @include mixins.adf-error-icon;
} }
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter { .adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 44px; margin-bottom: 20px;
} }
@@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils'; import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { of, Subject } from 'rxjs'; import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('MultilineTextWidgetComponentComponent', () => { describe('MultilineTextWidgetComponentComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader; let loader: HarnessLoader;
let widget: MultilineTextWidgetComponentComponent; let widget: MultilineTextWidgetComponentComponent;
let fixture: ComponentFixture<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', () => { describe('when is required', () => {
beforeEach(() => { beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), { 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 { TranslatePipe } from '@ngx-translate/core';
import { isObservable } from 'rxjs'; import { isObservable } from 'rxjs';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
ngOnInit(): void { ngOnInit(): void {
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
if (this.enableCustomMessage != null) { if (this.enableCustomMessage != null) {
if (isObservable(this.enableCustomMessage)) { if (isObservable(this.enableCustomMessage)) {
this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => { this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => {
@@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onMultilineTextFieldChanged(): void { onMultilineTextFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
!this.field.isValid && this.isTouched() !this.field.isValid && this.isTouched()
}; };
} }
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
} }
@@ -9,7 +9,7 @@
</label> </label>
</div> </div>
<div class="adf-number-widget-container"> <div class="adf-number-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null"> <mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || this.field?.required) && !field.leftLabels) { @if( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id"> <mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }} {{ field.name | translate }}
@@ -30,12 +30,13 @@
[errorStateMatcher]="errorStateMatcher" [errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()"> (blur)="onBlur()">
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) { @if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span> >@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core'; import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { NumberWidgetComponent } from './number.widget'; import { NumberWidgetComponent } from './number.widget';
import { DecimalNumberPipe } from '../../../../pipes'; import { DecimalNumberPipe } from '../../../../pipes';
import { TranslateService } from '@ngx-translate/core';
describe('NumberWidgetComponent', () => { 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 loader: HarnessLoader;
let widget: NumberWidgetComponent; let widget: NumberWidgetComponent;
let fixture: ComponentFixture<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', () => { describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => { it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), { widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -18,7 +18,8 @@
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { DecimalNumberPipe } from '../../../../pipes'; import { DecimalNumberPipe } from '../../../../pipes';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component'; import { WidgetComponent } from '../widget.component';
@Component({ @Component({
@@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
translateParameters: Record<string, string> = {}; translateParameters: Record<string, string> = {};
private readonly decimalNumberPipe = inject(DecimalNumberPipe); private readonly decimalNumberPipe = inject(DecimalNumberPipe);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() { ngOnInit() {
if (this.field.readOnly) { if (this.field.readOnly) {
@@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
this.displayValue = this.field.value; this.displayValue = this.field.value;
} }
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
protected onNumberChange(value: string) { protected onNumberChange(value: string) {
@@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
} }
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { private initErrorStateMatcher(): void {
@@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched()) !!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 { &-row-action {
margin-left: 10px; margin-inline-start: 10px;
margin-block-end: 35px;
} }
&-row-limit { &-row-limit {
@@ -8,7 +8,11 @@
</label> </label>
</div> </div>
<div class="adf-text-widget-container"> <div class="adf-text-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="placeholder ? 'always' : null"> <mat-form-field
class="adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || this.field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id"> @if ( (field.name || this.field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }} {{ field.name | translate }}
</mat-label> </mat-label>
@@ -30,7 +34,7 @@
(paste)="onPaste($event)" (paste)="onPaste($event)"
(blur)="onBlur()"> (blur)="onBlur()">
@if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) { @if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<span class="adf-error-text"> <span class="adf-error-text">
@if (maxLengthPasteError.isActive()) { @if (maxLengthPasteError.isActive()) {
@@ -43,6 +47,9 @@
</span> </span>
</mat-error> </mat-error>
} }
@if (!fieldStatusTemplate) {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field> </mat-form-field>
<ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" /> <ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" />
<ng-template #maxLengthPasteErrorTemplate> <ng-template #maxLengthPasteErrorTemplate>
@@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token'; import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token'; import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs'; import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('TextWidgetComponent', () => { describe('TextWidgetComponent', () => {
const form = new FormModel({ taskId: 'fake-task-id' }); 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 loader: HarnessLoader;
let widget: TextWidgetComponent; 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('when template is ready', () => {
describe('and no mask is configured on text element', () => { describe('and no mask is configured on text element', () => {
it('should raise ngModelChange event', async () => { it('should raise ngModelChange event', async () => {
@@ -19,6 +19,7 @@
import { NgIf, NgTemplateOutlet } from '@angular/common'; import { NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core'; 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 { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core'; import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { WidgetComponent } from '../widget.component'; 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 { FormattableTextWidgetComponent } from '../core/formattable-text.widget';
import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator'; import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator';
import { InputMaskDirective } from './text-mask.component'; 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.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
} }
this.initErrorStateMatcher(); this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
} }
onPaste(event: ClipboardEvent): void { onPaste(event: ClipboardEvent): void {
@@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
onBlur(): void { onBlur(): void {
this.markAsTouched(); this.markAsTouched();
this.updateTranslateParameters();
} }
onTextFieldChanged(): void { onTextFieldChanged(): void {
this.onFieldChanged(this.field); this.onFieldChanged(this.field);
this.updateTranslateParameters();
} }
private initErrorStateMatcher(): void { 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 { private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number {
const value = input.value ?? ''; const value = input.value ?? '';
const selectionStart = input.selectionStart ?? value.length; const selectionStart = input.selectionStart ?? value.length;
@@ -16,6 +16,7 @@
*/ */
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { FormEvent } from '../events/form.event';
import { FormFieldEvent } from '../events/form-field.event'; import { FormFieldEvent } from '../events/form-field.event';
import { FormRulesEvent } from '../events/form-rules.event'; import { FormRulesEvent } from '../events/form-rules.event';
import { ValidateFormFieldEvent } from '../events/validate-form-field.event'; import { ValidateFormFieldEvent } from '../events/validate-form-field.event';
@@ -26,4 +27,5 @@ export interface FormValidationService {
validateForm: Subject<ValidateFormEvent>; validateForm: Subject<ValidateFormEvent>;
validateFormField: Subject<ValidateFormFieldEvent>; validateFormField: Subject<ValidateFormFieldEvent>;
formRulesEvent?: Subject<FormRulesEvent>; formRulesEvent?: Subject<FormRulesEvent>;
formVisibilityRefreshed?: Subject<FormEvent>;
} }
@@ -62,6 +62,12 @@ export class FormService implements FormValidationService {
formRulesEvent = new Subject<FormRulesEvent>(); 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() { constructor() {
const injectedFieldValidators = inject(FORM_SERVICE_FIELD_VALIDATORS_TOKEN, { optional: true }); 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 { ContainerModel, FormFieldModel, FormFieldTypes, FormModel, TabModel } from '../components/widgets/core';
import { WidgetVisibilityModel } from '../models/widget-visibility.model'; import { WidgetVisibilityModel } from '../models/widget-visibility.model';
import { WidgetVisibilityService } from './widget-visibility.service'; import { WidgetVisibilityService } from './widget-visibility.service';
import { FormService } from './form.service';
import { import {
fakeFormJson, fakeFormJson,
formTest, formTest,
@@ -50,6 +51,30 @@ describe('WidgetVisibilityService', () => {
service = TestBed.inject(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', () => { describe('should be able to evaluate next condition operations', () => {
it('using == and return true', () => { it('using == and return true', () => {
const resultsArray = evaluateConditions( const resultsArray = evaluateConditions(
@@ -15,16 +15,20 @@
* limitations under the License. * 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 { FormFieldModel, FormModel, TabModel, ContainerModel, FormOutcomeModel } from '../components/widgets/core';
import { FormEvent } from '../events/form.event';
import { TaskProcessVariableModel } from '../models/task-process-variable.model'; import { TaskProcessVariableModel } from '../models/task-process-variable.model';
import { WidgetVisibilityModel, WidgetTypeEnum } from '../models/widget-visibility.model'; import { WidgetVisibilityModel, WidgetTypeEnum } from '../models/widget-visibility.model';
import { format, isValid, parse } from 'date-fns'; import { format, isValid, parse } from 'date-fns';
import { FormService } from './form.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class WidgetVisibilityService { export class WidgetVisibilityService {
private readonly formService = inject(FormService);
private processVarList: TaskProcessVariableModel[]; private processVarList: TaskProcessVariableModel[];
private form: FormModel; private form: FormModel;
@@ -45,6 +49,8 @@ export class WidgetVisibilityService {
} }
form.getFormFields().map((field) => this.refreshEntityVisibility(field)); form.getFormFields().map((field) => this.refreshEntityVisibility(field));
this.formService.formVisibilityRefreshed.next(new FormEvent(form));
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ module.exports = function (config) {
coverageReporter: { coverageReporter: {
dir: join(__dirname, '../../coverage/extensions'), dir: join(__dirname, '../../coverage/extensions'),
subdir: '.', 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: { check: {
global: { global: {
statements: 75, statements: 75,
+2 -2
View File
@@ -12,8 +12,8 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues" "url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": ">=20.3.25", "@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.25", "@angular/core": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0" "@alfresco/js-api": ">=10.0.0"
}, },
"keywords": [ "keywords": [
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = function (config) {
coverageReporter: { coverageReporter: {
dir: join(__dirname, '../../coverage/insights'), dir: join(__dirname, '../../coverage/insights'),
subdir: '.', 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: { check: {
global: { global: {
statements: 75, statements: 75,
+4 -4
View File
@@ -16,10 +16,10 @@
"raphael": ">=2.3.0" "raphael": ">=2.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": ">=20.3.25", "@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.25", "@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.25", "@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.25", "@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14", "@angular/material": ">=20.2.14",
"@alfresco/adf-core": ">=9.0.0", "@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=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);
});
});
});
@@ -1033,4 +1033,63 @@ export class NodesApi extends BaseApi {
returnType: SizeDetailsEntry returnType: SizeDetailsEntry
}); });
} }
/**
* Checkout a node
*
* Checks out a file node for offline editing. Creates a private working copy and locks the original.
*
* @param nodeId The identifier of a file node to check out.
* @param opts Optional parameters
* @returns Promise<NodeEntry> - the working copy node
*/
checkoutNode(nodeId: string, opts?: NodesIncludeQuery): Promise<NodeEntry> {
throwIfNotDefined(nodeId, 'nodeId');
const pathParams = {
nodeId
};
const queryParams = {
include: buildCollectionParam(opts?.include, 'csv'),
fields: buildCollectionParam(opts?.fields, 'csv')
};
return this.post({
path: '/nodes/{nodeId}/checkout',
pathParams,
queryParams,
returnType: NodeEntry
});
}
/**
* Cancel checkout of a node
*
* Cancels a checkout. Accepts either the working copy or the original checked-out node.
* Deletes the working copy, unlocks the original, and returns the original node.
*
* @param nodeId The identifier of the working copy or the original checked-out node.
* @param opts Optional parameters
* @returns Promise<NodeEntry> - the original (unlocked) node
*/
cancelCheckoutNode(nodeId: string, opts?: NodesIncludeQuery): Promise<NodeEntry> {
throwIfNotDefined(nodeId, 'nodeId');
const pathParams = {
nodeId
};
const queryParams = {
include: buildCollectionParam(opts?.include, 'csv'),
fields: buildCollectionParam(opts?.fields, 'csv')
};
return this.post({
path: '/nodes/{nodeId}/cancel-checkout',
pathParams,
queryParams,
returnType: NodeEntry
});
}
} }
@@ -25,8 +25,10 @@ All URIs are relative to *https://localhost/alfresco/api/-default-/public/alfres
| [unlockNode](#unlockNode) | **POST** /nodes/{nodeId}/unlock | Unlock a node | | [unlockNode](#unlockNode) | **POST** /nodes/{nodeId}/unlock | Unlock a node |
| [updateNode](#updateNode) | **PUT** /nodes/{nodeId} | Update a node | | [updateNode](#updateNode) | **PUT** /nodes/{nodeId} | Update a node |
| [updateNodeContent](#updateNodeContent) | **PUT** /nodes/{nodeId}/content | Update node content | | [updateNodeContent](#updateNodeContent) | **PUT** /nodes/{nodeId}/content | Update node content |
| [initiateFolderSizeCalculation](#initiateFolderSizeCalculation) | **POST** /nodes/{nodeId}/size-details | Initiate a new request to calculate folder size | | [initiateFolderSizeCalculation](#initiateFolderSizeCalculation) | **POST** /nodes/{nodeId}/size-details | Initiate a new request to calculate folder size |
| [getFolderSizeInfo](#getFolderSizeInfo) | **GET** /nodes/{nodeId}/size-details/{jobId} | Gets the details of a folder | | [getFolderSizeInfo](#getFolderSizeInfo) | **GET** /nodes/{nodeId}/size-details/{jobId} | Gets the details of a folder |
| [cancelCheckoutNode](#cancelCheckoutNode) | **POST** /nodes/{nodeId}/cancel-checkout | Cancel checkout of a node |
| [checkoutNode](#checkoutNode) | **POST** /nodes/{nodeId}/checkout | Checkout a node |
## copyNode ## copyNode
@@ -1260,6 +1262,66 @@ nodesApi.getFolderSizeInfo(`<nodeId>`, `<jobId>`).then((data) => {
}); });
``` ```
## checkoutNode
Checkout a node
Checks out a file node for offline editing. Creates a private working copy and locks the original.
**Parameters**
| Name | Type | Description |
|--------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **nodeId** | string | The identifier of a file node to check out. |
| opts.include | string[] | Returns additional information about the node. The following optional fields can be requested: `allowableOperations`, `association`, `isLink`, `isFavorite`, `isLocked`, `path`, `permissions`, `definition` |
| opts.fields | string[] | A list of field names. You can use this parameter to restrict the fields returned within a response if, for example, you want to save on overall bandwidth. The list applies to a returned individual entity or entries within a collection. If the API method also supports the **include** parameter, then the fields specified in the **include** parameter are returned in addition to those specified in the **fields** parameter. |
**Return type**: [NodeEntry](NodeEntry.md)
**Example**
```javascript
import { AlfrescoApi, NodesApi } from '@alfresco/js-api';
const alfrescoApi = new AlfrescoApi(/*..*/);
const nodesApi = new NodesApi(alfrescoApi);
const opts = {};
nodesApi.checkoutNode(`<nodeId>`, opts).then((data) => {
console.log('API called successfully. Returned data: ' + data);
});
```
## cancelCheckoutNode
Cancel checkout of a node
Cancels a checkout. Accepts either the working copy or the original checked-out node. Deletes the working copy, unlocks the original, and returns the original node.
**Parameters**
| Name | Type | Description |
|--------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **nodeId** | string | The identifier of the working copy or original checked-out node. |
| opts.include | string[] | Returns additional information about the node. The following optional fields can be requested: `allowableOperations`, `association`, `isLink`, `isFavorite`, `isLocked`, `path`, `permissions`, `definition` |
| opts.fields | string[] | A list of field names. You can use this parameter to restrict the fields returned within a response if, for example, you want to save on overall bandwidth. The list applies to a returned individual entity or entries within a collection. If the API method also supports the **include** parameter, then the fields specified in the **include** parameter are returned in addition to those specified in the **fields** parameter. |
**Return type**: [NodeEntry](NodeEntry.md)
**Example**
```javascript
import { AlfrescoApi, NodesApi } from '@alfresco/js-api';
const alfrescoApi = new AlfrescoApi(/*..*/);
const nodesApi = new NodesApi(alfrescoApi);
const opts = {};
nodesApi.cancelCheckoutNode(`<nodeId>`, opts).then((data) => {
console.log('API called successfully. Returned data: ' + data);
});
```
# Models # Models
## NodeBodyUpdate ## NodeBodyUpdate
@@ -141,6 +141,30 @@ describe('Node', () => {
}); });
}); });
describe('Checkout', () => {
it('should POST to the checkout endpoint', async () => {
nodeMock.post200CheckoutNode('fake-node-id');
const result = await nodesApi.checkoutNode('fake-node-id');
assert.ok(result.entry, 'cancelCheckoutNode should return a NodeEntry');
});
it('should throw if nodeId is not defined', () => {
assert.throws(() => nodesApi.checkoutNode(undefined));
});
});
describe('Cancel Checkout', () => {
it('should POST to the cancel-checkout endpoint', async () => {
nodeMock.post200CancelCheckoutNode('fake-node-id');
const result = await nodesApi.cancelCheckoutNode('fake-node-id');
assert.ok(result.entry, 'checkoutNode should return a NodeEntry');
});
it('should throw if nodeId is not defined', () => {
assert.throws(() => nodesApi.cancelCheckoutNode(undefined));
});
});
describe('FolderInformation', () => { describe('FolderInformation', () => {
it('should return jobId on initiateFolderSizeCalculation API call if everything is ok', async () => { it('should return jobId on initiateFolderSizeCalculation API call if everything is ok', async () => {
nodeMock.post200ResponseInitiateFolderSizeCalculation(); nodeMock.post200ResponseInitiateFolderSizeCalculation();
@@ -16,6 +16,7 @@
*/ */
import { BaseMock } from '../base.mock'; import { BaseMock } from '../base.mock';
import { NodeEntry } from '../../../src';
export class NodeMock extends BaseMock { export class NodeMock extends BaseMock {
get200ResponseChildren(): void { get200ResponseChildren(): void {
@@ -284,4 +285,20 @@ export class NodeMock extends BaseMock {
} }
}); });
} }
post200CheckoutNode(nodeId: string): void {
this.mock()
.post(`/alfresco/api/-default-/public/alfresco/versions/1/nodes/${nodeId}/checkout`)
.reply(200, {
entry: { id: 'test-node-id', name: 'Test Node' }
} as NodeEntry);
}
post200CancelCheckoutNode(nodeId: string): void {
this.mock()
.post(`/alfresco/api/-default-/public/alfresco/versions/1/nodes/${nodeId}/cancel-checkout`)
.reply(200, {
entry: { id: 'test-node-id', name: 'Test Node' }
} as NodeEntry);
}
} }
+1 -1
View File
@@ -48,7 +48,7 @@ module.exports = function (config) {
coverageReporter: { coverageReporter: {
dir: join(__dirname, '../../coverage/process-services-cloud'), dir: join(__dirname, '../../coverage/process-services-cloud'),
subdir: '.', 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: { check: {
global: { global: {
statements: 75, statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
}, },
"peerDependencies": { "peerDependencies": {
"@angular/cdk": ">=20.2.14", "@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25", "@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.25", "@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.25", "@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.25", "@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14", "@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25", "@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.25", "@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.25", "@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0", "@alfresco/js-api": ">=10.0.0",
"@alfresco/adf-core": ">=9.0.0", "@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0", "@alfresco/adf-content-services": ">=9.0.0",
@@ -17,6 +17,7 @@
import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services'; import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services';
import { import {
ADF_DISPLAY_TEXT_SETTINGS,
ContentLinkModel, ContentLinkModel,
CoreModule, CoreModule,
FormFieldModel, FormFieldModel,
@@ -31,6 +32,8 @@ import {
provideTranslations, provideTranslations,
AuthModule, AuthModule,
FormFieldEvent, FormFieldEvent,
FormEvent,
FormRulesEvent,
NoopTranslateModule, NoopTranslateModule,
NoopAuthModule, NoopAuthModule,
FORM_FIELD_VALIDATORS FORM_FIELD_VALIDATORS
@@ -117,7 +120,8 @@ describe('FormCloudComponent', () => {
useValue: {} useValue: {}
}, },
{ provide: FormRenderingService, useClass: CloudFormRenderingService }, { 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); const apiService = TestBed.inject(AlfrescoApiService);
@@ -874,6 +878,39 @@ describe('FormCloudComponent', () => {
expect(savedForm).toEqual(formModel); 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', () => { it('should handle error during form save', () => {
const error = 'Error'; const error = 'Error';
spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error)); spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error));
@@ -981,6 +1018,39 @@ describe('FormCloudComponent', () => {
expect(completedForm).toBe(formComponent.form); 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 () => { it('should open confirmation dialog on complete task', async () => {
formComponent.form = new FormModel({ formComponent.form = new FormModel({
confirmMessage: { confirmMessage: {
@@ -1231,6 +1301,38 @@ describe('FormCloudComponent', () => {
expect(formComponent.visibleOutcomes).toEqual([]); 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 () => { it('should raise [executeOutcome] event for formService', async () => {
spyOn(formComponent.executeOutcome, 'emit'); spyOn(formComponent.executeOutcome, 'emit');
@@ -30,14 +30,17 @@ import {
SimpleChanges, SimpleChanges,
ViewChild ViewChild
} from '@angular/core'; } 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 { filter, map, switchMap } from 'rxjs/operators';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
ContentLinkModel, ContentLinkModel,
ADF_DISPLAY_TEXT_SETTINGS,
DisplayTextWidgetSettings,
FormatSpacePipe, FormatSpacePipe,
FormBaseComponent, FormBaseComponent,
FormEvent, FormEvent,
FormExpressionService,
FormFieldModel, FormFieldModel,
FormRulesEvent, FormRulesEvent,
FormFieldValidator, FormFieldValidator,
@@ -67,6 +70,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { A11yModule } from '@angular/cdk/a11y'; import { A11yModule } from '@angular/cdk/a11y';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../services/form-cloud-submission-values';
interface FormFieldRuntimeState { interface FormFieldRuntimeState {
value: any; value: any;
@@ -228,6 +232,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
protected changeDetector = inject(ChangeDetectorRef); protected changeDetector = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly expressions = inject(FormExpressionService);
private enableExpressionEvaluation = false;
private get currentForm(): FormModel | undefined { private get currentForm(): FormModel | undefined {
return super.form; return super.form;
@@ -252,6 +258,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
constructor() { constructor() {
const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true }); const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true });
const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { 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(); super();
this.loadInjectedFieldValidators(injectedFieldValidators); 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) => { this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => {
if (content instanceof UploadWidgetContentLinkModel) { if (content instanceof UploadWidgetContentLinkModel) {
this.form.setNodeIdValueForViewersLinkedToUploadWidget(content); this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);
@@ -291,11 +306,11 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
} }
}); });
this.formService.formRulesEvent merge(
.pipe( this.formService.formVisibilityRefreshed.pipe(filter((event) => event.form?.id === this.form?.id)),
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id), this.formService.formRulesEvent.pipe(filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id))
takeUntilDestroyed() )
) .pipe(takeUntilDestroyed())
.subscribe(() => this.recomputeVisibleOutcomes()); .subscribe(() => this.recomputeVisibleOutcomes());
} }
@@ -482,7 +497,7 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
saveTaskForm() { saveTaskForm() {
if (this.form && this.appName && this.taskId) { if (this.form && this.appName && this.taskId) {
this.formCloudService 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)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -523,7 +538,15 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
private completeForm(outcome?: string, outcomeId?: string) { private completeForm(outcome?: string, outcomeId?: string) {
if (this.form && this.appName && this.taskId) { if (this.form && this.appName && this.taskId) {
this.formCloudService 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)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => { 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 { parseForm(formCloudRepresentationJSON?: any): FormModel | null {
if (formCloudRepresentationJSON) { if (formCloudRepresentationJSON) {
const formValues: FormValues = {}; const formValues: FormValues = {};
@@ -568,7 +595,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
checkVisibility(field: FormFieldModel) { checkVisibility(field: FormFieldModel) {
if (field?.form) { if (field?.form) {
this.visibilityService.refreshVisibility(field.form); this.visibilityService.refreshVisibility(field.form);
this.recomputeVisibleOutcomes();
} }
} }
@@ -586,7 +612,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.setCheckParentVisibilityForValidationOnFields(); this.setCheckParentVisibilityForValidationOnFields();
this.visibilityService.refreshVisibility(this.form); this.visibilityService.refreshVisibility(this.form);
this.form.validateForm(); this.form.validateForm();
this.recomputeVisibleOutcomes();
this.onFormLoaded(this.form); this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form))); this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form); this.onFormDataRefreshed(this.form);
@@ -1,21 +1,26 @@
<div class="adf-attach-file-widget-container"> <div class="adf-attach-file-widget-container">
<div class="adf-attach-widget {{field.className}}" <div class="adf-attach-widget {{ field.className }}" [class.adf-readonly]="field.readOnly">
[class.adf-readonly]="field.readOnly"> <label class="adf-label" [attr.for]="field.id + '-label'"
<label class="adf-label" [attr.for]="field.id + '-label'">{{field.name}} >{{ field.name }}
<span class="adf-asterisk" *ngIf="isRequired()">*</span> @if (isRequired()) {
<span class="adf-asterisk">*</span>
}
</label> </label>
<div class="adf-attach-widget-container" (focusout)="markAsTouched()"> <div class="adf-attach-widget-container" (focusout)="markAsTouched()">
<div class="adf-attach-widget__menu-upload" *ngIf="isUploadButtonVisible()"> @if (isUploadButtonVisible()) {
<button <div class="adf-attach-widget__menu-upload">
(click)="openSelectDialog()" <button
mat-raised-button (click)="openSelectDialog()"
class="adf-attach-widget__menu-upload__button" mat-raised-button
[id]="field.id" class="adf-attach-widget__menu-upload__button"
[title]="field.tooltip"> [id]="field.id"
[title]="field.tooltip"
>
{{ 'FORM.FIELD.ATTACH' | translate }} {{ 'FORM.FIELD.ATTACH' | translate }}
<mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" /> <mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" />
</button> </button>
</div> </div>
}
</div> </div>
</div> </div>
@@ -34,12 +39,15 @@
(contentModelFileHandler)="contentModelFormFileHandler($event)" (contentModelFileHandler)="contentModelFormFileHandler($event)"
(removeAttachFile)="onRemoveAttachFile($event)" (removeAttachFile)="onRemoveAttachFile($event)"
/> />
<div *ngIf="!hasFile && field.readOnly" id="{{'adf-attach-empty-list-'+field.id}}"> @if (!hasFile && field.readOnly) {
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }} <div id="{{ 'adf-attach-empty-list-' + field.id }}">
</div> {{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
}
</div> </div>
<error-widget [error]="field.validationSummary" /> <error-widget
<error-widget *ngIf="!field.isValid && isTouched() && !isSelected()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" /> [error]="field.validationSummary"
[required]="!field.isValid && isTouched() && !isSelected() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div> </div>
@@ -1,31 +1,23 @@
<div class="adf-data-table-widget-container"> <div class="adf-data-table-widget-container">
<div class="adf-data-table-widget-label"> <div class="adf-data-table-widget-label">
<label <label class="adf-label" [class.adf-left-label]="field.leftLabels" [attr.for]="field.id"> {{field.name | translate }} </label>
class="adf-label"
[class.adf-left-label]="field.leftLabels"
[attr.for]="field.id">
{{field.name | translate }}
</label>
</div> </div>
<ng-container *ngIf="!previewState; else previewTemplate"> @if (!previewState) {
<adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource"> <adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource">
<adf-no-content-template> <adf-no-content-template>
<ng-template> <ng-template>
<adf-empty-content <adf-empty-content icon="border_all" [title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
icon="border_all"
[title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
</ng-template> </ng-template>
</adf-no-content-template> </adf-no-content-template>
</adf-datatable> </adf-datatable>
<error-widget *ngIf="dataTableLoadFailed" <error-widget
class="adf-data-table-widget-failed-message" class="adf-data-table-widget-failed-message"
[required]="'FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate" /> [required]="dataTableLoadFailed ? ('FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate) : ''"
</ng-container> />
} @else {
<ng-template #previewTemplate>
<adf-datatable data-automation-id="adf-data-table-widget-preview" /> <adf-datatable data-automation-id="adf-data-table-widget-preview" />
<div class="adf-preview-placeholder"></div> <div class="adf-preview-placeholder"></div>
</ng-template> }
</div> </div>
@@ -1,10 +1,12 @@
.adf-data-table-widget-failed-message { .adf-data-table-widget-failed-message {
margin: 10px; display: block;
} }
.adf-preview-placeholder { .adf-data-table-widget-container {
height: 100%; .adf-preview-placeholder {
width: 100%; height: 100%;
min-height: 100px; width: 100%;
margin-bottom: 10px; min-height: 100px;
margin-bottom: 10px;
}
} }
@@ -283,7 +283,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message')); const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, []); assertData(mockCountryColumns, []);
expect(failedErrorMsgElement).toBeNull(); expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
}); });
it('path points to single object with appropriate schema definition', () => { 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')); const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]); assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]);
expect(failedErrorMsgElement).toBeNull(); expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
}); });
}); });
@@ -27,7 +27,6 @@ import {
NoContentTemplateDirective, NoContentTemplateDirective,
EmptyContentComponent EmptyContentComponent
} from '@alfresco/adf-core'; } from '@alfresco/adf-core';
import { NgIf } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { FormCloudService } from '../../../services/form-cloud.service'; import { FormCloudService } from '../../../services/form-cloud.service';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model'; import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
@@ -36,7 +35,7 @@ import { DataTablePathParserHelper } from './helpers/data-table-path-parser.help
@Component({ @Component({
standalone: true, standalone: true,
imports: [NgIf, TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent], imports: [TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
selector: 'data-table', selector: 'data-table',
templateUrl: './data-table.widget.html', templateUrl: './data-table.widget.html',
styleUrls: ['./data-table.widget.scss'], styleUrls: ['./data-table.widget.scss'],
@@ -15,7 +15,11 @@
> >
</div> </div>
<div class="adf-date-widget-container"> <div class="adf-date-widget-container">
<mat-form-field class="adf-date-widget adf-form-field-input" [class.adf-left-label-input-datepicker]="field.leftLabels"> <mat-form-field
class="adf-date-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
>
@if ( (field.name || field?.required) && !field.leftLabels) { @if ( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id"> <mat-label class="adf-label" [attr.for]="field.id">
{{field.name | translate }} ({{field.dateDisplayFormat}}) {{field.name | translate }} ({{field.dateDisplayFormat}})
@@ -36,12 +40,13 @@
<mat-datepicker-toggle matSuffix [for]="datePicker" [disabled]="field.readOnly" /> <mat-datepicker-toggle matSuffix [for]="datePicker" [disabled]="field.readOnly" />
<mat-datepicker #datePicker [startAt]="startAt" [disabled]="field.readOnly" /> <mat-datepicker #datePicker [startAt]="startAt" [disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) { @if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span> >@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -11,7 +11,7 @@
</div> </div>
<div> <div>
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null"> <mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) { @if( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id"> {{ field.name | translate }} </mat-label> <mat-label class="adf-label" [attr.for]="field.id"> {{ field.name | translate }} </mat-label>
} }
@@ -32,8 +32,9 @@
</span> </span>
</ng-container> </ng-container>
@if (propertyLoadFailed && !previewState) { @if (propertyLoadFailed && !previewState) {
<mat-error><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error> <mat-error class="adf-form-field-status-slot"><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -200,6 +200,51 @@ describe('DisplayRichTextWidgetComponent', () => {
expect(widget.field.value.blocks[0].data.text).toBe('Hello John'); 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', () => { it('should resolve expressions in multiple blocks', () => {
const form = new FormModel({ const form = new FormModel({
fields: [ fields: [
@@ -22,6 +22,7 @@ import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { RichTextParserService } from '../../../services/rich-text-parser.service'; 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', { export const RICH_TEXT_PARSER_TOKEN = new InjectionToken<RichTextParserService>('RichTextParserService', {
factory: () => new RichTextParserService() factory: () => new RichTextParserService()
@@ -66,7 +67,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
protected storeOriginalValue(): void { protected storeOriginalValue(): void {
if (this.field) { 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; return;
} }
const value = JSON.parse(JSON.stringify(this.field.value)); const authoredValue = this.field.authoredValue;
this.applyExpressionsToBlocks(value); if (authoredValue !== undefined) {
this.applyExpressionsToBlocks(authoredValue);
}
} }
protected reevaluateExpressions(): void { protected reevaluateExpressions(): void {
@@ -89,16 +95,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
} }
private applyExpressionsToBlocks(value: any): void { private applyExpressionsToBlocks(value: any): void {
for (const block of value.blocks) { this.field.value = resolveRichTextExpressions(value, (content) => this.resolveExpressions(content, true));
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;
} }
private parseAndSanitize(): void { 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;
};
@@ -12,7 +12,7 @@
</div> </div>
} }
<div class="adf-dropdown-widget-container"> <div class="adf-dropdown-widget-container">
<mat-form-field class="adf-form-field-input"> <mat-form-field class="adf-form-field-input" subscriptSizing="dynamic">
@if ( (field.name || this.field?.required) && !field.leftLabels) { @if ( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label>
} }
@@ -49,12 +49,13 @@
} }
</mat-select> </mat-select>
@if ((dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) || (!previewState && !field.readOnly && (isRestApiFailed || variableOptionsFailed))) { @if ((dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) || (!previewState && !field.readOnly && (isRestApiFailed || variableOptionsFailed))) {
<mat-error> <mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon> <mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text" <span class="adf-error-text"
>@if (dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (isRestApiFailed) {{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}} @else if (variableOptionsFailed) {{{ 'FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED' | translate }}}</span> >@if (dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (isRestApiFailed) {{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}} @else if (variableOptionsFailed) {{{ 'FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED' | translate }}}</span>
</mat-error> </mat-error>
} }
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@@ -4,11 +4,13 @@
[class.adf-readonly]="field.readOnly" [class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels" [class.adf-left-label-input-container]="field.leftLabels"
> >
<div *ngIf="field.leftLabels"> @if (field.leftLabels) {
<label class="adf-label adf-left-label" [attr.for]="field.id" <div>
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label <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>
}
<div> <div>
<adf-cloud-group <adf-cloud-group
[mode]="mode" [mode]="mode"
@@ -22,13 +24,7 @@
[preSelectGroups]="preSelectGroup" [preSelectGroups]="preSelectGroup"
(blur)="markAsTouched()" (blur)="markAsTouched()"
[attr.title]="field.tooltip" [attr.title]="field.tooltip"
[label] = "field.name | translate" [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>
</div> </div>
@@ -141,8 +141,9 @@ describe('GroupCloudWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy(); const errorMessages = element.querySelectorAll('.adf-error-text');
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND'); 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 { 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 { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types'; import { ComponentSelectionMode } from '../../../../types';
@@ -31,7 +31,7 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
@Component({ @Component({
selector: 'group-cloud-widget', selector: 'group-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, GroupCloudComponent], imports: [CommonModule, TranslatePipe, GroupCloudComponent],
templateUrl: './group-cloud.widget.html', templateUrl: './group-cloud.widget.html',
host: { host: {
'(click)': 'event($event)', '(click)': 'event($event)',
@@ -1,10 +1,16 @@
<div class="adf-dropdown-widget {{field.className}}" <div
[class.adf-invalid]="!field.isValid && isTouched()" class="adf-dropdown-widget {{field.className}}"
[class.adf-readonly]="field.readOnly" [class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-left-label-input-container]="field.leftLabels"> [class.adf-readonly]="field.readOnly"
<div *ngIf="field.leftLabels"> [class.adf-left-label-input-container]="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> >
</div> @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> <div>
<adf-cloud-people <adf-cloud-people
[preSelectUsers]="preSelectUsers" [preSelectUsers]="preSelectUsers"
@@ -21,11 +27,5 @@
[attr.title]="field.tooltip" [attr.title]="field.tooltip"
[label]="field.name | translate" [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>
</div> </div>
@@ -171,8 +171,9 @@ describe('PeopleCloudWidgetComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy(); const errorMessages = element.querySelectorAll('.adf-error-text');
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND'); 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 { 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 { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types'; import { ComponentSelectionMode } from '../../../../types';
@@ -27,13 +27,12 @@ import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component'; import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
import { MatFormFieldModule } from '@angular/material/form-field';
/* eslint-disable @angular-eslint/component-selector */ /* eslint-disable @angular-eslint/component-selector */
@Component({ @Component({
selector: 'people-cloud-widget', selector: 'people-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, PeopleCloudComponent, MatFormFieldModule], imports: [CommonModule, TranslatePipe, PeopleCloudComponent],
templateUrl: './people-cloud.widget.html', templateUrl: './people-cloud.widget.html',
host: { host: {
'(click)': 'event($event)', '(click)': 'event($event)',
@@ -11,7 +11,6 @@
} }
&-radio-button-container-horizontal { &-radio-button-container-horizontal {
margin-bottom: 15px;
display: flex; display: flex;
flex-flow: column wrap; flex-flow: column wrap;
align-items: flex-start; align-items: flex-start;
@@ -46,8 +45,4 @@
word-break: break-word; word-break: break-word;
} }
} }
&-radio-group-error-message .adf-error-container {
margin-top: 5px;
}
} }
@@ -4,51 +4,50 @@
> >
<div class="adf-cloud-upload-widget-container"> <div class="adf-cloud-upload-widget-container">
<div> <div>
<mat-list *ngIf="hasFile"> @if (hasFile) {
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles"> <mat-list>
<img <mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
matListItemLine <img
class="adf-upload-widget__icon" matListItemLine
[id]="'file-'+file.id+'-icon'" class="adf-upload-widget__icon"
[src]="getIcon(file.content.mimeType)" [id]="'file-'+file.id+'-icon'"
[alt]="mimeTypeIcon" [src]="getIcon(file.content.mimeType)"
(click)="fileClicked(file)" [alt]="mimeTypeIcon"
(keyup.enter)="fileClicked(file)" (click)="fileClicked(file)"
role="button" (keyup.enter)="fileClicked(file)"
tabindex="0" role="button"
/> tabindex="0"
<span />
class="adf-upload-widget__button adf-file" <span
matLine class="adf-upload-widget__button adf-file"
id="{{'file-'+file.id}}" matLine
(click)="fileClicked(file)" id="{{'file-'+file.id}}"
(keyup.enter)="fileClicked(file)" (click)="fileClicked(file)"
role="button" (keyup.enter)="fileClicked(file)"
tabindex="0" role="button"
>{{file.name}}</span tabindex="0"
> >{{file.name}}</span
<button >
*ngIf="!field.readOnly" @if (!field.readOnly) {
mat-icon-button <button mat-icon-button [id]="'file-'+file.id+'-remove'" (click)="removeFile(file);" (keyup.enter)="removeFile(file);">
[id]="'file-'+file.id+'-remove'" <mat-icon class="mat-24" adf-icon="highlight_off" />
(click)="removeFile(file);" </button>
(keyup.enter)="removeFile(file);" }
> </mat-list-item>
<mat-icon class="mat-24" adf-icon="highlight_off" /> </mat-list>
</button> }
</mat-list-item>
</mat-list>
</div> </div>
<div *ngIf="(!hasFile || multipleOption) && !field.readOnly"> @if ((!hasFile || multipleOption) && !field.readOnly) {
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip"> <div>
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" /> <button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" /> {{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
</button> <input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</div> </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> </div>
<error-widget [error]="field.validationSummary" /> <error-widget [error]="field.validationSummary" [required]="isInvalidFieldRequired() ? ('FORM.FIELD.REQUIRED' | translate) : ''" />
<error-widget *ngIf="isInvalidFieldRequired()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
</div> </div>
@@ -55,4 +55,12 @@ describe('UploadCloudWidgetComponent', () => {
expect(eventSpy).toHaveBeenCalledWith(clickEvent); 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/content-cloud-node-selector.service';
export * from './services/process-cloud-content.service'; export * from './services/process-cloud-content.service';
export * from './services/display-mode.service'; export * from './services/display-mode.service';
export * from './services/form-cloud-submission-values';
export * from './form-cloud.module'; 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,27 +1,37 @@
<form> <form>
<mat-form-field class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()"> <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> } @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-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list">
<mat-chip-row <mat-chip-row
*ngFor="let group of selectedGroups" *ngFor="let group of selectedGroups"
[removable]="!(group.readonly)" [removable]="!group.readonly"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name" [attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
(removed)="onRemove(group)" (removed)="onRemove(group)"
[disabled]="readOnly || isValidationLoading()" [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" /> {{ group.name }}
@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> </mat-chip-row>
<input matInput <input
[formControl]="searchGroupsControl" matInput
[matAutocomplete]="auto" [formControl]="searchGroupsControl"
[matChipInputFor]="groupChipList" [matAutocomplete]="auto"
[placeholder]="isReadonly() ? '' : (title | translate)" [matChipInputFor]="groupChipList"
[required]="required" [placeholder]="isReadonly() ? '' : (title | translate)"
(focus)="setFocus(true)" [required]="required"
(blur)="setFocus(false); markAsTouched()" (focus)="setFocus(true)"
class="adf-group-input" (blur)="setFocus(false); markAsTouched()"
data-automation-id="adf-cloud-group-search-input" #groupInput> class="adf-group-input"
data-automation-id="adf-cloud-group-search-input"
#groupInput
/>
</mat-chip-grid> </mat-chip-grid>
<mat-autocomplete <mat-autocomplete
@@ -30,57 +40,77 @@
class="adf-cloud-group-list" class="adf-cloud-group-list"
(optionSelected)="onSelect($event.option.value)" (optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName" [displayWith]="getDisplayName"
data-automation-id="adf-cloud-group-autocomplete"> 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" @if ((searchGroups$ | async)?.length) {
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name" <mat-option
class="adf-cloud-group-option-active"> *ngFor="let group of searchGroups$ | async; let i = index"
<div [value]="group"
class="adf-cloud-group-row" [attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
id="adf-group-{{i}}" class="adf-cloud-group-option-active"
data-automation-id="adf-cloud-group-row"> >
<button class="adf-group-short-name" mat-fab>{{getGroupNameInitials(group)}}</button> <div class="adf-cloud-group-row" id="adf-group-{{ i }}" data-automation-id="adf-cloud-group-row">
<span>{{group.name}}</span> <button class="adf-group-short-name" mat-fab>{{ getGroupNameInitials(group) }}</button>
<span>{{ group.name }}</span>
</div> </div>
</mat-option> </mat-option>
</ng-container> } @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults> <ng-template #noResults>
<mat-option *ngIf="searchGroupsControl.hasError('searchTypingError') && !searchLoading" disabled @if (searchGroupsControl.hasError('searchTypingError') && !searchLoading) {
class="adf-cloud-group-option-not-active" <mat-option disabled class="adf-cloud-group-option-not-active" data-automation-id="adf-cloud-group-no-results">
data-automation-id="adf-cloud-group-no-results"> <span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span> </mat-option>
</mat-option> }
</ng-template> </ng-template>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container"> <div class="adf-error-container adf-error-messages-container">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error"> @if (validationLoading) {
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-progress-bar mode="indeterminate" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div> }
</mat-error> @if (hasPreselectError() && !isValidationLoading()) {
<mat-error *ngIf="searchGroupsControl.hasError('pattern')" class="adf-error"> <mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <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_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error> </mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('maxlength')" class="adf-error"> }
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> @if (searchGroupsControl.hasError('pattern')) {
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}</div> <mat-error class="adf-error">
</mat-error> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
<mat-error *ngIf="searchGroupsControl.hasError('minlength')" class="adf-error"> <div class="adf-error-text">
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MIN_LENGTH' | translate: { requiredLength: getValidationMinLength() } }}</div> </div>
</mat-error> </mat-error>
<mat-error *ngIf="(searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()" }
class="adf-error"> @if (searchGroupsControl.hasError('maxlength')) {
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> <mat-error class="adf-error">
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }} </div> <mat-icon class="adf-error-icon" adf-icon="error_outline" />
</mat-error> <div class="adf-error-text">
<mat-error *ngIf="searchGroupsControl.hasError('searchTypingError') && !this.isFocused" {{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
data-automation-id="invalid-groups-typing-error" class="adf-error"> </div>
<mat-icon class="adf-error-icon" adf-icon="error_outline" /> </mat-error>
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div> }
</mat-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>
</mat-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>
}
@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> </div>
</form> </form>

Some files were not shown because too many files have changed in this diff Show More