mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-16 18:13:06 +00:00
Compare commits
@@ -12,14 +12,9 @@ runs:
|
||||
- name: base vars
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${GITHUB_BASE_REF:-}" ]; then
|
||||
BASE_HASH=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || git rev-parse HEAD)
|
||||
else
|
||||
BASE_HASH=$(git rev-parse HEAD)
|
||||
fi
|
||||
{
|
||||
echo "GIT_HASH=$(git rev-parse HEAD)";
|
||||
echo "BASE_HASH=${BASE_HASH}";
|
||||
echo "BASE_HASH=$(git merge-base origin/${GITHUB_BASE_REF} HEAD)";
|
||||
echo "HEAD_HASH=HEAD";
|
||||
echo "HEAD_COMMIT_HASH=${GH_COMMIT}";
|
||||
echo "NX_CALCULATION_FLAGS=--all";
|
||||
@@ -60,7 +55,7 @@ runs:
|
||||
} >> $GITHUB_ENV
|
||||
|
||||
- name: RELEASE on master/develop patch branch
|
||||
if: ${{ env.BREAK_ACTION == false && (github.event.pull_request.merged || github.event_name == 'push') }}
|
||||
if: ${{ env.BREAK_ACTION == false && github.event.pull_request.merged }}
|
||||
shell: bash
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
|
||||
@@ -23,8 +23,8 @@ runs:
|
||||
env:
|
||||
DRY_RUN_FLAG: ${{ inputs.dry-run-flag }}
|
||||
run: |
|
||||
if [[ "$DRY_RUN_FLAG" == 'true' ]]; then
|
||||
echo "dryrun=--dry-run" >> $GITHUB_OUTPUT;
|
||||
if [[ '$DRY_RUN_FLAG' == 'true' ]]; then
|
||||
echo "dryrun=--dryrun" >> $GITHUB_OUTPUT;
|
||||
echo "enabling dryrun"
|
||||
else
|
||||
echo "dryrun=" >> $GITHUB_OUTPUT;
|
||||
|
||||
@@ -28,14 +28,14 @@ runs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- name: get latest tag sha
|
||||
if: ${{ inputs.full-setup == 'true' }}
|
||||
id: tag-sha
|
||||
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
|
||||
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
|
||||
- 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@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.29.5
|
||||
|
||||
@@ -8,22 +8,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.registry_package.package_type == 'npm' && github.event.registry_package.name == 'adf-core'
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_APP_ENGINEERING_CONTRIB_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: alfresco-apps
|
||||
permission-contents: write
|
||||
- name: Dispatch event to monorepo
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
PACKAGE_NAME: ${{ github.event.registry_package.name }}
|
||||
PACKAGE_VERSION: ${{ github.event.registry_package.package_version.name }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
github-token: ${{ secrets.PAT_WRITE_PKG }}
|
||||
retries: 3
|
||||
script: |
|
||||
const payload = {
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
secrets:
|
||||
GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY:
|
||||
BOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
CROWDIN_TRANSLATIONS_TOKEN:
|
||||
required: true
|
||||
@@ -15,21 +15,13 @@ jobs:
|
||||
pull-from-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate app token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.GH_APP_ENGINEERING_CONTRIB_CLIENT_ID }}
|
||||
private-key: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
- name: Pull translations from Crowdin
|
||||
uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
skip_ref_checkout: true
|
||||
upload_sources: false
|
||||
@@ -42,5 +34,5 @@ jobs:
|
||||
github_user_email: ${{ vars.HXPS_GIT_EMAIL }}
|
||||
gpg_private_key: ${{ secrets.HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TRANSLATIONS_TOKEN }}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
name: "release"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
dry-run-flag:
|
||||
description: 'enable dry-run on artifact push'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry-run-flag:
|
||||
@@ -8,10 +15,13 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- develop
|
||||
- develop-patch*
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- master-patch-*
|
||||
|
||||
@@ -24,6 +34,8 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
GH_COMMIT: ${{ github.sha }}
|
||||
GH_BUILD_NUMBER: ${{ github.run_id }}
|
||||
LOG_LEVEL: "ERROR"
|
||||
@@ -32,7 +44,7 @@ env:
|
||||
jobs:
|
||||
setup:
|
||||
timeout-minutes: 20
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
|
||||
name: "Setup"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -56,7 +68,7 @@ jobs:
|
||||
outputs:
|
||||
release_version: ${{ steps.set-version.outputs.release_version }}
|
||||
timeout-minutes: 30
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
@@ -90,22 +102,22 @@ jobs:
|
||||
run: |
|
||||
pnpm build:libs
|
||||
pnpm build:schematics
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
name: release libraries GH registry
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@alfresco'
|
||||
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} --provenance=false ${{ steps.set-dryrun.outputs.dryrun }}
|
||||
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} --provenance=false
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
name: release libraries Npm registry
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://${{ vars.NPM_REGISTRY_ADDRESS }}'
|
||||
scope: '@alfresco'
|
||||
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} ${{ steps.set-dryrun.outputs.dryrun }}
|
||||
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }}
|
||||
|
||||
create-git-tag:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -126,7 +138,7 @@ jobs:
|
||||
npm-check-bundle:
|
||||
needs: [release-npm]
|
||||
timeout-minutes: 15
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -147,7 +159,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Push Source Files to Crowdin
|
||||
uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
|
||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: --delete-obsolete
|
||||
@@ -159,7 +171,7 @@ jobs:
|
||||
name: Run Crowdin pull pipeline for up-to-date sync
|
||||
uses: ./.github/workflows/pull-from-crowdin.yml
|
||||
secrets:
|
||||
GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
|
||||
BOT_GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
CROWDIN_TRANSLATIONS_TOKEN: ${{ secrets.CROWDIN_TRANSLATIONS_TOKEN }}
|
||||
HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY: ${{ secrets.HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY }}
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale-pr-cleanup:
|
||||
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
|
||||
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
|
||||
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
|
||||
with:
|
||||
comment-identifier: supply-chain-review-instructions
|
||||
comment-body: |
|
||||
|
||||
+9
-9
@@ -39,9 +39,9 @@
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
# - github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -483,7 +483,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1125,7 +1125,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1390,7 +1390,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1486,7 +1486,7 @@ jobs:
|
||||
mkdir -p /tmp/gh-aw/threat-detection
|
||||
touch /tmp/gh-aw/threat-detection/detection.log
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
@@ -1633,7 +1633,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1710,7 +1710,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
+2
-9
@@ -351,6 +351,7 @@ for more information about installing and using the source code.
|
||||
|
||||
| Name | Description | Source link |
|
||||
| ---- | ----------- | ----------- |
|
||||
| [Agent Service](content-services/services/agent.service.md) | Manages agents in Content Services. | [Source](../lib/content-services/src/lib/agent/services/agent.service.ts) |
|
||||
| [Audit Service](content-services/services/audit.service.md) | Manages Audit apps and entries. | [Source](../lib/content-services/src/lib/audit/audit.service.ts) |
|
||||
| [Card View Content Update Service](content-services/services/card-view-content-update.service.md) | Manages Card View properties in the content services environment. Implements BaseCardViewContentUpdate. | [Source](../lib/content-services/src/lib/common/services/card-view-content-update.service.ts) | |
|
||||
| [Category tree datasource service](content-services/services/category-tree-datasource.service.md) | Datasource service for category tree. | [Source](../lib/content-services/src/lib/category/services/category-tree-datasource.service.ts) |
|
||||
@@ -366,6 +367,7 @@ for more information about installing and using the source code.
|
||||
| [Node Comments Service](content-services/services/node-comments.service.md) | Adds and retrieves comments for nodes in Content Services. | [Source](../lib/content-services/src/lib/node-comments/services/node-comments.service.ts) |
|
||||
| [Node permission dialog service](content-services/services/node-permission-dialog.service.md) | Displays dialogs to let the user set node permissions. | [Source](../lib/content-services/src/lib/permission-manager/services/node-permission-dialog.service.ts) |
|
||||
| [Node Permission service](content-services/services/node-permission.service.md) | Manages role permissions for content nodes. | [Source](../lib/content-services/src/lib/permission-manager/services/node-permission.service.ts) |
|
||||
| [Search Ai Service](content-services/services/search-ai.service.md) | Manages search AI in Content Services. | [Source](../lib/content-services/src/lib/search-ai/services/search-ai.service.ts) |
|
||||
| [Search filter service](content-services/services/search-filter.service.md) | Registers widgets for use with the Search Filter component. | [Source](../lib/content-services/src/lib/search/services/search-filter.service.ts) |
|
||||
| [Search Query Builder service](content-services/services/search-query-builder.service.md) | Stores information from all the custom search and faceted search widgets, compiles and runs the final search query. | [Source](../lib/content-services/src/lib/search/services/search-query-builder.service.ts) |
|
||||
| [Security Controls service](content-services/services/security-controls.service.md) | Manages security groups & marks in Content Services. | [Source](../lib/content-services/src/lib/security/services/security-controls-groups-marks-security.service.ts) |
|
||||
@@ -565,12 +567,3 @@ Contains all custom rules used by ESLint.
|
||||
<!--eslint-angular end-->
|
||||
|
||||
[(Back to Contents)](#contents)
|
||||
|
||||
## Notes
|
||||
|
||||
### Knowledge Discovery removal (v9.0.0)
|
||||
|
||||
The Knowledge Discovery components, services, and APIs have been removed as of version 9.0.0.
|
||||
The integrated Knowledge Discovery features have been replaced with a section in the side navigation panel containing a link to the standalone Knowledge Discovery UI application in higher level apps such as ACA and ADW.
|
||||
|
||||
If you were using those features, please consider cleaning Local Storage in your browser as *aiReferences* entry may remain.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
Title: Agent service
|
||||
Added: v7.0.0-alpha.3
|
||||
Status: Active
|
||||
Last reviewed: 2024-07-12
|
||||
---
|
||||
|
||||
# [Agent service](../../../lib/content-services/src/lib/agent/services/agent.service.ts "Defined in agent.service.ts")
|
||||
|
||||
Manages agents in Content Services.<br/>
|
||||
|
||||
<b>In order to use this service, you need to have the HX Insights Connector (additional ACS module) installed.</b>
|
||||
|
||||
## Class members
|
||||
|
||||
### Methods
|
||||
|
||||
- **getAgents**(): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AgentPaging`](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md#agentpaging)`>`<br/>
|
||||
Gets all agents.
|
||||
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AgentPaging`](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md#agentpaging)`>` - AgentPaging object containing the agents.
|
||||
|
||||
## Details
|
||||
|
||||
See the
|
||||
[Agents API](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md) for more information about the types returned by [Agent
|
||||
service](agent.service.md) methods and for the implementation of the REST API the service is
|
||||
based on.
|
||||
@@ -23,12 +23,11 @@ Manages Document List information that is specific to a user.
|
||||
- _node:_ `any` - Node object
|
||||
- _nodeId:_ `string` - ID of the node object
|
||||
- **Returns** `string` - ID value
|
||||
- **getRecentFiles**(personId: `string`, pagination: [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts), filters?: `string[]`, includeFields: `string[]` = `[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>`<br/>
|
||||
- **getRecentFiles**(personId: `string`, pagination: [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts), filters?: `string[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>`<br/>
|
||||
Gets files recently accessed by a user.
|
||||
- _personId:_ `string` - ID of the user
|
||||
- _pagination:_ [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts) - Specifies how to paginate the results
|
||||
- _filters:_ `string[]` - (Optional) Specifies additional filters to apply (joined with **AND**)
|
||||
- _includeFields:_ `string[]` - List of data field names to include in the results
|
||||
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>` - List of nodes for the recently used files
|
||||
- **hasCorrespondingNodeIds**(nodeId: `string`): `boolean`<br/>
|
||||
Does the well-known alias have a corresponding node ID?
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
Title: Search Ai service
|
||||
Added: v7.0.0-alpha.3
|
||||
Status: Active
|
||||
Last reviewed: 2024-07-12
|
||||
---
|
||||
|
||||
# [Search Ai service](../../../lib/content-services/src/lib/search-ai/services/search-ai.service.ts "Defined in search-ai.service.ts")
|
||||
|
||||
Manages search AI in Content Services.
|
||||
|
||||
<b>In order to use this service, you need to have the HX Insights Connector (additional ACS module) installed.</b>
|
||||
|
||||
## Class members
|
||||
|
||||
### Methods
|
||||
|
||||
- **updateSearchAiInputState**(state: `SearchAiInputState`): `void`<br/>
|
||||
Update the state of the search AI input.
|
||||
- _state:_ `SearchAiInputState` - The new state of the search AI input.
|
||||
- **ask**(question: [`QuestionRequest`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionrequest)): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`QuestionModel`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionmodel)`>`<br/>
|
||||
Ask a question to the AI.
|
||||
- _question:_ [`QuestionRequest`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionrequest) - The question to ask.
|
||||
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`QuestionModel`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionmodel)`>` - QuestionModel object containing information about questions.
|
||||
- **getAnswer**(questionId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AiAnswerEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#aianswerentry)`>`<br/>
|
||||
Get an answer to specific question.
|
||||
- _questionId:_ `string` - The ID of the question to get an answer for.
|
||||
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AiAnswerEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#aianswerentry)`>` - AiAnswerEntry object containing the answer.
|
||||
- **getConfig**(): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`KnowledgeRetrievalConfigEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#knowledgeretrievalconfigentry)`>`<br/>
|
||||
Get the knowledge retrieval configuration.
|
||||
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`KnowledgeRetrievalConfigEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#knowledgeretrievalconfigentry)`>` - KnowledgeRetrievalConfigEntry object containing the configuration.
|
||||
- **checkSearchAvailability**(selectedNodesState: `SelectionState`, maxSelectedNodes: `number`): `string`<br/>
|
||||
Check if using of search is possible (if all conditions are met).
|
||||
- _selectedNodesState:_ `SelectionState` - information about selected nodes.
|
||||
- _maxSelectedNodes:_ `number` - max number of selected nodes. Default 100.
|
||||
- **Returns** `string` - string with error if any condition is not met, empty string otherwise.
|
||||
|
||||
## Details
|
||||
|
||||
See the
|
||||
[Search Ai API](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md) for more information about the types returned by [Search Ai
|
||||
service](search-ai.service.md) methods and for the implementation of the REST API the service is
|
||||
based on.
|
||||
@@ -63,4 +63,3 @@ The pages linked below contain the licenses for all third party dependencies of
|
||||
- [ADF 8.3.1](license-info-8.3.1.md)
|
||||
- [ADF 8.4.1](license-info-8.4.1.md)
|
||||
- [ADF 8.5.0](license-info-8.5.0.md)
|
||||
- [ADF 9.0.0](license-info-9.0.0.md)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
Title: License info, alfresco-ng2-components 9.0.0
|
||||
---
|
||||
|
||||
# License information for alfresco-ng2-components 9.0.0
|
||||
|
||||
This page lists all third party libraries the project depends on.
|
||||
|
||||
## Libraries
|
||||
|
||||
| Name | Version | License |
|
||||
| --- | --- | --- |
|
||||
| [@angular-eslint/bundled-angular-compiler](https://github.com/angular-eslint/angular-eslint.git) | 20.7.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular-eslint/utils](https://github.com/angular-eslint/angular-eslint.git) | 20.7.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/animations](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/cdk](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/common](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/compiler](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/core](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/forms](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/material](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/material-date-fns-adapter](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/platform-browser](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/platform-browser-dynamic](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@angular/router](git+https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@apollo/client](git+https://github.com/apollographql/apollo-client.git) | 3.13.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@babel/runtime](https://github.com/babel/babel.git) | 7.26.10 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@cspell/eslint-plugin](https://github.com/streetsidesoftware/cspell.git) | 10.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@graphql-typed-document-node/core](git@github.com:dotansimha/graphql-typed-document-node.git) | 3.2.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@mat-datetimepicker/core](https://github.com/kuhnroyal/mat-datetimepicker.git) | 16.0.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@napi-rs/canvas](git+https://github.com/Brooooooklyn/canvas.git) | 0.1.100 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@napi-rs/canvas-darwin-arm64](git+https://github.com/Brooooooklyn/canvas.git) | 0.1.100 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@ngx-translate/core](https://github.com/ngx-translate/core) | 17.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@wry/caches](git+https://github.com/benjamn/wryware.git) | 1.0.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@wry/context](git+https://github.com/benjamn/wryware.git) | 0.7.4 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@wry/equality](git+https://github.com/benjamn/wryware.git) | 0.5.7 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [@wry/trie](git+https://github.com/benjamn/wryware.git) | 0.5.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [angular-oauth2-oidc](https://github.com/manfredsteyer/angular-oauth2-oidc) | 19.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [apollo-angular](https://github.com/kamilkisiela/apollo-angular) | 11.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [balanced-match](git://github.com/juliangruber/balanced-match.git) | 1.0.2 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [brace-expansion](git://github.com/juliangruber/brace-expansion.git) | 1.1.15 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [brace-expansion](git://github.com/juliangruber/brace-expansion.git) | 2.1.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [concat-map](git://github.com/substack/node-concat-map.git) | 0.0.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [cropperjs](git+https://github.com/fengyuanchen/cropperjs.git) | 1.6.2 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [date-fns](https://github.com/date-fns/date-fns) | 2.30.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| dotenv-expand | 5.1.0 | [BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause) |
|
||||
| editorjs-html | 4.0.5 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [eve-raphael](https://github.com/tomasAlabes/eve.git) | 0.5.0 | UNKNOWN |
|
||||
| [eventemitter3](git://github.com/primus/eventemitter3.git) | 5.0.4 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [fetch-blob](https://github.com/node-fetch/fetch-blob.git) | 3.2.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [formdata-polyfill](git+https://jimmywarting@github.com/jimmywarting/FormData.git) | 4.0.10 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [graphql-tag](git+https://github.com/apollographql/graphql-tag.git) | 2.12.7 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [graphql-ws](git+https://github.com/enisdenjo/graphql-ws.git) | 6.0.8 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [hoist-non-react-statics](git://github.com/mridgway/hoist-non-react-statics.git) | 3.3.2 | [BSD-3-Clause](https://opensource.org/licenses/BSD-3-Clause) |
|
||||
| [js-tokens](lydell/js-tokens) | 4.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [lodash-es](lodash/lodash) | 4.18.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [loose-envify](git://github.com/zertosh/loose-envify.git) | 1.4.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [material-icons](git+https://github.com/marella/material-icons.git) | 1.13.14 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
|
||||
| [minimatch](git://github.com/isaacs/minimatch.git) | 5.1.9 | [ISC](https://www.isc.org/licenses/) |
|
||||
| [minimatch-browser](git://github.com/isaacs/minimatch.git) | 1.0.0 | [ISC](https://www.isc.org/licenses/) |
|
||||
| [ng2-charts](git+https://github.com/valor-software/ng2-charts.git) | 4.1.1 | [ISC](https://www.isc.org/licenses/) |
|
||||
| [node-domexception](git+https://github.com/jimmywarting/node-domexception.git) | 1.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [node-fetch](https://github.com/node-fetch/node-fetch.git) | 3.3.2 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [object-assign](sindresorhus/object-assign) | 4.1.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [optimism](git+https://github.com/benjamn/optimism.git) | 0.18.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [pdfjs-dist](git+https://github.com/mozilla/pdf.js.git) | 5.1.91 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
|
||||
| [prop-types](facebook/prop-types) | 15.8.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [raphael](git://github.com/DmitryBaranovskiy/raphael.git) | 2.3.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [react-is](https://github.com/facebook/react.git) | 16.13.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [regenerator-runtime](https://github.com/facebook/regenerator/tree/main/packages/runtime) | 0.14.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [rehackt](git+https://github.com/phryneas/rehackt.git) | 0.1.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [rxjs](https://github.com/reactivex/rxjs.git) | 7.8.2 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
|
||||
| [symbol-observable](blesh/symbol-observable) | 4.0.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [ts-invariant](git+https://github.com/apollographql/invariant-packages.git) | 0.10.3 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [tslib](https://github.com/Microsoft/tslib.git) | 2.8.1 | [0BSD](http://landley.net/toybox/license.html) |
|
||||
| [uuid](https://github.com/uuidjs/uuid.git) | 14.0.1 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [web-streams-polyfill](git+https://github.com/MattiasBuelens/web-streams-polyfill.git) | 3.3.3 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [zen-observable](zenparsing/zen-observable) | 0.8.15 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [zen-observable-ts](git+https://github.com/apollographql/zen-observable-ts.git) | 1.2.5 | [MIT](https://opensource.org/license/mit/) |
|
||||
| [zone.js](git://github.com/angular/angular.git) | 0.15.0 | [MIT](https://opensource.org/license/mit/) |
|
||||
|
||||
@@ -9,7 +9,6 @@ The first **General Availability** release was v2.0.0.
|
||||
|
||||
## General Availability
|
||||
|
||||
- [9.0.0](RelNote-9.0.0.md)
|
||||
- [8.5.0](RelNote-8.5.0.md)
|
||||
- [8.4.1](RelNote-8.4.1.md)
|
||||
- [8.3.1](RelNote-8.3.1.md)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
Title: Changelog for alfresco-ng2-components v9.0.0
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
- [0a19be090e](git@github.com:Alfresco/alfresco-ng2-components/commit/0a19be090e) [ACS-12286] Pass includeFields through to getRecentFiles for -recent- (#12067)
|
||||
- [b69c3ae1ae](git@github.com:Alfresco/alfresco-ng2-components/commit/b69c3ae1ae) [MNT-25230] Displayed modified time for node version (#12066)
|
||||
- [ce907417b7](git@github.com:Alfresco/alfresco-ng2-components/commit/ce907417b7) AAE-48166 Compensate for VM/Citrix clock drift in token expiry check (#12024)
|
||||
- [4c7765e3fa](git@github.com:Alfresco/alfresco-ng2-components/commit/4c7765e3fa) AAE-46517 Add onRowCountChange form event for Repeatable Sections (#12062)
|
||||
- [407a64996e](git@github.com:Alfresco/alfresco-ng2-components/commit/407a64996e) AAE-46248 Format dropdown/radio option labels in display-text expressions (#12045)
|
||||
- [01cea60490](git@github.com:Alfresco/alfresco-ng2-components/commit/01cea60490) [MNT-25230] adw version history does not provide user information (#12050)
|
||||
- [09ef05038c](git@github.com:Alfresco/alfresco-ng2-components/commit/09ef05038c) chore: remove deprecated MaterialModule and its references (#12051)
|
||||
- [c7bd56f244](git@github.com:Alfresco/alfresco-ng2-components/commit/c7bd56f244) AAE-43974 Fix for alfresco-content-app e2e failure (#12055)
|
||||
- [b86e41515e](git@github.com:Alfresco/alfresco-ng2-components/commit/b86e41515e) [ACS-12037] Remove Knowledge Discovery from ADF (#12042)
|
||||
- [0b35118dee](git@github.com:Alfresco/alfresco-ng2-components/commit/0b35118dee) AAE-47880 UI breaks when pasting very large text into text field (#12043)
|
||||
- [fe2b3d85dd](git@github.com:Alfresco/alfresco-ng2-components/commit/fe2b3d85dd) [ACS-12108] Introduce signed commits into ADF workflows (#12040)
|
||||
- [b0dc85422c](git@github.com:Alfresco/alfresco-ng2-components/commit/b0dc85422c) Improvement/AAE-43974 Refactoring form fields (#11945)
|
||||
- [23f0392c12](git@github.com:Alfresco/alfresco-ng2-components/commit/23f0392c12) AAE-47634 Refactor: switch form service getTask to use runtime bundle API (#12020)
|
||||
- [997303e6a7](git@github.com:Alfresco/alfresco-ng2-components/commit/997303e6a7) [ACS-11325] remove chevron button from tab order in adf-tree (#12041)
|
||||
- [45d1818f04](git@github.com:Alfresco/alfresco-ng2-components/commit/45d1818f04) [ACS-12098] firefox headless race condition viewer fix (#12023)
|
||||
- [448e667c52](git@github.com:Alfresco/alfresco-ng2-components/commit/448e667c52) [MNT-25681] Content node selector panel filtering fixed (#12032)
|
||||
- [18e602aa85](git@github.com:Alfresco/alfresco-ng2-components/commit/18e602aa85) [PRODSEC-13253] Increased peerDependencies versions to allow to increase angular version in other projects (#12031)
|
||||
- [4cd8648a05](git@github.com:Alfresco/alfresco-ng2-components/commit/4cd8648a05) Adjust release triggers to use cache-writable token (#12030)
|
||||
- [7f57b3936b](git@github.com:Alfresco/alfresco-ng2-components/commit/7f57b3936b) AAE-39528 Session timeout features (#12008)
|
||||
- [5a30baad99](git@github.com:Alfresco/alfresco-ng2-components/commit/5a30baad99) Release cache read/write fix (#12029)
|
||||
- [5050c3d974](git@github.com:Alfresco/alfresco-ng2-components/commit/5050c3d974) [ACS-12041] Add debounceTime in PeopleWidgetComponent to limit API calls on every keystroke (#12027)
|
||||
- [d8b36606e2](git@github.com:Alfresco/alfresco-ng2-components/commit/d8b36606e2) [MNT-25681] Search refactoring and unification (#12019)
|
||||
- [37f8fe47ac](git@github.com:Alfresco/alfresco-ng2-components/commit/37f8fe47ac) AAE-46695 Upgrade to pnpm 11.9 (#12028)
|
||||
- [af0d1e4ead](git@github.com:Alfresco/alfresco-ng2-components/commit/af0d1e4ead) AAE-47093 Update Supply Chain Review GH AW to latest (#12025)
|
||||
- [bd7f393db9](git@github.com:Alfresco/alfresco-ng2-components/commit/bd7f393db9) AAE-47634 Fix block task claim checking status on Runtime Bundle (#12009)
|
||||
- [d2d6696c05](git@github.com:Alfresco/alfresco-ng2-components/commit/d2d6696c05) Migrate to Angular 20, TypeScript 5.9, and Angular Material 20 (#11657)
|
||||
- [7546aa91a3](git@github.com:Alfresco/alfresco-ng2-components/commit/7546aa91a3) AAE-47577 Form Spinner persists outside of Automate Form (#12005)
|
||||
- [74d67c39a0](git@github.com:Alfresco/alfresco-ng2-components/commit/74d67c39a0) [ACS-11317] a11y Fix: Tags interactive controls are not manageable via keyboard (#11845)
|
||||
- [20c20f2749](git@github.com:Alfresco/alfresco-ng2-components/commit/20c20f2749) AAE-46247 Add type-aware form field value adapter service (#11968)
|
||||
- [b498d5744c](git@github.com:Alfresco/alfresco-ng2-components/commit/b498d5744c) [AAE-47470] - Upgrade Pnpm to 11.8.0 (#12004)
|
||||
- [80eef35e1d](git@github.com:Alfresco/alfresco-ng2-components/commit/80eef35e1d) [ACS-11325] Add keyboard navigation and a11y support for adf-tree component (#11973)
|
||||
- [49a3f1e104](git@github.com:Alfresco/alfresco-ng2-components/commit/49a3f1e104) AAE-47412 Update suprocesses and linked processes for process instance model (#11996)
|
||||
- [0beaa18452](git@github.com:Alfresco/alfresco-ng2-components/commit/0beaa18452) [ACS-11973] Fix: context menu disappears during bulk upload (#11994)
|
||||
- [76fd461741](git@github.com:Alfresco/alfresco-ng2-components/commit/76fd461741) AAE-47042 Add auto grow support to multiline text widget (#11981)
|
||||
- [78697ee62a](git@github.com:Alfresco/alfresco-ng2-components/commit/78697ee62a) [AAE-0000] - Fixed js-api error index.js (#11970)
|
||||
- [43c5bc17fb](git@github.com:Alfresco/alfresco-ng2-components/commit/43c5bc17fb) [ACS-12020] Updated init-aps passwords with secret ones (#11993)
|
||||
- [cda20e0386](git@github.com:Alfresco/alfresco-ng2-components/commit/cda20e0386) Post-release version bump (#11988)
|
||||
- [8f5099e822](git@github.com:Alfresco/alfresco-ng2-components/commit/8f5099e822) [ACS-11928] convert ReadStream to Blob before appending to FormData (#11987)
|
||||
- [5dd709ed58](git@github.com:Alfresco/alfresco-ng2-components/commit/5dd709ed58) AAE-44809 Fix form multi-file attachment - document viewer not updated on file selection (#11980)
|
||||
- [9d353bf271](git@github.com:Alfresco/alfresco-ng2-components/commit/9d353bf271) [ACS-11974] [ACA] Upload new version button is no longer disabled when clicked once (#11977)
|
||||
- [633702860f](git@github.com:Alfresco/alfresco-ng2-components/commit/633702860f) Post-release version bump (#11984)
|
||||
- [bf5b3af285](git@github.com:Alfresco/alfresco-ng2-components/commit/bf5b3af285) AAE-47094 Add Stale PR Cleanup workflow (#11983)
|
||||
@@ -61,8 +61,8 @@ backend services have been tested with each released version of ADF.
|
||||
|
||||
<!--7.0.0-alpha.3 start-->
|
||||
|
||||
- AgentService — removed, see [Notes](README.md#knowledge-discovery-removal-v900)
|
||||
- SearchAiService — removed, see [Notes](README.md#knowledge-discovery-removal-v900)
|
||||
- [AgentService](content-services/services/agent.service.md)
|
||||
- [SearchAiService](content-services/services/search-ai.service.md)
|
||||
|
||||
<!--7.0.0-alpha.3 end-->
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ The pages linked below contain the audit for all third party dependencies of ADF
|
||||
- [ADF 8.3.1](audit-info-8.3.1.md)
|
||||
- [ADF 8.4.1](audit-info-8.4.1.md)
|
||||
- [ADF 8.5.0](audit-info-8.5.0.md)
|
||||
- [ADF 9.0.0](audit-info-9.0.0.md)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
Title: Audit info, alfresco-ng2-components 9.0.0
|
||||
---
|
||||
|
||||
# Audit information for alfresco-ng2-components 9.0.0
|
||||
|
||||
This page lists the security audit of the dependencies this project depends on.
|
||||
|
||||
## Risks
|
||||
|
||||
- Critical risk: 0
|
||||
- High risk: 0
|
||||
- Moderate risk: 0
|
||||
- Low risk: 0
|
||||
|
||||
Dependencies analyzed: 297
|
||||
|
||||
## Libraries
|
||||
|
||||
| Severity | Module | Vulnerable versions |
|
||||
| --- | --- | --- |
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ Options:
|
||||
const packageDir = path.dirname(packagePath);
|
||||
|
||||
// Use spawnSync with array arguments for safer command execution (prevents shell injection)
|
||||
// Cross-platform: pnpm is available on PATH on all platforms (Windows, macOS, Linux)
|
||||
const result = spawnSync('pnpm', ['audit', '--json', '--prod'], {
|
||||
// Cross-platform: npm is available on PATH on all platforms (Windows, macOS, Linux)
|
||||
const result = spawnSync('npm', ['audit', '--json', '--prod'], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf-8',
|
||||
// shell: false is the default and more secure (no shell interpretation)
|
||||
|
||||
@@ -11,19 +11,19 @@
|
||||
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": ">=20.3.25",
|
||||
"@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/material": ">=20.2.14",
|
||||
"@angular/platform-browser": ">=20.3.25",
|
||||
"@angular/platform-browser-dynamic": ">=20.3.25",
|
||||
"@angular/router": ">=20.3.25",
|
||||
"@alfresco/js-api": ">=10.0.0",
|
||||
"@angular/animations": ">=14.1.3",
|
||||
"@angular/cdk": ">=14.1.2",
|
||||
"@angular/common": ">=14.1.3",
|
||||
"@angular/compiler": ">=14.1.3",
|
||||
"@angular/core": ">=14.1.3",
|
||||
"@angular/forms": ">=14.1.3",
|
||||
"@angular/material": ">=14.1.2",
|
||||
"@angular/platform-browser": ">=14.1.3",
|
||||
"@angular/platform-browser-dynamic": ">=14.1.3",
|
||||
"@angular/router": ">=14.1.3",
|
||||
"@alfresco/js-api": ">=9.5.0",
|
||||
"@ngx-translate/core": ">=17.0.0",
|
||||
"@alfresco/adf-core": ">=9.0.0"
|
||||
"@alfresco/adf-core": ">=8.5.0"
|
||||
},
|
||||
"keywords": [
|
||||
"content-services",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
@mixin adf-error-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './public-api';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './services/agent.service';
|
||||
@@ -0,0 +1,67 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { AgentService } from './agent.service';
|
||||
import { Agent, AgentPaging } from '@alfresco/js-api';
|
||||
|
||||
const agent1: Agent = {
|
||||
id: '1',
|
||||
name: 'HR Agent',
|
||||
description: 'Your Claims Doc Agent streamlines the extraction, analysis, and management of data from insurance claims documents.',
|
||||
avatarUrl: ''
|
||||
};
|
||||
|
||||
const agent2: Agent = {
|
||||
id: '2',
|
||||
name: 'Policy Agent',
|
||||
description: 'Your Claims Doc Agent streamlines the extraction, analysis, and management of data from insurance claims documents.',
|
||||
avatarUrl: ''
|
||||
};
|
||||
|
||||
const agentPagingObjectMock: AgentPaging = {
|
||||
list: {
|
||||
entries: [
|
||||
{
|
||||
entry: agent1
|
||||
},
|
||||
{
|
||||
entry: agent2
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const agentListMock: Agent[] = [agent1, agent2];
|
||||
|
||||
describe('AgentService', () => {
|
||||
let agentService: AgentService;
|
||||
|
||||
beforeEach(() => {
|
||||
agentService = TestBed.inject(AgentService);
|
||||
});
|
||||
|
||||
it('should load agents', (done) => {
|
||||
spyOn(agentService.agentsApi, 'getAgents').and.returnValue(Promise.resolve(agentPagingObjectMock));
|
||||
|
||||
agentService.getAgents().subscribe((pagingResponse) => {
|
||||
expect(pagingResponse).toEqual(agentListMock);
|
||||
expect(agentService.agentsApi.getAgents).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { Injectable, inject } from '@angular/core';
|
||||
import { Agent, AgentsApi, LazyApi } from '@alfresco/js-api';
|
||||
import { BehaviorSubject, from, Observable, of } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { AlfrescoApiService } from '../../services';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AgentService {
|
||||
private readonly apiService = inject(AlfrescoApiService);
|
||||
|
||||
private readonly agents = new BehaviorSubject<Agent[]>([]);
|
||||
|
||||
@LazyApi((self: AgentService) => new AgentsApi(self.apiService.getInstance()))
|
||||
declare readonly agentsApi: AgentsApi;
|
||||
|
||||
agents$ = this.agents.asObservable();
|
||||
|
||||
/**
|
||||
* Gets all agents from cache. If cache is empty, fetches agents from backend.
|
||||
*
|
||||
* @returns Agent[] list containing agents.
|
||||
*/
|
||||
getAgents(): Observable<Agent[]> {
|
||||
return this.agents$.pipe(
|
||||
switchMap((agentsList) => {
|
||||
if (agentsList.length) {
|
||||
return of(agentsList);
|
||||
}
|
||||
return from(this.agentsApi.getAgents()).pipe(
|
||||
map((paging) => {
|
||||
const agentEntries = paging.list.entries.map((agentEntry) => agentEntry.entry);
|
||||
this.agents.next(agentEntries);
|
||||
return agentEntries;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -206,9 +206,5 @@ describe('ContentNodeDialogService', () => {
|
||||
expect(testContentNodeSelectorComponentData.isSelectionValid(testData.node)).toBe(testData.expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should scope the search to folders by disabling files in the result', () => {
|
||||
expect(testContentNodeSelectorComponentData.showFilesInResult).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,8 +154,7 @@ export class ContentNodeDialogService {
|
||||
where: '(isFolder=true)',
|
||||
isSelectionValid: this.isCopyMoveSelectionValid.bind(this),
|
||||
excludeSiteContent: excludeSiteContent || ContentNodeDialogService.nonDocumentSiteContent,
|
||||
select,
|
||||
showFilesInResult: false
|
||||
select
|
||||
};
|
||||
|
||||
const dialogRef = this.openContentNodeDialog(data, 'adf-content-node-selector-dialog', '630px');
|
||||
@@ -197,8 +196,7 @@ export class ContentNodeDialogService {
|
||||
imageResolver: this.imageResolver.bind(this),
|
||||
isSelectionValid: this.hasAllowableOperationsOnNodeFolder.bind(this),
|
||||
where: '(isFolder=true)',
|
||||
select,
|
||||
showFilesInResult: false
|
||||
select
|
||||
};
|
||||
|
||||
const dialogRef = this.openContentNodeDialog(data, 'adf-content-node-selector-dialog', '630px');
|
||||
|
||||
+1
-51
@@ -19,7 +19,7 @@ import { DebugElement } from '@angular/core';
|
||||
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Node, NodeEntry, NodePaging, RequestScope, ResultSetPaging, SiteEntry, SitePaging, SitePagingList } from '@alfresco/js-api';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { of } from 'rxjs';
|
||||
import { ContentNodeSelectorPanelComponent } from './content-node-selector-panel.component';
|
||||
import { DocumentListService } from '../../document-list/services/document-list.service';
|
||||
import { DocumentListComponent } from '../../document-list/components/document-list.component';
|
||||
@@ -294,32 +294,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
|
||||
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
|
||||
}));
|
||||
|
||||
it('should remove the previous site filter query when changing the selected site', fakeAsync(() => {
|
||||
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
|
||||
spyOn(searchQueryBuilderService, 'removeFilterQuery').and.callThrough();
|
||||
typeToSearchBox('search-term');
|
||||
tick(debounceSearch);
|
||||
|
||||
component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry);
|
||||
component.siteChanged({ entry: { guid: 'vegeta' } } as SiteEntry);
|
||||
|
||||
expect(searchQueryBuilderService.removeFilterQuery).toHaveBeenCalledWith(`ANCESTOR:'workspace://SpacesStore/namek'`);
|
||||
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(`ANCESTOR:'workspace://SpacesStore/vegeta'`);
|
||||
expect(searchQueryBuilderService.filterQueries).toEqual([{ query: `ANCESTOR:'workspace://SpacesStore/vegeta'` }]);
|
||||
}));
|
||||
|
||||
it('should browse the selected site folder without searching when no search term is present', fakeAsync(() => {
|
||||
expect(searchSpy.calls.count()).toBe(0);
|
||||
|
||||
component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry);
|
||||
tick(debounceSearch);
|
||||
|
||||
expect(searchSpy).not.toHaveBeenCalled();
|
||||
expect(component.searchTerm).toBe('');
|
||||
expect(component.folderIdToShow).toBe('namek');
|
||||
expect(component.showingSearchResults).toBe(false);
|
||||
}));
|
||||
|
||||
it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => {
|
||||
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
|
||||
component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging;
|
||||
@@ -369,29 +343,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
|
||||
expect(getCorrespondingNodeIdsSpy.calls.mostRecent().args[0]).toEqual('-sites-');
|
||||
}));
|
||||
|
||||
it('should execute the search only after the corresponding node ids filter query is applied for aliases', fakeAsync(() => {
|
||||
component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node;
|
||||
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
|
||||
const nodeIds$ = new Subject<string[]>();
|
||||
getCorrespondingNodeIdsSpy.and.returnValue(nodeIds$.asObservable());
|
||||
|
||||
typeToSearchBox('vegeta');
|
||||
tick(debounceSearch);
|
||||
|
||||
searchSpy.calls.reset();
|
||||
|
||||
component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry);
|
||||
|
||||
expect(searchSpy).not.toHaveBeenCalled();
|
||||
|
||||
nodeIds$.next(['123456testId']);
|
||||
nodeIds$.complete();
|
||||
|
||||
const expectedRequest = `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId'`;
|
||||
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
|
||||
expect(searchSpy).toHaveBeenCalledWith(false);
|
||||
}));
|
||||
|
||||
it('should NOT get the corresponding node ids on search when NOTHING is selected from dropdown', fakeAsync(() => {
|
||||
component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging;
|
||||
fixture.detectChanges();
|
||||
@@ -557,7 +508,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
|
||||
|
||||
it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => {
|
||||
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
|
||||
component.searchTerm = 'search-term';
|
||||
searchQueryBuilderService.userQuery = 'search-term*';
|
||||
component.currentFolderId = 'my-root-id';
|
||||
component.restrictRootToCurrentFolderId = true;
|
||||
|
||||
+21
-39
@@ -40,8 +40,7 @@ import { ImageResolver } from '../../document-list/data/image-resolver.model';
|
||||
import { CustomResourcesService } from '../../document-list/services/custom-resources.service';
|
||||
import { ShareDataRow } from '../../document-list/data/share-data-row.model';
|
||||
import { NodeEntryEvent } from '../../document-list/components/node.event';
|
||||
import { debounceTime, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ContentNodeSelectorPanelService } from './content-node-selector-panel.service';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
@@ -111,7 +110,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
|
||||
private showSearchField = true;
|
||||
private showCounter = false;
|
||||
private _emptyList = true;
|
||||
private lastParentFilterQuery: string | null = null;
|
||||
|
||||
/** If true will restrict the search and breadcrumbs to the currentFolderId */
|
||||
@Input()
|
||||
@@ -442,12 +440,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
|
||||
this.siteId = chosenSite.entry.guid;
|
||||
this.setTitleIfCustomSite(chosenSite);
|
||||
this.siteChange.emit(chosenSite.entry.title);
|
||||
if (this.searchTerm) {
|
||||
this.executeSearch(this.searchTerm);
|
||||
} else {
|
||||
this.resetFolderToShow();
|
||||
this.clearSearch();
|
||||
}
|
||||
this.executeSearch(this.searchTerm);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,6 +467,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
|
||||
this.folderIdToShow = null;
|
||||
this.preselectedNodes = [];
|
||||
this.loadingSearchResults = true;
|
||||
this.addCorrespondingNodeIdsQuery();
|
||||
this.resetChosenNode();
|
||||
}
|
||||
|
||||
@@ -505,35 +499,25 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
|
||||
this.showingSearch.emit(this.showingSearchResults);
|
||||
}
|
||||
|
||||
private addCorrespondingNodeIdsQuery(): Observable<void> {
|
||||
private addCorrespondingNodeIdsQuery() {
|
||||
let extraParentFiltering = '';
|
||||
|
||||
if (this.customResourcesService.hasCorrespondingNodeIds(this.siteId)) {
|
||||
return this.customResourcesService.getCorrespondingNodeIds(this.siteId).pipe(
|
||||
map((nodeIds) => {
|
||||
let extraParentFiltering = '';
|
||||
if (nodeIds?.length) {
|
||||
nodeIds
|
||||
.filter((id) => id !== this.siteId)
|
||||
.forEach((extraId) => {
|
||||
extraParentFiltering += ` OR ANCESTOR:'workspace://SpacesStore/${extraId}'`;
|
||||
});
|
||||
}
|
||||
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'${extraParentFiltering}` : '';
|
||||
this.setParentFilterQuery(parentFiltering);
|
||||
})
|
||||
);
|
||||
this.customResourcesService.getCorrespondingNodeIds(this.siteId).subscribe((nodeIds) => {
|
||||
if (nodeIds?.length) {
|
||||
nodeIds
|
||||
.filter((id) => id !== this.siteId)
|
||||
.forEach((extraId) => {
|
||||
extraParentFiltering += ` OR ANCESTOR:'workspace://SpacesStore/${extraId}'`;
|
||||
});
|
||||
}
|
||||
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'${extraParentFiltering}` : '';
|
||||
this.queryBuilderService.addFilterQuery(parentFiltering);
|
||||
});
|
||||
} else {
|
||||
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'` : '';
|
||||
this.queryBuilderService.addFilterQuery(parentFiltering);
|
||||
}
|
||||
|
||||
const siteFilter = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'` : '';
|
||||
this.setParentFilterQuery(siteFilter);
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
private setParentFilterQuery(parentFiltering: string) {
|
||||
if (this.lastParentFilterQuery) {
|
||||
this.queryBuilderService.removeFilterQuery(this.lastParentFilterQuery);
|
||||
}
|
||||
this.lastParentFilterQuery = parentFiltering || null;
|
||||
this.queryBuilderService.addFilterQuery(parentFiltering);
|
||||
}
|
||||
|
||||
private setSearchScopeToNodes() {
|
||||
@@ -701,8 +685,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
|
||||
this.queryBuilderService.searchMode = 'formula';
|
||||
const wildcardSuffix = this.queryBuilderService.wildcardsEnabled ? '*' : '';
|
||||
this.queryBuilderService.userQuery = searchValue.length > 0 ? `(${searchValue}${wildcardSuffix})` : searchValue;
|
||||
this.addCorrespondingNodeIdsQuery()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.queryBuilderService.execute(false));
|
||||
this.queryBuilderService.execute(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,14 @@ import { VersionCompatibilityService } from './version-compatibility/version-com
|
||||
import { contentAuthLoaderFactory } from './auth-loader/content-auth-loader-factory';
|
||||
import { ContentAuthLoaderService } from './auth-loader/content-auth-loader.service';
|
||||
import { CONTENT_UPLOAD_DIRECTIVES } from './upload';
|
||||
import { MaterialModule } from './material.module';
|
||||
import { AlfrescoApiService } from './services/alfresco-api.service';
|
||||
import { AlfrescoApiNoAuthService } from './api-factories/alfresco-api-no-auth.service';
|
||||
import { AlfrescoApiLoaderService, createAlfrescoApiInstance } from './api-factories/alfresco-api-v2-loader.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
MaterialModule,
|
||||
MatDatetimepickerModule,
|
||||
MatNativeDatetimeModule,
|
||||
...CONTENT_TAG_DIRECTIVES,
|
||||
@@ -57,6 +59,7 @@ import { AlfrescoApiLoaderService, createAlfrescoApiInstance } from './api-facto
|
||||
],
|
||||
providers: [provideTranslations('adf-content-services', 'assets/adf-content-services')],
|
||||
exports: [
|
||||
MaterialModule,
|
||||
...CONTENT_TAG_DIRECTIVES,
|
||||
...DOCUMENT_LIST_DIRECTIVES,
|
||||
...CONTENT_UPLOAD_DIRECTIVES,
|
||||
|
||||
@@ -16,10 +16,20 @@
|
||||
/>
|
||||
@if (form.controls['name'].dirty && form.controls['name'].invalid) {
|
||||
<mat-error>
|
||||
<span class="adf-error-text"
|
||||
>@if (form.controls['name'].errors?.required) {{{ 'CORE.FOLDER_DIALOG.FOLDER_NAME.ERRORS.REQUIRED' | translate }}} @else if (form.controls['name'].errors?.message) {{{ form.controls['name'].errors?.message | translate }}}</span>
|
||||
@if (form.controls['name'].hasError('required')) {
|
||||
<span>
|
||||
{{ 'CORE.FOLDER_DIALOG.FOLDER_NAME.ERRORS.REQUIRED' | translate }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@else if (form.controls['name'].hasError('message')) {
|
||||
<span>
|
||||
{{ form.controls['name'].errors?.message | translate }}
|
||||
</span>
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="adf-full-width adf-folder-dialog-form-field">
|
||||
|
||||
+1
-54
@@ -24,9 +24,6 @@ import {
|
||||
FavoritePagingList,
|
||||
NodeEntry,
|
||||
NodePaging,
|
||||
Person,
|
||||
PersonEntry,
|
||||
ResultSetPaging,
|
||||
Site,
|
||||
SiteEntry,
|
||||
SiteMember,
|
||||
@@ -197,57 +194,7 @@ describe('CustomResourcesService', () => {
|
||||
spyOn(customResourcesService, 'getRecentFiles').and.stub();
|
||||
|
||||
customResourcesService.loadFolderByNodeId('-recent-', pagination, ['include'], 'where', ['filters']);
|
||||
expect(customResourcesService.getRecentFiles).toHaveBeenCalledWith('-me-', pagination, ['filters'], ['include']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecentFiles', () => {
|
||||
const pagination: PaginationModel = { maxItems: 100, skipCount: 0 };
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(customResourcesService.peopleApi, 'getPerson').and.returnValue(
|
||||
Promise.resolve(new PersonEntry({ entry: new Person({ id: 'user' }) }))
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeFields through to the search request, keeping the defaults', (done) => {
|
||||
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
|
||||
|
||||
customResourcesService.getRecentFiles('-me-', pagination, undefined, ['isFavorite']).subscribe(() => {
|
||||
expect(searchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(searchSpy.calls.mostRecent().args[0].include).toEqual([
|
||||
'path',
|
||||
'properties',
|
||||
'allowableOperations',
|
||||
'aspectNames',
|
||||
'isFavorite'
|
||||
]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the default include fields when no includeFields are provided', (done) => {
|
||||
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
|
||||
|
||||
customResourcesService.getRecentFiles('-me-', pagination).subscribe(() => {
|
||||
expect(searchSpy.calls.mostRecent().args[0].include).toEqual(['path', 'properties', 'allowableOperations', 'aspectNames']);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not duplicate an includeField that is already a default', (done) => {
|
||||
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
|
||||
|
||||
customResourcesService.getRecentFiles('-me-', pagination, undefined, ['properties', 'isFavorite']).subscribe(() => {
|
||||
expect(searchSpy.calls.mostRecent().args[0].include).toEqual([
|
||||
'path',
|
||||
'properties',
|
||||
'allowableOperations',
|
||||
'aspectNames',
|
||||
'isFavorite'
|
||||
]);
|
||||
done();
|
||||
});
|
||||
expect(customResourcesService.getRecentFiles).toHaveBeenCalledWith('-me-', pagination, ['filters']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,10 +73,9 @@ export class CustomResourcesService {
|
||||
* @param personId ID of the user
|
||||
* @param pagination Specifies how to paginate the results
|
||||
* @param filters Specifies additional filters to apply (joined with **AND**)
|
||||
* @param includeFields List of data field names to include in the results
|
||||
* @returns List of nodes for the recently used files
|
||||
*/
|
||||
getRecentFiles(personId: string, pagination: PaginationModel, filters?: string[], includeFields: string[] = []): Observable<ResultSetPaging> {
|
||||
getRecentFiles(personId: string, pagination: PaginationModel, filters?: string[]): Observable<ResultSetPaging> {
|
||||
const defaultFilter = [
|
||||
'TYPE:"content"',
|
||||
'-PATH:"//cm:wiki/*"',
|
||||
@@ -122,7 +121,7 @@ export class CustomResourcesService {
|
||||
language: SEARCH_LANGUAGE.AFTS
|
||||
},
|
||||
filterQueries,
|
||||
include: [...new Set(['path', 'properties', 'allowableOperations', 'aspectNames', ...includeFields])],
|
||||
include: ['path', 'properties', 'allowableOperations', 'aspectNames'],
|
||||
sort: [
|
||||
{
|
||||
type: 'FIELD',
|
||||
@@ -381,7 +380,7 @@ export class CustomResourcesService {
|
||||
} else if (nodeId === '-favorites-') {
|
||||
return this.loadFavorites(pagination, includeFields, where);
|
||||
} else if (nodeId === '-recent-') {
|
||||
return this.getRecentFiles('-me-', pagination, filters, includeFields);
|
||||
return this.getRecentFiles('-me-', pagination, filters);
|
||||
} else {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Aktionsmenü öffnen"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "{{ name }} ausgewählt",
|
||||
"DESELECTED": "{{name}} Auswahl aufgehoben"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Anfrage zum Bibliotheksbeitritt abgeschickt"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Bitte wählen Sie nicht mehr als {{ maxFiles }} Dateien.",
|
||||
"FOLDER_SELECTED": "Ordner sind nicht kompatibel mit KI Agents."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "{{ name }} zu Favoriten hinzugefügt",
|
||||
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Request sent to join this library"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Please select no more than {{ maxFiles }} files.",
|
||||
"FOLDER_SELECTED": "Folders are not compatible with AI Agents."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Added {{ name }} to favorites",
|
||||
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Request sent to join this library"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Please select no more than {{ maxFiles }} files.",
|
||||
"FOLDER_SELECTED": "Folders are not compatible with AI Agents."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Added {{ name }} to favorites",
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Abrir menú de acciones"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "{{name}} seleccionado",
|
||||
"DESELECTED": "{{name}} deseleccionado"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Se ha enviado una solicitud para unirse a esta biblioteca"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "No seleccione más de {{ maxFiles }} ficheros.",
|
||||
"FOLDER_SELECTED": "Las carpetas no son compatibles con los Agentes IA."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Añadido {{ name }} a favoritos",
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Ouvrir le menu actions"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "{{name}} sélectionné",
|
||||
"DESELECTED": "{{name}} désélectionné"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Demande envoyée pour rejoindre cette bibliothèque"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Veuillez ne pas sélectionner plus de {{ maxFiles }} fichiers.",
|
||||
"FOLDER_SELECTED": "Les dossiers ne sont pas compatibles avec les agents IA."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Ajout de {{ name }} aux favoris",
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Apri menu azioni"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "{{name}} selezionato",
|
||||
"DESELECTED": "{{name}} deselezionato"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Richiesta di partecipazione alla libreria inviata"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Selezionare più di {{ maxFiles }} file.",
|
||||
"FOLDER_SELECTED": "Le cartelle non sono compatibili con gli agenti IA."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Aggiunto {{ name }} ai preferiti",
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Otwórz menu akcji"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "Wybrano {{name}}",
|
||||
"DESELECTED": "Odznaczono {{name}}"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Żądanie dołączenia do tej biblioteki zostało wysłane"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Proszę wybrać nie więcej niż {{ maxFiles }} plików.",
|
||||
"FOLDER_SELECTED": "Foldery nie są kompatybilne z agentami AI."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Dodano {{ name }} do ulubionych",
|
||||
|
||||
@@ -629,8 +629,8 @@
|
||||
"TOOLTIP": "Abrir menu de ações"
|
||||
},
|
||||
"ARIA": {
|
||||
"SELECTED": "{{name}} selecionado",
|
||||
"DESELECTED": "{{name}} desmarcado"
|
||||
"SELECTED": "{{ name }} selected",
|
||||
"DESELECTED": "{{ name }} deselected"
|
||||
}
|
||||
},
|
||||
"LIBRARY": {
|
||||
@@ -729,6 +729,14 @@
|
||||
"JOIN_REQUESTED": "Solicitação enviada para aderir a esta biblioteca"
|
||||
}
|
||||
},
|
||||
"KNOWLEDGE_RETRIEVAL": {
|
||||
"SEARCH": {
|
||||
"WARNINGS": {
|
||||
"TOO_MANY_FILES_SELECTED": "Selecione no máximo {{ maxFiles }} ficheiros.",
|
||||
"FOLDER_SELECTED": "As pastas não são compatíveis com os Agentes de IA."
|
||||
}
|
||||
}
|
||||
},
|
||||
"NODE_FAVORITE_DIRECTIVE": {
|
||||
"MESSAGES": {
|
||||
"NODE_ADDED": "Adicionado {{ name }} aos favoritos",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatRippleModule, MatOptionModule } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatTreeModule } from '@angular/material/tree';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
|
||||
/** @deprecated this module is deprecated and will be removed in future versions */
|
||||
@NgModule({
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatAutocompleteModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatCardModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatProgressBarModule,
|
||||
MatRippleModule,
|
||||
MatMenuModule,
|
||||
MatOptionModule,
|
||||
MatExpansionModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatDatepickerModule,
|
||||
MatSlideToggleModule,
|
||||
MatRadioModule,
|
||||
MatSliderModule,
|
||||
MatTreeModule,
|
||||
MatBadgeModule
|
||||
],
|
||||
exports: [
|
||||
MatButtonModule,
|
||||
MatAutocompleteModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatCardModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatProgressBarModule,
|
||||
MatRippleModule,
|
||||
MatMenuModule,
|
||||
MatOptionModule,
|
||||
MatExpansionModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatDatepickerModule,
|
||||
MatSlideToggleModule,
|
||||
MatRadioModule,
|
||||
MatSliderModule,
|
||||
MatTreeModule,
|
||||
MatBadgeModule
|
||||
]
|
||||
})
|
||||
export class MaterialModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './public-api';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './services';
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './prediction.service';
|
||||
@@ -0,0 +1,53 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { PredictionService } from './prediction.service';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Prediction, PredictionEntry, PredictionPaging, PredictionPagingList, ReviewStatus } from '@alfresco/js-api';
|
||||
|
||||
describe('PredictionService', () => {
|
||||
let service: PredictionService;
|
||||
|
||||
const mockPredictionPaging = (): PredictionPaging => {
|
||||
const prediction = new Prediction();
|
||||
prediction.id = 'test id';
|
||||
const predictionEntry = new PredictionEntry({ entry: prediction });
|
||||
const predictionPagingList = new PredictionPagingList({ entries: [predictionEntry] });
|
||||
return new PredictionPaging({ list: predictionPagingList });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: []
|
||||
});
|
||||
service = TestBed.inject(PredictionService);
|
||||
});
|
||||
|
||||
it('should call getPredictions on PredictionsApi with nodeId', () => {
|
||||
spyOn(service.predictionsApi, 'getPredictions').and.returnValue(Promise.resolve(mockPredictionPaging()));
|
||||
|
||||
service.getPredictions('test id');
|
||||
expect(service.predictionsApi.getPredictions).toHaveBeenCalledWith('test id');
|
||||
});
|
||||
|
||||
it('should call reviewPrediction on PredictionsApi with predictionId and reviewStatus', () => {
|
||||
spyOn(service.predictionsApi, 'reviewPrediction').and.returnValue(Promise.resolve());
|
||||
|
||||
service.reviewPrediction('test id', ReviewStatus.CONFIRMED);
|
||||
expect(service.predictionsApi.reviewPrediction).toHaveBeenCalledWith('test id', ReviewStatus.CONFIRMED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { Injectable, inject } from '@angular/core';
|
||||
import { PredictionsApi, PredictionPaging, ReviewStatus, LazyApi } from '@alfresco/js-api';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { AlfrescoApiService } from '../../services/alfresco-api.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PredictionService {
|
||||
private readonly apiService = inject(AlfrescoApiService);
|
||||
|
||||
@LazyApi((self: PredictionService) => new PredictionsApi(self.apiService.getInstance()))
|
||||
declare readonly predictionsApi: PredictionsApi;
|
||||
|
||||
/**
|
||||
* Get predictions for a given node
|
||||
*
|
||||
* @param nodeId The identifier of node.
|
||||
* @returns Observable<PredictionPaging>
|
||||
*/
|
||||
getPredictions(nodeId: string): Observable<PredictionPaging> {
|
||||
return from(this.predictionsApi.getPredictions(nodeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Review a prediction
|
||||
*
|
||||
* @param predictionId The identifier of prediction.
|
||||
* @param reviewStatus Review status to apply.
|
||||
* @returns Observable<void>
|
||||
*/
|
||||
reviewPrediction(predictionId: string, reviewStatus: ReviewStatus): Observable<void> {
|
||||
return from(this.predictionsApi.reviewPrediction(predictionId, reviewStatus));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './public-api';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export interface SearchAiInputState {
|
||||
active: boolean;
|
||||
selectedAgentId?: string;
|
||||
searchTerm?: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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.
|
||||
*/
|
||||
|
||||
export * from './services/search-ai.service';
|
||||
export * from './models/search-ai-input-state';
|
||||
@@ -0,0 +1,246 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { AiAnswerEntry, KnowledgeRetrievalConfigEntry, Node, QuestionModel, QuestionRequest } from '@alfresco/js-api';
|
||||
import { SearchAiService } from './search-ai.service';
|
||||
import { SearchAiInputState } from '../models/search-ai-input-state';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
describe('SearchAiService', () => {
|
||||
let service: SearchAiService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: []
|
||||
});
|
||||
service = TestBed.inject(SearchAiService);
|
||||
});
|
||||
|
||||
describe('ask', () => {
|
||||
it('should load information about question', (done) => {
|
||||
const question: QuestionModel = {
|
||||
question: 'some question',
|
||||
questionId: 'some id',
|
||||
restrictionQuery: { nodesIds: ['nodeId1', 'nodeId2'] }
|
||||
};
|
||||
spyOn(service.searchAiApi, 'ask').and.returnValue(Promise.resolve(question));
|
||||
const questionRequest: QuestionRequest = {
|
||||
question: 'some question',
|
||||
nodeIds: ['nodeId1', 'nodeId2'],
|
||||
agentId: 'some id'
|
||||
};
|
||||
|
||||
service.ask(questionRequest).subscribe((questionResponse) => {
|
||||
expect(questionResponse).toBe(question);
|
||||
expect(service.searchAiApi.ask).toHaveBeenCalledWith([questionRequest]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnswer', () => {
|
||||
it('should load information about question', (done) => {
|
||||
const questionId = 'some id';
|
||||
const answer: AiAnswerEntry = {
|
||||
entry: {
|
||||
answer: 'Some answer 1',
|
||||
complete: true,
|
||||
question: 'Some question',
|
||||
objectReferences: [
|
||||
{
|
||||
objectId: 'some id 1',
|
||||
references: [
|
||||
{
|
||||
referenceId: 'some reference id 1',
|
||||
rank: 1,
|
||||
rankScore: 0.005
|
||||
},
|
||||
{
|
||||
referenceId: 'some reference id 2',
|
||||
rank: 2,
|
||||
rankScore: 0.004
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
objectId: 'some id 2',
|
||||
references: [
|
||||
{
|
||||
referenceId: 'some reference id 3',
|
||||
rank: 1,
|
||||
rankScore: 0.005
|
||||
},
|
||||
{
|
||||
referenceId: 'some reference id 4',
|
||||
rank: 2,
|
||||
rankScore: 0.004
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
spyOn(service.searchAiApi, 'getAnswer').and.returnValue(Promise.resolve(answer));
|
||||
|
||||
service.getAnswer(questionId).subscribe((answerResponse) => {
|
||||
expect(answerResponse).toBe(answer);
|
||||
expect(service.searchAiApi.getAnswer).toHaveBeenCalledWith(questionId);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should load knowledge retrieval configuration', (done) => {
|
||||
const config: KnowledgeRetrievalConfigEntry = {
|
||||
entry: {
|
||||
knowledgeRetrievalUrl: 'https://some-url'
|
||||
}
|
||||
};
|
||||
spyOn(service.searchAiApi, 'getConfig').and.returnValue(Promise.resolve(config));
|
||||
|
||||
service.getConfig().subscribe((configResponse) => {
|
||||
expect(configResponse).toBe(config);
|
||||
expect(service.searchAiApi.getConfig).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSearchAiInputState', () => {
|
||||
it('should trigger toggleSearchAiInput$', () => {
|
||||
const state: SearchAiInputState = {
|
||||
active: true,
|
||||
selectedAgentId: 'some id'
|
||||
};
|
||||
service.updateSearchAiInputState(state);
|
||||
|
||||
service.toggleSearchAiInput$.subscribe((receivedState) => {
|
||||
expect(receivedState).toBe(state);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSearchAvailability', () => {
|
||||
let translateService: TranslateService;
|
||||
|
||||
const tooManyFilesSelectedError = 'Please select no more than 100 files.';
|
||||
const folderSelectedError = 'Folders are not compatible with AI Agents.';
|
||||
|
||||
beforeEach(() => {
|
||||
translateService = TestBed.inject(TranslateService);
|
||||
spyOn(translateService, 'instant').and.callFake((key) => {
|
||||
switch (key) {
|
||||
case 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED':
|
||||
return tooManyFilesSelectedError;
|
||||
case 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.FOLDER_SELECTED':
|
||||
return folderSelectedError;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should not return error if user did not select any files', () => {
|
||||
expect(
|
||||
service.checkSearchAvailability({
|
||||
count: 0,
|
||||
nodes: [],
|
||||
libraries: [],
|
||||
isEmpty: true
|
||||
})
|
||||
).toEqual('');
|
||||
});
|
||||
|
||||
it('should return error for too many files selected', () => {
|
||||
expect(
|
||||
service.checkSearchAvailability({
|
||||
count: 101,
|
||||
nodes: [],
|
||||
libraries: [],
|
||||
isEmpty: false
|
||||
})
|
||||
).toBe(tooManyFilesSelectedError);
|
||||
expect(translateService.instant).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED', {
|
||||
maxFiles: 100,
|
||||
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error for folder selected', () => {
|
||||
expect(
|
||||
service.checkSearchAvailability({
|
||||
count: 1,
|
||||
nodes: [
|
||||
{
|
||||
entry: {
|
||||
isFolder: true
|
||||
} as Node
|
||||
}
|
||||
],
|
||||
libraries: [],
|
||||
isEmpty: false
|
||||
})
|
||||
).toBe(folderSelectedError);
|
||||
});
|
||||
|
||||
it('should return error for folder and if non text mime type node is selected', () => {
|
||||
expect(
|
||||
service.checkSearchAvailability({
|
||||
count: 1,
|
||||
nodes: [
|
||||
{
|
||||
entry: {
|
||||
isFolder: true,
|
||||
content: {
|
||||
mimeType: 'some mime type',
|
||||
mimeTypeName: 'some mime type',
|
||||
sizeInBytes: 100
|
||||
}
|
||||
} as Node
|
||||
}
|
||||
],
|
||||
libraries: [],
|
||||
isEmpty: false
|
||||
})
|
||||
).toBe(folderSelectedError);
|
||||
});
|
||||
|
||||
it('should return more than one error if more validators detected issues', () => {
|
||||
expect(
|
||||
service.checkSearchAvailability({
|
||||
count: 101,
|
||||
nodes: [
|
||||
{
|
||||
entry: {
|
||||
isFolder: true,
|
||||
content: {
|
||||
mimeType: 'image/jpeg',
|
||||
mimeTypeName: 'image/jpeg',
|
||||
sizeInBytes: 100
|
||||
}
|
||||
} as Node
|
||||
}
|
||||
],
|
||||
libraries: [],
|
||||
isEmpty: false
|
||||
})
|
||||
).toBe(`${tooManyFilesSelectedError} ${folderSelectedError}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2025 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 { Injectable, inject } from '@angular/core';
|
||||
import { AiAnswerEntry, KnowledgeRetrievalConfigEntry, LazyApi, QuestionModel, QuestionRequest, SearchAiApi } from '@alfresco/js-api';
|
||||
import { BehaviorSubject, from, Observable } from 'rxjs';
|
||||
import { SelectionState } from '@alfresco/adf-extensions';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SearchAiInputState } from '../models/search-ai-input-state';
|
||||
import { AlfrescoApiService } from '../../services';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchAiService {
|
||||
private readonly apiService = inject(AlfrescoApiService);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
private readonly toggleSearchAiInput = new BehaviorSubject<SearchAiInputState>({
|
||||
active: false
|
||||
});
|
||||
|
||||
@LazyApi((self: SearchAiService) => new SearchAiApi(self.apiService.getInstance()))
|
||||
declare readonly searchAiApi: SearchAiApi;
|
||||
|
||||
toggleSearchAiInput$ = this.toggleSearchAiInput.asObservable();
|
||||
|
||||
/**
|
||||
* Update the state of the search AI input.
|
||||
*
|
||||
* @param state The new state of the search AI input.
|
||||
*/
|
||||
updateSearchAiInputState(state: SearchAiInputState): void {
|
||||
this.toggleSearchAiInput.next(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question to the AI.
|
||||
*
|
||||
* @param question The question to ask.
|
||||
* @returns QuestionModel object containing information about questions.
|
||||
*/
|
||||
ask(question: QuestionRequest): Observable<QuestionModel> {
|
||||
return from(this.searchAiApi.ask([question]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an answer to specific question.
|
||||
*
|
||||
* @param questionId The ID of the question to get an answer for.
|
||||
* @returns AiAnswerEntry object containing the answer.
|
||||
*/
|
||||
getAnswer(questionId: string): Observable<AiAnswerEntry> {
|
||||
return from(this.searchAiApi.getAnswer(questionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the knowledge retrieval configuration.
|
||||
*
|
||||
* @returns KnowledgeRetrievalConfigEntry object containing the configuration.
|
||||
*/
|
||||
getConfig(): Observable<KnowledgeRetrievalConfigEntry> {
|
||||
return from(this.searchAiApi.getConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using of search is possible (if all conditions are met).
|
||||
*
|
||||
* @param selectedNodesState information about selected nodes.
|
||||
* @param maxSelectedNodes max number of selected nodes. Default 100.
|
||||
* @returns string with error if any condition is not met, empty string otherwise.
|
||||
*/
|
||||
checkSearchAvailability(selectedNodesState: SelectionState, maxSelectedNodes = 100): string {
|
||||
const messages: {
|
||||
key: string;
|
||||
[parameter: string]: number | string;
|
||||
}[] = [];
|
||||
if (selectedNodesState.count > maxSelectedNodes) {
|
||||
messages.push({
|
||||
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED',
|
||||
maxFiles: maxSelectedNodes
|
||||
});
|
||||
}
|
||||
if (selectedNodesState.nodes.some((node) => node.entry.isFolder)) {
|
||||
messages.push({
|
||||
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.FOLDER_SELECTED'
|
||||
});
|
||||
}
|
||||
return messages.map((message) => this.translateService.instant(message.key, message)).join(' ');
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,8 @@
|
||||
type="text"
|
||||
(keypress)="cleanErrorMsg()"
|
||||
[(ngModel)]="newTagName"
|
||||
[errorStateMatcher]="tagErrorStateMatcher"
|
||||
/>
|
||||
@if (errorMsg) {
|
||||
<mat-error data-automation-id="errorMessage">
|
||||
<mat-icon class="adf-error-icon">error_outline</mat-icon>
|
||||
<span class="adf-error-text">{{ errorMsg }}</span>
|
||||
</mat-error>
|
||||
}
|
||||
<mat-hint data-automation-id="errorMessage" *ngIf="error" [ngStyle]="{ color: 'red' }" align="start">{{ errorMsg }} </mat-hint>
|
||||
</mat-form-field>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../_mixins' as mixins;
|
||||
|
||||
.adf-tag-node-actions-list {
|
||||
.adf-full-width {
|
||||
width: 100%;
|
||||
@@ -20,7 +18,3 @@
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.adf-full-width .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
*/
|
||||
|
||||
import { IconModule, TranslationService } from '@alfresco/adf-core';
|
||||
import { ErrorStateMatcher } from '@angular/material/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, ViewEncapsulation } from '@angular/core';
|
||||
import { TagService } from '../services/tag.service';
|
||||
import { TagPaging } from '@alfresco/js-api';
|
||||
@@ -37,17 +35,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-tag-node-actions-list',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatListModule,
|
||||
IconModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
TranslatePipe,
|
||||
FormsModule,
|
||||
MatButtonModule
|
||||
],
|
||||
imports: [CommonModule, MatListModule, IconModule, MatFormFieldModule, MatInputModule, TranslatePipe, FormsModule, MatButtonModule],
|
||||
templateUrl: './tag-actions.component.html',
|
||||
styleUrls: ['./tag-actions.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
@@ -78,10 +66,6 @@ export class TagActionsComponent implements OnChanges, OnInit {
|
||||
errorMsg: string;
|
||||
disableAddTag: boolean = true;
|
||||
|
||||
tagErrorStateMatcher: ErrorStateMatcher = {
|
||||
isErrorState: () => !!this.errorMsg
|
||||
};
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
@@ -58,9 +58,7 @@
|
||||
*ngIf="node.hasChildren"
|
||||
class="adf-tree-expand-collapse-button"
|
||||
mat-icon-button
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
(click)="expandCollapseNode(node); $event.stopPropagation()"
|
||||
matTreeNodeToggle
|
||||
>
|
||||
<mat-progress-spinner
|
||||
mode="indeterminate"
|
||||
|
||||
@@ -213,26 +213,6 @@ describe('TreeComponent', () => {
|
||||
expect(expandSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
|
||||
});
|
||||
|
||||
it('should call expandCollapseNode once when chevron is clicked', () => {
|
||||
component.refreshTree();
|
||||
fixture.detectChanges();
|
||||
component.treeService.treeNodes = Array.from(treeNodesMock);
|
||||
component.treeService.treeNodes[0].isLoading = false;
|
||||
fixture.detectChanges();
|
||||
const expandCollapseNodeSpy = spyOn(component, 'expandCollapseNode').and.callThrough();
|
||||
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
|
||||
expect(expandCollapseNodeSpy).toHaveBeenCalledOnceWith(component.treeService.treeNodes[0]);
|
||||
});
|
||||
|
||||
it('should not trigger node selection when chevron is clicked and selectableNodes is true', () => {
|
||||
component.selectableNodes = true;
|
||||
component.refreshTree();
|
||||
fixture.detectChanges();
|
||||
const onNodeSelectedSpy = spyOn(component, 'onNodeSelected');
|
||||
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
|
||||
expect(onNodeSelectedSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call collapseNode on TreeService when collapsing node by clicking at node label and node has children', () => {
|
||||
component.refreshTree();
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -12,15 +12,8 @@
|
||||
<span class="adf-version-list-item-line adf-version-list-item-version"
|
||||
[id]="'adf-version-list-item-version-' + version.entry.id">{{ version.entry.id }}</span> -
|
||||
<span class="adf-version-list-item-line adf-version-list-item-date"
|
||||
[id]="'adf-version-list-item-date-' + version.entry.id">{{ version.entry.modifiedAt | date: 'medium' }}</span>
|
||||
[id]="'adf-version-list-item-date-' + version.entry.id">{{ version.entry.modifiedAt | date }}</span>
|
||||
</p>
|
||||
@if (version.entry.modifiedByUser?.displayName) {
|
||||
<p
|
||||
class="adf-version-list-item-line"
|
||||
[attr.data-automation-id]="`adf-version-list-item-modified-by-${version.entry.id}`">
|
||||
{{ version.entry.modifiedByUser.displayName }}
|
||||
</p>
|
||||
}
|
||||
<p
|
||||
[id]="'adf-version-list-item-comment-' + version.entry.id"
|
||||
class="adf-version-list-item-comment"
|
||||
|
||||
@@ -16,49 +16,29 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { VersionListComponent, VersionListDataSource } from './version-list.component';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { Node, NodeEntry, VersionEntry, Version, UserInfo } from '@alfresco/js-api';
|
||||
import { Node, NodeEntry, VersionEntry, Version } from '@alfresco/js-api';
|
||||
import { ContentVersionService } from './content-version.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
|
||||
import { NoopAuthModule, UnitTestingUtils } from '@alfresco/adf-core';
|
||||
import { NoopAuthModule } from '@alfresco/adf-core';
|
||||
import { provideApiTesting } from '../testing/providers';
|
||||
import { DebugElement } from '@angular/core';
|
||||
|
||||
describe('VersionListComponent', () => {
|
||||
let component: VersionListComponent;
|
||||
let fixture: ComponentFixture<VersionListComponent>;
|
||||
let dialog: MatDialog;
|
||||
let contentVersionService: ContentVersionService;
|
||||
let testingUtils: UnitTestingUtils;
|
||||
|
||||
const nodeId = 'test-id';
|
||||
const versionId = '1.0';
|
||||
|
||||
const versionTest = [
|
||||
new VersionEntry({
|
||||
entry: new Version({
|
||||
name: 'test-file-name',
|
||||
id: '1.0',
|
||||
versionComment: 'test-version-comment',
|
||||
modifiedByUser: new UserInfo({
|
||||
displayName: 'TestUser1'
|
||||
}),
|
||||
modifiedAt: new Date(2026, 0, 15, 9, 56, 42, 581)
|
||||
})
|
||||
}),
|
||||
new VersionEntry({
|
||||
entry: new Version({
|
||||
name: 'test-file-name-two',
|
||||
id: '1.1',
|
||||
versionComment: 'test-version-comment',
|
||||
modifiedByUser: new UserInfo({
|
||||
displayName: 'TestUser2'
|
||||
})
|
||||
})
|
||||
})
|
||||
new VersionEntry({ entry: new Version({ name: 'test-file-name', id: '1.0', versionComment: 'test-version-comment' }) }),
|
||||
new VersionEntry({ entry: new Version({ name: 'test-file-name-two', id: '1.0', versionComment: 'test-version-comment' }) })
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
@@ -74,7 +54,6 @@ describe('VersionListComponent', () => {
|
||||
fixture = TestBed.createComponent(VersionListComponent);
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
contentVersionService = TestBed.inject(ContentVersionService);
|
||||
testingUtils = new UnitTestingUtils(fixture.debugElement);
|
||||
|
||||
component = fixture.componentInstance;
|
||||
component.node = { id: nodeId, allowableOperations: ['update'] } as Node;
|
||||
@@ -135,18 +114,16 @@ describe('VersionListComponent', () => {
|
||||
});
|
||||
|
||||
describe('Version history fetching', () => {
|
||||
const getLoadingProgressBar = (): DebugElement => testingUtils.getByDataAutomationId('version-history-loading-bar');
|
||||
|
||||
const getCommentElement = (): DebugElement => testingUtils.getByCSS('.adf-version-list-item-comment');
|
||||
|
||||
it('should use loading bar', (done) => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLoadingProgressBar()).toBeNull();
|
||||
let loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]'));
|
||||
expect(loadingProgressBar).toBeNull();
|
||||
|
||||
component.versionsDataSource.isLoading.pipe(take(1)).subscribe(() => {
|
||||
fixture.detectChanges();
|
||||
expect(getLoadingProgressBar()).not.toBeNull();
|
||||
loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]'));
|
||||
expect(loadingProgressBar).not.toBeNull();
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -170,18 +147,13 @@ describe('VersionListComponent', () => {
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
const versionFileName = testingUtils.getInnerTextByCSS('.adf-version-list-item-name');
|
||||
const versionIdText = testingUtils.getInnerTextByCSS('.adf-version-list-item-version');
|
||||
const versionComment = getCommentElement().nativeElement.innerText;
|
||||
const versionFileName = fixture.debugElement.query(By.css('.adf-version-list-item-name')).nativeElement.innerText;
|
||||
const versionIdText = fixture.debugElement.query(By.css('.adf-version-list-item-version')).nativeElement.innerText;
|
||||
const versionComment = fixture.debugElement.query(By.css('.adf-version-list-item-comment')).nativeElement.innerText;
|
||||
|
||||
expect(versionFileName).toBe('test-file-name');
|
||||
expect(versionIdText).toBe('1.0');
|
||||
expect(versionComment.trim()).toBe('test-version-comment');
|
||||
expect(testingUtils.getInnerTextByDataAutomationId('adf-version-list-item-modified-by-1.0')).toBe('TestUser1');
|
||||
const expectedModifiedAt = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'medium' }).format(
|
||||
versionTest[0].entry.modifiedAt
|
||||
);
|
||||
expect(testingUtils.getInnerTextByCSS('#adf-version-list-item-date-1\\.0')).toBe(expectedModifiedAt);
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -194,8 +166,9 @@ describe('VersionListComponent', () => {
|
||||
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
const versionCommentEl = fixture.debugElement.query(By.css('.adf-version-list-item-comment'));
|
||||
|
||||
expect(getCommentElement()).toBeNull();
|
||||
expect(versionCommentEl).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
@@ -295,15 +268,15 @@ describe('VersionListComponent', () => {
|
||||
|
||||
const getActionMenuButton = (version = '1.0'): HTMLButtonElement => {
|
||||
fixture.detectChanges();
|
||||
return testingUtils.getByCSS(`[id="adf-version-list-action-menu-button-${version}"]`)?.nativeElement;
|
||||
return fixture.debugElement.query(By.css(`[id="adf-version-list-action-menu-button-${version}"]`))?.nativeElement;
|
||||
};
|
||||
|
||||
const getRestoreButton = (version = '1.0'): HTMLButtonElement => {
|
||||
getActionMenuButton(version).click();
|
||||
return testingUtils.getByCSS(`[id="adf-version-list-action-restore-${version}"]`)?.nativeElement;
|
||||
return fixture.debugElement.query(By.css(`[id="adf-version-list-action-restore-${version}"]`))?.nativeElement;
|
||||
};
|
||||
|
||||
const getDeleteButton = (version = '1.1'): DebugElement => testingUtils.getByCSS(`[id="adf-version-list-action-delete-${version}"]`);
|
||||
const getDeleteButton = (version = '1.1') => fixture.debugElement.query(By.css(`[id="adf-version-list-action-delete-${version}"]`));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
@@ -423,7 +396,7 @@ describe('VersionListComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.detectChanges();
|
||||
virtualListViewport = testingUtils.getByDirective(CdkFixedSizeVirtualScroll).injector.get(CdkFixedSizeVirtualScroll);
|
||||
virtualListViewport = fixture.debugElement.query(By.directive(CdkFixedSizeVirtualScroll)).injector.get(CdkFixedSizeVirtualScroll);
|
||||
});
|
||||
|
||||
it('should have assigned correct minBufferPx', () => {
|
||||
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
ViewerMoreActionsComponent,
|
||||
ViewerToolbarActionsComponent,
|
||||
NoopAuthModule,
|
||||
NoopTranslateModule,
|
||||
UnitTestingUtils
|
||||
NoopTranslateModule
|
||||
} from '@alfresco/adf-core';
|
||||
import { NodesApiService } from '../../common/services/nodes-api.service';
|
||||
import { UploadService } from '../../common/services/upload.service';
|
||||
@@ -178,7 +177,6 @@ describe('AlfrescoViewerComponent', () => {
|
||||
let renditionService: RenditionService;
|
||||
let viewUtilService: ViewUtilService;
|
||||
let nodeActionsService: NodeActionsService;
|
||||
let testingUtils: UnitTestingUtils;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -199,7 +197,6 @@ describe('AlfrescoViewerComponent', () => {
|
||||
fixture = TestBed.createComponent(AlfrescoViewerComponent);
|
||||
element = fixture.nativeElement;
|
||||
component = fixture.componentInstance;
|
||||
testingUtils = new UnitTestingUtils(fixture.debugElement);
|
||||
uploadService = TestBed.inject(UploadService);
|
||||
nodesApiService = TestBed.inject(NodesApiService);
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
@@ -401,16 +398,12 @@ describe('AlfrescoViewerComponent', () => {
|
||||
component.nodeId = 'id1';
|
||||
component.showViewer = true;
|
||||
component.versionId = null;
|
||||
component.ngOnChanges(getSimpleChanges('id1'));
|
||||
|
||||
await fixture.ngZone.run(async () => {
|
||||
component.ngOnChanges(getSimpleChanges('id1'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
|
||||
expect(viewer.fileName).toBe('file1.pdf');
|
||||
expect(viewer.blobFile).toBe(mockBlob);
|
||||
expect(component.fileName).toBe('file1.pdf');
|
||||
expect(component.blobFileContent).toBe(mockBlob);
|
||||
});
|
||||
|
||||
it('should change display name every time node`s version changes', fakeAsync(() => {
|
||||
@@ -1070,127 +1063,4 @@ describe('AlfrescoViewerComponent', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF blob fetch and atomic state assignment', () => {
|
||||
it('should assign blobFileContent and null urlFileContent when PDF blob fetch succeeds', async () => {
|
||||
const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' });
|
||||
const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response;
|
||||
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
|
||||
|
||||
spyOn(component.nodesApi, 'getNode').and.returnValue(
|
||||
Promise.resolve(
|
||||
new NodeEntry({
|
||||
entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) })
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
component.nodeId = 'node-1';
|
||||
component.showViewer = true;
|
||||
|
||||
await fixture.ngZone.run(async () => {
|
||||
component.ngOnChanges(getSimpleChanges('node-1'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
|
||||
expect(viewer.blobFile).toBe(mockBlob);
|
||||
expect(viewer.urlFile).toBeFalsy();
|
||||
expect(viewer.mimeType).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should fall back to URL-based viewing when PDF blob fetch fails', async () => {
|
||||
spyOn(window, 'fetch').and.returnValue(Promise.reject(new Error('Network error')));
|
||||
spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url');
|
||||
|
||||
spyOn(component.nodesApi, 'getNode').and.returnValue(
|
||||
Promise.resolve(
|
||||
new NodeEntry({
|
||||
entry: new Node({
|
||||
name: 'test.pdf',
|
||||
id: 'node-1',
|
||||
content: new ContentInfo({ mimeType: 'application/pdf' }),
|
||||
properties: { 'cm:versionLabel': '1.0' }
|
||||
})
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
component.nodeId = 'node-1';
|
||||
component.showViewer = true;
|
||||
|
||||
await fixture.ngZone.run(async () => {
|
||||
component.ngOnChanges(getSimpleChanges('node-1'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
|
||||
expect(viewer.blobFile).toBeFalsy();
|
||||
expect(viewer.urlFile).toBeTruthy();
|
||||
expect(viewer.mimeType).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should fall back to URL-based viewing when PDF fetch returns non-ok response', async () => {
|
||||
const mockResponse = { ok: false, status: 403 } as Response;
|
||||
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
|
||||
spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url');
|
||||
|
||||
spyOn(component.nodesApi, 'getNode').and.returnValue(
|
||||
Promise.resolve(
|
||||
new NodeEntry({
|
||||
entry: new Node({
|
||||
name: 'test.pdf',
|
||||
id: 'node-1',
|
||||
content: new ContentInfo({ mimeType: 'application/pdf' }),
|
||||
properties: { 'cm:versionLabel': '1.0' }
|
||||
})
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
component.nodeId = 'node-1';
|
||||
component.showViewer = true;
|
||||
|
||||
await fixture.ngZone.run(async () => {
|
||||
component.ngOnChanges(getSimpleChanges('node-1'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
|
||||
expect(viewer.blobFile).toBeFalsy();
|
||||
expect(viewer.urlFile).toBeTruthy();
|
||||
expect(viewer.mimeType).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should not expose intermediate null state during node setup', async () => {
|
||||
const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' });
|
||||
const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response;
|
||||
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
|
||||
|
||||
spyOn(component.nodesApi, 'getNode').and.returnValue(
|
||||
Promise.resolve(
|
||||
new NodeEntry({
|
||||
entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) })
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
component.nodeId = 'node-1';
|
||||
component.showViewer = true;
|
||||
|
||||
await fixture.ngZone.run(async () => {
|
||||
component.ngOnChanges(getSimpleChanges('node-1'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
|
||||
expect(viewer.blobFile).toBe(mockBlob);
|
||||
expect(viewer.urlFile).toBeFalsy();
|
||||
expect(viewer.fileName).toBe('test.pdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,9 +275,9 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
|
||||
private async onNodeUpdated(node: Node) {
|
||||
if (node && node.id === this.nodeId) {
|
||||
this.generateCacheBusterNumber();
|
||||
this.blobFileContent = null;
|
||||
|
||||
await this.setUpNodeFile(node);
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,7 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
|
||||
|
||||
private async setupNode() {
|
||||
try {
|
||||
this.blobFileContent = null;
|
||||
this.nodeEntry = await this.nodesApi.getNode(this.nodeId, { include: ['allowableOperations'] });
|
||||
if (this.versionId) {
|
||||
this.versionEntry = await this.versionsApi.getVersion(this.nodeId, this.versionId);
|
||||
@@ -317,7 +318,6 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
|
||||
this.canEditNode = this.contentService.hasAllowableOperations(nodeData, 'update');
|
||||
let mimeType: string;
|
||||
let urlFileContent: string;
|
||||
let blobContent: Blob = null;
|
||||
|
||||
if (versionData?.content) {
|
||||
mimeType = versionData.content.mimeType;
|
||||
@@ -352,24 +352,32 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
|
||||
} else if (viewerType === 'media') {
|
||||
this.tracks = await this.renditionService.generateMediaTracksRendition(this.nodeId);
|
||||
} else if (viewerType === 'pdf') {
|
||||
const contentUrl = versionData
|
||||
? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id)
|
||||
: this.contentApi.getContentUrl(this.nodeId);
|
||||
try {
|
||||
const contentUrl = versionData
|
||||
? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id)
|
||||
: this.contentApi.getContentUrl(this.nodeId);
|
||||
|
||||
const blob = await fetch(contentUrl, { credentials: 'include', headers: { 'Cache-Control': 'no-cache' } })
|
||||
.then((response) => (response.ok ? response.blob() : null))
|
||||
.catch(() => null);
|
||||
// Fetch the content as Blob using fetch API with credentials
|
||||
const response = await fetch(contentUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
blobContent = blob;
|
||||
this.blobFileContent = await response.blob();
|
||||
urlFileContent = null;
|
||||
} catch (error) {
|
||||
console.error('[ADF DEBUG] Failed to fetch PDF as blob, falling back to URL', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.mimeType = mimeType;
|
||||
this.nodeMimeType = nodeMimeType;
|
||||
this.fileName = versionData ? versionData.name : nodeData.name;
|
||||
this.blobFileContent = blobContent;
|
||||
this.urlFileContent = urlFileContent ? urlFileContent + (this.cacheBusterNumber ? '&' + this.cacheBusterNumber : '') : null;
|
||||
this.sidebarRightTemplateContext.node = nodeData;
|
||||
this.sidebarLeftTemplateContext.node = nodeData;
|
||||
|
||||
@@ -44,8 +44,12 @@ export * from './lib/security/index';
|
||||
export * from './lib/api-factories';
|
||||
export * from './lib/services/index';
|
||||
export * from './lib/infinite-scroll-datasource';
|
||||
export * from './lib/prediction/index';
|
||||
export * from './lib/legal-hold/index';
|
||||
export * from './lib/api-factories';
|
||||
export * from './lib/mock/alfresco-api.service.mock';
|
||||
export * from './lib/agent/index';
|
||||
export * from './lib/search-ai/index';
|
||||
|
||||
export * from './lib/content.module';
|
||||
export * from './lib/material.module';
|
||||
|
||||
+11
-11
@@ -22,19 +22,19 @@
|
||||
"date-fns": "^2.30.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": ">=20.3.25",
|
||||
"@angular/cdk": ">=20.2.14",
|
||||
"@angular/common": ">=20.3.25",
|
||||
"@angular/core": ">=20.3.25",
|
||||
"@angular/forms": ">=20.3.25",
|
||||
"@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/animations": ">=16.0.0",
|
||||
"@angular/cdk": ">=16.0.0",
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@angular/forms": ">=16.0.0",
|
||||
"@angular/material": ">=16.0.0",
|
||||
"@angular/material-date-fns-adapter": ">=16.0.0",
|
||||
"@angular/platform-browser": ">=16.0.0",
|
||||
"@angular/router": ">=16.0.0",
|
||||
"@mat-datetimepicker/core": ">=12.0.1",
|
||||
"@ngx-translate/core": ">=17.0.0",
|
||||
"@alfresco/js-api": ">=10.0.0",
|
||||
"@alfresco/adf-extensions": ">=9.0.0",
|
||||
"@alfresco/js-api": ">=9.5.0",
|
||||
"@alfresco/adf-extensions": ">=8.5.0",
|
||||
"minimatch": ">=10.0.0",
|
||||
"pdfjs-dist": ">=3.3.122",
|
||||
"rxjs": ">=7.8.0"
|
||||
|
||||
@@ -204,48 +204,4 @@ describe('AppConfigService', () => {
|
||||
expect(appConfigService.get<any>('objectKey').secondUrl).toEqual('http://localhost:8080');
|
||||
expect(appConfigService.get<any>('objectKey').thirdUrl).toEqual('http://localhost:8080');
|
||||
});
|
||||
|
||||
describe('oauth2', () => {
|
||||
it('should set showDebugInformation to true when configured as boolean true', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: true } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set showDebugInformation to true when configured as the string "true"', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: 'true' } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set showDebugInformation to false when configured as false', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: false } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
|
||||
});
|
||||
|
||||
it('should default showDebugInformation to false when not configured', () => {
|
||||
appConfigService.config = { oauth2: {} };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
|
||||
});
|
||||
|
||||
it('should set timeSync to true when configured as boolean true', () => {
|
||||
appConfigService.config = { oauth2: { timeSync: true } };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set timeSync to true when configured as the string "true"', () => {
|
||||
appConfigService.config = { oauth2: { timeSync: 'true' } };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeTrue();
|
||||
});
|
||||
|
||||
it('should default timeSync to false when not configured', () => {
|
||||
appConfigService.config = { oauth2: {} };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,7 @@ export const AppConfigValues = {
|
||||
LOGIN_ROUTE: 'loginRoute',
|
||||
DISABLECSRF: 'disableCSRF',
|
||||
AUTH_WITH_CREDENTIALS: 'auth.withCredentials',
|
||||
AUTH_TIME_SYNC_ENABLED: 'oauth2.timeSync',
|
||||
AUTH_SHOW_DEBUG_INFORMATION: 'oauth2.showDebugInformation',
|
||||
AUTH_TIME_SYNC_ENABLED: 'auth.timeSync.enabled',
|
||||
SERVER_TIME_URL: 'serverTimeUrl',
|
||||
APPLICATION: 'application',
|
||||
STORAGE_PREFIX: 'application.storagePrefix',
|
||||
@@ -260,15 +259,13 @@ export class AppConfigService {
|
||||
const silentLogin = config['silentLogin'] === true || config['silentLogin'] === 'true';
|
||||
const codeFlow = config['codeFlow'] === true || config['codeFlow'] === 'true';
|
||||
const timeSync = config['timeSync'] === true || config['timeSync'] === 'true';
|
||||
const showDebugInformation = config['showDebugInformation'] === true || config['showDebugInformation'] === 'true';
|
||||
|
||||
return {
|
||||
...(config as OauthConfigModel),
|
||||
implicitFlow,
|
||||
silentLogin,
|
||||
codeFlow,
|
||||
timeSync,
|
||||
showDebugInformation
|
||||
timeSync
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,5 +33,4 @@ export interface OauthConfigModel {
|
||||
clockSkewInSec?: number;
|
||||
sessionChecksEnabled?: boolean;
|
||||
timeSync?: boolean;
|
||||
showDebugInformation?: boolean;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,15 +29,27 @@ import {
|
||||
OAuthLogger
|
||||
} from 'angular-oauth2-oidc';
|
||||
import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler';
|
||||
import { firstValueFrom, from, Observable, race, ReplaySubject } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
|
||||
import { firstValueFrom, from, Observable, of, ReplaySubject } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, filter, map, scan, shareReplay, switchMap, take } from 'rxjs/operators';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
|
||||
import { RetryLoginService } from './retry-login.service';
|
||||
import { TimeSyncService } from '../services/time-sync.service';
|
||||
import { ClockSyncResult, TimeSync, TimeSyncService } from '../services/time-sync.service';
|
||||
|
||||
const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
|
||||
|
||||
/** Tracks OAuth errors so token refresh errors can keep their existing one-retry behavior. */
|
||||
interface OAuthErrorProcessingState {
|
||||
/** Latest OAuth error event that may need clock-skew handling. */
|
||||
event: OAuthErrorEvent | null;
|
||||
|
||||
/** Whether the current event should be evaluated by the clock-skew pipeline. */
|
||||
shouldProcess: boolean;
|
||||
|
||||
/** Number of consecutive token refresh errors seen since the last successful token event. */
|
||||
tokenRefreshErrorCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedirectAuthService extends AuthService {
|
||||
private readonly oauthService = inject(OAuthService);
|
||||
@@ -51,9 +63,9 @@ export class RedirectAuthService extends AuthService {
|
||||
private readonly _isDiscoveryDocumentLoadedSubject$ = new ReplaySubject<boolean>();
|
||||
public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable();
|
||||
|
||||
onLogin: Observable<any>;
|
||||
onLogin!: Observable<any>;
|
||||
|
||||
onTokenReceived: Observable<any>;
|
||||
onTokenReceived!: Observable<any>;
|
||||
|
||||
private _loadDiscoveryDocumentPromise = Promise.resolve(false);
|
||||
|
||||
@@ -63,7 +75,7 @@ export class RedirectAuthService extends AuthService {
|
||||
* This observable listens to the events emitted by the OAuth service and filters
|
||||
* them to only include instances of OAuthSuccessEvent with the type `logout`.
|
||||
*/
|
||||
onLogout$: Observable<void>;
|
||||
onLogout$!: Observable<void>;
|
||||
|
||||
/**
|
||||
* Observable stream that emits OAuthErrorEvent instances.
|
||||
@@ -72,40 +84,36 @@ export class RedirectAuthService extends AuthService {
|
||||
* them to only include instances of OAuthErrorEvent. It then maps these events
|
||||
* to the correct type.
|
||||
*/
|
||||
oauthErrorEvent$: Observable<OAuthErrorEvent>;
|
||||
oauthErrorEvent$!: Observable<OAuthErrorEvent>;
|
||||
|
||||
/**
|
||||
* Observable stream that emits the first OAuth error event that occurs.
|
||||
*/
|
||||
firstOauthErrorEventOccur$: Observable<OAuthErrorEvent>;
|
||||
firstOauthErrorEventOccur$!: Observable<OAuthErrorEvent>;
|
||||
|
||||
/**
|
||||
* Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors.
|
||||
*/
|
||||
firstOauthErrorEventExcludingTokenRefreshError$: Observable<OAuthErrorEvent>;
|
||||
firstOauthErrorEventExcludingTokenRefreshError$!: Observable<OAuthErrorEvent>;
|
||||
|
||||
/**
|
||||
* Observable stream that emits the second OAuth token refresh error event that occurs.
|
||||
*/
|
||||
secondTokenRefreshErrorEventOccur$: Observable<OAuthErrorEvent>;
|
||||
|
||||
/**
|
||||
* Observable that emits an error when the token has expired due to
|
||||
* the local machine clock being out of sync with the server time.
|
||||
*/
|
||||
tokenHasExpiredDueToClockOutOfSync$: Observable<Error>;
|
||||
secondTokenRefreshErrorEventOccur$!: Observable<OAuthErrorEvent>;
|
||||
|
||||
/**
|
||||
* Observable that emits an error when the OAuth error event occurs due to
|
||||
* the local machine clock being out of sync with the server time.
|
||||
* When clock drift is detected, it re-syncs the clock and requests a new token
|
||||
* before propagating the error (if the refresh still fails).
|
||||
*/
|
||||
oauthErrorEventOccurDueToClockOutOfSync$: Observable<Error>;
|
||||
oauthErrorEventOccurDueToClockOutOfSync$!: Observable<Error>;
|
||||
|
||||
/**
|
||||
* Observable stream that emits either OAuthErrorEvent or Error.
|
||||
* This stream combines multiple OAuth error sources into a single observable.
|
||||
*/
|
||||
combinedOAuthErrorsStream$: Observable<OAuthErrorEvent | Error>;
|
||||
combinedOAuthErrorsStream$!: Observable<OAuthErrorEvent | Error>;
|
||||
|
||||
/** Subscribe to whether the user has valid Id/Access tokens. */
|
||||
authenticated$!: Observable<boolean>;
|
||||
@@ -149,76 +157,313 @@ export class RedirectAuthService extends AuthService {
|
||||
|
||||
this.oauthService.clearHashAfterLogin = true;
|
||||
|
||||
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation)).subscribe((event) => {
|
||||
this.subscribeToDebugOAuthEvents(oauthService);
|
||||
this.initializeOAuthEventStreams();
|
||||
this.subscribeToCombinedOAuthErrors();
|
||||
this.removeInvalidStoredAccessTokenAfterClockSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs OAuth events when the underlying OAuth service has debug output enabled.
|
||||
* This preserves the library-controlled debug behavior and keeps production logging quiet.
|
||||
*
|
||||
* @param oauthService OAuth service instance captured before event subscriptions are created
|
||||
*/
|
||||
private subscribeToDebugOAuthEvents(oauthService: OAuthService): void {
|
||||
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation === true)).subscribe((event) => {
|
||||
if (event instanceof OAuthErrorEvent) {
|
||||
this._oauthLogger.error('OAuthErrorEvent Object:', event);
|
||||
} else {
|
||||
this._oauthLogger.info('OAuthEvent Object:', event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.oauthErrorEvent$ = this.oauthService.events.pipe(
|
||||
/**
|
||||
* Creates all public OAuth event streams exposed by this service.
|
||||
* These streams preserve the existing logout/error behavior and isolate the constructor from
|
||||
* the mechanics of each observable pipeline.
|
||||
*/
|
||||
private initializeOAuthEventStreams(): void {
|
||||
this.oauthErrorEvent$ = this.createOAuthErrorEventStream();
|
||||
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
|
||||
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.createFirstOAuthErrorExcludingTokenRefreshStream();
|
||||
this.secondTokenRefreshErrorEventOccur$ = this.createSecondTokenRefreshErrorStream();
|
||||
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.createClockOutOfSyncErrorStream();
|
||||
this.authenticated$ = this.createAuthenticatedStream();
|
||||
this.onLogout$ = this.createLogoutStream();
|
||||
this.combinedOAuthErrorsStream$ = this.createCombinedOAuthErrorStream();
|
||||
this.onLogin = this.createLoginStream();
|
||||
this.onTokenReceived = this.createTokenReceivedStream();
|
||||
this.idpUnreachable$ = this.createIdpUnreachableStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits every OAuth event that represents an OAuth error.
|
||||
*
|
||||
* @returns OAuth error event stream
|
||||
*/
|
||||
private createOAuthErrorEventStream(): Observable<OAuthErrorEvent> {
|
||||
return this.oauthService.events.pipe(
|
||||
filter((event) => event instanceof OAuthErrorEvent),
|
||||
map((event) => event as OAuthErrorEvent)
|
||||
);
|
||||
}
|
||||
|
||||
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
|
||||
|
||||
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.oauthErrorEvent$.pipe(
|
||||
/**
|
||||
* Emits the first OAuth error that is not a token refresh error.
|
||||
* Token refresh errors have a separate second-failure path so the OAuth library can retry once.
|
||||
*
|
||||
* @returns first non-token-refresh OAuth error stream
|
||||
*/
|
||||
private createFirstOAuthErrorExcludingTokenRefreshStream(): Observable<OAuthErrorEvent> {
|
||||
return this.oauthErrorEvent$.pipe(
|
||||
filter((event) => event instanceof OAuthErrorEvent && event.type !== 'token_refresh_error'),
|
||||
take(1)
|
||||
);
|
||||
}
|
||||
|
||||
this.secondTokenRefreshErrorEventOccur$ = this.oauthErrorEvent$.pipe(
|
||||
/**
|
||||
* Emits the second token refresh error, preserving the existing retry allowance for the first one.
|
||||
*
|
||||
* @returns second token refresh error stream
|
||||
*/
|
||||
private createSecondTokenRefreshErrorStream(): Observable<OAuthErrorEvent> {
|
||||
return this.oauthErrorEvent$.pipe(
|
||||
filter((event) => event.type === 'token_refresh_error'),
|
||||
take(2),
|
||||
filter((_, index) => index === 1)
|
||||
);
|
||||
}
|
||||
|
||||
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe(
|
||||
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
|
||||
filter((timeSync) => timeSync?.outOfSync),
|
||||
map(
|
||||
(timeSync) =>
|
||||
new Error(
|
||||
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
|
||||
)
|
||||
),
|
||||
/**
|
||||
* Emits a clock-out-of-sync error when OAuth errors occur and the corrected clock still shows
|
||||
* the token cannot be trusted after a re-sync attempt.
|
||||
*
|
||||
* @returns clock-out-of-sync error stream
|
||||
*/
|
||||
private createClockOutOfSyncErrorStream(): Observable<Error> {
|
||||
return this.createLogoutCausingOAuthErrorStream().pipe(
|
||||
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
|
||||
filter((result): result is Error => result instanceof Error),
|
||||
take(1)
|
||||
);
|
||||
}
|
||||
|
||||
this.authenticated$ = this.oauthService.events.pipe(
|
||||
/**
|
||||
* Emits OAuth errors that would log the user out unless clock recovery suppresses them.
|
||||
* Non-refresh errors are handled once; token refresh errors keep the existing one-retry
|
||||
* behavior and are handled only on the second consecutive failure.
|
||||
*
|
||||
* @returns logout-causing OAuth error stream
|
||||
*/
|
||||
private createLogoutCausingOAuthErrorStream(): Observable<OAuthErrorEvent> {
|
||||
return this.oauthService.events.pipe(
|
||||
scan((state, event) => this.updateOAuthErrorProcessingState(state, event), {
|
||||
event: null,
|
||||
shouldProcess: false,
|
||||
tokenRefreshErrorCount: 0
|
||||
} as OAuthErrorProcessingState),
|
||||
filter(({ shouldProcess }) => shouldProcess),
|
||||
map(({ event }) => event as OAuthErrorEvent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the token-refresh error counter used by clock-skew detection.
|
||||
* The first token refresh error is skipped so the OAuth library retry path can run; successful
|
||||
* token events reset the counter so isolated refresh failures do not accumulate.
|
||||
*
|
||||
* @param state current OAuth error processing state
|
||||
* @param event latest OAuth event
|
||||
* @returns updated OAuth error processing state
|
||||
*/
|
||||
private updateOAuthErrorProcessingState(state: OAuthErrorProcessingState, event: OAuthEvent): OAuthErrorProcessingState {
|
||||
if (event instanceof OAuthErrorEvent) {
|
||||
return {
|
||||
event,
|
||||
shouldProcess: event.type !== 'token_refresh_error' || state.tokenRefreshErrorCount >= 1,
|
||||
tokenRefreshErrorCount: event.type === 'token_refresh_error' ? state.tokenRefreshErrorCount + 1 : state.tokenRefreshErrorCount
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
event: null,
|
||||
shouldProcess: false,
|
||||
tokenRefreshErrorCount: this.isSuccessfulTokenEvent(event) ? 0 : state.tokenRefreshErrorCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an OAuth event represents a successful token update.
|
||||
*
|
||||
* @param event OAuth event emitted by the OAuth service
|
||||
* @returns true when token refresh error counters should be reset
|
||||
*/
|
||||
private isSuccessfulTokenEvent(event: OAuthEvent): boolean {
|
||||
return event.type === 'token_received' || event.type === 'token_refreshed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-syncs the clock before deciding whether an OAuth error should still log the user out.
|
||||
* Disabled time sync or a failed first sync falls back to the original OAuth error. A fresh
|
||||
* sync, or a failed sync with a previous trusted offset, allows corrected-clock recovery.
|
||||
*
|
||||
* @param event OAuth error currently being handled
|
||||
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
|
||||
*/
|
||||
private resolveOAuthErrorAfterClockSync(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
|
||||
return this._timeSyncService.syncClockOffsetResult().pipe(
|
||||
switchMap((syncResult) => {
|
||||
if (!this.canUseCorrectedClock(syncResult)) {
|
||||
return of(event);
|
||||
}
|
||||
|
||||
return this.resolveOAuthErrorWithCorrectedClock(event);
|
||||
}),
|
||||
catchError(() => of(event))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sync result gives the service a trusted corrected clock for recovery decisions.
|
||||
* A failed sync can still be trusted when a previous successful sync supplied the active offset.
|
||||
*
|
||||
* @param syncResult result from the latest clock sync attempt
|
||||
* @returns true when corrected-clock recovery can be attempted
|
||||
*/
|
||||
private canUseCorrectedClock(syncResult: ClockSyncResult): boolean {
|
||||
return syncResult.status === 'synced' || syncResult.hasSuccessfulSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the corrected clock to decide whether an OAuth error is recoverable.
|
||||
* If the corrected token time is valid, logout is suppressed. If the token is still expired,
|
||||
* a refresh is attempted before emitting the clock-out-of-sync error.
|
||||
*
|
||||
* @param event OAuth error currently being handled
|
||||
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
|
||||
*/
|
||||
private resolveOAuthErrorWithCorrectedClock(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
|
||||
return this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec ?? 0).pipe(
|
||||
switchMap((timeSync) => {
|
||||
if (!timeSync?.outOfSync) {
|
||||
return of(event);
|
||||
}
|
||||
|
||||
return this.refreshExpiredTokenWhenClockOutOfSync(timeSync);
|
||||
}),
|
||||
catchError(() => of(event))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts token recovery when the corrected clock shows the local clock is out of sync.
|
||||
*
|
||||
* @param timeSync corrected clock status
|
||||
* @returns null when token validation or refresh succeeds, otherwise a clock-out-of-sync error
|
||||
*/
|
||||
private refreshExpiredTokenWhenClockOutOfSync(timeSync: TimeSync): Observable<Error | null> {
|
||||
if (!this.tokenHasExpired()) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return from(this.oauthService.refreshToken()).pipe(
|
||||
map(() => null),
|
||||
catchError(() => of(this.createClockOutOfSyncError(timeSync)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the error emitted when OAuth handling determines the local clock is out of sync.
|
||||
*
|
||||
* @param timeSync clock sync details used in the error message
|
||||
* @returns clock-out-of-sync error
|
||||
*/
|
||||
private createClockOutOfSyncError(timeSync: TimeSync): Error {
|
||||
return new Error(
|
||||
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits authentication state changes derived from OAuth events.
|
||||
*
|
||||
* @returns authenticated state stream
|
||||
*/
|
||||
private createAuthenticatedStream(): Observable<boolean> {
|
||||
return this.oauthService.events.pipe(
|
||||
map(() => this.authenticated),
|
||||
distinctUntilChanged(),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
|
||||
this.tokenHasExpiredDueToClockOutOfSync$ = this.oauthService.events.pipe(
|
||||
map(() => !!this.oauthService.getIdentityClaims() && this.tokenHasExpired()),
|
||||
filter((hasExpired) => hasExpired),
|
||||
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
|
||||
filter((timeSync) => timeSync?.outOfSync),
|
||||
map(
|
||||
(timeSync) =>
|
||||
new Error(
|
||||
`Token has expired due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
|
||||
)
|
||||
),
|
||||
take(1)
|
||||
);
|
||||
|
||||
this.onLogout$ = this.oauthService.events.pipe(
|
||||
/**
|
||||
* Emits when the OAuth service reports logout.
|
||||
*
|
||||
* @returns logout notification stream
|
||||
*/
|
||||
private createLogoutStream(): Observable<void> {
|
||||
return this.oauthService.events.pipe(
|
||||
filter((event) => event.type === 'logout'),
|
||||
map(() => undefined)
|
||||
);
|
||||
}
|
||||
|
||||
this.combinedOAuthErrorsStream$ = race([
|
||||
this.oauthErrorEventOccurDueToClockOutOfSync$,
|
||||
this.firstOauthErrorEventExcludingTokenRefreshError$,
|
||||
this.tokenHasExpiredDueToClockOutOfSync$,
|
||||
this.secondTokenRefreshErrorEventOccur$
|
||||
]);
|
||||
/**
|
||||
* Combines the OAuth error streams that should cause a single logout.
|
||||
*
|
||||
* @returns first logout-causing OAuth error or clock error
|
||||
*/
|
||||
private createCombinedOAuthErrorStream(): Observable<OAuthErrorEvent | Error> {
|
||||
return this.createLogoutCausingOAuthErrorStream().pipe(
|
||||
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
|
||||
filter((result): result is OAuthErrorEvent | Error => result !== null),
|
||||
take(1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits when the user becomes authenticated.
|
||||
*
|
||||
* @returns login notification stream
|
||||
*/
|
||||
private createLoginStream(): Observable<void> {
|
||||
return this.authenticated$.pipe(
|
||||
filter((authenticated) => authenticated),
|
||||
map(() => undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits when OAuth tokens are received.
|
||||
*
|
||||
* @returns token received notification stream
|
||||
*/
|
||||
private createTokenReceivedStream(): Observable<void> {
|
||||
return this.oauthService.events.pipe(
|
||||
filter((event: OAuthEvent) => event.type === 'token_received'),
|
||||
map(() => undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits discovery-document load failures as IdP reachability errors.
|
||||
*
|
||||
* @returns IdP unreachable error stream
|
||||
*/
|
||||
private createIdpUnreachableStream(): Observable<Error> {
|
||||
return this.oauthService.events.pipe(
|
||||
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
|
||||
map((event) => event.reason as Error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the combined OAuth error stream and logs out once an error wins the race.
|
||||
*/
|
||||
private subscribeToCombinedOAuthErrors(): void {
|
||||
this.combinedOAuthErrorsStream$.subscribe({
|
||||
next: (res) => {
|
||||
this._oauthLogger.error(res);
|
||||
@@ -226,21 +471,16 @@ export class RedirectAuthService extends AuthService {
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken();
|
||||
|
||||
this.oauthService.events.pipe(take(1)).subscribe(() => {
|
||||
if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) {
|
||||
if (this.oauthService.showDebugInformation) {
|
||||
this._oauthLogger.warn('Access token not valid. Removing all auth items from storage');
|
||||
}
|
||||
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Removes stored auth data when an initially invalid access token remains invalid after clock sync.
|
||||
*/
|
||||
private removeInvalidStoredAccessTokenAfterClockSync(): void {
|
||||
this.oauthService.events
|
||||
.pipe(
|
||||
filter(() => this._timeSyncService.isEnabled() && hasInvalidAccessToken()),
|
||||
take(1),
|
||||
filter(() => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()),
|
||||
switchMap(() => this._timeSyncService.syncClockOffset())
|
||||
)
|
||||
.subscribe(() => {
|
||||
@@ -248,24 +488,9 @@ export class RedirectAuthService extends AuthService {
|
||||
if (this.oauthService.showDebugInformation) {
|
||||
this._oauthLogger.warn('Access token not valid after clock resync. Removing all auth items from storage');
|
||||
}
|
||||
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
|
||||
this.AUTH_STORAGE_ITEMS.forEach((item: string) => this._oauthStorage.removeItem(item));
|
||||
}
|
||||
});
|
||||
|
||||
this.onLogin = this.authenticated$.pipe(
|
||||
filter((authenticated) => authenticated),
|
||||
map(() => undefined)
|
||||
);
|
||||
|
||||
this.onTokenReceived = this.oauthService.events.pipe(
|
||||
filter((event: OAuthEvent) => event.type === 'token_received'),
|
||||
map(() => undefined)
|
||||
);
|
||||
|
||||
this.idpUnreachable$ = this.oauthService.events.pipe(
|
||||
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
|
||||
map((event) => event.reason as Error)
|
||||
);
|
||||
}
|
||||
|
||||
init(): Promise<boolean> {
|
||||
@@ -277,6 +502,7 @@ export class RedirectAuthService extends AuthService {
|
||||
}
|
||||
|
||||
logout() {
|
||||
this._timeSyncService.stopPeriodicSync();
|
||||
this.oauthService.logOut();
|
||||
}
|
||||
|
||||
@@ -326,19 +552,41 @@ export class RedirectAuthService extends AuthService {
|
||||
}
|
||||
|
||||
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
|
||||
const tryToLogin = () =>
|
||||
this._retryLoginService.tryToLoginTimes({
|
||||
...loginOptions,
|
||||
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
|
||||
});
|
||||
|
||||
return this.ensureDiscoveryDocument().then(() =>
|
||||
(this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(tryToLogin) : tryToLogin()).then(() =>
|
||||
this._getRedirectUrl()
|
||||
)
|
||||
);
|
||||
return this.ensureDiscoveryDocument()
|
||||
.then(() => this.syncClockBeforeLoginCallback())
|
||||
.then(() => this.tryLoginCallback(loginOptions))
|
||||
.then(() => this._getRedirectUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the optional clock sync attempt before OAuth validates the login callback tokens.
|
||||
* When time sync is disabled or cannot sync, `TimeSyncService` completes using the old raw-clock
|
||||
* behavior, so this only changes behavior when a trusted server time is available.
|
||||
*
|
||||
* @returns promise that resolves after the clock sync decision has completed
|
||||
*/
|
||||
private syncClockBeforeLoginCallback(): Promise<void> {
|
||||
return firstValueFrom(this._timeSyncService.syncClockOffset());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the existing retry-login flow with the auth-module login callback options applied.
|
||||
*
|
||||
* @param loginOptions options received by `loginCallback`
|
||||
* @returns promise that resolves when OAuth login succeeds
|
||||
*/
|
||||
private tryLoginCallback(loginOptions?: LoginOptions): Promise<boolean> {
|
||||
return this._retryLoginService.tryToLoginTimes({
|
||||
...loginOptions,
|
||||
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the redirect URL stored before login, then removes the temporary state entry.
|
||||
*
|
||||
* @returns stored redirect URL, or `/` when no redirect state exists
|
||||
*/
|
||||
private _getRedirectUrl() {
|
||||
const DEFAULT_REDIRECT = '/';
|
||||
const stateKey = this.oauthService.state;
|
||||
@@ -355,26 +603,53 @@ export class RedirectAuthService extends AuthService {
|
||||
return DEFAULT_REDIRECT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies OAuth configuration, loads discovery metadata, and starts auth background helpers.
|
||||
* Loading errors are converted to `false` so unprotected routes can still render.
|
||||
*
|
||||
* @param config OAuth configuration to apply
|
||||
* @returns promise resolving to true when configuration completes, otherwise false
|
||||
*/
|
||||
private configureAuth(config: AuthConfig): Promise<boolean> {
|
||||
this.oauthService.configure(config);
|
||||
this.oauthService.tokenValidationHandler = new WebCryptoJwksValidationHandler();
|
||||
|
||||
this.subscribeToSessionTermination(config);
|
||||
|
||||
return this.ensureDiscoveryDocument()
|
||||
.then(() => this.completeAuthConfiguration())
|
||||
.catch(() => {
|
||||
// catch error to prevent the app from crashing when trying to access unprotected routes
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to session termination logout events only when session checks are enabled.
|
||||
*
|
||||
* @param config OAuth configuration currently being applied
|
||||
*/
|
||||
private subscribeToSessionTermination(config: AuthConfig): void {
|
||||
if (config.sessionChecksEnabled) {
|
||||
this.oauthService.events.pipe(filter((event) => event.type === 'session_terminated')).subscribe(() => {
|
||||
this.oauthService.logOut();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const initializeAuth = () =>
|
||||
this.ensureDiscoveryDocument().then(() => {
|
||||
this._isDiscoveryDocumentLoadedSubject$.next(true);
|
||||
this.oauthService.setupAutomaticSilentRefresh();
|
||||
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
|
||||
});
|
||||
|
||||
return (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(initializeAuth) : initializeAuth()).catch(() => {
|
||||
// catch error to prevent the app from crashing when trying to access unprotected routes
|
||||
});
|
||||
/**
|
||||
* Finishes auth setup after discovery metadata has loaded.
|
||||
* This keeps the existing eager sync/periodic sync behavior and multi-tab refresh patch.
|
||||
*
|
||||
* @returns true when auth configuration completes
|
||||
*/
|
||||
private completeAuthConfiguration(): boolean {
|
||||
this._isDiscoveryDocumentLoadedSubject$.next(true);
|
||||
this.oauthService.setupAutomaticSilentRefresh();
|
||||
this._timeSyncService.syncClockOffset().subscribe();
|
||||
this._timeSyncService.startPeriodicSync();
|
||||
this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,12 +673,13 @@ export class RedirectAuthService extends AuthService {
|
||||
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_received'));
|
||||
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_refreshed'));
|
||||
lastUpdatedAccessToken = this.oauthService.getAccessToken();
|
||||
return;
|
||||
return undefined as unknown as TokenResponse;
|
||||
}
|
||||
|
||||
const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
|
||||
|
||||
return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken();
|
||||
return originalRefreshToken().then((resp) => {
|
||||
lastUpdatedAccessToken = resp.access_token;
|
||||
return resp;
|
||||
});
|
||||
});
|
||||
|
||||
const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService);
|
||||
@@ -417,18 +693,11 @@ export class RedirectAuthService extends AuthService {
|
||||
lastUpdatedAccessToken = this.oauthService.getAccessToken();
|
||||
return event;
|
||||
} else {
|
||||
if (this._timeSyncService.isEnabled()) {
|
||||
await this.firstValueFromSyncClockOffset();
|
||||
}
|
||||
return originalSilentRefresh(params, noPrompt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private firstValueFromSyncClockOffset(): Promise<void> {
|
||||
return firstValueFrom(this._timeSyncService.syncClockOffset());
|
||||
}
|
||||
|
||||
updateIDPConfiguration(config: AuthConfig) {
|
||||
this.oauthService.configure(config);
|
||||
}
|
||||
@@ -451,10 +720,11 @@ export class RedirectAuthService extends AuthService {
|
||||
const now = this._timeSyncService.getCorrectedNow();
|
||||
const issuedAtMSec = claims.iat * 1000;
|
||||
const expiresAtMSec = claims.exp * 1000;
|
||||
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
|
||||
const clockSkewInMSec = (this.oauthService.clockSkewInSec ?? 0) * 1000;
|
||||
const decreaseExpirationBySec = this.oauthService.decreaseExpirationBySec ?? 0;
|
||||
|
||||
this.showTokenExpiredDebugInformations(now, issuedAtMSec, expiresAtMSec, clockSkewInMSec);
|
||||
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now;
|
||||
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - decreaseExpirationBySec <= now;
|
||||
}
|
||||
|
||||
private showTokenExpiredDebugInformations(now: number, issuedAtMSec: number, expiresAtMSec: number, clockSkewInMSec: number) {
|
||||
@@ -463,11 +733,10 @@ export class RedirectAuthService extends AuthService {
|
||||
this._oauthLogger.warn('issuedAt: ', new Date(issuedAtMSec));
|
||||
this._oauthLogger.warn('expiresAt: ', new Date(expiresAtMSec));
|
||||
this._oauthLogger.warn('clockSkewInMSec: ', clockSkewInMSec);
|
||||
this._oauthLogger.warn('this.oauthService.decreaseExpirationBySec: ', this.oauthService.decreaseExpirationBySec);
|
||||
this._oauthLogger.warn('issuedAtMSec - clockSkewInMSec >= now: ', issuedAtMSec - clockSkewInMSec >= now);
|
||||
this._oauthLogger.warn(
|
||||
'expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now: ',
|
||||
expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now
|
||||
expiresAtMSec + clockSkewInMSec - (this.oauthService.decreaseExpirationBySec ?? 0) <= now
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,14 @@
|
||||
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting, TestRequest } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { OAuthLogger } from 'angular-oauth2-oidc';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
||||
import { LogService } from '../../common/services/log.service';
|
||||
import { ClockSyncResult, TimeSyncService } from './time-sync.service';
|
||||
|
||||
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
|
||||
const MAX_ALLOWED_CLOCK_SKEW_IN_SEC = 120;
|
||||
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
|
||||
const SERVER_TIME_URL = '/api/server-time';
|
||||
|
||||
type ClockDirection = 'behind' | 'ahead';
|
||||
|
||||
@@ -38,22 +37,13 @@ interface ClockSkewScenario {
|
||||
|
||||
interface AppConfigOptions {
|
||||
timeSync?: boolean | string;
|
||||
omitTimeSync?: boolean;
|
||||
showDebugInformation?: boolean | string;
|
||||
}
|
||||
|
||||
interface TimeSyncResult {
|
||||
outOfSync: boolean;
|
||||
timeOffsetInSec?: number;
|
||||
localDateTimeISO: string;
|
||||
serverDateTimeISO: string;
|
||||
serverTimeUrl?: unknown;
|
||||
}
|
||||
|
||||
describe('TimeSyncService', () => {
|
||||
let service: TimeSyncService;
|
||||
let httpMock: HttpTestingController;
|
||||
let appConfigService: AppConfigService;
|
||||
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
|
||||
let appConfigGetSpy: jasmine.Spy;
|
||||
|
||||
const clockSkewScenarios: ClockSkewScenario[] = [
|
||||
{ id: 'TC-01', description: 'baseline login with an accurate clock', skewSeconds: 0, direction: 'behind' },
|
||||
@@ -84,363 +74,575 @@ describe('TimeSyncService', () => {
|
||||
{ id: 'TC-26', description: 'relogin after logout while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }
|
||||
];
|
||||
|
||||
const configureApp = (options: AppConfigOptions = {}): void => {
|
||||
appConfigService.config = {
|
||||
oauth2: options.omitTimeSync ? {} : { timeSync: options.timeSync ?? true, showDebugInformation: options.showDebugInformation ?? false }
|
||||
};
|
||||
const configureApp = ({ timeSync = true, serverTimeUrl = SERVER_TIME_URL }: AppConfigOptions = {}): void => {
|
||||
appConfigGetSpy.and.callFake(<T>(key: string, defaultValue?: T): T => {
|
||||
if (key === AppConfigValues.OAUTHCONFIG) {
|
||||
return timeSync === undefined ? ({} as T) : ({ timeSync } as T);
|
||||
}
|
||||
|
||||
if (key === AppConfigValues.SERVER_TIME_URL) {
|
||||
return serverTimeUrl as T;
|
||||
}
|
||||
|
||||
return defaultValue as T;
|
||||
});
|
||||
};
|
||||
|
||||
const rawLocalInstantFor = ({ skewSeconds, direction }: Pick<ClockSkewScenario, 'skewSeconds' | 'direction'>): number =>
|
||||
const rawLocalInstantFor = (skewSeconds: number, direction: ClockDirection): number =>
|
||||
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
|
||||
|
||||
const expectedOffsetInMsFor = (localNow: number, serverNow = SERVER_NOW): number => serverNow - localNow;
|
||||
const expectedOffsetFor = (localNow: number, serverNow: number = SERVER_NOW): number => serverNow - localNow;
|
||||
|
||||
const appRootUrl = (): string => window.location.href.split('?')[0].split('#')[0];
|
||||
|
||||
const expectAppRootTimeRequest = (expectCacheBusting = true): TestRequest => {
|
||||
const request = httpMock.expectOne((req) => req.url === appRootUrl());
|
||||
const expectServerTimeRequest = (url = SERVER_TIME_URL): TestRequest => {
|
||||
const request = httpMock.expectOne(url);
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(request.request.responseType).toBe('text');
|
||||
if (expectCacheBusting) {
|
||||
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
|
||||
expect(request.request.headers.get('Pragma')).toBe('no-cache');
|
||||
expect(request.request.params.has('adf-time-sync')).toBeTrue();
|
||||
} else {
|
||||
expect(request.request.headers.has('Cache-Control')).toBeFalse();
|
||||
expect(request.request.headers.has('Pragma')).toBeFalse();
|
||||
expect(request.request.params.has('adf-time-sync')).toBeFalse();
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const flushDateHeader = (request: TestRequest, serverNow = SERVER_NOW): void => {
|
||||
request.flush('', { headers: { date: new Date(serverNow).toUTCString() } });
|
||||
};
|
||||
const syncWithServerTime = async (localNow: number, serverNow: number = SERVER_NOW): Promise<ClockSyncResult> => {
|
||||
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow);
|
||||
|
||||
const expectTimeSyncResult = (result: TimeSyncResult, expected: TimeSyncResult): void => {
|
||||
expect(result).toEqual(expected);
|
||||
const sync = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${serverNow}`);
|
||||
|
||||
return sync;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
oauthLoggerSpy = jasmine.createSpyObj<OAuthLogger>('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, provideHttpClient(), provideHttpClientTesting()]
|
||||
providers: [TimeSyncService, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
|
||||
service = TestBed.inject(TimeSyncService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
appConfigService = TestBed.inject(AppConfigService);
|
||||
appConfigGetSpy = spyOn(TestBed.inject(AppConfigService), 'get');
|
||||
configureApp();
|
||||
spyOn(performance, 'now').and.returnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stopPeriodicSync();
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('syncClockOffset', () => {
|
||||
it('should keep raw local time and not request server time when timeSync is not configured', async () => {
|
||||
configureApp({ omitTimeSync: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
it('should complete as disabled and not request server time when time sync is absent', async () => {
|
||||
configureApp({ timeSync: undefined });
|
||||
service.clockOffsetMs = 60_000;
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
const result = await firstValueFrom(service.syncClockOffsetResult());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 238_000);
|
||||
expect(result).toEqual({
|
||||
status: 'disabled',
|
||||
appliedOffsetMs: 0,
|
||||
previousOffsetMs: 60_000,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it('should keep raw local time and not request server time when timeSync is false', async () => {
|
||||
configureApp({ timeSync: false });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
|
||||
});
|
||||
|
||||
it('should correct a slow local clock when timeSync is true', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should correct a fast local clock when timeSync is the string true', async () => {
|
||||
it('should accept string true from oauth2.timeSync because AppConfigService normalizes it', async () => {
|
||||
configureApp({ timeSync: 'true' });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
expect(await result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: 60_000,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
});
|
||||
|
||||
it('should read server time from the app root Date header', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
it('should fail without requesting when serverTimeUrl is not configured', async () => {
|
||||
configureApp({ serverTimeUrl: undefined });
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
const result = await firstValueFrom(service.syncClockOffsetResult());
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
httpMock.expectNone(() => true);
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 0,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should correct local time without requiring serverTimeUrl configuration', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
it('should fail without requesting when serverTimeUrl has an unsupported scheme', async () => {
|
||||
configureApp({ serverTimeUrl: 'ftp://example.com/server-time' });
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
const result = await firstValueFrom(service.syncClockOffsetResult());
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
httpMock.expectNone(() => true);
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 0,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep raw local time when timeSync is true but the server time request fails', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
it('should request a configured relative serverTimeUrl using GET text', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
await sync;
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest('/api/server-time').flush(`${SERVER_NOW - 480_000}`);
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
|
||||
expect(await result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: -480_000,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to raw local time when a later sync fails after the cached server time expires', fakeAsync(() => {
|
||||
let localNow = SERVER_NOW - 238_000;
|
||||
spyOn(Date, 'now').and.callFake(() => localNow);
|
||||
it('should request a configured absolute serverTimeUrl using GET text', async () => {
|
||||
const serverTimeUrl = 'https://time.example.com/server-time';
|
||||
configureApp({ serverTimeUrl });
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
service.syncClockOffset().subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest(serverTimeUrl).flush(`${SERVER_NOW + 60_000}`);
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
expect(await result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: 60_000,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
});
|
||||
|
||||
localNow = SERVER_NOW + 60_000;
|
||||
service.syncClockOffset().subscribe();
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
it('should calculate the offset with half the measured round-trip time', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW + 1000);
|
||||
(performance.now as jasmine.Spy).and.returnValues(0, 1000);
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
|
||||
}));
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: 59_500,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW + 1000
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(59_500);
|
||||
});
|
||||
|
||||
[
|
||||
{ format: 'date string', responseBody: new Date(SERVER_NOW + 30_000).toUTCString() },
|
||||
{ format: 'epoch millisecond', responseBody: `${SERVER_NOW + 30_000}` },
|
||||
{ format: 'epoch second', responseBody: `${(SERVER_NOW + 30_000) / 1000}` }
|
||||
].forEach(({ format, responseBody }) => {
|
||||
it(`should parse ${format} response bodies`, async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(responseBody);
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: 30_000,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the current offset when the request fails', async () => {
|
||||
service.clockOffsetMs = 5000;
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 5000,
|
||||
previousOffsetMs: 5000,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should report failed with a trusted previous sync when a later request fails', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 30_000);
|
||||
|
||||
const synced = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
|
||||
await synced;
|
||||
|
||||
const failed = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expect(await failed).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 60_000,
|
||||
previousOffsetMs: 60_000,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
});
|
||||
|
||||
it('should report missing-server-time and keep the current offset for an empty body', async () => {
|
||||
service.clockOffsetMs = 5000;
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(' ');
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'missing-server-time',
|
||||
appliedOffsetMs: 5000,
|
||||
previousOffsetMs: 5000,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should report invalid-server-time and keep the current offset for an invalid body', async () => {
|
||||
service.clockOffsetMs = 5000;
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush('not-a-date');
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'invalid-server-time',
|
||||
appliedOffsetMs: 5000,
|
||||
previousOffsetMs: 5000,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should share an in-flight sync request between overlapping callers', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const firstSync = firstValueFrom(service.syncClockOffsetResult());
|
||||
const secondSync = firstValueFrom(service.syncClockOffsetResult());
|
||||
|
||||
const requests = httpMock.match(SERVER_TIME_URL);
|
||||
expect(requests.length).toBe(1);
|
||||
requests[0].flush(`${SERVER_NOW + 60_000}`);
|
||||
|
||||
const expectedResult = {
|
||||
status: 'synced' as const,
|
||||
appliedOffsetMs: 60_000,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
};
|
||||
expect(await firstSync).toEqual(expectedResult);
|
||||
expect(await secondSync).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should not treat an un-subscribed sync observable as in flight', async () => {
|
||||
service.syncClockOffsetResult();
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
|
||||
|
||||
expect((await result).status).toBe('synced');
|
||||
});
|
||||
|
||||
it('should reject an implausible offset and keep the previous value', async () => {
|
||||
const events: { measuredOffsetMs: number; maxAllowedOffsetMs: number }[] = [];
|
||||
service.clockOffsetMs = 1234;
|
||||
service.implausibleOffsetDetected$.subscribe((event) => events.push(event));
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 660_000}`);
|
||||
|
||||
expect(await result).toEqual({
|
||||
status: 'implausible-offset',
|
||||
appliedOffsetMs: 1234,
|
||||
previousOffsetMs: 1234,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(1234);
|
||||
expect(events).toEqual([{ measuredOffsetMs: 660_000, maxAllowedOffsetMs: 600_000 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTimeSync', () => {
|
||||
it('should error when the server time request fails', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
describe('clock reads and status checks', () => {
|
||||
it('should return corrected now when enabled and an offset is stored', () => {
|
||||
service.clockOffsetMs = -60_000;
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time');
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should use the app root Date header as the server time source', async () => {
|
||||
it('should return raw now when disabled even if an offset is stored', () => {
|
||||
configureApp({ timeSync: false });
|
||||
service.clockOffsetMs = -60_000;
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
|
||||
});
|
||||
|
||||
it('should report out-of-sync from the stored offset when enabled', async () => {
|
||||
service.clockOffsetMs = 180_000;
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
const result = await firstValueFrom(service.checkTimeSync(120));
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
expect(result).toEqual({
|
||||
outOfSync: true,
|
||||
timeOutOfSyncInSec: 180,
|
||||
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW + 180_000).toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
it('should report in-sync from raw time when disabled even if an offset is stored', async () => {
|
||||
configureApp({ timeSync: false });
|
||||
service.clockOffsetMs = 180_000;
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const result = await firstValueFrom(service.checkTimeSync(120));
|
||||
|
||||
expect(result).toEqual({
|
||||
outOfSync: false,
|
||||
timeOffsetInSec: 0,
|
||||
timeOutOfSyncInSec: 0,
|
||||
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
it('should map checkTimeSync to a boolean in isLocalTimeOutOfSync', async () => {
|
||||
service.clockOffsetMs = 121_000;
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
expect(await firstValueFrom(service.isLocalTimeOutOfSync(120))).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('server time request sharing', () => {
|
||||
it('should perform a single HTTP request when multiple callers subscribe concurrently', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
const emitted: number[] = [];
|
||||
describe('periodic sync', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
});
|
||||
|
||||
service.syncClockOffset().subscribe(() => emitted.push(service.getCorrectedNow()));
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe((result) => emitted.push(new Date(result.serverDateTimeISO).getTime()));
|
||||
it('should not register periodic sync when disabled', () => {
|
||||
configureApp({ timeSync: false });
|
||||
const addEventListenerSpy = spyOn(document, 'addEventListener');
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
service.startPeriodicSync(1000);
|
||||
|
||||
expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]);
|
||||
expect(addEventListenerSpy).not.toHaveBeenCalled();
|
||||
httpMock.expectNone(() => true);
|
||||
});
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
it('should re-sync when the document becomes visible', () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW);
|
||||
|
||||
it('should reuse the cached server time for callers within the 2s window without a new request', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
service.startPeriodicSync(60_000);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 30_000}`);
|
||||
expect(service.clockOffsetMs).toBe(30_000);
|
||||
});
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
httpMock.expectNone((req) => req.url === appRootUrl());
|
||||
it('should debounce repeated visibility-triggered syncs', () => {
|
||||
spyOn(Date, 'now').and.returnValues(
|
||||
SERVER_NOW,
|
||||
SERVER_NOW,
|
||||
SERVER_NOW,
|
||||
SERVER_NOW + 5000,
|
||||
SERVER_NOW + 31_000,
|
||||
SERVER_NOW + 31_000,
|
||||
SERVER_NOW + 31_000
|
||||
);
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
service.startPeriodicSync(60_000);
|
||||
|
||||
it('should perform a new request once the 2s cache window has expired', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW}`);
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
httpMock.expectNone(() => true);
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 31_000}`);
|
||||
});
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
it('should remove the visibility listener when stopped', () => {
|
||||
service.startPeriodicSync(60_000);
|
||||
service.stopPeriodicSync();
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
it('should not cache errors and let the next caller retry immediately with a new request', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
const errors: string[] = [];
|
||||
httpMock.expectNone(() => true);
|
||||
});
|
||||
});
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe({ error: (error: Error) => errors.push(error.message) });
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
describe('observability', () => {
|
||||
let warnSpy: jasmine.Spy;
|
||||
let debugSpy: jasmine.Spy;
|
||||
|
||||
expect(errors.length).toBe(1);
|
||||
beforeEach(() => {
|
||||
const logService = TestBed.inject(LogService);
|
||||
warnSpy = spyOn(logService, 'warn');
|
||||
debugSpy = spyOn(logService, 'debug');
|
||||
});
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
it('should debug-log missing server time, invalid server time, and request failures', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW, SERVER_NOW);
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
const missing = firstValueFrom(service.syncClockOffset());
|
||||
expectServerTimeRequest().flush('');
|
||||
await missing;
|
||||
|
||||
const invalid = firstValueFrom(service.syncClockOffset());
|
||||
expectServerTimeRequest().flush('not-a-valid-date\r\ninjected-line');
|
||||
await invalid;
|
||||
|
||||
const failed = firstValueFrom(service.syncClockOffset());
|
||||
expectServerTimeRequest().error(new ProgressEvent('error'));
|
||||
await failed;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledTimes(3);
|
||||
expect(debugSpy.calls.allArgs().some(([message]) => `${message}`.includes('\r') || `${message}`.includes('\n'))).toBeFalse();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should warn-log unsupported URLs and implausible offsets', async () => {
|
||||
configureApp({ serverTimeUrl: 'javascript:alert(1)' });
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
configureApp();
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
|
||||
const result = firstValueFrom(service.syncClockOffset());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60 * 60 * 1000}`);
|
||||
await result;
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clock skew scenario matrix', () => {
|
||||
describe('timeSync not configured', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should keep raw local time for ${scenario.description}`, async () => {
|
||||
configureApp({ omitTimeSync: true });
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
describe('time sync off', () => {
|
||||
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
|
||||
it(`${id}: should keep raw local time for ${description}`, async () => {
|
||||
configureApp({ timeSync: false });
|
||||
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
|
||||
const result = await firstValueFrom(service.syncClockOffsetResult());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(result.status).toBe('disabled');
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSync false', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should run the old raw-clock skew check for ${scenario.description}`, async () => {
|
||||
configureApp({ timeSync: false });
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
describe('time sync malfunction', () => {
|
||||
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
|
||||
it(`${id}: should keep raw local time for ${description} when server time cannot be fetched`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest(false));
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
outOfSync: scenario.skewSeconds > MAX_ALLOWED_CLOCK_SKEW_IN_SEC,
|
||||
timeOffsetInSec: scenario.skewSeconds,
|
||||
localDateTimeISO: new Date(rawLocalNow).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
expect(await result).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 0,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: false
|
||||
});
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSync true but server time fails', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should fall back to raw local time for ${scenario.description}`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
it('should keep a previous trusted offset when a later sync malfunctions', async () => {
|
||||
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 120_000, SERVER_NOW + 120_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
await sync;
|
||||
const synced = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
|
||||
await synced;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
const failed = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expect(await failed).toEqual({
|
||||
status: 'failed',
|
||||
appliedOffsetMs: 60_000,
|
||||
previousOffsetMs: 60_000,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: SERVER_NOW
|
||||
});
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 180_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSync true and server time succeeds', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should correct ${scenario.description} and report the clock as in sync`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
describe('time sync on', () => {
|
||||
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
|
||||
it(`${id}: should correct ${description} to server time`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
|
||||
const expectedOffset = expectedOffsetFor(rawLocalNow);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
const result = await syncWithServerTime(rawLocalNow);
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
outOfSync: false,
|
||||
timeOffsetInSec: 0,
|
||||
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
expect(result).toEqual({
|
||||
status: 'synced',
|
||||
appliedOffsetMs: expectedOffset,
|
||||
previousOffsetMs: 0,
|
||||
hasSuccessfulSync: true,
|
||||
lastSuccessfulSyncAtMs: rawLocalNow
|
||||
});
|
||||
expect(service.clockOffsetMs).toBe(expectedOffset);
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow + expectedOffsetInMsFor(rawLocalNow));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug logging', () => {
|
||||
describe('syncClockOffset', () => {
|
||||
it('should log time sync debug information when showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
describe('offset clamp', () => {
|
||||
[
|
||||
{ id: 'TC-27', skewSeconds: 599, direction: 'behind' as const },
|
||||
{ id: 'TC-28', skewSeconds: 599, direction: 'ahead' as const },
|
||||
{ id: 'TC-29', skewSeconds: 600, direction: 'behind' as const },
|
||||
{ id: 'TC-30', skewSeconds: 600, direction: 'ahead' as const }
|
||||
].forEach(({ id, skewSeconds, direction }) => {
|
||||
it(`${id}: should apply an offset at or within the trust bound`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
|
||||
await syncWithServerTime(rawLocalNow);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] syncClockOffset: offset set to'));
|
||||
expect(service.clockOffsetMs).toBe(expectedOffsetFor(rawLocalNow));
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not log time sync debug information when showDebugInformation is false', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: false });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
[
|
||||
{ id: 'TC-31', skewSeconds: 601, direction: 'behind' as const },
|
||||
{ id: 'TC-32', skewSeconds: 601, direction: 'ahead' as const }
|
||||
].forEach(({ id, skewSeconds, direction }) => {
|
||||
it(`${id}: should reject an offset beyond the trust bound`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
|
||||
spyOn(Date, 'now').and.returnValues(rawLocalNow, rawLocalNow, rawLocalNow);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
const result = firstValueFrom(service.syncClockOffsetResult());
|
||||
expectServerTimeRequest().flush(`${SERVER_NOW}`);
|
||||
|
||||
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log time sync debug information when timeSync is disabled even if showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: false, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTimeSync', () => {
|
||||
it('should log time sync debug information for checkTimeSync when showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await check;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
|
||||
});
|
||||
|
||||
it('should log checkTimeSync debug information even when timeSync is disabled', async () => {
|
||||
configureApp({ timeSync: false, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest(false));
|
||||
await check;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
|
||||
expect((await result).status).toBe('implausible-offset');
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,12 @@
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { OAuthLogger } from 'angular-oauth2-oidc';
|
||||
import { Observable, ReplaySubject, defer, of, throwError, timer } from 'rxjs';
|
||||
import { catchError, map, share, timeout } from 'rxjs/operators';
|
||||
import { Injectable, NgZone, inject } from '@angular/core';
|
||||
import { interval, Observable, of, Subject, Subscription } from 'rxjs';
|
||||
import { catchError, finalize, map, shareReplay, switchMap, timeout } from 'rxjs/operators';
|
||||
import { LogService } from '../../common/services/log.service';
|
||||
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
||||
|
||||
const SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME = 'adf-time-sync';
|
||||
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
|
||||
|
||||
export interface TimeSync {
|
||||
outOfSync: boolean;
|
||||
timeOutOfSyncInSec?: number;
|
||||
@@ -32,36 +29,99 @@ export interface TimeSync {
|
||||
serverDateTimeISO: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when a measured clock offset is rejected for exceeding `maxAllowedOffsetMs`.
|
||||
* Consumers can subscribe to `implausibleOffsetDetected$` and forward this to central
|
||||
* telemetry to detect misconfigured time sources or server-time tampering across the fleet.
|
||||
*/
|
||||
export interface ImplausibleClockOffsetEvent {
|
||||
measuredOffsetMs: number;
|
||||
maxAllowedOffsetMs: number;
|
||||
}
|
||||
|
||||
export type ClockSyncStatus = 'disabled' | 'synced' | 'failed' | 'missing-server-time' | 'invalid-server-time' | 'implausible-offset';
|
||||
|
||||
export interface ClockSyncResult {
|
||||
status: ClockSyncStatus;
|
||||
appliedOffsetMs: number;
|
||||
previousOffsetMs: number;
|
||||
hasSuccessfulSync: boolean;
|
||||
lastSuccessfulSyncAtMs?: number;
|
||||
}
|
||||
|
||||
/** Timestamps captured during a single server-time request and used to calculate clock offset. */
|
||||
interface ClockSyncMeasurement {
|
||||
serverTimeInMs: number;
|
||||
startMonotonicTimeInMs: number;
|
||||
endTimeInMs: number;
|
||||
}
|
||||
|
||||
/** Default interval for periodic clock re-sync (5 minutes). */
|
||||
const DEFAULT_PERIODIC_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Default upper bound, in milliseconds, for a clock offset that will be trusted (10 minutes).
|
||||
* Offsets larger than this are treated as implausible and ignored so that one time response
|
||||
* cannot arbitrarily extend client-side token validity. See `maxAllowedOffsetMs`.
|
||||
*/
|
||||
const DEFAULT_MAX_ALLOWED_OFFSET_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Minimum delay between visibility-triggered re-syncs (30 seconds) to avoid request storms. */
|
||||
const VISIBILITY_SYNC_DEBOUNCE_MS = 30 * 1000;
|
||||
|
||||
/** Timeout applied to the time-sync request (5 seconds). */
|
||||
const SYNC_REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TimeSyncService {
|
||||
private readonly _http = inject(HttpClient);
|
||||
private readonly _appConfigService = inject(AppConfigService);
|
||||
private readonly _oauthLogger = inject(OAuthLogger, { optional: true });
|
||||
private readonly _ngZone = inject(NgZone);
|
||||
private readonly _logService = inject(LogService);
|
||||
private readonly _appConfig = inject(AppConfigService);
|
||||
|
||||
/**
|
||||
* Shared, self-expiring server-time request.
|
||||
*
|
||||
* OAuth-event-driven callers ask for the server time in quick succession, which
|
||||
* previously fired one HTTP request per caller. `share` collapses concurrent
|
||||
* subscribers onto a single in-flight request and replays the resolved value to
|
||||
* any caller for the next {@link SERVER_TIME_CACHE_WINDOW_IN_MS}; after the window
|
||||
* elapses the next subscriber triggers a fresh request. `defer` rebuilds the
|
||||
* request options (including a new cache-busting timestamp) for every genuinely
|
||||
* new request. Errors are never cached, so the next caller retries immediately.
|
||||
* The signed offset in milliseconds between the adjusted server time and the local clock.
|
||||
* Positive means the local clock is behind the server; negative means it is ahead.
|
||||
* Defaults to 0 until `syncClockOffset` has successfully run.
|
||||
*/
|
||||
private readonly serverTime$: Observable<number> = defer(() => this.requestServerTime()).pipe(
|
||||
share({
|
||||
connector: () => new ReplaySubject<number>(1),
|
||||
resetOnError: true,
|
||||
resetOnComplete: () => timer(SERVER_TIME_CACHE_WINDOW_IN_MS),
|
||||
resetOnRefCountZero: false
|
||||
})
|
||||
);
|
||||
clockOffsetMs = 0;
|
||||
|
||||
private clockOffsetMs = 0;
|
||||
/**
|
||||
* Maximum magnitude, in milliseconds, of a measured clock offset that will be trusted and
|
||||
* applied. Any measured offset whose absolute value exceeds this bound is treated as
|
||||
* implausible (a hostile / misconfigured server-time response or an unreliable measurement) and is
|
||||
* ignored, so a single response can never arbitrarily extend client-side token validity.
|
||||
* Defaults to 10 minutes.
|
||||
*/
|
||||
maxAllowedOffsetMs = DEFAULT_MAX_ALLOWED_OFFSET_MS;
|
||||
|
||||
private readonly _implausibleOffsetDetected = new Subject<ImplausibleClockOffsetEvent>();
|
||||
|
||||
/**
|
||||
* Emits whenever a measured offset is rejected for exceeding `maxAllowedOffsetMs`.
|
||||
* Surface this to monitoring/telemetry to detect potential server-time tampering or a
|
||||
* misconfigured time source; the client console alone is not a reliable security signal.
|
||||
*/
|
||||
readonly implausibleOffsetDetected$ = this._implausibleOffsetDetected.asObservable();
|
||||
|
||||
private _periodicSyncSubscription: Subscription | null = null;
|
||||
private _visibilityChangeHandler: (() => void) | null = null;
|
||||
private _lastSyncAtMs = 0;
|
||||
private _lastSuccessfulSyncAtMs: number | null = null;
|
||||
private _inFlightSync$: Observable<ClockSyncResult> | null = null;
|
||||
|
||||
/**
|
||||
* Returns the current local time corrected by the last measured clock offset.
|
||||
* Use this instead of `Date.now()` when evaluating token expiration to avoid
|
||||
* false positives caused by VM / Citrix clock drift.
|
||||
*
|
||||
* When the feature is disabled via AppConfig, this returns the raw local time so the
|
||||
* consuming application behaves exactly as it did before clock-skew correction existed.
|
||||
*
|
||||
* @returns corrected timestamp in milliseconds
|
||||
*/
|
||||
getCorrectedNow(): number {
|
||||
if (!this.isEnabled()) {
|
||||
return Date.now();
|
||||
@@ -70,145 +130,435 @@ export class TimeSyncService {
|
||||
return Date.now() + this.clockOffsetMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the local correction offset from `serverTimeUrl`.
|
||||
*
|
||||
* When `oauth2.timeSync` is false or missing, no request is made and callers keep using the raw
|
||||
* local clock. When it is true, `serverTimeUrl` must return a server-generated time string in
|
||||
* the response body. Any missing URL, failed request, missing/invalid body, or rejected offset
|
||||
* leaves the current offset unchanged; with no previous successful sync this is `0`, which is
|
||||
* the old raw-clock behavior.
|
||||
*
|
||||
* @returns Observable that completes after the offset decision has been made
|
||||
*/
|
||||
syncClockOffset(): Observable<void> {
|
||||
if (!this.isEnabled()) {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let serverTime$: Observable<number>;
|
||||
|
||||
try {
|
||||
serverTime$ = this.getServerTime();
|
||||
} catch {
|
||||
this.clockOffsetMs = 0;
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return serverTime$.pipe(
|
||||
map((serverTimeResponse: number) => {
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
|
||||
|
||||
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
|
||||
this.debug(
|
||||
`syncClockOffset: offset set to ${this.clockOffsetMs}ms ` +
|
||||
`(server=${new Date(adjustedServerTimeInMs).toISOString()}, local=${new Date(localCurrentTimeInMs).toISOString()})`
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.clockOffsetMs = 0;
|
||||
this.debug('syncClockOffset: failed to reach server, offset reset to 0');
|
||||
return of(void 0);
|
||||
})
|
||||
);
|
||||
return this.syncClockOffsetResult().pipe(map(() => void 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the clock offset and reports whether the current offset came from a fresh successful
|
||||
* measurement, a previous trusted sync, or the old raw-clock path.
|
||||
*
|
||||
* @returns Observable that emits the sync outcome after the offset decision has been made
|
||||
*/
|
||||
syncClockOffsetResult(): Observable<ClockSyncResult> {
|
||||
return new Observable<ClockSyncResult>((subscriber) => {
|
||||
const previousOffsetMs = this.clockOffsetMs;
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
subscriber.next(this.createClockSyncResult('disabled', 0, previousOffsetMs));
|
||||
subscriber.complete();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this._inFlightSync$) {
|
||||
this._inFlightSync$ = this.createClockSyncRequest(previousOffsetMs);
|
||||
}
|
||||
|
||||
const subscription = this._inFlightSync$.subscribe(subscriber);
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the time synchronisation status using the stored clock offset.
|
||||
*
|
||||
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
|
||||
* @returns An Observable that emits a TimeSync result.
|
||||
*/
|
||||
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
|
||||
const startTime = Date.now();
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const clockOffsetMs = this.isEnabled() ? this.clockOffsetMs : 0;
|
||||
const adjustedServerTimeInMs = localCurrentTimeInMs + clockOffsetMs;
|
||||
const timeOffsetInMs = Math.abs(clockOffsetMs);
|
||||
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
|
||||
|
||||
return this.getServerTime().pipe(
|
||||
map((serverTimeResponse: number) => {
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
|
||||
let localTimeInMs = localCurrentTimeInMs;
|
||||
return of({
|
||||
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
|
||||
timeOutOfSyncInSec: timeOffsetInMs / 1000,
|
||||
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
|
||||
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isEnabled()) {
|
||||
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
|
||||
localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs;
|
||||
/**
|
||||
* Checks if the local time is out of sync with the server time.
|
||||
*
|
||||
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
|
||||
* @returns An Observable that emits a boolean indicating whether the local time is out of sync.
|
||||
*/
|
||||
isLocalTimeOutOfSync(maxAllowedClockSkewInSec: number): Observable<boolean> {
|
||||
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(map((sync) => sync.outOfSync));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts periodic re-synchronization of the clock offset to protect against
|
||||
* progressive clock drift during a user session (common in Citrix/VM environments).
|
||||
*
|
||||
* Re-sync is triggered:
|
||||
* - On a regular interval (default: every 5 minutes)
|
||||
* - When the document becomes visible again (e.g., Citrix session resumes after idle)
|
||||
*
|
||||
* @param intervalMs How often to re-sync in milliseconds (default: 5 minutes)
|
||||
*/
|
||||
startPeriodicSync(intervalMs: number = DEFAULT_PERIODIC_SYNC_INTERVAL_MS): void {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopPeriodicSync();
|
||||
|
||||
this._ngZone.runOutsideAngular(() => {
|
||||
this._periodicSyncSubscription = interval(intervalMs)
|
||||
.pipe(switchMap(() => this.syncClockOffset()))
|
||||
.subscribe();
|
||||
|
||||
this._visibilityChangeHandler = () => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
// Debounce rapid visibility toggles (and multiple resumes across tabs) so we
|
||||
// do not issue a burst of redundant time-sync requests when a session resumes.
|
||||
if (Date.now() - this._lastSyncAtMs < VISIBILITY_SYNC_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
this.syncClockOffset().subscribe();
|
||||
}
|
||||
};
|
||||
|
||||
const timeOffsetInMs = Math.abs(localTimeInMs - adjustedServerTimeInMs);
|
||||
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
|
||||
const outOfSync = timeOffsetInMs > maxAllowedClockSkewInMs;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this._visibilityChangeHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.debug(
|
||||
`checkTimeSync: outOfSync=${outOfSync} ` +
|
||||
`(local=${new Date(localTimeInMs).toISOString()}, server=${new Date(adjustedServerTimeInMs).toISOString()}, offset=${this.clockOffsetMs}ms)`
|
||||
);
|
||||
/**
|
||||
* Stops the periodic clock re-synchronization and removes the visibility change listener.
|
||||
*/
|
||||
stopPeriodicSync(): void {
|
||||
this._periodicSyncSubscription?.unsubscribe();
|
||||
this._periodicSyncSubscription = null;
|
||||
|
||||
return {
|
||||
outOfSync,
|
||||
timeOffsetInSec: timeOffsetInMs / 1000,
|
||||
localDateTimeISO: new Date(localTimeInMs).toISOString(),
|
||||
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||
};
|
||||
}),
|
||||
catchError((error) => throwError(() => new Error(error)))
|
||||
if (this._visibilityChangeHandler && typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this._visibilityChangeHandler);
|
||||
this._visibilityChangeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether clock-skew correction is enabled. Controlled by the optional `oauth2.timeSync`
|
||||
* AppConfig flag so a consuming application can turn the feature on without code changes.
|
||||
* The feature is opt-in: it defaults to `false` when the flag is absent, so an application
|
||||
* behaves exactly as it did before clock-skew correction existed until it explicitly enables it.
|
||||
*
|
||||
* @returns true when the feature is enabled
|
||||
*/
|
||||
private isEnabled(): boolean {
|
||||
return this._appConfig.oauth2.timeSync === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the one HTTP request used by all overlapping sync callers.
|
||||
* The request is shared until it completes, so multiple consumers waiting on the same sync do
|
||||
* not issue duplicate calls to `serverTimeUrl`.
|
||||
*
|
||||
* @param previousOffsetMs offset that was active before this sync attempt started
|
||||
* @returns shared sync result observable
|
||||
*/
|
||||
private createClockSyncRequest(previousOffsetMs: number): Observable<ClockSyncResult> {
|
||||
const serverTimeUrl = this.getServerTimeUrl();
|
||||
|
||||
if (!serverTimeUrl) {
|
||||
return this.createSharedResult(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
|
||||
}
|
||||
|
||||
const startTimeInMs = Date.now();
|
||||
const startMonotonicTimeInMs = this.getMonotonicNow();
|
||||
this._lastSyncAtMs = startTimeInMs;
|
||||
|
||||
return this.requestServerTime(serverTimeUrl).pipe(
|
||||
timeout(SYNC_REQUEST_TIMEOUT_MS),
|
||||
map((response) => this.handleServerTimeResponse(response, startMonotonicTimeInMs, previousOffsetMs)),
|
||||
catchError((error) => this.handleClockSyncRequestError(error, previousOffsetMs)),
|
||||
finalize(() => (this._inFlightSync$ = null)),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
}
|
||||
|
||||
private getAdjustedServerTimeInMs(serverTimeResponse: number, startTime: number): number {
|
||||
let serverTimeInMs: number;
|
||||
const endTime = Date.now();
|
||||
const roundTripTimeInMs = endTime - startTime;
|
||||
/**
|
||||
* Reads and validates `serverTimeUrl` from app.config.json.
|
||||
* Relative same-origin URLs and absolute HTTP(S) URLs are supported. Unsupported schemes are
|
||||
* ignored so configuration mistakes cannot trigger unexpected browser protocols.
|
||||
*
|
||||
* @returns configured server time URL, or null when time sync should fall back to raw clock
|
||||
*/
|
||||
private getServerTimeUrl(): string | null {
|
||||
const serverTimeUrlValue = this._appConfig.get<unknown>(AppConfigValues.SERVER_TIME_URL);
|
||||
const serverTimeUrl = typeof serverTimeUrlValue === 'string' ? serverTimeUrlValue.trim() : '';
|
||||
|
||||
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
|
||||
if (!isServerTimeResponseInMs) {
|
||||
serverTimeInMs = serverTimeResponse * 1000;
|
||||
} else {
|
||||
serverTimeInMs = serverTimeResponse;
|
||||
if (!serverTimeUrl) {
|
||||
this._logService.debug('TimeSyncService: serverTimeUrl is not configured; keeping the current clock offset.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return serverTimeInMs + roundTripTimeInMs / 2;
|
||||
if (this.isSupportedServerTimeUrl(serverTimeUrl)) {
|
||||
return serverTimeUrl;
|
||||
}
|
||||
|
||||
this._logService.warn(`TimeSyncService: ignoring unsupported serverTimeUrl "${this.sanitizeForLog(serverTimeUrl)}".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
const timeSync = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_TIME_SYNC_ENABLED, false);
|
||||
return timeSync === true || timeSync === 'true';
|
||||
/**
|
||||
* Allows relative same-origin URLs and absolute HTTP(S) URLs for `serverTimeUrl`.
|
||||
* Protocol-relative URLs and unsupported schemes are rejected to avoid surprising browser
|
||||
* behavior from configuration values.
|
||||
*
|
||||
* @param url configured server time URL
|
||||
* @returns true when the URL can be requested by the time-sync service
|
||||
*/
|
||||
private isSupportedServerTimeUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url) || (!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith('//'));
|
||||
}
|
||||
|
||||
private getServerTime(): Observable<number> {
|
||||
return this.serverTime$;
|
||||
/**
|
||||
* Requests server time as plain text.
|
||||
* The endpoint is expected to return the server instant in the response body; no response
|
||||
* headers are required for the configured time-sync path.
|
||||
*
|
||||
* @param serverTimeUrl configured time source URL
|
||||
* @returns HTTP response containing a server time body
|
||||
*/
|
||||
private requestServerTime(serverTimeUrl: string): Observable<HttpResponse<string>> {
|
||||
return this._http.get(serverTimeUrl, { observe: 'response', responseType: 'text' });
|
||||
}
|
||||
|
||||
private requestServerTime(): Observable<number> {
|
||||
const requestOptions = {
|
||||
observe: 'response' as const,
|
||||
responseType: 'text' as const,
|
||||
...(this.isEnabled() && {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache'
|
||||
},
|
||||
params: {
|
||||
[SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME]: Date.now().toString()
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Turns the server response into a sync result.
|
||||
* This method intentionally reads as a small pipeline: extract text, parse it, measure the
|
||||
* offset, then apply or reject the correction.
|
||||
*
|
||||
* @param response HTTP response from `serverTimeUrl`
|
||||
* @param startMonotonicTimeInMs monotonic timestamp captured before the request
|
||||
* @param previousOffsetMs offset active before this sync attempt
|
||||
* @returns sync result for the response
|
||||
*/
|
||||
private handleServerTimeResponse(response: HttpResponse<string>, startMonotonicTimeInMs: number, previousOffsetMs: number): ClockSyncResult {
|
||||
const serverTime = this.extractServerTime(response);
|
||||
|
||||
if (serverTime === null) {
|
||||
return this.createClockSyncResult('missing-server-time', this.clockOffsetMs, previousOffsetMs);
|
||||
}
|
||||
|
||||
const serverTimeInMs = this.parseServerTime(serverTime);
|
||||
if (isNaN(serverTimeInMs)) {
|
||||
return this.createClockSyncResult('invalid-server-time', this.clockOffsetMs, previousOffsetMs);
|
||||
}
|
||||
|
||||
return this.applyMeasuredOffset(
|
||||
{
|
||||
serverTimeInMs,
|
||||
startMonotonicTimeInMs,
|
||||
endTimeInMs: Date.now()
|
||||
},
|
||||
previousOffsetMs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the configured server-time value from the response body.
|
||||
* Empty bodies are treated as a failed sync and leave the current offset unchanged.
|
||||
*
|
||||
* @param response HTTP response from `serverTimeUrl`
|
||||
* @returns trimmed server time value, or null when the body is empty
|
||||
*/
|
||||
private extractServerTime(response: HttpResponse<string>): string | null {
|
||||
const serverTime = response.body?.trim();
|
||||
|
||||
if (!serverTime) {
|
||||
this._logService.debug('TimeSyncService: response has no server time value; keeping the current clock offset.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return serverTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a measured offset when it is within the configured trust bound.
|
||||
* The round-trip duration is measured with a monotonic clock so wall-clock jumps during the
|
||||
* request cannot distort the latency adjustment.
|
||||
*
|
||||
* @param measurement timestamps needed to calculate the offset
|
||||
* @param previousOffsetMs offset active before this sync attempt
|
||||
* @returns sync result after applying or rejecting the measured offset
|
||||
*/
|
||||
private applyMeasuredOffset(measurement: ClockSyncMeasurement, previousOffsetMs: number): ClockSyncResult {
|
||||
const measuredOffsetMs = this.calculateOffsetMs(measurement);
|
||||
|
||||
if (this.isImplausibleOffset(measuredOffsetMs)) {
|
||||
this.reportImplausibleOffset(measuredOffsetMs);
|
||||
return this.createClockSyncResult('implausible-offset', this.clockOffsetMs, previousOffsetMs);
|
||||
}
|
||||
|
||||
this.clockOffsetMs = measuredOffsetMs;
|
||||
this._lastSuccessfulSyncAtMs = measurement.endTimeInMs;
|
||||
|
||||
return this.createClockSyncResult('synced', measuredOffsetMs, previousOffsetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the signed difference between the local clock and adjusted server time.
|
||||
* Positive means the client is behind the server; negative means it is ahead.
|
||||
*
|
||||
* @param measurement timestamps from the sync attempt
|
||||
* @returns signed clock offset in milliseconds
|
||||
*/
|
||||
private calculateOffsetMs(measurement: ClockSyncMeasurement): number {
|
||||
const roundTripTimeInMs = Math.max(0, this.getMonotonicNow() - measurement.startMonotonicTimeInMs);
|
||||
const adjustedServerTimeInMs = measurement.serverTimeInMs + roundTripTimeInMs / 2;
|
||||
|
||||
return adjustedServerTimeInMs - measurement.endTimeInMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the measured offset is too large to trust.
|
||||
* Bounding the offset keeps a bad time source from extending client-side token validity by an
|
||||
* arbitrary amount. The server remains the final authority for token validity.
|
||||
*
|
||||
* @param offsetMs measured signed offset in milliseconds
|
||||
* @returns true when the offset must be rejected
|
||||
*/
|
||||
private isImplausibleOffset(offsetMs: number): boolean {
|
||||
return Math.abs(offsetMs) > this.maxAllowedOffsetMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits and logs a rejected offset measurement.
|
||||
* Consumers can subscribe to `implausibleOffsetDetected$` and forward the event to telemetry.
|
||||
*
|
||||
* @param offsetMs measured signed offset in milliseconds
|
||||
*/
|
||||
private reportImplausibleOffset(offsetMs: number): void {
|
||||
const roundedOffsetMs = Math.round(offsetMs);
|
||||
|
||||
this._logService.warn(
|
||||
`TimeSyncService: ignoring implausible clock offset of ${roundedOffsetMs} ms ` +
|
||||
`(exceeds the maximum allowed ${this.maxAllowedOffsetMs} ms). Keeping the current clock offset.`
|
||||
);
|
||||
this._implausibleOffsetDetected.next({
|
||||
measuredOffsetMs: roundedOffsetMs,
|
||||
maxAllowedOffsetMs: this.maxAllowedOffsetMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses supported server time response formats.
|
||||
* Numeric values below `1_000_000_000_000` are treated as epoch seconds; larger numeric values
|
||||
* are treated as epoch milliseconds. Non-numeric values are parsed as date strings.
|
||||
*
|
||||
* @param serverTime raw server time response body
|
||||
* @returns parsed epoch milliseconds, or NaN when the value cannot be parsed
|
||||
*/
|
||||
private parseServerTime(serverTime: string): number {
|
||||
const trimmedServerTime = serverTime.trim();
|
||||
const numericServerTime = Number(trimmedServerTime);
|
||||
|
||||
if (trimmedServerTime && Number.isFinite(numericServerTime)) {
|
||||
return Math.abs(numericServerTime) < 1_000_000_000_000 ? numericServerTime * 1000 : numericServerTime;
|
||||
}
|
||||
|
||||
const parsedServerTime = new Date(trimmedServerTime).getTime();
|
||||
|
||||
if (isNaN(parsedServerTime)) {
|
||||
this._logService.debug(
|
||||
`TimeSyncService: unable to parse server time "${this.sanitizeForLog(serverTime)}"; keeping the current clock offset.`
|
||||
);
|
||||
}
|
||||
|
||||
return parsedServerTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an immediate result in the same shared/finalized shape as HTTP-backed sync requests.
|
||||
* This keeps `_inFlightSync$` lifecycle handling identical for missing configuration and real
|
||||
* network requests.
|
||||
*
|
||||
* @param result immediate sync result
|
||||
* @returns shared result observable
|
||||
*/
|
||||
private createSharedResult(result: ClockSyncResult): Observable<ClockSyncResult> {
|
||||
return of(result).pipe(
|
||||
finalize(() => (this._inFlightSync$ = null)),
|
||||
shareReplay({ bufferSize: 1, refCount: false })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts network, timeout, and unexpected HTTP errors into a non-throwing sync result.
|
||||
* Failed syncs keep the current offset so the caller falls back to the internal clock when no
|
||||
* earlier successful sync exists.
|
||||
*
|
||||
* @param error request error to log
|
||||
* @param previousOffsetMs offset active before this sync attempt
|
||||
* @returns failed sync result observable
|
||||
*/
|
||||
private handleClockSyncRequestError(error: unknown, previousOffsetMs: number): Observable<ClockSyncResult> {
|
||||
this._logService.debug('TimeSyncService: failed to synchronise the clock offset; keeping the current clock offset.', error);
|
||||
return of(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the normalized result object returned by sync callers.
|
||||
* `hasSuccessfulSync` and `lastSuccessfulSyncAtMs` describe whether the current offset has ever
|
||||
* come from a trusted server-time response.
|
||||
*
|
||||
* @param status outcome of this sync attempt
|
||||
* @param appliedOffsetMs offset kept or applied after this attempt
|
||||
* @param previousOffsetMs offset that was active before this attempt
|
||||
* @returns normalized sync result
|
||||
*/
|
||||
private createClockSyncResult(status: ClockSyncStatus, appliedOffsetMs: number, previousOffsetMs: number): ClockSyncResult {
|
||||
return {
|
||||
status,
|
||||
appliedOffsetMs,
|
||||
previousOffsetMs,
|
||||
hasSuccessfulSync: this._lastSuccessfulSyncAtMs !== null,
|
||||
...(this._lastSuccessfulSyncAtMs === null ? {} : { lastSuccessfulSyncAtMs: this._lastSuccessfulSyncAtMs })
|
||||
};
|
||||
|
||||
return this._http.get(this.getAppRootUrl(), requestOptions).pipe(
|
||||
map((response: HttpResponse<string>) => this.getServerTimeFromDateHeader(response)),
|
||||
timeout(5000),
|
||||
catchError(() => throwError(() => new Error('Failed to get server time')))
|
||||
);
|
||||
}
|
||||
|
||||
private getServerTimeFromDateHeader(response: HttpResponse<string>): number {
|
||||
const dateHeader = response.headers.get('date');
|
||||
if (!dateHeader) {
|
||||
throw new Error('Date header is not available.');
|
||||
/**
|
||||
* Reads a monotonic timestamp for request round-trip measurement.
|
||||
* Falls back to `Date.now()` only in non-browser environments where `performance.now` is not
|
||||
* available.
|
||||
*
|
||||
* @returns monotonic timestamp in milliseconds
|
||||
*/
|
||||
private getMonotonicNow(): number {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
return new Date(dateHeader).getTime();
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
private getAppRootUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.href.split('?')[0].split('#')[0];
|
||||
}
|
||||
|
||||
return '/';
|
||||
}
|
||||
|
||||
private get showDebugInformation(): boolean {
|
||||
const enableDebugInformation = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_SHOW_DEBUG_INFORMATION, false);
|
||||
return enableDebugInformation === true || enableDebugInformation === 'true';
|
||||
}
|
||||
|
||||
private debug(message: string): void {
|
||||
if (this.showDebugInformation) {
|
||||
this._oauthLogger?.info(`[TimeSync] ${message}`);
|
||||
}
|
||||
/**
|
||||
* Sanitizes an untrusted, configuration-controlled or server-controlled value before
|
||||
* it is written to a log. Strips control characters (including CR/LF) to prevent log forging
|
||||
* if the log bus is ever forwarded to a backend store, and caps the length to bound noise.
|
||||
*
|
||||
* @param value raw value to sanitize
|
||||
* @returns a log-safe representation of the value
|
||||
*/
|
||||
private sanitizeForLog(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\u0000-\u001F\u007F]/g, ' ').slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@
|
||||
/>
|
||||
@if (cardViewDateTimeControl.hasError('matDatepickerParse')) {
|
||||
<mat-error>
|
||||
<span class="adf-error-text">{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate }}</span>
|
||||
{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate }}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { IconComponent } from './icon';
|
||||
import { DynamicChipListComponent } from './dynamic-chip-list';
|
||||
import { IdentityUserInfoComponent } from './identity-user-info';
|
||||
import { UnsavedChangesDialogComponent } from './dialogs';
|
||||
import { MaterialModule } from './material.module';
|
||||
import { provideAppConfig } from './app-config/provide-app-config';
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,8 @@ import { provideAppConfig } from './app-config/provide-app-config';
|
||||
...NOTIFICATION_HISTORY_DIRECTIVES,
|
||||
...SEARCH_TEXT_INPUT_DIRECTIVES,
|
||||
UnsavedChangesDialogComponent,
|
||||
DynamicChipListComponent
|
||||
DynamicChipListComponent,
|
||||
MaterialModule
|
||||
],
|
||||
providers: [...CORE_PIPES],
|
||||
exports: [
|
||||
@@ -110,7 +112,8 @@ import { provideAppConfig } from './app-config/provide-app-config';
|
||||
...NOTIFICATION_HISTORY_DIRECTIVES,
|
||||
...SEARCH_TEXT_INPUT_DIRECTIVES,
|
||||
UnsavedChangesDialogComponent,
|
||||
DynamicChipListComponent
|
||||
DynamicChipListComponent,
|
||||
MaterialModule
|
||||
]
|
||||
})
|
||||
export class CoreModule {
|
||||
|
||||
@@ -38,12 +38,8 @@ export class InfiniteSelectScrollDirective implements AfterViewInit {
|
||||
ngAfterViewInit() {
|
||||
this.matSelect.openedChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((opened: boolean) => {
|
||||
if (opened) {
|
||||
setTimeout(() => {
|
||||
if (this.matSelect.panel?.nativeElement) {
|
||||
this.itemHeightToWaitBeforeLoadNext = this.getItemHeight() * (InfiniteSelectScrollDirective.MAX_ITEMS / 2);
|
||||
this.matSelect.panel.nativeElement.addEventListener('scroll', (event: Event) => this.handleScrollEvent(event));
|
||||
}
|
||||
});
|
||||
this.itemHeightToWaitBeforeLoadNext = this.getItemHeight() * (InfiniteSelectScrollDirective.MAX_ITEMS / 2);
|
||||
this.matSelect.panel.nativeElement.addEventListener('scroll', (event: Event) => this.handleScrollEvent(event));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -60,7 +56,6 @@ export class InfiniteSelectScrollDirective implements AfterViewInit {
|
||||
}
|
||||
|
||||
private getItemHeight(): number {
|
||||
const panelElement = this.matSelect.panel?.nativeElement;
|
||||
return panelElement ? parseFloat(getComputedStyle(panelElement).fontSize || '0') * SELECT_ITEM_HEIGHT_EM : 0;
|
||||
return parseFloat(getComputedStyle(this.matSelect.panel.nativeElement).fontSize || '0') * SELECT_ITEM_HEIGHT_EM;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@
|
||||
}
|
||||
|
||||
.adf-container-widget {
|
||||
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
|
||||
.adf-grid-list {
|
||||
display: grid;
|
||||
|
||||
|
||||
@@ -392,8 +392,8 @@ describe('Form Renderer Component', () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expectElementToBeHidden(testingUtils, 'Number2');
|
||||
const errorText = testingUtils.getByCSS('#field-Number1-container .adf-error-text').nativeElement;
|
||||
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
|
||||
const errorWidgetText = testingUtils.getByCSS('#field-Number1-container error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
|
||||
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
|
||||
});
|
||||
|
||||
@@ -569,16 +569,16 @@ describe('Form Renderer Component', () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expectElementToBeInvalid(testingUtils, 'Number0x8cbv');
|
||||
let errorText = testingUtils.getByCSS('#field-Number0x8cbv-container .adf-error-text').nativeElement;
|
||||
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
|
||||
let errorWidgetText = testingUtils.getByCSS('#field-Number0x8cbv-container error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
|
||||
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
|
||||
|
||||
typeIntoInput(testingUtils, '#Number0x8cbv', '?');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expectElementToBeInvalid(testingUtils, 'Number0x8cbv');
|
||||
errorText = testingUtils.getByCSS('#field-Number0x8cbv-container .adf-error-text').nativeElement;
|
||||
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
|
||||
errorWidgetText = testingUtils.getByCSS('#field-Number0x8cbv-container error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
|
||||
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
|
||||
|
||||
typeIntoInput(testingUtils, '#Number0x8cbv', '-5');
|
||||
@@ -600,8 +600,8 @@ describe('Form Renderer Component', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expectElementToBeInvalid(testingUtils, 'Number0him2z');
|
||||
let errorText = testingUtils.getByCSS('#field-Number0him2z-container .adf-error-text').nativeElement;
|
||||
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_LESS_THAN`);
|
||||
let errorWidgetText = testingUtils.getByCSS('#field-Number0him2z-container error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_LESS_THAN`);
|
||||
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without valid field');
|
||||
|
||||
typeIntoInput(testingUtils, '#Number0him2z', '10');
|
||||
@@ -621,8 +621,8 @@ describe('Form Renderer Component', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expectElementToBeInvalid(testingUtils, 'Number0him2z');
|
||||
errorText = testingUtils.getByCSS('#field-Number0him2z-container .adf-error-text').nativeElement;
|
||||
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
|
||||
errorWidgetText = testingUtils.getByCSS('#field-Number0him2z-container error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
|
||||
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without valid field');
|
||||
});
|
||||
|
||||
|
||||
@@ -27,17 +27,16 @@
|
||||
[(ngModel)]="amountWidgetValue"
|
||||
(ngModelChange)="onFieldChangedAmountWidget()"
|
||||
[disabled]="field.readOnly"
|
||||
[errorStateMatcher]="errorStateMatcher"
|
||||
(focus)="amountWidgetOnFocus()"
|
||||
(blur)="amountWidgetOnBlur()"
|
||||
/>
|
||||
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
|
||||
<mat-error>
|
||||
<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-form-field>
|
||||
<div class="adf-error-messages-container">
|
||||
<error-widget [error]="field.validationSummary" />
|
||||
<error-widget
|
||||
*ngIf="isInvalidFieldRequired() && isTouched()"
|
||||
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
@use '../../../../styles/mat-selectors' as ms;
|
||||
@use '../../../../styles/mixins' as mixins;
|
||||
|
||||
.adf {
|
||||
&-amount-widget {
|
||||
@@ -26,7 +25,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adf-form-field-input .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
@@ -335,9 +335,8 @@ describe('AmountWidgetComponent - rendering', () => {
|
||||
await input.setValue('gdfgdf');
|
||||
expect(widget.field.isValid).toBe(false);
|
||||
|
||||
const formField = await testingUtils.getMatFormField();
|
||||
const errors = await formField.getTextErrors();
|
||||
expect(errors[0]).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
|
||||
const errorWidget = testingUtils.getByCSS('error-widget .adf-error-text').nativeElement;
|
||||
expect(errorWidget.textContent).toBe('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
|
||||
});
|
||||
|
||||
it('[C309693] - Should be possible to set the Advanced Properties for Amount Widget', async () => {
|
||||
@@ -373,14 +372,13 @@ describe('AmountWidgetComponent - rendering', () => {
|
||||
await input.setValue('8');
|
||||
expect(widget.field.isValid).toBe(false);
|
||||
|
||||
const formField = await testingUtils.getMatFormField();
|
||||
let errors = await formField.getTextErrors();
|
||||
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
|
||||
let errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
|
||||
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
|
||||
|
||||
await input.setValue('99');
|
||||
expect(widget.field.isValid).toBe(false);
|
||||
errors = await formField.getTextErrors();
|
||||
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.NOT_GREATER_THAN');
|
||||
errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
|
||||
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.NOT_GREATER_THAN');
|
||||
|
||||
await input.setValue('80');
|
||||
expect(widget.field.isValid).toBe(true);
|
||||
@@ -390,8 +388,8 @@ describe('AmountWidgetComponent - rendering', () => {
|
||||
|
||||
await input.setValue('incorrect format');
|
||||
expect(widget.field.isValid).toBe(false);
|
||||
errors = await formField.getTextErrors();
|
||||
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
|
||||
errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
|
||||
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
|
||||
});
|
||||
|
||||
describe('when form model has left labels', () => {
|
||||
|
||||
@@ -19,12 +19,11 @@
|
||||
|
||||
import { CurrencyPipe, NgIf } from '@angular/common';
|
||||
import { Component, OnInit, ViewEncapsulation, InjectionToken, inject, DestroyRef } from '@angular/core';
|
||||
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
|
||||
import { ErrorStateMatcher } from '@angular/material/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { ErrorWidgetComponent } from '../error/error.component';
|
||||
import { WidgetComponent } from '../widget.component';
|
||||
import { filter, isObservable, Observable } from 'rxjs';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
@@ -53,7 +52,7 @@ export const ADF_AMOUNT_SETTINGS = new InjectionToken<Observable<AmountWidgetSet
|
||||
'(invalid)': 'event($event)',
|
||||
'(select)': 'event($event)'
|
||||
},
|
||||
imports: [MatFormFieldModule, MatInputModule, FormsModule, TranslatePipe, NgIf, MatIconModule],
|
||||
imports: [MatFormFieldModule, MatInputModule, FormsModule, ErrorWidgetComponent, TranslatePipe, NgIf],
|
||||
providers: [CurrencyPipe],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
@@ -70,13 +69,11 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
currencyDisplay: string | boolean = 'symbol';
|
||||
decimalProperty: string;
|
||||
enableDisplayBasedOnLocale: boolean;
|
||||
errorStateMatcher: ErrorStateMatcher;
|
||||
isInputInFocus = false;
|
||||
locale: string;
|
||||
notShowDecimalDigits = '1.0-0';
|
||||
showDecimalDigits = '1.2-2';
|
||||
showReadonlyPlaceholder: boolean;
|
||||
translateParameters: Record<string, string> = {};
|
||||
valueAsNumber: number;
|
||||
|
||||
get placeholder(): string {
|
||||
@@ -120,8 +117,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
}
|
||||
this.subscribeToFieldChanges();
|
||||
this.setInitialValues();
|
||||
this.initErrorStateMatcher();
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +138,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
this.markAsTouched();
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
|
||||
amountWidgetOnFocus(): void {
|
||||
@@ -162,8 +156,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
onFieldChangedAmountWidget(): void {
|
||||
this.field.value = this.amountWidgetValue;
|
||||
super.onFieldChanged(this.field);
|
||||
this.markAsTouched();
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
|
||||
setInitialValues(): void {
|
||||
@@ -188,7 +180,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
} else if (!this.isInputInFocus) {
|
||||
this.amountWidgetValue = ev.field.value;
|
||||
}
|
||||
this.updateTranslateParameters();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,19 +192,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
|
||||
this.enableDisplayBasedOnLocale = data?.enableDisplayBasedOnLocale ?? false;
|
||||
this.showReadonlyPlaceholder = data?.showReadonlyPlaceholder;
|
||||
}
|
||||
|
||||
private initErrorStateMatcher(): void {
|
||||
this.errorStateMatcher = {
|
||||
isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean =>
|
||||
!!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 = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,6 @@ export class FormFieldTypes {
|
||||
|
||||
static READONLY_TYPES: string[] = [FormFieldTypes.HYPERLINK, FormFieldTypes.DISPLAY_VALUE, FormFieldTypes.READONLY_TEXT, FormFieldTypes.GROUP];
|
||||
|
||||
static readonly DISPLAY_TEXT_TYPES: string[] = [
|
||||
FormFieldTypes.TEXT,
|
||||
FormFieldTypes.MULTILINE_TEXT,
|
||||
FormFieldTypes.READONLY_TEXT,
|
||||
FormFieldTypes.DISPLAY_VALUE
|
||||
];
|
||||
|
||||
static VALIDATABLE_TYPES: string[] = [FormFieldTypes.DISPLAY_EXTERNAL_PROPERTY];
|
||||
|
||||
static REACTIVE_TYPES: string[] = [FormFieldTypes.DATE, FormFieldTypes.DATETIME, FormFieldTypes.DROPDOWN];
|
||||
@@ -74,10 +67,6 @@ export class FormFieldTypes {
|
||||
return FormFieldTypes.READONLY_TYPES.includes(type);
|
||||
}
|
||||
|
||||
static isDisplayTextType(type: string): boolean {
|
||||
return FormFieldTypes.DISPLAY_TEXT_TYPES.includes(type);
|
||||
}
|
||||
|
||||
static isValidatableType(type: string): boolean {
|
||||
return FormFieldTypes.VALIDATABLE_TYPES.includes(type);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ContainerModel } from './container.model';
|
||||
import { ErrorMessageModel } from './error-message.model';
|
||||
import { FormFieldTypes } from './form-field-types';
|
||||
import {
|
||||
DEFAULT_TEXT_MAX_LENGTH,
|
||||
FixedValueFieldValidator,
|
||||
MaxLengthFieldValidator,
|
||||
MaxValueFieldValidator,
|
||||
@@ -682,42 +681,6 @@ describe('FormFieldValidator', () => {
|
||||
expect(validator.isSupported(field)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support text field when fallback maxLength is defined', () => {
|
||||
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
|
||||
const field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.TEXT
|
||||
});
|
||||
|
||||
expect(fallbackValidator.isSupported(field)).toBe(true);
|
||||
expect(fallbackValidator.getMaxLength(field)).toBe(DEFAULT_TEXT_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it('should validate text field against fallback maxLength when maxLength is not configured', () => {
|
||||
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
|
||||
const field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.TEXT,
|
||||
value: 'a'.repeat(DEFAULT_TEXT_MAX_LENGTH + 1)
|
||||
});
|
||||
|
||||
field.validationSummary = new ErrorMessageModel();
|
||||
expect(fallbackValidator.validate(field)).toBe(false);
|
||||
expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN');
|
||||
expect(field.validationSummary.attributes.get('maxLength')).toBe(DEFAULT_TEXT_MAX_LENGTH.toLocaleString());
|
||||
});
|
||||
|
||||
it('should use configured maxLength instead of fallback maxLength', () => {
|
||||
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
|
||||
const field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.TEXT,
|
||||
maxLength: 3,
|
||||
value: '1234'
|
||||
});
|
||||
|
||||
field.validationSummary = new ErrorMessageModel();
|
||||
expect(fallbackValidator.validate(field)).toBe(false);
|
||||
expect(field.validationSummary.attributes.get('maxLength')).toBe('3');
|
||||
});
|
||||
|
||||
it('should allow empty values', () => {
|
||||
const field = new FormFieldModel(new FormModel(), {
|
||||
type: FormFieldTypes.TEXT,
|
||||
|
||||
@@ -21,8 +21,6 @@ import { FormFieldTypes } from './form-field-types';
|
||||
import { isNumberValue } from './form-field-utils';
|
||||
import { FormFieldModel } from './form-field.model';
|
||||
|
||||
export const DEFAULT_TEXT_MAX_LENGTH = 1024;
|
||||
|
||||
export interface FormFieldValidator {
|
||||
isSupported(field: FormFieldModel): boolean;
|
||||
validate(field: FormFieldModel): boolean;
|
||||
@@ -137,8 +135,7 @@ export class MinLengthFieldValidator implements FormFieldValidator {
|
||||
export class MaxLengthFieldValidator implements FormFieldValidator {
|
||||
constructor(
|
||||
private readonly supportedTypes: FormFieldTypes[] = [FormFieldTypes.TEXT, FormFieldTypes.MULTILINE_TEXT],
|
||||
private readonly maxLength?: number,
|
||||
private readonly fallbackMaxLength?: number
|
||||
private readonly maxLength?: number
|
||||
) {}
|
||||
|
||||
isSupported(field: FormFieldModel): boolean {
|
||||
@@ -161,11 +158,7 @@ export class MaxLengthFieldValidator implements FormFieldValidator {
|
||||
}
|
||||
|
||||
getMaxLength(field: FormFieldModel): number | undefined {
|
||||
if (this.maxLength !== undefined) {
|
||||
return this.maxLength;
|
||||
}
|
||||
|
||||
return field.maxLength > 0 ? field.maxLength : this.fallbackMaxLength;
|
||||
return this.maxLength ?? field.maxLength;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,8 +317,7 @@ export const FORM_FIELD_VALIDATORS = [
|
||||
new RequiredFieldValidator(),
|
||||
new NumberFieldValidator(),
|
||||
new MinLengthFieldValidator(),
|
||||
new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH),
|
||||
new MaxLengthFieldValidator([FormFieldTypes.MULTILINE_TEXT]),
|
||||
new MaxLengthFieldValidator(),
|
||||
new MaxLengthFieldValidator([FormFieldTypes.NUMBER], 10),
|
||||
new MinValueFieldValidator(),
|
||||
new MaxValueFieldValidator(),
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
*/
|
||||
|
||||
import { DateFnsUtils } from '../../../../common';
|
||||
import { FormRulesEvent } from '../../../events/form-rules.event';
|
||||
import { firstValueFrom, map, Subject, take, timeout } from 'rxjs';
|
||||
import { FormFieldTypes } from './form-field-types';
|
||||
import { RequiredFieldValidator } from './form-field-validator';
|
||||
import { FormFieldModel } from './form-field.model';
|
||||
@@ -1543,17 +1541,6 @@ describe('FormFieldModel', () => {
|
||||
});
|
||||
|
||||
describe('add row', () => {
|
||||
const assignFormRulesEventSubject = (): Subject<FormRulesEvent> => {
|
||||
const formRulesEvent = new Subject<FormRulesEvent>();
|
||||
(field.form as any).formService = {
|
||||
formRulesEvent,
|
||||
validateForm: new Subject(),
|
||||
validateFormField: new Subject(),
|
||||
formFieldValueChanged: new Subject()
|
||||
};
|
||||
return formRulesEvent;
|
||||
};
|
||||
|
||||
it('should add row if allowed by limit param', () => {
|
||||
expect(field.rows.length).toBe(2);
|
||||
|
||||
@@ -1581,56 +1568,18 @@ describe('FormFieldModel', () => {
|
||||
expect(field.rows.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should call onRepeatableSectionRowCountChanged', () => {
|
||||
spyOn(field.form, 'onRepeatableSectionRowCountChanged').and.callThrough();
|
||||
it('should call onRepeatableSectionChanged', () => {
|
||||
spyOn(field.form, 'onRepeatableSectionChanged').and.callThrough();
|
||||
|
||||
expect(field.form.onRepeatableSectionRowCountChanged).not.toHaveBeenCalled();
|
||||
expect(field.form.onRepeatableSectionChanged).not.toHaveBeenCalled();
|
||||
|
||||
field.addRow(field.fields, form);
|
||||
|
||||
expect(field.form.onRepeatableSectionRowCountChanged).toHaveBeenCalledWith(field);
|
||||
});
|
||||
|
||||
it('should emit onRowCountChanged via formService.formRulesEvent', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
const emissionPromise = firstValueFrom(formRulesEvent);
|
||||
|
||||
field.addRow(field.fields, form);
|
||||
|
||||
const emittedEvent = await emissionPromise;
|
||||
expect(emittedEvent.type).toBe('onRowCountChanged');
|
||||
expect(emittedEvent.field).toBe(field);
|
||||
});
|
||||
|
||||
it('should NOT emit onRowCountChanged when add row is not allowed', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
|
||||
field.addRow(field.fields, form);
|
||||
field.addRow(field.fields, form);
|
||||
field.addRow(field.fields, form);
|
||||
expect(field.rows.length).toBe(5);
|
||||
|
||||
const emissionPromise = firstValueFrom(formRulesEvent.pipe(timeout(50)));
|
||||
|
||||
field.addRow(field.fields, form);
|
||||
|
||||
await expectAsync(emissionPromise).toBeRejected();
|
||||
expect(field.rows.length).toBe(5);
|
||||
expect(field.form.onRepeatableSectionChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove row', () => {
|
||||
const assignFormRulesEventSubject = (): Subject<FormRulesEvent> => {
|
||||
const formRulesEvent = new Subject<FormRulesEvent>();
|
||||
(field.form as any).formService = {
|
||||
formRulesEvent,
|
||||
validateForm: new Subject(),
|
||||
validateFormField: new Subject(),
|
||||
formFieldValueChanged: new Subject()
|
||||
};
|
||||
return formRulesEvent;
|
||||
};
|
||||
|
||||
it('should remove row if target index exists', () => {
|
||||
expect(field.rows.length).toBe(2);
|
||||
|
||||
@@ -1697,77 +1646,14 @@ describe('FormFieldModel', () => {
|
||||
expect(field.form.onFormFieldChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onRepeatableSectionRowCountChanged', () => {
|
||||
spyOn(field.form, 'onRepeatableSectionRowCountChanged').and.callThrough();
|
||||
it('should call onRepeatableSectionChanged', () => {
|
||||
spyOn(field.form, 'onRepeatableSectionChanged').and.callThrough();
|
||||
|
||||
expect(field.form.onRepeatableSectionRowCountChanged).not.toHaveBeenCalled();
|
||||
expect(field.form.onRepeatableSectionChanged).not.toHaveBeenCalled();
|
||||
|
||||
field.removeRow(1);
|
||||
|
||||
expect(field.form.onRepeatableSectionRowCountChanged).toHaveBeenCalledWith(field);
|
||||
});
|
||||
|
||||
it('should emit onRowCountChanged via formService.formRulesEvent', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
const emissionPromise = firstValueFrom(formRulesEvent);
|
||||
|
||||
field.removeRow(1);
|
||||
|
||||
const emittedEvent = await emissionPromise;
|
||||
expect(emittedEvent.type).toBe('onRowCountChanged');
|
||||
expect(emittedEvent.field).toBe(field);
|
||||
});
|
||||
|
||||
it('should update form values before emitting onRowCountChanged', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
form.values[field.id] = [
|
||||
{
|
||||
Text0wwp7n: 'mock-1',
|
||||
Integer0rzkwq: 1
|
||||
},
|
||||
{
|
||||
Text0wwp7n: 'mock-2',
|
||||
Integer0rzkwq: 2
|
||||
}
|
||||
];
|
||||
|
||||
const valuesLengthAtEmissionPromise = firstValueFrom(
|
||||
formRulesEvent.pipe(
|
||||
take(1),
|
||||
map(() => form.values[field.id].length)
|
||||
)
|
||||
);
|
||||
|
||||
field.removeRow(1);
|
||||
|
||||
const valuesLengthAtEmission = await valuesLengthAtEmissionPromise;
|
||||
expect(valuesLengthAtEmission).toBe(1);
|
||||
expect(form.values[field.id]).toEqual([
|
||||
{
|
||||
Text0wwp7n: 'mock-1',
|
||||
Integer0rzkwq: 1
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should emit onRowCountChanged even when form values do not contain section id', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
form.values = {};
|
||||
const emissionPromise = firstValueFrom(formRulesEvent);
|
||||
|
||||
field.removeRow(1);
|
||||
|
||||
const emittedEvent = await emissionPromise;
|
||||
expect(emittedEvent.type).toBe('onRowCountChanged');
|
||||
});
|
||||
|
||||
it('should NOT emit onRowCountChanged when remove row target index does not exist', async () => {
|
||||
const formRulesEvent = assignFormRulesEventSubject();
|
||||
const emissionPromise = firstValueFrom(formRulesEvent.pipe(timeout(50)));
|
||||
|
||||
field.removeRow(2);
|
||||
|
||||
await expectAsync(emissionPromise).toBeRejected();
|
||||
expect(field.form.onRepeatableSectionChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1937,25 +1823,3 @@ describe('FormFieldModel', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormFieldTypes', () => {
|
||||
describe('isDisplayTextType', () => {
|
||||
it('should return true for text, multi-line text, readonly text and display value types', () => {
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.TEXT)).toBe(true);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.MULTILINE_TEXT)).toBe(true);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.READONLY_TEXT)).toBe(true);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.DISPLAY_VALUE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for typed source field types', () => {
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.PEOPLE)).toBe(false);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.FUNCTIONAL_GROUP)).toBe(false);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.DROPDOWN)).toBe(false);
|
||||
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.RADIO_BUTTONS)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for an unknown type', () => {
|
||||
expect(FormFieldTypes.isDisplayTextType('unknown-type')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -470,7 +470,7 @@ export class FormFieldModel extends FormWidgetModel {
|
||||
}
|
||||
|
||||
this.rows.push(this.createRow(fields, form, this.rows.length));
|
||||
this.form.onRepeatableSectionRowCountChanged(this);
|
||||
this.form.onRepeatableSectionChanged();
|
||||
}
|
||||
|
||||
private shouldAddRow(): boolean {
|
||||
@@ -484,17 +484,14 @@ export class FormFieldModel extends FormWidgetModel {
|
||||
|
||||
this.rows.splice(index, 1);
|
||||
this.updateChildrenFieldsRowIndex();
|
||||
this.form.onRepeatableSectionChanged();
|
||||
|
||||
const hasSectionValues = !!this.form.values[this.id];
|
||||
if (hasSectionValues) {
|
||||
this.form.values[this.id].splice(index, 1);
|
||||
if (!this.form.values[this.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.onRepeatableSectionRowCountChanged(this);
|
||||
|
||||
if (hasSectionValues) {
|
||||
this.form.onFormFieldChanged(this);
|
||||
}
|
||||
this.form.values[this.id].splice(index, 1);
|
||||
this.form.onFormFieldChanged(this);
|
||||
}
|
||||
|
||||
private shouldRemoveRow(index: number): boolean {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
|
||||
import { FormFieldEvent } from '../../../events/form-field.event';
|
||||
import { FormRulesEvent } from '../../../events/form-rules.event';
|
||||
import { ValidateFormFieldEvent } from '../../../events/validate-form-field.event';
|
||||
import { ValidateFormEvent } from '../../../events/validate-form.event';
|
||||
import { ContainerModel } from './container.model';
|
||||
@@ -155,11 +154,6 @@ export class FormModel implements ProcessFormModel {
|
||||
this.fieldsCache = this.getFormFields([], true);
|
||||
}
|
||||
|
||||
onRepeatableSectionRowCountChanged(sectionField: FormFieldModel): void {
|
||||
this.onRepeatableSectionChanged();
|
||||
this.formService?.formRulesEvent?.next(new FormRulesEvent('onRowCountChanged', new FormFieldEvent(this, sectionField)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates entire form and all form fields.
|
||||
*/
|
||||
|
||||
@@ -37,12 +37,9 @@
|
||||
[touchUi]="true"
|
||||
[timeInterval]="5"
|
||||
[disabled]="field.readOnly" />
|
||||
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
|
||||
<mat-error>
|
||||
<mat-icon class="adf-error-icon">error_outline</mat-icon>
|
||||
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<div class="adf-error-messages-container">
|
||||
<error-widget *ngIf="datetimeInputControl.invalid && datetimeInputControl.touched" [error]="field.validationSummary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* stylelint-disable selector-class-pattern */
|
||||
@use '../../../../styles/mat-selectors' as ms;
|
||||
@use '../../../../styles/mixins' as mixins;
|
||||
|
||||
.adf {
|
||||
&-date-time-widget {
|
||||
@@ -24,10 +23,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.adf-form-field-input .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
#{ms.$mat-datetimepicker-toggle} {
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angul
|
||||
import { FormControl, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
|
||||
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { DatetimeAdapter, MAT_DATETIME_FORMATS, MatDatetimepickerModule } from '@mat-datetimepicker/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ADF_DATE_FORMATS, ADF_DATETIME_FORMATS, AdfDateFnsAdapter, AdfDateTimeFnsAdapter, DateFnsUtils } from '../../../../common';
|
||||
import { FormService } from '../../../services/form.service';
|
||||
import { ErrorWidgetComponent } from '../error/error.component';
|
||||
import { WidgetComponent } from '../widget.component';
|
||||
import { ErrorMessageModel } from '../core/error-message.model';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
@@ -46,14 +46,13 @@ import { ReactiveFormWidget } from '../reactive-widget.interface';
|
||||
host: {
|
||||
'(click)': 'event($event)'
|
||||
},
|
||||
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, MatDatetimepickerModule, ReactiveFormsModule, MatIconModule],
|
||||
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, MatDatetimepickerModule, ReactiveFormsModule, ErrorWidgetComponent],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class DateTimeWidgetComponent extends WidgetComponent implements OnInit, ReactiveFormWidget {
|
||||
minDate: Date;
|
||||
maxDate: Date;
|
||||
datetimeInputControl: FormControl<Date> = new FormControl<Date>(null);
|
||||
translateParameters: Record<string, string> = {};
|
||||
|
||||
public readonly formService = inject(FormService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -117,15 +116,6 @@ export class DateTimeWidgetComponent extends WidgetComponent implements OnInit,
|
||||
this.resetErrors();
|
||||
this.field.markAsValid();
|
||||
}
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
|
||||
private updateTranslateParameters(): void {
|
||||
if (this.field.validationSummary?.isActive()) {
|
||||
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
|
||||
} else {
|
||||
this.translateParameters = {};
|
||||
}
|
||||
}
|
||||
|
||||
private handleErrors(errors: ValidationErrors): void {
|
||||
|
||||
@@ -21,12 +21,11 @@
|
||||
<mat-datepicker #datePicker
|
||||
[startAt]="startAt"
|
||||
[disabled]="field.readOnly" />
|
||||
@if (dateInputControl.invalid && dateInputControl.touched) {
|
||||
<mat-error>
|
||||
<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-form-field>
|
||||
<div class="adf-error-messages-container">
|
||||
<error-widget
|
||||
*ngIf="dateInputControl.invalid && dateInputControl.touched"
|
||||
[error]="field.validationSummary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../styles/mixins' as mixins;
|
||||
|
||||
.adf {
|
||||
&-widget {
|
||||
&-container {
|
||||
@@ -9,7 +7,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adf-form-field-input .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
|
||||
/* eslint-disable @angular-eslint/component-selector */
|
||||
|
||||
import { NgIf } from '@angular/common';
|
||||
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
|
||||
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
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 { ADF_DATE_FORMATS, AdfDateFnsAdapter, DateFnsUtils, DEFAULT_DATE_FORMAT } from '../../../../common';
|
||||
import { FormService } from '../../../services/form.service';
|
||||
import { ErrorWidgetComponent } from '../error/error.component';
|
||||
import { WidgetComponent } from '../widget.component';
|
||||
import { ErrorMessageModel } from '../core/error-message.model';
|
||||
import { parseISO } from 'date-fns';
|
||||
@@ -52,7 +53,7 @@ import { ReactiveFormWidget } from '../reactive-widget.interface';
|
||||
'(invalid)': 'event($event)',
|
||||
'(select)': 'event($event)'
|
||||
},
|
||||
imports: [MatFormFieldModule, TranslatePipe, MatInputModule, MatDatepickerModule, ReactiveFormsModule, MatIconModule],
|
||||
imports: [MatFormFieldModule, TranslatePipe, MatInputModule, MatDatepickerModule, ReactiveFormsModule, ErrorWidgetComponent, NgIf],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class DateWidgetComponent extends WidgetComponent implements OnInit, ReactiveFormWidget {
|
||||
@@ -117,16 +118,6 @@ export class DateWidgetComponent extends WidgetComponent implements OnInit, Reac
|
||||
this.onFieldChanged(this.field);
|
||||
}
|
||||
|
||||
get formattedMinDate(): string {
|
||||
const min = this.dateInputControl.errors?.matDatepickerMin?.min;
|
||||
return min ? DateFnsUtils.formatDate(min, this.field.dateDisplayFormat).toLocaleUpperCase() : '';
|
||||
}
|
||||
|
||||
get formattedMaxDate(): string {
|
||||
const max = this.dateInputControl.errors?.matDatepickerMax?.max;
|
||||
return max ? DateFnsUtils.formatDate(max, this.field.dateDisplayFormat).toLocaleUpperCase() : '';
|
||||
}
|
||||
|
||||
private validateField(): void {
|
||||
if (this.dateInputControl.invalid) {
|
||||
this.handleErrors(this.dateInputControl.errors);
|
||||
|
||||
@@ -18,19 +18,19 @@
|
||||
[id]="field.id"
|
||||
[required]="field.required && field.isVisible"
|
||||
[(ngModel)]="field.value"
|
||||
(ngModelChange)="onDecimalFieldChanged()"
|
||||
(ngModelChange)="onFieldChanged(field)"
|
||||
[disabled]="field.readOnly"
|
||||
[placeholder]="field.placeholder"
|
||||
[title]="field.tooltip"
|
||||
[errorStateMatcher]="errorStateMatcher"
|
||||
(blur)="onBlur()" />
|
||||
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
|
||||
<mat-error>
|
||||
<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>
|
||||
}
|
||||
(blur)="markAsTouched()" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="adf-error-messages-container">
|
||||
<error-widget [error]="field.validationSummary" />
|
||||
<error-widget
|
||||
*ngIf="isInvalidFieldRequired() && isTouched()"
|
||||
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@use '../../../../styles/mixins' as mixins;
|
||||
|
||||
.adf {
|
||||
&-decimal-widget {
|
||||
width: 100%;
|
||||
@@ -11,7 +9,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adf-form-field-input .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
*/
|
||||
|
||||
import { NgIf } from '@angular/common';
|
||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
|
||||
import { ErrorStateMatcher } from '@angular/material/core';
|
||||
import { Component, ViewEncapsulation } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { ErrorWidgetComponent } from '../error/error.component';
|
||||
import { WidgetComponent } from '../widget.component';
|
||||
|
||||
@Component({
|
||||
@@ -40,39 +39,7 @@ import { WidgetComponent } from '../widget.component';
|
||||
'(invalid)': 'event($event)',
|
||||
'(select)': 'event($event)'
|
||||
},
|
||||
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule, MatIconModule],
|
||||
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule, ErrorWidgetComponent],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
|
||||
errorStateMatcher: ErrorStateMatcher;
|
||||
translateParameters: Record<string, string> = {};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initErrorStateMatcher();
|
||||
}
|
||||
|
||||
onBlur(): void {
|
||||
this.markAsTouched();
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
|
||||
onDecimalFieldChanged(): void {
|
||||
this.onFieldChanged(this.field);
|
||||
this.updateTranslateParameters();
|
||||
}
|
||||
|
||||
private initErrorStateMatcher(): void {
|
||||
this.errorStateMatcher = {
|
||||
isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean =>
|
||||
!this.field.isValid && this.isTouched()
|
||||
};
|
||||
}
|
||||
|
||||
private updateTranslateParameters(): void {
|
||||
if (this.field.validationSummary?.isActive()) {
|
||||
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
|
||||
} else {
|
||||
this.translateParameters = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
export class DecimalWidgetComponent extends WidgetComponent {}
|
||||
|
||||
+14
-15
@@ -7,7 +7,6 @@
|
||||
<mat-form-field
|
||||
floatPlaceholder="never"
|
||||
class="adf-form-field-input"
|
||||
[class.adf-has-counter]="field.maxLength > 0"
|
||||
[floatLabel]="field.placeholder ? 'always' : null"
|
||||
>
|
||||
@if(field.name || field.required) {
|
||||
@@ -21,24 +20,24 @@
|
||||
rows="3"
|
||||
[id]="field.id"
|
||||
[required]="field.required"
|
||||
[(ngModel)]="field.value"
|
||||
(ngModelChange)="onMultilineTextFieldChanged()"
|
||||
[ngModel]="displayValue"
|
||||
(ngModelChange)="onValueChange($event)"
|
||||
[disabled]="field.readOnly || readOnly"
|
||||
[placeholder]="field.placeholder"
|
||||
[title]="field.tooltip"
|
||||
[errorStateMatcher]="errorStateMatcher"
|
||||
(blur)="onBlur()"
|
||||
(blur)="markAsTouched()"
|
||||
>
|
||||
</textarea>
|
||||
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
|
||||
<mat-error>
|
||||
@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-form-field>
|
||||
<div *ngIf="field.maxLength > 0" class="adf-multiline-word-counter">
|
||||
<span class="adf-multiline-word-counter-value">{{ displayValue?.length || 0 }}/{{ field.maxLength }}</span>
|
||||
</div>
|
||||
<div class="adf-error-messages-container">
|
||||
<error-widget [error]="field.validationSummary" />
|
||||
<error-widget
|
||||
*ngIf="isInvalidFieldRequired() && isTouched()"
|
||||
class="adf-multiline-required-message"
|
||||
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-22
@@ -1,12 +1,9 @@
|
||||
@use '../../../../styles/mixins' as mixins;
|
||||
|
||||
.adf {
|
||||
&-multiline-text-widget {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.adf-label {
|
||||
top: 20px;
|
||||
@@ -19,30 +16,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-multiline-counter {
|
||||
&-multiline-word-counter:has(.adf-multiline-word-counter-value) {
|
||||
float: right;
|
||||
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
&-multiline-counter-block {
|
||||
display: block;
|
||||
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
&-multiline-hint {
|
||||
margin-top: 7px;
|
||||
margin-top: -20px;
|
||||
min-height: 24px;
|
||||
min-width: 1px;
|
||||
font-size: var(--mat-sys-body-small-size);
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.55, 0, 0.55, 0.2);
|
||||
opacity: 1;
|
||||
padding-top: 5px;
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&-multiline-required-message {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.adf-form-field-input .adf-error-icon {
|
||||
@include mixins.adf-error-icon;
|
||||
}
|
||||
|
||||
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MultilineTextWidgetComponentComponent } from './multiline-text.widget';
|
||||
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 { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
|
||||
import { of, Subject } from 'rxjs';
|
||||
|
||||
describe('MultilineTextWidgetComponentComponent', () => {
|
||||
@@ -329,4 +330,92 @@ describe('MultilineTextWidgetComponentComponent - ADF_CUSTOM_MESSAGE', () => {
|
||||
expect(widget.field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_VALUE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('typed value formatting', () => {
|
||||
describe('when flag is on', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MultilineTextWidgetComponentComponent],
|
||||
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
|
||||
});
|
||||
fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent);
|
||||
widget = fixture.componentInstance;
|
||||
testingUtils = new UnitTestingUtils(fixture.debugElement);
|
||||
});
|
||||
|
||||
it('should return formatted name for a People value in read-only mode', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alice', lastName: 'Brown' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).toBe('Alice Brown');
|
||||
});
|
||||
|
||||
it('should not return [object Object] for a complex field value', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alice', lastName: 'Brown' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(String(widget.displayValue)).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('should pass through plain string values unchanged', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'multiline-id',
|
||||
type: FormFieldTypes.MULTILINE_TEXT,
|
||||
value: 'plain text',
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).toBe('plain text');
|
||||
});
|
||||
|
||||
it('should not JSON-stringify a Date value for an unregistered type', () => {
|
||||
const date = new Date('2026-06-02T14:30:00.000Z');
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'date-id',
|
||||
type: FormFieldTypes.MULTILINE_TEXT,
|
||||
value: date,
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(String(widget.displayValue)).toBe(String(date));
|
||||
expect(String(widget.displayValue)).not.toContain('"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when flag is off', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MultilineTextWidgetComponentComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent);
|
||||
widget = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should not format complex field values (default behaviour preserved)', () => {
|
||||
widget.field = new FormFieldModel(new FormModel(), {
|
||||
id: 'people-field',
|
||||
type: FormFieldTypes.PEOPLE,
|
||||
value: [{ firstName: 'Alice', lastName: 'Brown' }],
|
||||
readOnly: true
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(widget.displayValue).not.toBe('Alice Brown');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user