mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
Merge branch 'develop' into acs-10221-change-folder-icon-to-maintain-contrast-ratio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
+5
@@ -7,6 +7,11 @@
|
||||
height: 32px;
|
||||
font: var(--mat-sys-body-medium);
|
||||
|
||||
&-heading {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
&-action {
|
||||
float: right;
|
||||
}
|
||||
|
||||
+7
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }} </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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
+27
-19
@@ -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>
|
||||
|
||||
+8
-16
@@ -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>
|
||||
|
||||
+8
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-2
@@ -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'],
|
||||
|
||||
+7
-2
@@ -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>
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+45
@@ -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: [
|
||||
|
||||
+10
-13
@@ -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 {
|
||||
|
||||
+137
@@ -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}');
|
||||
});
|
||||
});
|
||||
+111
@@ -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;
|
||||
};
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+8
-12
@@ -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>
|
||||
|
||||
+3
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -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)',
|
||||
|
||||
+13
-13
@@ -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>
|
||||
|
||||
|
||||
+3
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-3
@@ -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)',
|
||||
|
||||
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+43
-44
@@ -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>
|
||||
|
||||
+8
@@ -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';
|
||||
|
||||
+185
@@ -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 - - <b>John</b>'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(form.values).toEqual(originalValues);
|
||||
expect(form.json).toEqual(originalDefinition);
|
||||
expect(richTextField.value).toEqual({ blocks: [{ type: 'paragraph', data: { text: 'stale rendered value' } }] });
|
||||
});
|
||||
|
||||
it('should produce stable values across repeated materialization', () => {
|
||||
const form = new FormModel({
|
||||
fields: [
|
||||
{
|
||||
id: 'richText',
|
||||
type: FormFieldTypes.DISPLAY_RICH_TEXT,
|
||||
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
|
||||
},
|
||||
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
|
||||
]
|
||||
});
|
||||
|
||||
const firstValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
|
||||
const secondValues = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
|
||||
|
||||
expect(secondValues).toEqual(firstValues);
|
||||
});
|
||||
|
||||
it('should return a shallow clone without resolving expressions when evaluation is disabled', () => {
|
||||
const form = new FormModel({
|
||||
fields: [
|
||||
{
|
||||
id: 'richText',
|
||||
type: FormFieldTypes.DISPLAY_RICH_TEXT,
|
||||
value: { blocks: [{ type: 'paragraph', data: { text: '${field.name}' } }] }
|
||||
},
|
||||
{ id: 'name', type: FormFieldTypes.TEXT, value: 'John' }
|
||||
]
|
||||
});
|
||||
|
||||
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: false }, expressions);
|
||||
|
||||
expect(values).toEqual(form.values);
|
||||
expect(values).not.toBe(form.values);
|
||||
expect(values.richText).toBe(form.values.richText);
|
||||
});
|
||||
|
||||
it('should isolate materialized repeatable section rows', () => {
|
||||
const form = new FormModel();
|
||||
form.values = {
|
||||
section: [
|
||||
{ richText: 'saved row one', untouched: 'one' },
|
||||
{ richText: 'saved row two', untouched: 'two' }
|
||||
],
|
||||
name: 'John'
|
||||
};
|
||||
const nameField = new FormFieldModel(form, { id: 'name', type: FormFieldTypes.TEXT, value: 'John' });
|
||||
const firstField = new FormFieldModel(
|
||||
form,
|
||||
{
|
||||
id: 'richText',
|
||||
type: FormFieldTypes.DISPLAY_RICH_TEXT,
|
||||
value: { blocks: [{ type: 'paragraph', data: { text: 'First ${field.name}' } }] }
|
||||
},
|
||||
{ id: 'section', uid: 'richText-Row1', fields: {}, rowIndex: 0 }
|
||||
);
|
||||
const secondField = new FormFieldModel(
|
||||
form,
|
||||
{
|
||||
id: 'richText',
|
||||
type: FormFieldTypes.DISPLAY_RICH_TEXT,
|
||||
value: { blocks: [{ type: 'paragraph', data: { text: 'Second ${field.name}' } }] }
|
||||
},
|
||||
{ id: 'section', uid: 'richText-Row2', fields: {}, rowIndex: 1 }
|
||||
);
|
||||
form.fieldsCache = [nameField, firstField, secondField];
|
||||
form.values.section = [
|
||||
{ richText: 'saved row one', untouched: 'one' },
|
||||
{ richText: 'saved row two', untouched: 'two' }
|
||||
];
|
||||
const originalSection = form.values.section;
|
||||
const originalFirstRow = form.values.section[0];
|
||||
const originalSecondRow = form.values.section[1];
|
||||
|
||||
const values = materializeSubmissionValues(form, { enableExpressionEvaluation: true }, expressions);
|
||||
|
||||
expect(values.section).toEqual([
|
||||
{
|
||||
richText: { blocks: [{ type: 'paragraph', data: { text: 'First John' } }] },
|
||||
untouched: 'one'
|
||||
},
|
||||
{
|
||||
richText: { blocks: [{ type: 'paragraph', data: { text: 'Second John' } }] },
|
||||
untouched: 'two'
|
||||
}
|
||||
]);
|
||||
expect(values.section).not.toBe(originalSection);
|
||||
expect(values.section[0]).not.toBe(originalFirstRow);
|
||||
expect(values.section[1]).not.toBe(originalSecondRow);
|
||||
expect(form.values.section).toBe(originalSection);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user