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
if: ${{ inputs.full-setup == 'true' }}
id: tag-sha
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
- name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
# Override language selection by uncommenting this and choosing your languages
with:
languages: javascript
@@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -53,4 +53,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
+35 -1
View File
@@ -7,6 +7,7 @@ on:
permissions:
pull-requests: read
issues: read
jobs:
notify-bdu:
@@ -18,8 +19,41 @@ jobs:
github.event.label.name == 'A/N BDU' &&
github.event.pull_request.state == 'open'
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
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:
webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }}
skip_checkout: true
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: develop
token: ${{ steps.app-token.outputs.token }}
- name: Pull translations from Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
skip_ref_checkout: true
upload_sources: false
+2
View File
@@ -254,6 +254,8 @@ jobs:
name: "Unit Tests"
needs: [setup]
uses: ./.github/workflows/unit-test-workflow.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
base_ref: ${{ github.base_ref || 'develop' }}
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Push Source Files to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_sources_args: --delete-obsolete
+22
View File
@@ -0,0 +1,22 @@
name: "SonarCloud Full Scan (develop)"
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
full-unit-tests-and-sonar-scan:
name: "Full Unit Tests + SonarCloud Scan"
uses: ./.github/workflows/unit-test-workflow.yml
secrets: inherit
with:
full: true
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
with:
comment-identifier: supply-chain-review-instructions
comment-body: |
+7 -7
View File
@@ -41,7 +41,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
# - github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -494,7 +494,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1128,7 +1128,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1404,7 +1404,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1653,7 +1653,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1732,7 +1732,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@8914f47b6c1bb8a802c7549f5ac1a81434b66403 # v0.86.1
uses: github/gh-aw-actions/setup@b304200a0ef4b3998673bfc7945acb08ab8c88b7 # v0.87.2
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+65 -2
View File
@@ -2,12 +2,21 @@ name: "Unit Tests Workflow"
on:
workflow_call:
secrets:
SONAR_TOKEN:
description: 'Token for SonarCloud analysis'
required: false
inputs:
base_ref:
description: 'Base branch for affected calculation'
required: false
type: string
default: 'develop'
full:
description: 'Run the full (non-affected) test suite for every project instead of only affected ones'
required: false
type: boolean
default: false
jobs:
generate-affected-matrix:
@@ -30,9 +39,15 @@ jobs:
id: set-matrix
env:
BASE_REF: ${{ inputs.base_ref }}
FULL_RUN: ${{ inputs.full }}
run: |
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
if [ "$FULL_RUN" == "true" ]; then
echo "Running full (non-affected) test suite"
AFFECTED_UNIT=$(pnpm nx show projects --target=test --select=projects --plain --exclude=cli,stories,eslint-angular)
else
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
fi
echo "Affected projects for UNIT: $AFFECTED_UNIT"
if [ -z "$AFFECTED_UNIT" ]; then
@@ -74,8 +89,56 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=5120"
run: |
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@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
if: ${{ success() }}
uses: ./.github/actions/save-nx-cache
with:
cache-suffix: test-${{ matrix.project }}
sonarcloud:
name: "SonarCloud Scan"
runs-on: ubuntu-latest
needs: [generate-affected-matrix, unit-tests]
if: ${{ needs.generate-affected-matrix.outputs.hasProjects == 'true' && always() && needs.unit-tests.result != 'cancelled' }}
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@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 |
| rows | [`ContainerRowModel`](../../../lib/core/src/lib/form/components/widgets/core/container-row.model.ts)\[] | \[] | Row definitions for a repeatable section field |
| emptyOption | [`FormFieldOption`](../../../lib/core/src/lib/form/components/widgets/core/form-field-option.ts) | | Dropdown menu item to use when no option is chosen |
| validationSummary | string | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummary | [`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts) | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummaryChanges$ | Observable<[`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts)> | | Replays the current validation summary to subscribers and emits the completed summary after each validation |
## Details
+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)
- [Creating and updating nodes](#creating-and-updating-nodes)
- [Deleting and restoring nodes](#deleting-and-restoring-nodes)
- [Checking out nodes](#checking-out-nodes)
- [See also](#see-also)
## Class members
@@ -103,6 +104,16 @@ Accesses and manipulates ACS document nodes using their node IDs.
- _nodeId:_ `string` - ID of the target node
- _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.
- **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
@@ -160,6 +171,12 @@ and
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.
### 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
- [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 |
| filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default |
| showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
@@ -36,6 +36,7 @@ Shows all available filters.
| appName | `string` | "" | Display filters available to the current user for the application with the specified name. |
| filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. |
| showIcons | `boolean` | false | Toggles display of the filter's icons. |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
+1 -1
View File
@@ -56,7 +56,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/content-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/adf-core": ">=9.0.0"
@@ -18,11 +18,19 @@
import { TestBed } from '@angular/core/testing';
import { RedirectAuthService } from '@alfresco/adf-core';
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 { AlfrescoApiService } from '../../services/alfresco-api.service';
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 = {
entry: {
jobId: 'fake-job-id'
@@ -75,4 +83,20 @@ describe('NodesApiService', () => {
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));
}
/**
* 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 {
return `node_${Date.now()}`;
}
@@ -1,6 +1,8 @@
<div class="adf-search-filter-menu-card">
<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
class="adf-search-filter-title-action"
aria-hidden="false"
@@ -7,6 +7,11 @@
height: 32px;
font: var(--mat-sys-body-medium);
&-heading {
margin: 0;
font: inherit;
}
&-action {
float: right;
}
@@ -38,4 +38,11 @@ describe('SearchFilterMenuComponent', () => {
closeButton.click();
expect(spyCloseEvent).toHaveBeenCalled();
});
it('should expose the title as a heading', () => {
const heading = fixture.debugElement.nativeElement.querySelector('.adf-search-filter-title-heading');
expect(heading).not.toBeNull();
expect(heading.tagName).toBe('H2');
});
});
+1 -1
View File
@@ -69,7 +69,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/core'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+5 -5
View File
@@ -23,13 +23,13 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/material-date-fns-adapter": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@mat-datetimepicker/core": ">=12.0.1",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/js-api": ">=10.0.0",
@@ -0,0 +1,76 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { ElementRef } from '@angular/core';
import { DropZoneDirective } from './drop-zone.directive';
describe('DropZoneDirective', () => {
let directive: DropZoneDirective;
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
TestBed.configureTestingModule({
providers: [{ provide: ElementRef, useValue: new ElementRef(element) }]
});
directive = TestBed.runInInjectionContext(() => new DropZoneDirective());
directive.dropTarget = 'cell';
directive.ngOnInit();
});
it('should dispatch a namespaced custom event on dragenter while attached', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.dispatchEvent(new DragEvent('dragenter'));
expect(dispatched).toContain('cell-dragenter');
});
it('should not handle drag events after the directive is destroyed', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.addEventListener('cell-dragover', () => dispatched.push('cell-dragover'));
element.addEventListener('cell-drop', () => dispatched.push('cell-drop'));
directive.ngOnDestroy();
element.dispatchEvent(new DragEvent('dragenter'));
element.dispatchEvent(new DragEvent('dragover'));
element.dispatchEvent(new DragEvent('drop'));
expect(dispatched).toEqual([]);
});
it('should remove listeners using the same references that were added', () => {
const addSpy = spyOn(element, 'addEventListener').and.callThrough();
const removeSpy = spyOn(element, 'removeEventListener').and.callThrough();
directive.ngOnInit();
directive.ngOnDestroy();
const addedByEvent = new Map<string, EventListenerOrEventListenerObject>();
addSpy.calls.allArgs().forEach(([evt, fn]) => addedByEvent.set(evt as string, fn as EventListenerOrEventListenerObject));
removeSpy.calls.allArgs().forEach(([evt, fn]) => {
expect(fn).toBe(addedByEvent.get(evt as string));
});
});
});
@@ -36,6 +36,10 @@ export class DropZoneDirective implements OnInit, OnDestroy {
@Input()
dropColumn: DataColumn;
private readonly onDragEnterHandler = this.onDragEnter.bind(this);
private readonly onDragOverHandler = this.onDragOver.bind(this);
private readonly onDropHandler = this.onDrop.bind(this);
constructor() {
const elementRef = inject(ElementRef);
@@ -44,16 +48,16 @@ export class DropZoneDirective implements OnInit, OnDestroy {
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
this.element.addEventListener('dragenter', this.onDragEnter.bind(this));
this.element.addEventListener('dragover', this.onDragOver.bind(this));
this.element.addEventListener('drop', this.onDrop.bind(this));
this.element.addEventListener('dragenter', this.onDragEnterHandler);
this.element.addEventListener('dragover', this.onDragOverHandler);
this.element.addEventListener('drop', this.onDropHandler);
});
}
ngOnDestroy() {
this.element.removeEventListener('dragenter', this.onDragEnter);
this.element.removeEventListener('dragover', this.onDragOver);
this.element.removeEventListener('drop', this.onDrop);
this.element.removeEventListener('dragenter', this.onDragEnterHandler);
this.element.removeEventListener('dragover', this.onDragOverHandler);
this.element.removeEventListener('drop', this.onDropHandler);
}
onDragEnter(event: DragEvent) {
@@ -131,7 +131,10 @@ export class ResizableDirective implements OnInit, OnDestroy {
.pipe(filter(() => !!this.currentRect));
mouseDrag
.pipe(map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)))
.pipe(
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rectangle: BoundingRectangle) => {
if (this.resizing.observers.length > 0) {
this.zone.run(() => {
@@ -131,6 +131,28 @@ describe('ResizeHandleDirective', () => {
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function));
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function));
});
it('should unregister previous mouseup listener before registering a new one on repeated mousedown', () => {
const firstUnlistenMouseUp = jasmine.createSpy('firstUnlistenMouseUp');
const secondUnlistenMouseUp = jasmine.createSpy('secondUnlistenMouseUp');
let mouseUpCallCount = 0;
renderer.listen.and.callFake((_target: any, eventName: string, _callback: (event: MouseEvent) => void) => {
if (eventName === 'mouseup') {
mouseUpCallCount++;
return mouseUpCallCount === 1 ? firstUnlistenMouseUp : secondUnlistenMouseUp;
}
return () => {};
});
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).not.toHaveBeenCalled();
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).toHaveBeenCalled();
});
});
describe('keyboard resizing', () => {
@@ -86,6 +86,7 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
});
}
this.unlistenMouseUp?.();
this.unlistenMouseUp = this.renderer.listen('document', 'mouseup', (mouseUpEvent: MouseEvent) => {
this.onMouseup(mouseUpEvent);
});
@@ -96,7 +97,8 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
private onMouseup(event: MouseEvent): void {
this.unlistenMouseMove?.();
this.unlistenMouseMove = undefined;
this.unlistenMouseUp();
this.unlistenMouseUp?.();
this.unlistenMouseUp = undefined;
this.resizableContainer.mouseup.next(event);
}
@@ -98,7 +98,7 @@
[hidden]="!currentRootElement?.isVisible"
>
<adf-repeat-widget [element]="currentRootElement" [isEditor]="false">
@for (row of currentRootElement.field.rows; track row; let rowIndex = $index) {
@for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1;
<div
class="adf-grid-list-container"
@@ -125,11 +125,11 @@
}
</div>
<section class="adf-grid-list-column-view">
@for (column of row.columns; track column; let columnIndex = $index) {
@for (column of row.columns; track column.id; let columnIndex = $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)">
@for (field of column?.fields; track field) {
@for (field of column?.fields; track field.id) {
@if (field.type === 'section') {
<adf-form-section [field]="field"/>
} @else {
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix {
width: auto;
}
.adf-form-field-status-slot {
display: block;
height: 40px;
box-sizing: border-box;
}
}
.alfresco-tabs-widget {
@@ -27,6 +33,7 @@
.adf-form-tab-content {
margin-top: 1em;
padding-bottom: 3px;
}
.adf-form-tab-group {
@@ -40,7 +47,7 @@
.adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
margin-bottom: 35px;
margin-bottom: 0;
}
.adf-grid-list {
@@ -265,7 +272,7 @@
}
&-error-messages-container {
min-height: 35px;
height: 40px;
}
&-error-messages-container-visible {
@@ -5,7 +5,7 @@
&-single-column {
display: flex;
flex-wrap: inherit;
align-items: center;
align-items: flex-start;
gap: 1%;
@include flex.layout-bp(lt-md) {
@@ -10,7 +10,11 @@
>
</div>
<div class="adf-amount-widget-container">
<mat-form-field class="adf-amount-widget__input adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-amount-widget__input adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</mat-label> }
@if(!enableDisplayBasedOnLocale) {
<span matTextPrefix class="adf-amount-widget__prefix-spacing">{{ currency }}&nbsp;</span>
@@ -32,12 +36,13 @@
(blur)="amountWidgetOnBlur()"
/>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { of } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { FormService } from '../../../services/form.service';
import { FormFieldEvent } from '../../../events/form-field.event';
import { TranslationService } from '../../../../translation/translation.service';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { TranslateService } from '@ngx-translate/core';
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => {
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
});
describe('when validation runs without amount interaction', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}"
}
}
}
};
let amountField: FormFieldModel;
let form: FormModel;
beforeEach(async () => {
const translateService = TestBed.inject(TranslateService);
translateService.setTranslation('en', validatorTranslations);
await firstValueFrom(translateService.use('en'));
form = new FormModel({ taskId: '<id>' }, undefined, false, formService);
amountField = new FormFieldModel(form, {
id: 'amount-id',
type: FormFieldTypes.AMOUNT,
value: 1,
minValue: '10'
});
form.fieldsCache = [amountField];
amountField.validate();
fixture.componentRef.setInput('field', amountField);
fixture.detectChanges();
});
it('should render updated parameters after direct revalidation', async () => {
const formField = await testingUtils.formField.get();
let errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be less than 10");
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
amountField.validate();
fixture.detectChanges();
errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
it('should render updated parameters after sibling field revalidation', async () => {
const siblingField = new FormFieldModel(form, {
id: 'sibling-id',
type: FormFieldTypes.TEXT,
value: 'before'
});
form.fieldsCache = [amountField, siblingField];
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
siblingField.value = 'after';
form.onFormFieldChanged(siblingField);
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -25,6 +25,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.subscribeToFieldChanges();
this.setInitialValues();
this.initErrorStateMatcher();
this.updateTranslateParameters();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
}
@@ -143,7 +146,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
}
}
this.markAsTouched();
this.updateTranslateParameters();
}
amountWidgetOnFocus(): void {
@@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field);
this.markAsTouched();
this.updateTranslateParameters();
}
setInitialValues(): void {
@@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value;
}
this.updateTranslateParameters();
});
}
@@ -208,12 +208,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field?.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -16,10 +16,9 @@
<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[error]="field.validationSummary"
[required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
</div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common';
import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf],
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -17,24 +17,36 @@
export class ErrorMessageModel {
message: string = '';
attributes: Map<string, string> = null;
attributes: Map<string, string> = new Map();
constructor(obj?: any) {
this.message = obj?.message || '';
this.attributes = obj?.attributes || new Map();
if (obj?.attributes) {
this.attributes = obj.attributes;
}
}
isActive(): boolean {
return !!this.message;
}
getAttributesAsJsonObj() {
const result = {};
getAttributesAsJsonObj(): Record<string, string> {
const result: Record<string, string> = {};
if (this.attributes.size > 0) {
this.attributes.forEach((value, key) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
});
}
return result;
}
}
export const getValidationSummaryTranslationParameters = (validationSummary?: ErrorMessageModel): Record<string, string> => {
if (validationSummary?.isActive()) {
return validationSummary.getAttributesAsJsonObj();
}
return {};
};
@@ -17,9 +17,10 @@
import { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event';
import { firstValueFrom, map, Subject, take, timeout } from 'rxjs';
import { firstValueFrom, map, skip, Subject, take, timeout } from 'rxjs';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from './error-message.model';
import { FormFieldTypes } from './form-field-types';
import { RequiredFieldValidator } from './form-field-validator';
import { MinValueFieldValidator, RequiredFieldValidator } from './form-field-validator';
import { FormFieldModel } from './form-field.model';
import { FormModel } from './form.model';
@@ -36,6 +37,58 @@ describe('FormFieldModel', () => {
expect(model.json).toBe(json);
});
it('should return an isolated authored value snapshot', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const model = new FormFieldModel(new FormModel(), { id: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue });
const snapshot = model.authoredValue as typeof authoredValue;
snapshot.blocks[0].data.text = 'changed';
expect((model.authoredValue as typeof authoredValue).blocks[0].data.text).toBe('${field.name}');
expect(authoredValue.blocks[0].data.text).toBe('${field.name}');
});
it('should not capture authored values for other field types', () => {
const model = new FormFieldModel(new FormModel(), { id: 'json', type: FormFieldTypes.JSON, value: { content: 'value' } });
expect(model.authoredValue).toBeUndefined();
});
it('should return undefined for authored values that cannot be cloned', () => {
const circularValue: { self?: unknown } = {};
circularValue.self = circularValue;
const circularValueModel = new FormFieldModel(new FormModel(), {
id: 'circular',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: circularValue
});
const bigintValueModel = new FormFieldModel(new FormModel(), {
id: 'bigint',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: BigInt(1)
});
expect(circularValueModel.authoredValue).toBeUndefined();
expect(bigintValueModel.authoredValue).toBeUndefined();
});
it('should preserve authored value when form data overrides the field value', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const savedValue = { blocks: [{ data: { text: 'John' } }] };
const form = new FormModel(
{
fields: [{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }]
},
{ richText: savedValue }
);
const model = form.getFieldById('richText');
expect(model.value).toEqual(savedValue);
expect(model.authoredValue).toEqual(authoredValue);
expect(model.authoredValue).not.toBe(authoredValue);
});
it('should setup with json config', () => {
const json = {
fieldType: '<fieldType>',
@@ -1222,6 +1275,57 @@ describe('FormFieldModel', () => {
});
});
describe('validation summary changes', () => {
const createField = (): FormFieldModel => {
const form = new FormModel();
form.fieldValidators = [new MinValueFieldValidator()];
return new FormFieldModel(form, {
id: 'number-field',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: '10'
});
};
it('should replay an inactive validation summary before the first validation', async () => {
const field = new FormFieldModel(new FormModel());
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary).toEqual(jasmine.any(ErrorMessageModel));
expect(validationSummary.isActive()).toBe(false);
});
it('should replay the completed validation summary when subscribing after validation', async () => {
const field = createField();
field.validate();
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(validationSummary.attributes.get('minValue')).toBe('10');
});
it('should emit completed summaries when validation state changes', async () => {
const field = createField();
const invalidSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const invalidSummary = await invalidSummaryPromise;
field.value = 10;
const validSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const validSummary = await validSummaryPromise;
expect(invalidSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(invalidSummary.attributes.get('minValue')).toBe('10');
expect(validSummary.isActive()).toBe(false);
expect(validSummary.attributes.size).toBe(0);
});
});
it('should fail validation for readOnly required display-external-property field with null value', () => {
const form = new FormModel();
const field = new FormFieldModel(form, {
@@ -2056,3 +2160,35 @@ describe('FormFieldTypes', () => {
});
});
});
describe('ErrorMessageModel', () => {
it('should initialize empty attributes when attributes are omitted', () => {
const errorMessage = new ErrorMessageModel();
expect(errorMessage.attributes).toEqual(new Map());
});
it('should retain provided attributes', () => {
const attributes = new Map([['minValue', '10']]);
const errorMessage = new ErrorMessageModel({ attributes });
expect(errorMessage.attributes).toBe(attributes);
});
});
describe('getValidationSummaryTranslationParameters', () => {
it('should return validation attributes when the summary is active', () => {
const validationSummary = new ErrorMessageModel({
message: 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN',
attributes: new Map([['minValue', '10']])
});
expect(getValidationSummaryTranslationParameters(validationSummary)).toEqual({ minValue: '10' });
});
it('should return empty parameters when the summary is inactive or omitted', () => {
expect(getValidationSummaryTranslationParameters(new ErrorMessageModel())).toEqual({});
expect(getValidationSummaryTranslationParameters()).toEqual({});
});
});
@@ -29,6 +29,7 @@ import { VariableConfig } from './form-field-variable-options';
import { DataColumn } from '../../../../datatable/data/data-column.model';
import { DateFnsUtils } from '../../../../common';
import { isValid as isValidDate } from 'date-fns';
import { Observable, ReplaySubject } from 'rxjs';
import { ContainerRowModel } from './container-row.model';
import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model';
import { formFieldRuleHandler } from './handlers/form-field-rule.handler';
@@ -38,12 +39,35 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable';
export type FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal';
interface ValidationSummaryChangesState {
subject: ReplaySubject<ErrorMessageModel>;
observable: Observable<ErrorMessageModel>;
}
const validationSummaryChangesByField = new WeakMap<FormFieldModel, ValidationSummaryChangesState>();
const isJsonPrimitive = (value: unknown): value is null | string | number | boolean =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
const cloneJsonCompatibleValue = (value: unknown): unknown => {
if (value === undefined || isJsonPrimitive(value)) {
return value;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
};
// Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel {
private _value: string;
private _readOnly: boolean = false;
private _isValid: boolean = true;
private _required: boolean = false;
private readonly _authoredValue: unknown;
readonly defaultDateFormat: string = 'D-M-YYYY';
readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A';
@@ -110,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel {
// util members
emptyOption: FormFieldOption;
validationSummary: ErrorMessageModel;
validationSummary: ErrorMessageModel = new ErrorMessageModel();
get validationSummaryChanges$(): Observable<ErrorMessageModel> {
const existingState = validationSummaryChangesByField.get(this);
if (existingState) {
return existingState.observable;
}
const subject = new ReplaySubject<ErrorMessageModel>(1);
const observable = subject.asObservable();
validationSummaryChangesByField.set(this, { subject, observable });
subject.next(this.validationSummary);
return observable;
}
get value(): any {
return this._value;
@@ -123,6 +161,10 @@ export class FormFieldModel extends FormWidgetModel {
}
}
get authoredValue(): unknown {
return cloneJsonCompatibleValue(this._authoredValue);
}
get readOnly(): boolean {
if (this.form?.readOnly) {
return true;
@@ -173,16 +215,19 @@ export class FormFieldModel extends FormWidgetModel {
for (const validator of validators) {
if (!validator.validate(this)) {
this._isValid = false;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
}
this._isValid = true;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
super(form, json);
this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined;
if (json) {
this.fieldType = json.fieldType;
this.id = this.getId(json.id, parent);
@@ -221,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel {
this.enableFractions = json.enableFractions;
this.currency = json.currency;
this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json);
this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip || '';
this.selectionType = json.selectionType;
this.alignmentType = json.alignmentType;
@@ -10,6 +10,7 @@
</div>
<div class="adf-date-time-widget-container">
<mat-form-field class="adf-date-time-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
[floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) {
@@ -38,11 +39,12 @@
[timeInterval]="5"
[disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -1,7 +1,7 @@
<div class="{{ field.className }} date-widget-container" id="data-widget"
[class.adf-invalid]="dateInputControl.invalid && dateInputControl.touched"
[class.adf-readonly]="field.readOnly">
<mat-form-field class="adf-date-widget adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-date-widget adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
<mat-label class="adf-label"
[id]="field.id + '-label'"
[attr.for]="field.id">
@@ -22,11 +22,12 @@
[startAt]="startAt"
[disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
@@ -9,7 +9,7 @@
</div>
<div class="adf-decimal-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> }
<input matInput
class="adf-input"
@@ -25,12 +25,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()" />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormService } from '../../../services/form.service';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { DecimalWidgetComponent } from './decimal.component';
import { TranslateService } from '@ngx-translate/core';
describe('DecimalComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
INVALID_DECIMAL_PRECISION: 'Precision {{ precision }}'
}
}
}
};
let loader: HarnessLoader;
let widget: DecimalWidgetComponent;
let fixture: ComponentFixture<DecimalWidgetComponent>;
@@ -107,6 +119,62 @@ describe('DecimalComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'decimal-id',
type: FormFieldTypes.DECIMAL,
value: 1,
minValue: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render decimal precision when programmatic revalidation fails', async () => {
field.value = 1.234;
field.minValue = '1';
field.precision = 2;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Precision 2');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -16,13 +16,15 @@
*/
import { NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component';
export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {};
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onDecimalFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -10,12 +10,16 @@
}
}
error-widget {
display: block;
}
.adf-error {
display: flex;
align-items: center;
&-widget-container {
height: auto;
height: 40px;
}
&-animate {
@@ -1,6 +1,6 @@
.adf-hyperlink-widget {
padding: 0.4375em 0;
border-top: 0.8438em solid transparent;
margin-bottom: 40px;
a {
color: var(--mat-sys-primary);
@@ -7,6 +7,7 @@
<mat-form-field
floatPlaceholder="never"
class="adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-has-counter]="field.maxLength > 0"
[floatLabel]="field.placeholder ? 'always' : null"
>
@@ -31,14 +32,16 @@
>
</textarea>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
@if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>}
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
} @else if (field.maxLength > 0) {
<mat-hint class="adf-multiline-hint">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
<mat-hint class="adf-multiline-hint adf-form-field-status-slot">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
} @else {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
</div>
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon;
}
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
margin-bottom: 44px;
.adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 20px;
}
@@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('MultilineTextWidgetComponentComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: MultilineTextWidgetComponentComponent;
let fixture: ComponentFixture<MultilineTextWidgetComponentComponent>;
@@ -109,6 +120,48 @@ describe('MultilineTextWidgetComponentComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'multiline-text-id',
type: FormFieldTypes.MULTILINE_TEXT,
value: 'text',
minLength: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when is required', () => {
beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
@@ -27,6 +27,7 @@ import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { isObservable } from 'rxjs';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
if (this.enableCustomMessage != null) {
if (isObservable(this.enableCustomMessage)) {
this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => {
@@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onMultilineTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -9,7 +9,7 @@
</label>
</div>
<div class="adf-number-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
@@ -30,12 +30,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()">
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { NumberWidgetComponent } from './number.widget';
import { DecimalNumberPipe } from '../../../../pipes';
import { TranslateService } from '@ngx-translate/core';
describe('NumberWidgetComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
NO_LONGER_THAN: 'Maximum length {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: NumberWidgetComponent;
let fixture: ComponentFixture<NumberWidgetComponent>;
@@ -175,6 +187,60 @@ describe('NumberWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'number-id',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render the maximum length when programmatic revalidation fails', async () => {
field.value = 12345678901;
field.minValue = '1';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum length 10');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -18,7 +18,8 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common';
import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { DecimalNumberPipe } from '../../../../pipes';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
translateParameters: Record<string, string> = {};
private readonly decimalNumberPipe = inject(DecimalNumberPipe);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
if (this.field.readOnly) {
@@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
this.displayValue = this.field.value;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
protected onNumberChange(value: string) {
@@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
}
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -9,7 +9,8 @@
}
&-row-action {
margin-left: 10px;
margin-inline-start: 10px;
margin-block-end: 35px;
}
&-row-limit {
@@ -8,7 +8,11 @@
</label>
</div>
<div class="adf-text-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || this.field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
</mat-label>
@@ -30,7 +34,7 @@
(paste)="onPaste($event)"
(blur)="onBlur()">
@if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<span class="adf-error-text">
@if (maxLengthPasteError.isActive()) {
@@ -43,6 +47,9 @@
</span>
</mat-error>
}
@if (!fieldStatusTemplate) {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
<ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" />
<ng-template #maxLengthPasteErrorTemplate>
@@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('TextWidgetComponent', () => {
const form = new FormModel({ taskId: 'fake-task-id' });
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: TextWidgetComponent;
@@ -64,6 +75,47 @@ describe('TextWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(form, {
id: 'text-id',
type: FormFieldTypes.TEXT,
value: 'text',
minLength: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when template is ready', () => {
describe('and no mask is configured on text element', () => {
it('should raise ngModelChange event', async () => {
@@ -19,6 +19,7 @@
import { NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { WidgetComponent } from '../widget.component';
import { ErrorMessageModel } from '../core/error-message.model';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { FormattableTextWidgetComponent } from '../core/formattable-text.widget';
import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator';
import { InputMaskDirective } from './text-mask.component';
@@ -97,6 +98,9 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
this.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onPaste(event: ClipboardEvent): void {
@@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -143,14 +145,6 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number {
const value = input.value ?? '';
const selectionStart = input.selectionStart ?? value.length;
@@ -16,6 +16,7 @@
*/
import { Subject } from 'rxjs';
import { FormEvent } from '../events/form.event';
import { FormFieldEvent } from '../events/form-field.event';
import { FormRulesEvent } from '../events/form-rules.event';
import { ValidateFormFieldEvent } from '../events/validate-form-field.event';
@@ -26,4 +27,5 @@ export interface FormValidationService {
validateForm: Subject<ValidateFormEvent>;
validateFormField: Subject<ValidateFormFieldEvent>;
formRulesEvent?: Subject<FormRulesEvent>;
formVisibilityRefreshed?: Subject<FormEvent>;
}
@@ -62,6 +62,12 @@ export class FormService implements FormValidationService {
formRulesEvent = new Subject<FormRulesEvent>();
/**
* Emitted after form field/outcome visibility has been re-evaluated via WidgetVisibilityService.refreshVisibility.
* Internal ADF form-rendering event — not part of the FormValidationService contract.
*/
formVisibilityRefreshed = new Subject<FormEvent>();
constructor() {
const injectedFieldValidators = inject(FORM_SERVICE_FIELD_VALIDATORS_TOKEN, { optional: true });
@@ -19,6 +19,7 @@ import { TestBed } from '@angular/core/testing';
import { ContainerModel, FormFieldModel, FormFieldTypes, FormModel, TabModel } from '../components/widgets/core';
import { WidgetVisibilityModel } from '../models/widget-visibility.model';
import { WidgetVisibilityService } from './widget-visibility.service';
import { FormService } from './form.service';
import {
fakeFormJson,
formTest,
@@ -50,6 +51,30 @@ describe('WidgetVisibilityService', () => {
service = TestBed.inject(WidgetVisibilityService);
});
it('should emit formVisibilityRefreshed when visibility is refreshed', () => {
const formService = TestBed.inject(FormService);
let emittedForm: FormModel | undefined;
formService.formVisibilityRefreshed.subscribe((event) => {
emittedForm = event.form;
});
service.refreshVisibility(stubFormWithFields);
expect(emittedForm).toBe(stubFormWithFields);
});
it('should not emit formVisibilityRefreshed when form is null', () => {
const formService = TestBed.inject(FormService);
let emitCount = 0;
formService.formVisibilityRefreshed.subscribe(() => emitCount++);
service.refreshVisibility(null);
expect(emitCount).toBe(0);
});
describe('should be able to evaluate next condition operations', () => {
it('using == and return true', () => {
const resultsArray = evaluateConditions(
@@ -15,16 +15,20 @@
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { FormFieldModel, FormModel, TabModel, ContainerModel, FormOutcomeModel } from '../components/widgets/core';
import { FormEvent } from '../events/form.event';
import { TaskProcessVariableModel } from '../models/task-process-variable.model';
import { WidgetVisibilityModel, WidgetTypeEnum } from '../models/widget-visibility.model';
import { format, isValid, parse } from 'date-fns';
import { FormService } from './form.service';
@Injectable({
providedIn: 'root'
})
export class WidgetVisibilityService {
private readonly formService = inject(FormService);
private processVarList: TaskProcessVariableModel[];
private form: FormModel;
@@ -45,6 +49,8 @@ export class WidgetVisibilityService {
}
form.getFormFields().map((field) => this.refreshEntityVisibility(field));
this.formService.formVisibilityRefreshed.next(new FormEvent(form));
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/extensions'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+2 -2
View File
@@ -12,8 +12,8 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
},
"peerDependencies": {
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0"
},
"keywords": [
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/insights'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+4 -4
View File
@@ -16,10 +16,10 @@
"raphael": ">=2.3.0"
},
"peerDependencies": {
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0",
@@ -0,0 +1,148 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Chart } from './chart.model';
describe('Chart Model', () => {
describe('constructor', () => {
it('should create with default values when no argument is provided', () => {
const chart = new Chart();
expect(chart.labels).toEqual([]);
expect(chart.data).toEqual([]);
expect(chart.datasets).toEqual([]);
expect(chart.showDetails).toBe(false);
});
it('should populate properties from input object', () => {
const chart = new Chart({
id: '1',
title: 'Test Chart',
titleKey: 'KEY',
labels: ['a', 'b'],
data: [1, 2],
datasets: [{ data: [1] }],
showDetails: true,
detailsTable: { key: 'value' },
options: { responsive: true }
});
expect(chart.id).toBe('1');
expect(chart.title).toBe('Test Chart');
expect(chart.titleKey).toBe('KEY');
expect(chart.labels).toEqual(['a', 'b']);
expect(chart.data).toEqual([1, 2]);
expect(chart.datasets).toEqual([{ data: [1] }]);
expect(chart.showDetails).toBe(true);
expect(chart.detailsTable).toEqual({ key: 'value' });
expect(chart.options).toEqual({ responsive: true });
});
it('should convert type and set icon for pieChart', () => {
const chart = new Chart({ type: 'pieChart' });
expect(chart.type).toBe('pie');
expect(chart.icon).toBe('pie_chart');
});
it('should convert type and set icon for barChart', () => {
const chart = new Chart({ type: 'barChart' });
expect(chart.type).toBe('bar');
expect(chart.icon).toBe('equalizer');
});
it('should convert type and set icon for line', () => {
const chart = new Chart({ type: 'line' });
expect(chart.type).toBe('line');
expect(chart.icon).toBe('show_chart');
});
it('should convert type and set icon for table', () => {
const chart = new Chart({ type: 'table' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
it('should convert type and set icon for multiBarChart', () => {
const chart = new Chart({ type: 'multiBarChart' });
expect(chart.type).toBe('multiBar');
expect(chart.icon).toBe('poll');
});
it('should convert type and set icon for processDefinitionHeatMap', () => {
const chart = new Chart({ type: 'processDefinitionHeatMap' });
expect(chart.type).toBe('HeatMap');
expect(chart.icon).toBe('share');
});
it('should convert type and set icon for masterDetailTable', () => {
const chart = new Chart({ type: 'masterDetailTable' });
expect(chart.type).toBe('masterDetailTable');
expect(chart.icon).toBe('subtitles');
});
it('should default to table type for unknown types', () => {
const chart = new Chart({ type: 'unknown' });
expect(chart.type).toBe('table');
expect(chart.icon).toBe('web');
});
});
describe('hasData', () => {
it('should return true when data is not empty', () => {
const chart = new Chart({ data: [1, 2, 3] });
expect(chart.hasData()).toBe(true);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasData()).toBe(false);
});
it('should return false when no data is provided', () => {
const chart = new Chart();
expect(chart.hasData()).toBe(false);
});
});
describe('hasDatasets', () => {
it('should return true when datasets is not empty', () => {
const chart = new Chart({ datasets: [{ data: [1] }] });
expect(chart.hasDatasets()).toBe(true);
});
it('should return false when datasets is empty', () => {
const chart = new Chart({ datasets: [] });
expect(chart.hasDatasets()).toBe(false);
});
});
describe('hasZeroValues', () => {
it('should return true when all data values are zero', () => {
const chart = new Chart({ data: [0, 0, 0] });
expect(chart.hasZeroValues()).toBe(true);
});
it('should return false when at least one value is non-zero', () => {
const chart = new Chart({ data: [0, 1, 0] });
expect(chart.hasZeroValues()).toBe(false);
});
it('should return false when data is empty', () => {
const chart = new Chart({ data: [] });
expect(chart.hasZeroValues()).toBe(false);
});
});
});
@@ -1033,4 +1033,63 @@ export class NodesApi extends BaseApi {
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 |
| [updateNode](#updateNode) | **PUT** /nodes/{nodeId} | Update a node |
| [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 |
| [cancelCheckoutNode](#cancelCheckoutNode) | **POST** /nodes/{nodeId}/cancel-checkout | Cancel checkout of a node |
| [checkoutNode](#checkoutNode) | **POST** /nodes/{nodeId}/checkout | Checkout a node |
## 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
## 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', () => {
it('should return jobId on initiateFolderSizeCalculation API call if everything is ok', async () => {
nodeMock.post200ResponseInitiateFolderSizeCalculation();
@@ -16,6 +16,7 @@
*/
import { BaseMock } from '../base.mock';
import { NodeEntry } from '../../../src';
export class NodeMock extends BaseMock {
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: {
dir: join(__dirname, '../../coverage/process-services-cloud'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@alfresco/adf-core": ">=9.0.0",
"@alfresco/adf-content-services": ">=9.0.0",
@@ -17,6 +17,7 @@
import { VersionCompatibilityService, AlfrescoApiService } from '@alfresco/adf-content-services';
import {
ADF_DISPLAY_TEXT_SETTINGS,
ContentLinkModel,
CoreModule,
FormFieldModel,
@@ -31,6 +32,8 @@ import {
provideTranslations,
AuthModule,
FormFieldEvent,
FormEvent,
FormRulesEvent,
NoopTranslateModule,
NoopAuthModule,
FORM_FIELD_VALIDATORS
@@ -117,7 +120,8 @@ describe('FormCloudComponent', () => {
useValue: {}
},
{ provide: FormRenderingService, useClass: CloudFormRenderingService },
{ provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] }
{ provide: FORM_CLOUD_FIELD_VALIDATORS_TOKEN, useValue: [fakeValidator] },
{ provide: ADF_DISPLAY_TEXT_SETTINGS, useValue: { enableExpressionEvaluation: true } }
]
});
const apiService = TestBed.inject(AlfrescoApiService);
@@ -874,6 +878,39 @@ describe('FormCloudComponent', () => {
expect(savedForm).toEqual(formModel);
});
it('should materialize unrendered rich text expressions when saving a task form', () => {
spyOn(formCloudService, 'saveTaskForm').and.returnValue(of(undefined));
const formModel = new FormModel({
id: '23',
taskId: '123-223',
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const originalValues = JSON.parse(JSON.stringify(formModel.values));
formComponent.form = formModel;
formComponent.taskId = formModel.taskId;
formComponent.appName = 'test-app';
formComponent.saveTaskForm();
expect(formCloudService.saveTaskForm).toHaveBeenCalledWith(
'test-app',
formModel.taskId,
undefined,
formModel.id,
jasmine.objectContaining({
richText: { blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }] }
})
);
expect(formModel.values).toEqual(originalValues);
});
it('should handle error during form save', () => {
const error = 'Error';
spyOn(formCloudService, 'saveTaskForm').and.callFake(() => throwError(error));
@@ -981,6 +1018,39 @@ describe('FormCloudComponent', () => {
expect(completedForm).toBe(formComponent.form);
});
it('should materialize unrendered rich text expressions when completing a task form', () => {
spyOn(formCloudService, 'completeTaskForm').and.returnValue(of(undefined));
const formModel = new FormModel({
id: '23',
taskId: '123-223',
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
formComponent.form = formModel;
formComponent.taskId = formModel.taskId;
formComponent.appName = 'test-app';
formComponent.completeTaskForm('complete');
expect(formCloudService.completeTaskForm).toHaveBeenCalledWith(
'test-app',
formModel.taskId,
undefined,
formModel.id,
jasmine.objectContaining({
richText: { blocks: [{ type: 'paragraph', data: { text: 'John' } }] }
}),
'complete',
undefined
);
});
it('should open confirmation dialog on complete task', async () => {
formComponent.form = new FormModel({
confirmMessage: {
@@ -1231,6 +1301,38 @@ describe('FormCloudComponent', () => {
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when form visibility is refreshed', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formVisibilityRefreshed.next(new FormEvent(formModel));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should recompute visibleOutcomes when fieldValueChanged rule event fires', () => {
formComponent.showCompleteButton = true;
const formModel = new FormModel(cloudFormMock);
formComponent.form = formModel;
expect(formComponent.visibleOutcomes.length).toBeGreaterThan(0);
formModel.outcomes.forEach((outcome) => {
outcome.isVisible = false;
});
TestBed.inject(FormService).formRulesEvent.next(new FormRulesEvent('fieldValueChanged', new FormEvent(formModel)));
expect(formComponent.visibleOutcomes).toEqual([]);
});
it('should raise [executeOutcome] event for formService', async () => {
spyOn(formComponent.executeOutcome, 'emit');
@@ -30,14 +30,17 @@ import {
SimpleChanges,
ViewChild
} from '@angular/core';
import { forkJoin, isObservable, Observable, of, Subscription } from 'rxjs';
import { forkJoin, isObservable, merge, Observable, of, Subscription } from 'rxjs';
import { filter, map, switchMap } from 'rxjs/operators';
import {
ConfirmDialogComponent,
ContentLinkModel,
ADF_DISPLAY_TEXT_SETTINGS,
DisplayTextWidgetSettings,
FormatSpacePipe,
FormBaseComponent,
FormEvent,
FormExpressionService,
FormFieldModel,
FormRulesEvent,
FormFieldValidator,
@@ -67,6 +70,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { A11yModule } from '@angular/cdk/a11y';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from '../services/form-cloud-submission-values';
interface FormFieldRuntimeState {
value: any;
@@ -228,6 +232,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
protected changeDetector = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly expressions = inject(FormExpressionService);
private enableExpressionEvaluation = false;
private get currentForm(): FormModel | undefined {
return super.form;
@@ -252,6 +258,9 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
constructor() {
const injectedFieldValidators = inject(FORM_CLOUD_FIELD_VALIDATORS_TOKEN, { optional: true });
const tabNavEnabledToken = inject(ADF_FORM_TAB_NAV_ENABLED, { optional: true });
const displayTextSettings = inject<Observable<DisplayTextWidgetSettings> | DisplayTextWidgetSettings>(ADF_DISPLAY_TEXT_SETTINGS, {
optional: true
});
super();
this.loadInjectedFieldValidators(injectedFieldValidators);
@@ -270,6 +279,12 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
}
getExpressionEvaluationEnabled$(displayTextSettings)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((enabled) => {
this.enableExpressionEvaluation = enabled;
});
this.formService.formContentClicked.pipe(takeUntilDestroyed()).subscribe((content) => {
if (content instanceof UploadWidgetContentLinkModel) {
this.form.setNodeIdValueForViewersLinkedToUploadWidget(content);
@@ -291,11 +306,11 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
});
this.formService.formRulesEvent
.pipe(
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id),
takeUntilDestroyed()
)
merge(
this.formService.formVisibilityRefreshed.pipe(filter((event) => event.form?.id === this.form?.id)),
this.formService.formRulesEvent.pipe(filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.form?.id))
)
.pipe(takeUntilDestroyed())
.subscribe(() => this.recomputeVisibleOutcomes());
}
@@ -482,7 +497,7 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
saveTaskForm() {
if (this.form && this.appName && this.taskId) {
this.formCloudService
.saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values)
.saveTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.getSubmissionValues())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
@@ -523,7 +538,15 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
private completeForm(outcome?: string, outcomeId?: string) {
if (this.form && this.appName && this.taskId) {
this.formCloudService
.completeTaskForm(this.appName, this.taskId, this.processInstanceId, `${this.form.id}`, this.form.values, outcome, this.appVersion)
.completeTaskForm(
this.appName,
this.taskId,
this.processInstanceId,
`${this.form.id}`,
this.getSubmissionValues(),
outcome,
this.appVersion
)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
@@ -536,6 +559,10 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
}
}
private getSubmissionValues(): FormValues {
return materializeSubmissionValues(this.form, { enableExpressionEvaluation: this.enableExpressionEvaluation }, this.expressions);
}
parseForm(formCloudRepresentationJSON?: any): FormModel | null {
if (formCloudRepresentationJSON) {
const formValues: FormValues = {};
@@ -568,7 +595,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
checkVisibility(field: FormFieldModel) {
if (field?.form) {
this.visibilityService.refreshVisibility(field.form);
this.recomputeVisibleOutcomes();
}
}
@@ -586,7 +612,6 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.setCheckParentVisibilityForValidationOnFields();
this.visibilityService.refreshVisibility(this.form);
this.form.validateForm();
this.recomputeVisibleOutcomes();
this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form);
@@ -1,21 +1,26 @@
<div class="adf-attach-file-widget-container">
<div class="adf-attach-widget {{field.className}}"
[class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'">{{field.name}}
<span class="adf-asterisk" *ngIf="isRequired()">*</span>
<div class="adf-attach-widget {{ field.className }}" [class.adf-readonly]="field.readOnly">
<label class="adf-label" [attr.for]="field.id + '-label'"
>{{ field.name }}
@if (isRequired()) {
<span class="adf-asterisk">*</span>
}
</label>
<div class="adf-attach-widget-container" (focusout)="markAsTouched()">
<div class="adf-attach-widget__menu-upload" *ngIf="isUploadButtonVisible()">
<button
(click)="openSelectDialog()"
mat-raised-button
class="adf-attach-widget__menu-upload__button"
[id]="field.id"
[title]="field.tooltip">
@if (isUploadButtonVisible()) {
<div class="adf-attach-widget__menu-upload">
<button
(click)="openSelectDialog()"
mat-raised-button
class="adf-attach-widget__menu-upload__button"
[id]="field.id"
[title]="field.tooltip"
>
{{ 'FORM.FIELD.ATTACH' | translate }}
<mat-icon class="adf-attach-widget__menu-upload__button__icon" [adf-icon]="getWidgetIcon()" />
</button>
</div>
</button>
</div>
}
</div>
</div>
@@ -34,12 +39,15 @@
(contentModelFileHandler)="contentModelFormFileHandler($event)"
(removeAttachFile)="onRemoveAttachFile($event)"
/>
<div *ngIf="!hasFile && field.readOnly" id="{{'adf-attach-empty-list-'+field.id}}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
@if (!hasFile && field.readOnly) {
<div id="{{ 'adf-attach-empty-list-' + field.id }}">
{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}
</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="!field.isValid && isTouched() && !isSelected()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget
[error]="field.validationSummary"
[required]="!field.isValid && isTouched() && !isSelected() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
@@ -1,31 +1,23 @@
<div class="adf-data-table-widget-container">
<div class="adf-data-table-widget-label">
<label
class="adf-label"
[class.adf-left-label]="field.leftLabels"
[attr.for]="field.id">
{{field.name | translate }}
</label>
<label class="adf-label" [class.adf-left-label]="field.leftLabels" [attr.for]="field.id"> {{field.name | translate }} </label>
</div>
<ng-container *ngIf="!previewState; else previewTemplate">
@if (!previewState) {
<adf-datatable data-automation-id="adf-data-table-widget" [data]="dataSource">
<adf-no-content-template>
<ng-template>
<adf-empty-content
icon="border_all"
[title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
<adf-empty-content icon="border_all" [title]="'FORM.FIELD.DATA_TABLE_EMPTY_CONTENT' | translate" />
</ng-template>
</adf-no-content-template>
</adf-datatable>
<error-widget *ngIf="dataTableLoadFailed"
<error-widget
class="adf-data-table-widget-failed-message"
[required]="'FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate" />
</ng-container>
<ng-template #previewTemplate>
[required]="dataTableLoadFailed ? ('FORM.FIELD.DATA_TABLE_LOAD_FAILED' | translate) : ''"
/>
} @else {
<adf-datatable data-automation-id="adf-data-table-widget-preview" />
<div class="adf-preview-placeholder"></div>
</ng-template>
}
</div>
@@ -1,10 +1,12 @@
.adf-data-table-widget-failed-message {
margin: 10px;
display: block;
}
.adf-preview-placeholder {
height: 100%;
width: 100%;
min-height: 100px;
margin-bottom: 10px;
.adf-data-table-widget-container {
.adf-preview-placeholder {
height: 100%;
width: 100%;
min-height: 100px;
margin-bottom: 10px;
}
}
@@ -283,7 +283,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, []);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
it('path points to single object with appropriate schema definition', () => {
@@ -294,7 +295,8 @@ describe('DataTableWidgetComponent', () => {
const failedErrorMsgElement = fixture.debugElement.query(By.css('.adf-data-table-widget-failed-message'));
assertData(mockCountryColumns, [mockEuropeCountriesRows[1]]);
expect(failedErrorMsgElement).toBeNull();
expect(failedErrorMsgElement).toBeTruthy();
expect(failedErrorMsgElement.nativeElement.textContent.trim()).toBe('');
});
});
@@ -27,7 +27,6 @@ import {
NoContentTemplateDirective,
EmptyContentComponent
} from '@alfresco/adf-core';
import { NgIf } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { FormCloudService } from '../../../services/form-cloud.service';
import { TaskVariableCloud } from '../../../models/task-variable-cloud.model';
@@ -36,7 +35,7 @@ import { DataTablePathParserHelper } from './helpers/data-table-path-parser.help
@Component({
standalone: true,
imports: [NgIf, TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
imports: [TranslatePipe, FormBaseModule, DataTableComponent, NoContentTemplateDirective, EmptyContentComponent],
selector: 'data-table',
templateUrl: './data-table.widget.html',
styleUrls: ['./data-table.widget.scss'],
@@ -15,7 +15,11 @@
>
</div>
<div class="adf-date-widget-container">
<mat-form-field class="adf-date-widget adf-form-field-input" [class.adf-left-label-input-datepicker]="field.leftLabels">
<mat-form-field
class="adf-date-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
>
@if ( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">
{{field.name | translate }} ({{field.dateDisplayFormat}})
@@ -36,12 +40,13 @@
<mat-datepicker-toggle matSuffix [for]="datePicker" [disabled]="field.readOnly" />
<mat-datepicker #datePicker [startAt]="startAt" [disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -11,7 +11,7 @@
</div>
<div>
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id"> {{ field.name | translate }} </mat-label>
}
@@ -32,8 +32,9 @@
</span>
</ng-container>
@if (propertyLoadFailed && !previewState) {
<mat-error><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error>
<mat-error class="adf-form-field-status-slot"><mat-icon class="adf-error-icon">error_outline</mat-icon><span class="adf-error-text">{{ 'FORM.FIELD.EXTERNAL_PROPERTY_LOAD_FAILED' | translate }}</span></mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -200,6 +200,51 @@ describe('DisplayRichTextWidgetComponent', () => {
expect(widget.field.value.blocks[0].data.text).toBe('Hello John');
});
it('should resolve from authored value after saved data rehydrates the field', () => {
const form = new FormModel(
{
fields: [
{
id: 'richText1',
name: 'richText1',
type: 'display-rich-text',
value: {
blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }]
}
},
{ id: 'name', name: 'name', type: 'text', value: 'John' }
]
},
{
richText1: {
blocks: [{ type: 'paragraph', data: { text: 'Hello John' } }]
},
name: 'Jane'
}
);
widget.field = form.getFieldById('richText1');
fixture.detectChanges();
expect(widget.field.value.blocks[0].data.text).toBe('Hello Jane');
});
it('should preserve the current value when the authored value is unavailable', () => {
const form = new FormModel({
fields: [{ id: 'richText1', type: 'display-rich-text' }]
});
const currentValue = {
blocks: [{ type: 'paragraph', data: { text: 'Current value' } }]
};
const field = form.getFieldById('richText1');
field.value = currentValue;
widget.field = field;
fixture.detectChanges();
expect(widget.field.value).toBe(currentValue);
});
it('should resolve expressions in multiple blocks', () => {
const form = new FormModel({
fields: [
@@ -22,6 +22,7 @@ import { BaseDisplayTextWidgetComponent } from '@alfresco/adf-core';
import { DomSanitizer } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { RichTextParserService } from '../../../services/rich-text-parser.service';
import { resolveRichTextExpressions } from './rich-text-expression-resolver';
export const RICH_TEXT_PARSER_TOKEN = new InjectionToken<RichTextParserService>('RichTextParserService', {
factory: () => new RichTextParserService()
@@ -66,7 +67,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
protected storeOriginalValue(): void {
if (this.field) {
this.originalFieldValue = JSON.stringify(this.field.value);
const authoredValue = this.field.authoredValue;
if (authoredValue !== undefined) {
this.originalFieldValue = JSON.stringify(authoredValue);
}
}
}
@@ -75,8 +79,10 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
return;
}
const value = JSON.parse(JSON.stringify(this.field.value));
this.applyExpressionsToBlocks(value);
const authoredValue = this.field.authoredValue;
if (authoredValue !== undefined) {
this.applyExpressionsToBlocks(authoredValue);
}
}
protected reevaluateExpressions(): void {
@@ -89,16 +95,7 @@ export class DisplayRichTextWidgetComponent extends BaseDisplayTextWidgetCompone
}
private applyExpressionsToBlocks(value: any): void {
for (const block of value.blocks) {
if (block.type === 'list') {
for (const item of block.data.items) {
item.content = this.resolveExpressions(item.content, true);
}
} else {
block.data.text = this.resolveExpressions(block.data.text, true);
}
}
this.field.value = value;
this.field.value = resolveRichTextExpressions(value, (content) => this.resolveExpressions(content, true));
}
private parseAndSanitize(): void {
@@ -0,0 +1,137 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolveRichTextExpressions } from './rich-text-expression-resolver';
describe('resolveRichTextExpressions', () => {
const resolve = (value: string) => value.replaceAll('${field.name}', 'John').replaceAll('${variable.status}', 'Active');
it('should resolve supported rich text content without mutating the input', () => {
const value = {
time: 1,
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello ${field.name}',
caption: 'Status: ${variable.status}',
content: [
['Cell ${field.name}'],
{
label: '${variable.status}'
}
]
}
},
{
type: 'list',
data: {
items: [
{
content: '${field.name}',
items: [{ content: '${variable.status}' }]
}
]
}
}
],
version: '2.30.0'
};
const originalValue = JSON.parse(JSON.stringify(value));
const result = resolveRichTextExpressions(value, resolve);
expect(result).toEqual({
time: 1,
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello John',
caption: 'Status: Active',
content: [['Cell John'], { label: 'Active' }]
}
},
{
type: 'list',
data: {
items: [{ content: 'John', items: [{ content: 'Active' }] }]
}
}
],
version: '2.30.0'
});
expect(value).toEqual(originalValue);
expect(result).not.toBe(value);
});
it('should resolve a caller-owned clone without cloning it again', () => {
const value = {
blocks: [{ type: 'paragraph', data: { text: 'Hello ${field.name}' } }]
};
const result = resolveRichTextExpressions(value, resolve, { cloneValue: false }) as typeof value;
expect(result).toBe(value);
expect(result.blocks[0].data.text).toBe('Hello John');
});
it('should preserve unknown blocks and properties', () => {
const value = {
blocks: [
{
type: 'custom',
data: {
label: '${field.name}'
},
metadata: '${variable.status}'
}
]
};
expect(resolveRichTextExpressions(value, resolve)).toEqual(value);
});
it('should not introduce missing content properties', () => {
const result = resolveRichTextExpressions({ blocks: [{ type: 'paragraph', data: {} }] }, resolve) as {
blocks: Array<{ data: Record<string, unknown> }>;
};
expect(result.blocks[0].data).toEqual({});
});
it('should return malformed values unchanged', () => {
const malformedValues = [null, undefined, 'text', [], {}, { blocks: null }];
malformedValues.forEach((value) => {
expect(resolveRichTextExpressions(value, resolve)).toBe(value);
});
});
it('should return non-cloneable values without mutating them', () => {
const value: {
blocks: Array<{ data: { text: string } }>;
self?: unknown;
} = {
blocks: [{ data: { text: '${field.name}' } }]
};
value.self = value;
expect(resolveRichTextExpressions(value, resolve)).toBe(value);
expect(value.blocks[0].data.text).toBe('${field.name}');
});
});
@@ -0,0 +1,111 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
type JsonObject = Record<string, unknown>;
export type RichTextExpressionResolver = (value: string) => string;
export interface RichTextExpressionResolverOptions {
cloneValue?: boolean;
}
const isJsonObject = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value);
const cloneJsonValue = (value: unknown): unknown => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
};
const resolveNestedContent = (value: unknown, resolve: RichTextExpressionResolver): unknown => {
if (typeof value === 'string') {
return resolve(value);
}
if (Array.isArray(value)) {
return value.map((entry) => resolveNestedContent(entry, resolve));
}
if (isJsonObject(value)) {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveNestedContent(entry, resolve)]));
}
return value;
};
const resolveListItems = (items: unknown, resolve: RichTextExpressionResolver): unknown => {
if (!Array.isArray(items)) {
return items;
}
return items.map((item) => {
if (!isJsonObject(item)) {
return item;
}
if (Object.hasOwn(item, 'content')) {
item.content = resolveNestedContent(item.content, resolve);
}
if (Object.hasOwn(item, 'items')) {
item.items = resolveListItems(item.items, resolve);
}
return item;
});
};
export const resolveRichTextExpressions = (
value: unknown,
resolve: RichTextExpressionResolver,
options: RichTextExpressionResolverOptions = {}
): unknown => {
if (!isJsonObject(value) || !Array.isArray(value.blocks)) {
return value;
}
const resolvedValue = options.cloneValue === false ? value : cloneJsonValue(value);
if (!isJsonObject(resolvedValue) || !Array.isArray(resolvedValue.blocks)) {
return value;
}
resolvedValue.blocks.forEach((block) => {
if (!isJsonObject(block) || !isJsonObject(block.data)) {
return;
}
if (typeof block.data.text === 'string') {
block.data.text = resolve(block.data.text);
}
if (typeof block.data.caption === 'string') {
block.data.caption = resolve(block.data.caption);
}
if (Object.hasOwn(block.data, 'content')) {
block.data.content = resolveNestedContent(block.data.content, resolve);
}
if (block.type === 'list' && Object.hasOwn(block.data, 'items')) {
block.data.items = resolveListItems(block.data.items, resolve);
}
});
return resolvedValue;
};
@@ -12,7 +12,7 @@
</div>
}
<div class="adf-dropdown-widget-container">
<mat-form-field class="adf-form-field-input">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic">
@if ( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label>
}
@@ -49,12 +49,13 @@
}
</mat-select>
@if ((dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) || (!previewState && !field.readOnly && (isRestApiFailed || variableOptionsFailed))) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (dropdownControl.hasError('required') && !isRestApiFailed && !variableOptionsFailed) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (isRestApiFailed) {{{ 'FORM.FIELD.REST_API_FAILED' | translate: { hostname: restApiHostName } }}} @else if (variableOptionsFailed) {{{ 'FORM.FIELD.VARIABLE_DROPDOWN_OPTIONS_FAILED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -4,11 +4,13 @@
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels"
>
<div *ngIf="field.leftLabels">
<label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
</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>
<adf-cloud-group
[mode]="mode"
@@ -22,13 +24,7 @@
[preSelectGroups]="preSelectGroup"
(blur)="markAsTouched()"
[attr.title]="field.tooltip"
[label] = "field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[label]="field.name | translate"
/>
</div>
</div>
@@ -141,8 +141,9 @@ describe('GroupCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_GROUPS.ERROR.NOT_FOUND');
});
});
@@ -16,7 +16,7 @@
*/
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -31,7 +31,7 @@ import { GroupCloudComponent } from '../../../../group/components/group-cloud.co
@Component({
selector: 'group-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, GroupCloudComponent],
imports: [CommonModule, TranslatePipe, GroupCloudComponent],
templateUrl: './group-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -1,10 +1,16 @@
<div class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels">
<div *ngIf="field.leftLabels">
<label class="adf-label adf-left-label" [attr.for]="field.id">{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label>
</div>
<div
class="adf-dropdown-widget {{field.className}}"
[class.adf-invalid]="!field.isValid && isTouched()"
[class.adf-readonly]="field.readOnly"
[class.adf-left-label-input-container]="field.leftLabels"
>
@if (field.leftLabels) {
<div>
<label class="adf-label adf-left-label" [attr.for]="field.id"
>{{field.name | translate }}<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span></label
>
</div>
}
<div>
<adf-cloud-people
[preSelectUsers]="preSelectUsers"
@@ -21,11 +27,5 @@
[attr.title]="field.tooltip"
[label]="field.name | translate"
/>
<error-widget [error]="field.validationSummary" />
<error-widget
class="adf-dropdown-required-message"
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
</div>
</div>
@@ -171,8 +171,9 @@ describe('PeopleCloudWidgetComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).toBeTruthy();
expect(element.querySelector('.adf-error-text').textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
const errorMessages = element.querySelectorAll('.adf-error-text');
expect(errorMessages.length).toBe(1);
expect(errorMessages[0].textContent).toContain('ADF_CLOUD_USERS.ERROR.NOT_FOUND');
});
});
@@ -16,7 +16,7 @@
*/
import { Component, DestroyRef, inject, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ErrorWidgetComponent, WidgetComponent } from '@alfresco/adf-core';
import { WidgetComponent } from '@alfresco/adf-core';
import { UntypedFormControl } from '@angular/forms';
import { filter } from 'rxjs/operators';
import { ComponentSelectionMode } from '../../../../types';
@@ -27,13 +27,12 @@ import { ReactivePreselectionService } from '../reactive-preselection.service';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { PeopleCloudComponent } from '../../../../people/components/people-cloud.component';
import { MatFormFieldModule } from '@angular/material/form-field';
/* eslint-disable @angular-eslint/component-selector */
@Component({
selector: 'people-cloud-widget',
imports: [CommonModule, TranslatePipe, ErrorWidgetComponent, PeopleCloudComponent, MatFormFieldModule],
imports: [CommonModule, TranslatePipe, PeopleCloudComponent],
templateUrl: './people-cloud.widget.html',
host: {
'(click)': 'event($event)',
@@ -11,7 +11,6 @@
}
&-radio-button-container-horizontal {
margin-bottom: 15px;
display: flex;
flex-flow: column wrap;
align-items: flex-start;
@@ -46,8 +45,4 @@
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>
<mat-list *ngIf="hasFile">
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img
matListItemLine
class="adf-upload-widget__icon"
[id]="'file-'+file.id+'-icon'"
[src]="getIcon(file.content.mimeType)"
[alt]="mimeTypeIcon"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
/>
<span
class="adf-upload-widget__button adf-file"
matLine
id="{{'file-'+file.id}}"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
>{{file.name}}</span
>
<button
*ngIf="!field.readOnly"
mat-icon-button
[id]="'file-'+file.id+'-remove'"
(click)="removeFile(file);"
(keyup.enter)="removeFile(file);"
>
<mat-icon class="mat-24" adf-icon="highlight_off" />
</button>
</mat-list-item>
</mat-list>
@if (hasFile) {
<mat-list>
<mat-list-item class="adf-upload-files-row" *ngFor="let file of uploadedFiles">
<img
matListItemLine
class="adf-upload-widget__icon"
[id]="'file-'+file.id+'-icon'"
[src]="getIcon(file.content.mimeType)"
[alt]="mimeTypeIcon"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
/>
<span
class="adf-upload-widget__button adf-file"
matLine
id="{{'file-'+file.id}}"
(click)="fileClicked(file)"
(keyup.enter)="fileClicked(file)"
role="button"
tabindex="0"
>{{file.name}}</span
>
@if (!field.readOnly) {
<button mat-icon-button [id]="'file-'+file.id+'-remove'" (click)="removeFile(file);" (keyup.enter)="removeFile(file);">
<mat-icon class="mat-24" adf-icon="highlight_off" />
</button>
}
</mat-list-item>
</mat-list>
}
</div>
<div *ngIf="(!hasFile || multipleOption) && !field.readOnly">
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button>
</div>
<div *ngIf="!hasFile && field.readOnly">{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
@if ((!hasFile || multipleOption) && !field.readOnly) {
<div>
<button mat-raised-button (click)="uploadFiles.click()" [title]="field.tooltip">
{{ 'FORM.FIELD.UPLOAD' | translate }}<mat-icon adf-icon="file_upload" />
<input #uploadFiles [multiple]="multipleOption" type="file" [id]="field.form.nodeId" (change)="onFileChanged($event)" />
</button>
</div>
} @if (!hasFile && field.readOnly) {
<div>{{ 'FORM.FIELD.NO_FILE_ATTACHED' | translate }}</div>
}
</div>
<error-widget [error]="field.validationSummary" />
<error-widget *ngIf="isInvalidFieldRequired()" required="{{ 'FORM.FIELD.REQUIRED' | translate }}" />
<error-widget [error]="field.validationSummary" [required]="isInvalidFieldRequired() ? ('FORM.FIELD.REQUIRED' | translate) : ''" />
</div>
@@ -55,4 +55,12 @@ describe('UploadCloudWidgetComponent', () => {
expect(eventSpy).toHaveBeenCalledWith(clickEvent);
});
});
it('should render one reserved form field status area', () => {
widget.field = new FormFieldModel(new FormModel(), {});
fixture.detectChanges();
const statusAreas = fixture.nativeElement.querySelectorAll('error-widget');
expect(statusAreas.length).toBe(1);
});
});
@@ -43,5 +43,6 @@ export * from './services/form-cloud.service';
export * from './services/content-cloud-node-selector.service';
export * from './services/process-cloud-content.service';
export * from './services/display-mode.service';
export * from './services/form-cloud-submission-values';
export * from './form-cloud.module';
@@ -0,0 +1,185 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { FormExpressionService, FormFieldModel, FormFieldTypes, FormModel } from '@alfresco/adf-core';
import { firstValueFrom, of } from 'rxjs';
import { getExpressionEvaluationEnabled$, materializeSubmissionValues } from './form-cloud-submission-values';
describe('getExpressionEvaluationEnabled$', () => {
it('should return the configured static value', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$({ enableExpressionEvaluation: true }));
expect(enabled).toBe(true);
});
it('should return values emitted by observable settings', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(of({ enableExpressionEvaluation: true })));
expect(enabled).toBe(true);
});
it('should return false when settings are unavailable', async () => {
const enabled = await firstValueFrom(getExpressionEvaluationEnabled$(undefined));
expect(enabled).toBe(false);
});
});
describe('materializeSubmissionValues', () => {
let expressions: FormExpressionService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [FormExpressionService]
});
expressions = TestBed.inject(FormExpressionService);
});
it('should resolve root rich text values from the authored template without mutating the form', () => {
const authoredValue = {
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello ${field.name} - ${variable.status} - ${field.missing} - ${field.unsafe}'
}
}
]
};
const form = new FormModel({
fields: [
{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue },
{ id: 'name', name: 'name', type: FormFieldTypes.TEXT, value: 'John' },
{ id: 'unsafe', name: 'unsafe', type: FormFieldTypes.TEXT, value: '<b>John</b>' }
],
variables: [{ id: 'status', name: 'status', type: 'string', value: 'Active' }]
});
const richTextField = form.getFieldById('richText');
richTextField.value = { blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] };
const originalValues = JSON.parse(JSON.stringify(form.values));
const originalDefinition = JSON.parse(JSON.stringify(form.json));
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(values.richText).toEqual({
blocks: [
{
type: 'paragraph',
data: {
text: 'Hello John - Active - - &lt;b&gt;John&lt;/b&gt;'
}
}
]
});
expect(form.values).toEqual(originalValues);
expect(form.json).toEqual(originalDefinition);
expect(richTextField.value).toEqual({ blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] });
});
it('should produce stable values across repeated materialization', () => {
const form = new FormModel({
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const firstValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
const secondValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(secondValues).toEqual(firstValues);
});
it('should return a shallow clone without resolving expressions when evaluation is disabled', () => {
const form = new FormModel({
fields: [
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
},
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
]
});
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: false }, expressions);
expect(values).toEqual(form.values);
expect(values).not.toBe(form.values);
expect(values.richText).toBe(form.values.richText);
});
it('should isolate materialized repeatable section rows', () => {
const form = new FormModel();
form.values = {
section: [
{ richText: 'saved row one', untouched: 'one' },
{ richText: 'saved row two', untouched: 'two' }
],
name: 'John'
};
const nameField = new FormFieldModel(form, { id: 'name', type: FormFieldTypes.TEXT, value: 'John' });
const firstField = new FormFieldModel(
form,
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'First ${field.name}' } }] }
},
{ id: 'section', uid: 'richText-Row1', fields: {}, rowIndex: 0 }
);
const secondField = new FormFieldModel(
form,
{
id: 'richText',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: { blocks: [{ type: 'paragraph', data: { text: 'Second ${field.name}' } }] }
},
{ id: 'section', uid: 'richText-Row2', fields: {}, rowIndex: 1 }
);
form.fieldsCache = [nameField, firstField, secondField];
form.values.section = [
{ richText: 'saved row one', untouched: 'one' },
{ richText: 'saved row two', untouched: 'two' }
];
const originalSection = form.values.section;
const originalFirstRow = form.values.section[0];
const originalSecondRow = form.values.section[1];
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
expect(values.section).toEqual([
{
richText: { blocks: [{ type: 'paragraph', data: { text: 'First John' } }] },
untouched: 'one'
},
{
richText: { blocks: [{ type: 'paragraph', data: { text: 'Second John' } }] },
untouched: 'two'
}
]);
expect(values.section).not.toBe(originalSection);
expect(values.section[0]).not.toBe(originalFirstRow);
expect(values.section[1]).not.toBe(originalSecondRow);
expect(form.values.section).toBe(originalSection);
});
});
@@ -0,0 +1,80 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DisplayTextWidgetSettings, FormExpressionService, FormFieldTypes, FormModel, FormValues, ROW_ID_PREFIX } from '@alfresco/adf-core';
import { isObservable, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { resolveRichTextExpressions } from '../components/widgets/display-rich-text/rich-text-expression-resolver';
type SubmissionRow = Record<string, unknown>;
const isSubmissionRow = (value: unknown): value is SubmissionRow => typeof value === 'object' && value !== null && !Array.isArray(value);
export interface FormCloudSubmissionValuesOptions {
enableExpressionEvaluation: boolean;
}
export const getExpressionEvaluationEnabled$ = (
settings: Observable<DisplayTextWidgetSettings> | DisplayTextWidgetSettings | null | undefined
): Observable<boolean> =>
isObservable(settings)
? settings.pipe(map((value) => value?.enableExpressionEvaluation ?? false))
: of(settings?.enableExpressionEvaluation ?? false);
export const materializeSubmissionValues = (
form: FormModel,
options: FormCloudSubmissionValuesOptions,
expressions: FormExpressionService
): FormValues => {
const values = { ...form.values };
if (!options.enableExpressionEvaluation) {
return values;
}
for (const field of form.getFormFields([FormFieldTypes.DISPLAY_RICH_TEXT])) {
const { authoredValue, parent } = field;
if (authoredValue === undefined || parent?.isTemplate) {
continue;
}
const materializedValue = resolveRichTextExpressions(authoredValue, (content) => expressions.resolveExpressions(form, content, true), {
cloneValue: false
});
if (!parent) {
values[field.id] = materializedValue;
continue;
}
const sectionValues = values[parent.id];
const sectionRow = Array.isArray(sectionValues) ? sectionValues[parent.rowIndex] : undefined;
if (!isSubmissionRow(sectionRow)) {
continue;
}
const materializedRows = [...sectionValues];
const fieldId = field.id.split(ROW_ID_PREFIX)[0];
materializedRows[parent.rowIndex] = {
...sectionRow,
[fieldId]: materializedValue
};
values[parent.id] = materializedRows;
}
return values;
};
@@ -1,27 +1,37 @@
<form>
<mat-form-field class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) { <mat-label><span>{{label}}</span></mat-label> }
<mat-form-field subscriptSizing="dynamic" class="adf-cloud-group adf-form-field-input" [class.adf-invalid]="hasError() && isDirty()">
@if (label || required) {
<mat-label
><span>{{ label }}</span></mat-label
>
}
<mat-chip-grid [required]="required" [disabled]="isReadonly()" #groupChipList data-automation-id="adf-cloud-group-chip-list">
<mat-chip-row
*ngFor="let group of selectedGroups"
[removable]="!(group.readonly)"
[removable]="!group.readonly"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
(removed)="onRemove(group)"
[disabled]="readOnly || isValidationLoading()"
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}">
{{group.name}}
<mat-icon *ngIf="!(group.readonly || readOnly)" matChipRemove [attr.data-automation-id]="'adf-cloud-group-chip-remove-icon-' + group.name" adf-icon="cancel" />
title="{{ (group.readonly ? 'ADF_CLOUD_GROUPS.MANDATORY' : '') | translate }}"
>
{{ 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>
<input matInput
[formControl]="searchGroupsControl"
[matAutocomplete]="auto"
[matChipInputFor]="groupChipList"
[placeholder]="isReadonly() ? '' : (title | translate)"
[required]="required"
(focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()"
class="adf-group-input"
data-automation-id="adf-cloud-group-search-input" #groupInput>
<input
matInput
[formControl]="searchGroupsControl"
[matAutocomplete]="auto"
[matChipInputFor]="groupChipList"
[placeholder]="isReadonly() ? '' : (title | translate)"
[required]="required"
(focus)="setFocus(true)"
(blur)="setFocus(false); markAsTouched()"
class="adf-group-input"
data-automation-id="adf-cloud-group-search-input"
#groupInput
/>
</mat-chip-grid>
<mat-autocomplete
@@ -30,57 +40,77 @@
class="adf-cloud-group-list"
(optionSelected)="onSelect($event.option.value)"
[displayWith]="getDisplayName"
data-automation-id="adf-cloud-group-autocomplete">
<ng-container *ngIf="(searchGroups$ | async)?.length else noResults">
<mat-option *ngFor="let group of searchGroups$ | async; let i = index" [value]="group"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
class="adf-cloud-group-option-active">
<div
class="adf-cloud-group-row"
id="adf-group-{{i}}"
data-automation-id="adf-cloud-group-row">
<button class="adf-group-short-name" mat-fab>{{getGroupNameInitials(group)}}</button>
<span>{{group.name}}</span>
data-automation-id="adf-cloud-group-autocomplete"
>
@if ((searchGroups$ | async)?.length) {
<mat-option
*ngFor="let group of searchGroups$ | async; let i = index"
[value]="group"
[attr.data-automation-id]="'adf-cloud-group-chip-' + group.name"
class="adf-cloud-group-option-active"
>
<div class="adf-cloud-group-row" id="adf-group-{{ i }}" data-automation-id="adf-cloud-group-row">
<button class="adf-group-short-name" mat-fab>{{ getGroupNameInitials(group) }}</button>
<span>{{ group.name }}</span>
</div>
</mat-option>
</ng-container>
} @else {
<ng-container [ngTemplateOutlet]="noResults" />
}
<ng-template #noResults>
<mat-option *ngIf="searchGroupsControl.hasError('searchTypingError') && !searchLoading" disabled
class="adf-cloud-group-option-not-active"
data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option>
@if (searchGroupsControl.hasError('searchTypingError') && !searchLoading) {
<mat-option disabled class="adf-cloud-group-option-not-active" data-automation-id="adf-cloud-group-no-results">
<span> {{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</span>
</mat-option>
}
</ng-template>
</mat-autocomplete>
</mat-form-field>
<mat-progress-bar *ngIf="validationLoading" mode="indeterminate" />
<div class="adf-error-container adf-error-messages-container">
<mat-error *ngIf="hasPreselectError() && !isValidationLoading()" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('pattern')" class="adf-error">
<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>
@if (validationLoading) {
<mat-progress-bar mode="indeterminate" />
}
@if (hasPreselectError() && !isValidationLoading()) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_GROUPS.ERROR.NOT_FOUND' | translate }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('maxlength')" class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}</div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('minlength')" 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>
<mat-error *ngIf="(searchGroupsControl.hasError('required') || groupChipsCtrl.hasError('required')) && isDirty()"
class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.REQUIRED' | translate }} </div>
</mat-error>
<mat-error *ngIf="searchGroupsControl.hasError('searchTypingError') && !this.isFocused"
data-automation-id="invalid-groups-typing-error" class="adf-error">
<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>
}
@if (searchGroupsControl.hasError('pattern')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_PATTERN' | translate: { pattern: getValidationPattern() } }}
</div>
</mat-error>
}
@if (searchGroupsControl.hasError('maxlength')) {
<mat-error class="adf-error">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<div class="adf-error-text">
{{ 'ADF_CLOUD_PEOPLE_GROUPS.ERROR.INVALID_MAX_LENGTH' | translate: { requiredLength: getValidationMaxLength() } }}
</div>
</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>
</form>

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