Compare commits

...
Author SHA1 Message Date
Michal Kinas 1a7a59cea7 [MNT-25896] Introduce page rotation button in pdf viewer (#12241)
* [MNT-25896] Introduce page rotation button in pdf viewer

* [MNT-25896] CR fixes
2026-09-16 11:16:57 +02:00
Shivangi Shree 762eba8490 [ACS-10264] Add aria-labelledby so that placeholder is announced only… (#12222)
* [ACS-10264] Add aria-labelledby so that placeholder is announced only once

* [ACS-10264] Add unit test

* [ACS-10264] CR fixes
2026-09-15 17:46:55 +05:30
Domenico Sibilio 62d7d9084d AAE-51800 Update Supply Chain Review GH AW to latest (#12238)
[skip ci]
2026-09-15 10:58:27 +02:00
Bartosz Sekula 6f414ae98e AAE-51538 Xmlxdom library bump (#12239)
* AAE-51538 Xmlxdom library bump

* update
2026-09-15 10:22:46 +02:00
Alex Molodyh c7c2ba292e AAE-49688 Revalidate form after rule-driven visibility changes (#12224) 2026-09-14 18:15:36 -07:00
dependabot[bot] 84e710191d build(deps): bump crowdin/github-action from 2.17.1 to 3.0.2 (#12220)
Bumps [crowdin/github-action](https://github.com/crowdin/github-action) from 2.17.1 to 3.0.2.
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/8f01d54f70f1713ee3f09d82c2bbb2daeac28689...0d5670f539973aea2f01abce61a8989934df0025)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 14:55:22 +02:00
Domenico Sibilio a776cd02c0 AAE-51793 Log attempt number around npm pack retries (#12236) 2026-09-14 12:12:05 +00:00
Domenico Sibilio 2c1405d053 AAE-51793 Restore verbose npm pack logs (#12235) 2026-09-14 11:40:29 +00:00
Domenico Sibilio 0bc823beea AAE-51793 Retry npm pack directly, drop redundant npm view pre check (#12234) 2026-09-14 13:01:22 +02:00
Domenico Sibilio 89053a1b9d AAE-51793 Widen npm-check-bundle retry window and fix set -e short circuit (#12233) 2026-09-14 10:27:38 +00:00
Domenico Sibilio 68d382a401 AAE-51793 Add retry with backoff to npm view in npm-check-bundle (#12232) 2026-09-14 09:45:22 +00:00
dependabot[bot] ddfabc0e44 build(deps-dev): bump semver from 7.6.3 to 7.8.5 (#12189)
Bumps [semver](https://github.com/npm/node-semver) from 7.6.3 to 7.8.5.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.6.3...v7.8.5)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:56:56 +02:00
dependabot[bot] f524b365f6 build(deps-dev): bump the typescript-eslint group with 4 updates (#12229)
Bumps the typescript-eslint group with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-estree) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.69.0 to 8.70.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.69.0 to 8.70.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/parser)

Updates `@typescript-eslint/typescript-estree` from 8.69.0 to 8.70.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/typescript-estree)

Updates `@typescript-eslint/utils` from 8.69.0 to 8.70.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.0/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.70.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.70.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.70.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:56:25 +02:00
dependabot[bot] 4c15ab2395 build(deps): bump the github-actions group across 2 directories with 9 updates (#12231)
Bumps the github-actions group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.9` | `4.38.0` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.37.9` | `4.38.0` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.9` | `4.38.0` |
| [Alfresco/alfresco-build-tools/.github/actions/send-teams-notification](https://github.com/alfresco/alfresco-build-tools) | `18.27.0` | `18.28.0` |
| [Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml](https://github.com/alfresco/alfresco-build-tools) | `18.27.0` | `18.28.0` |
| [Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment](https://github.com/alfresco/alfresco-build-tools) | `18.27.0` | `18.28.0` |
| [github/gh-aw-actions/setup](https://github.com/github/gh-aw-actions) | `0.88.4` | `0.89.7` |

Bumps the github-actions group with 2 updates in the /.github/actions/setup directory: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [Alfresco/alfresco-build-tools/.github/actions/git-latest-tag](https://github.com/alfresco/alfresco-build-tools).


Updates `github/codeql-action/init` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/autobuild` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `github/codeql-action/analyze` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/cdf488f595d80d6e07e03d4674febd5ab45fa938...b96794f015dfd88f77b49b1c93e0fa7110f94c63)

Updates `Alfresco/alfresco-build-tools/.github/actions/send-teams-notification` from 18.27.0 to 18.28.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/567a9393acca033008ce34eafcb200469e4ea2f5...a297ff6c5bf20c667047db658dd23f60241b270a)

Updates `Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml` from 18.27.0 to 18.28.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/567a9393acca033008ce34eafcb200469e4ea2f5...a297ff6c5bf20c667047db658dd23f60241b270a)

Updates `Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment` from 18.27.0 to 18.28.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/567a9393acca033008ce34eafcb200469e4ea2f5...a297ff6c5bf20c667047db658dd23f60241b270a)

Updates `github/gh-aw-actions/setup` from 0.88.4 to 0.89.7
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/be41add0f204de343293628bb99a72a3894cfd7a...c9a91649fa6263a455ef783f7c06a96940052fec)

Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0977fd99725f1db4007ccb2928dbb4e90d06cc86...ea17c68df8912ef543352723c149a84f56e3d413)

Updates `Alfresco/alfresco-build-tools/.github/actions/git-latest-tag` from 18.27.0 to 18.28.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/567a9393acca033008ce34eafcb200469e4ea2f5...a297ff6c5bf20c667047db658dd23f60241b270a)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification
  dependency-version: 18.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml
  dependency-version: 18.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment
  dependency-version: 18.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/gh-aw-actions/setup
  dependency-version: 0.89.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag
  dependency-version: 18.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:18:35 +02:00
Michal Kinas 9480cc39bd [MNT-25235] Add folder searching capability in autocomplete filter (#12223)
* [MNT-25235] Add folder searching capability in autocomplete filter

* [MNT-25235] CR fixes

* [MNT-25235] CR fixes

* [MNT-25235] CR fixes
2026-09-11 07:13:18 +02:00
Dominik Iwanek e28267621a [ACS-12582] [ADW] Document list container is not accessible via keyboard navigation for file selection (#12210) 2026-09-09 14:20:24 +02:00
Bartosz Sekula 27c700165c AAE-50901 Allow provideMockFeatureFlags to receive observables (#12221)
* AAE-50901 Allow provideMockFeatureFlags to receive observables

* update

* update

* cr

* cr
2026-09-07 14:21:03 +02:00
dependabot[bot] 2824543bf0 build(deps-dev): bump the typescript-eslint group with 4 updates (#12218)
Bumps the typescript-eslint group with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-estree) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/parser)

Updates `@typescript-eslint/typescript-estree` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/typescript-estree)

Updates `@typescript-eslint/utils` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 08:20:17 +00:00
dependabot[bot] 352d2728e4 build(deps): bump github/gh-aw-actions/setup (#12219)
Bumps the github-actions group with 1 update in the / directory: [github/gh-aw-actions/setup](https://github.com/github/gh-aw-actions).


Updates `github/gh-aw-actions/setup` from 0.88.0 to 0.88.4
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/afc709f45ed6a3f756eb4551856c6a9c42e15b2c...be41add0f204de343293628bb99a72a3894cfd7a)

---
updated-dependencies:
- dependency-name: github/gh-aw-actions/setup
  dependency-version: 0.88.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 09:51:23 +02:00
Shivangi Shree c038252179 [ACS-10221] Modify folder icon's color (#12166)
* [ACS-10221] Modify folder icon's color

* [ACS-10221] Change color to blue
2026-09-07 12:23:56 +05:30
Shivangi Shree 804dcd9c43 [ACS-10246] Add logic to make dates semantically conveyed (#12171)
* [ACS-10246] Add logic to make dates semantically conveyed

* [ACS-10246] Fix unit test

* [ACS-10246] Enhance the logic for converting date

* [ACS-10246] Remove tabindex=0 from time element

* [ACS-10246] Add unit test for iso method

* [ACS-10246] Add unit test

* [ACS-10246] Trigger build

* [ACS-10246] Remove date checking from isoDate function
2026-09-07 12:21:48 +05:30
dependabot[bot] e100d384fa build(deps-dev): bump the typescript-eslint group with 4 updates (#12203)
Bumps the typescript-eslint group with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-estree) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/parser)

Updates `@typescript-eslint/typescript-estree` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-estree)

Updates `@typescript-eslint/utils` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 12:05:22 +00:00
dependabot[bot] 5233828ad2 build(deps): bump the github-actions group across 2 directories with 8 updates (#12215)
Bumps the github-actions group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.8` | `4.37.9` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.37.8` | `4.37.9` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.8` | `4.37.9` |
| [Alfresco/alfresco-build-tools/.github/actions/send-teams-notification](https://github.com/alfresco/alfresco-build-tools) | `18.24.1` | `18.27.0` |
| [Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml](https://github.com/alfresco/alfresco-build-tools) | `18.24.1` | `18.27.0` |
| [Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment](https://github.com/alfresco/alfresco-build-tools) | `18.24.1` | `18.27.0` |
| [github/gh-aw-actions/setup](https://github.com/github/gh-aw-actions) | `0.87.10` | `0.88.0` |

Bumps the github-actions group with 1 update in the /.github/actions/setup directory: [Alfresco/alfresco-build-tools/.github/actions/git-latest-tag](https://github.com/alfresco/alfresco-build-tools).


Updates `github/codeql-action/init` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/autobuild` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `Alfresco/alfresco-build-tools/.github/actions/send-teams-notification` from 18.24.1 to 18.27.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/da99ab845e78301fcb0680d16cbc185a40a1938c...567a9393acca033008ce34eafcb200469e4ea2f5)

Updates `Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml` from 18.24.1 to 18.27.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/da99ab845e78301fcb0680d16cbc185a40a1938c...567a9393acca033008ce34eafcb200469e4ea2f5)

Updates `Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment` from 18.24.1 to 18.27.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/da99ab845e78301fcb0680d16cbc185a40a1938c...567a9393acca033008ce34eafcb200469e4ea2f5)

Updates `github/gh-aw-actions/setup` from 0.87.10 to 0.88.0
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/bc8c008a419c5b7a29df6f5641edd35fd1c6ea85...afc709f45ed6a3f756eb4551856c6a9c42e15b2c)

Updates `Alfresco/alfresco-build-tools/.github/actions/git-latest-tag` from 18.24.1 to 18.27.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/da99ab845e78301fcb0680d16cbc185a40a1938c...567a9393acca033008ce34eafcb200469e4ea2f5)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification
  dependency-version: 18.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml
  dependency-version: 18.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment
  dependency-version: 18.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/gh-aw-actions/setup
  dependency-version: 0.88.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag
  dependency-version: 18.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 12:01:47 +00:00
Ehsan Rezaei bef9ddd83e AAE-51361 Upgrading ng2-charts lib to v9 (#12213)
* AAE-51361 Upgrading ng2-charts lib to v9

* AAE-51361 using exact version

* AAE-51361 Update chart-js dep

* AAE-51361 Upgrading chart.js
2026-09-03 13:52:41 +00:00
Bartosz Sekula 6f2d7813b9 AAE-48569 browserslist library bump (#12211)
* AAE-48569 browserslist library bump

* cr
2026-09-03 13:17:52 +02:00
Ehsan Rezaei f56c2a27bb AAE-51358 Cleaning up websocket service from old subscription protocol (#12212)
* AAE-51358 Cleaning up websocket service from old subscription protocol

* AAE-51358 Fixing retry on error bug
2026-09-03 13:11:24 +02:00
Darren Thornton 6bb7ec3b7d AAE-50868 Fix some visibility conditions scenarios with form variables (#12206)
* AAE-50868 Fix some visibility conditions senarios with form variables

* form 0 check updates

* copilot review suggestions
2026-09-02 11:34:41 -05:00
Domenico Sibilio b773e2b49d AAE-49678 Update Supply Chain Review GH AW to latest (#12208)
[skip ci]
2026-09-02 12:45:31 +02:00
Alex Molodyh b1e9d044ee AAE-46057 Absorb 3px display-text overflow to prevent form tab scrollbar (#12202) 2026-08-31 12:04:55 -07:00
Ehsan Rezaei 62be76e6fa AAE-49653 Migrating to batch count endpoint (#12186)
* AAE-49653 Migrating to batch count endpoint

* AAE-49653 Updating code with the latest BE contract

* AAE-49653 Improving type safety

* AAE-49653 Code improvement

* AAE-49653 fixed the DI coupling and type safety

* AAE-49653 Code improvements

* AAE-49653 Adding more unit tests and fixing a subscription

* AAE-49653 Updating the comment

* AAE-49653 Code improvement

* AAE-49653 Removing the non-batched counter fallback

* AAE-49653 Making filter key mandatory string

* AAE-49653 Removing dead code and improvements

* Revert "AAE-49653 Removing dead code and improvements"

This reverts commit a8fc99b044.

* Revert "AAE-49653 Making filter key mandatory string"

This reverts commit ba916b7c22.

* Revert "AAE-49653 Removing the non-batched counter fallback"

This reverts commit 416a61fb9e.

* AAE-49653 Putting implementation under FF

* AAE-49653 Adding new input to filter components, moving FF handling to consumer app

* AAE-49653 Removing extra comments
2026-08-31 15:04:23 +02:00
Tomasz Gnyp 77c549830f Revert "fix(core): fix ResizableDirective memory leak from orphaned document …" (#12201)
This reverts commit 1e40e5effd.
2026-08-27 16:13:41 +01:00
Maurizio Vitale 91b5164ab2 fix(core): fix DropZoneDirective listener leak from bind(this) mismatch [AAE-50892] (#12200)
addEventListener was called with this.onDragEnter.bind(this), creating a
new function reference each time, while removeEventListener was called with
the unbound this.onDragEnter. The references never matched, so drag
listeners were never removed on destroy.

Each surviving listener closed over the directive and its host cell,
retaining the whole detached datatable/task-list subtree via the parentNode
chain. Heap analysis showed 624 dragenter + 624 dragover + 624 drop
listeners bound to detached cells.

Store the bound handlers once and use the same references for both add and
remove so listeners are properly cleaned up.
2026-08-27 12:56:51 +01:00
Maurizio Vitale 1e40e5effd fix(core): fix ResizableDirective memory leak from orphaned document listeners [AAE-50892] (#12197)
* fix(core): fix ResizableDirective memory leak from orphaned document listeners [AAE-50892]

Return unlisten teardown from Observable subscribers so RxJS properly
removes document event listeners when share() refcount drops to zero.

Previously, renderer.listen() unlisten functions were stored in mutable
fields that got overwritten on each drag cycle, orphaning old listeners
and retaining entire detached DOM subtrees via the closure chain.

* fix after second report
2026-08-27 12:47:49 +01:00
Tomasz Gnyp 3f2c81de86 AAE-50899 Improve form renderer for loop track (#12198) 2026-08-26 16:12:40 +02:00
dependabot[bot] 6816205f75 build(deps): bump SonarSource/sonarqube-scan-action from 5.1.0 to 8.2.1 (#12193)
Bumps [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action) from 5.1.0 to 8.2.1.
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/aa494459d7c39c106cc77b166de8b4250a32bb97...22918119ff8e1ca75a623e15c8296b6ea4fbe28f)

---
updated-dependencies:
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 13:17:45 +01:00
dependabot[bot] ce15f8f410 build(deps): bump actions/upload-artifact from 4.6.2 to 7.0.1 (#12194)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.2...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 13:14:30 +01:00
dependabot[bot] f2f3edb6c3 build(deps): bump actions/download-artifact from 4.3.0 to 8.0.1 (#12195)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4.3.0...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 13:14:07 +01:00
Alex Molodyh f9940f0db8 AAE-46057 Refine form field status spacing (#12196) 2026-08-25 13:19:19 -07:00
dependabot[bot]andDomenico Sibilio 9f37f22702 build(deps): bump @cspell/eslint-plugin from 10.0.0 to 10.0.1 (#12191)
Bumps [@cspell/eslint-plugin](https://github.com/streetsidesoftware/cspell/tree/HEAD/packages/cspell-eslint-plugin) from 10.0.0 to 10.0.1.
- [Release notes](https://github.com/streetsidesoftware/cspell/releases)
- [Changelog](https://github.com/streetsidesoftware/cspell/blob/main/packages/cspell-eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/streetsidesoftware/cspell/commits/v10.0.1/packages/cspell-eslint-plugin)

---
updated-dependencies:
- dependency-name: "@cspell/eslint-plugin"
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Domenico Sibilio <domenicosibilio@gmail.com>
2026-08-24 08:03:57 +00:00
dependabot[bot] 4d8d760e43 build(deps): bump the github-actions group across 2 directories with 9 updates (#12192)
Bumps the github-actions group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.7` | `4.37.8` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.37.7` | `4.37.8` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.7` | `4.37.8` |
| [Alfresco/alfresco-build-tools/.github/actions/send-teams-notification](https://github.com/alfresco/alfresco-build-tools) | `18.23.0` | `18.24.1` |
| [crowdin/github-action](https://github.com/crowdin/github-action) | `2.17.0` | `2.17.1` |
| [Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml](https://github.com/alfresco/alfresco-build-tools) | `18.23.0` | `18.24.1` |
| [Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment](https://github.com/alfresco/alfresco-build-tools) | `18.23.0` | `18.24.1` |
| [github/gh-aw-actions/setup](https://github.com/github/gh-aw-actions) | `0.86.2` | `0.87.2` |

Bumps the github-actions group with 1 update in the /.github/actions/setup directory: [Alfresco/alfresco-build-tools/.github/actions/git-latest-tag](https://github.com/alfresco/alfresco-build-tools).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/autobuild` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `Alfresco/alfresco-build-tools/.github/actions/send-teams-notification` from 18.23.0 to 18.24.1
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/98bcfbe06aafffdc0e9a790f352602316f82303b...da99ab845e78301fcb0680d16cbc185a40a1938c)

Updates `crowdin/github-action` from 2.17.0 to 2.17.1
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/c7af9bc98b01694653031fef2a0dc6c7888ce9bc...8f01d54f70f1713ee3f09d82c2bbb2daeac28689)

Updates `Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml` from 18.23.0 to 18.24.1
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/98bcfbe06aafffdc0e9a790f352602316f82303b...da99ab845e78301fcb0680d16cbc185a40a1938c)

Updates `Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment` from 18.23.0 to 18.24.1
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/98bcfbe06aafffdc0e9a790f352602316f82303b...da99ab845e78301fcb0680d16cbc185a40a1938c)

Updates `github/gh-aw-actions/setup` from 0.86.2 to 0.87.2
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/6aab9e5b5c91c615506061f09bedd81a23babe3c...b304200a0ef4b3998673bfc7945acb08ab8c88b7)

Updates `Alfresco/alfresco-build-tools/.github/actions/git-latest-tag` from 18.23.0 to 18.24.1
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/98bcfbe06aafffdc0e9a790f352602316f82303b...da99ab845e78301fcb0680d16cbc185a40a1938c)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification
  dependency-version: 18.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crowdin/github-action
  dependency-version: 2.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml
  dependency-version: 18.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment
  dependency-version: 18.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/gh-aw-actions/setup
  dependency-version: 0.87.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag
  dependency-version: 18.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 09:46:14 +02:00
Alex Molodyh 83b5769fbc AAE-46057 Stabilize form field status layout (#12165) 2026-08-20 09:55:29 -07:00
Bartosz Sekula d0368ba9c8 AAE-48569 Security bump for socket-io.parser (#12185)
* AAE-48569 Security bump for socket-io.parser

* cr
2026-08-20 16:36:12 +01:00
Shivangi Shree beebf737b9 [ACS-10230] Add role to search filter like logic, properties, date etc. (#12179)
* [ACS-10230] Add role to search filter like logic, properties, date etc.

* [ACS-10230] CR fixes
2026-08-20 16:15:29 +05:30
Ehsan Rezaei cc39b19eea AAE-50665 Handling dynamic component destroy (#12183)
* AAE-50665 Handling dynamic component destroy

* AAE-50665 Code improvements
2026-08-19 22:33:38 +02:00
Copilotanderomano 5e0c52afcb AAE-50678 SonarCloud scan on daily cron instead of every develop push (#12184)
* ci: change sonar-develop workflow to run on daily cron instead of every push to develop

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* ci: add pull-requests: read permission to sonar-develop workflow

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
2026-08-19 20:10:52 +02:00
7b8d616a9f AAE-50678 Fix: report test coverage to SonarCloud (#12181),
* fix: enable LCOV coverage reporting for SonarCloud

Add lcov reporter to all karma configs, upload coverage artifacts from
unit test matrix jobs, and add a SonarCloud scan job that merges
coverage reports and runs the sonar-scanner with proper LCOV paths.

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: pass secrets to unit-test-workflow and set SONAR_HOST_URL for SonarCloud

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* fix: add test outputs to nx.json so NX caches and restores coverage reports

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: add unit tests for Chart model to verify coverage reporting

* fix: use find to locate lcov.info in downloaded artifacts for SonarCloud coverage

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: add fake file with unit test to verify coverage reporting

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* ci: add full SonarCloud scan workflow on develop push

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: remove fake coverage-canary file and its spec

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* fix: replace secrets inherit with explicit SONAR_TOKEN in workflow call

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
Co-authored-by: Eugenio Romano <eromano@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-19 19:44:15 +02:00
Darren Thornton f915c813f0 AAE-49862 Fix Form Rule Does Not Update Hidden Status of Outcomes on First Run (#12168) 2026-08-19 10:17:52 -05:00
Copilotanderomano 70db1e383f AAE-50593 Fix: memory leaks in datatable column-resize directives (#12180)
* fix: memory leaks in datatable column-resize directives

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

* test: add regression test for mouseup listener cleanup on repeated mousedown

Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eromano <1030050+eromano@users.noreply.github.com>
2026-08-19 10:23:43 +02:00
Alex Molodyh f907f8ef10 AAE-50099 Fix parameterized validation message interpolation (#12170) 2026-08-18 19:37:03 -07:00
Alex Molodyh dfbe1adbd5 AAE-50098 Fix unrendered rich text expressions in form submissions (#12169) 2026-08-18 09:56:10 -07:00
Bartosz Sekula d7b1d74b05 AAE-50130 js-yaml quadratic CPU consumption (#12178) 2026-08-18 09:15:58 +01:00
dependabot[bot] 6eb32c3ec0 build(deps-dev): bump jasmine-core from 5.13.0 to 6.3.0 (#12118)
Bumps [jasmine-core](https://github.com/jasmine/jasmine) from 5.13.0 to 6.3.0.
- [Release notes](https://github.com/jasmine/jasmine/releases)
- [Changelog](https://github.com/jasmine/jasmine/blob/main/RELEASE.md)
- [Commits](https://github.com/jasmine/jasmine/compare/v5.13.0...v6.3.0)

---
updated-dependencies:
- dependency-name: jasmine-core
  dependency-version: 6.3.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 13:20:27 +01:00
dependabot[bot] f5d3e3df37 build(deps): bump actions/github-script from 7.1.0 to 9.0.0 (#12176)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.1.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7.1.0...3a2844b7e9c422d3c10d287c895573f7108da1b3)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 13:19:25 +01:00
dependabot[bot] 1612b4817f build(deps): bump the github-actions group across 2 directories with 8 updates (#12175)
Bumps the github-actions group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.6` | `4.37.7` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.37.6` | `4.37.7` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.6` | `4.37.7` |
| [Alfresco/alfresco-build-tools/.github/actions/send-teams-notification](https://github.com/alfresco/alfresco-build-tools) | `18.21.3` | `18.23.0` |
| [Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml](https://github.com/alfresco/alfresco-build-tools) | `18.21.3` | `18.23.0` |
| [Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment](https://github.com/alfresco/alfresco-build-tools) | `18.21.3` | `18.23.0` |
| [github/gh-aw-actions/setup](https://github.com/github/gh-aw-actions) | `0.86.1` | `0.86.2` |

Bumps the github-actions group with 1 update in the /.github/actions/setup directory: [Alfresco/alfresco-build-tools/.github/actions/git-latest-tag](https://github.com/alfresco/alfresco-build-tools).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/autobuild` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `Alfresco/alfresco-build-tools/.github/actions/send-teams-notification` from 18.21.3 to 18.23.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b...98bcfbe06aafffdc0e9a790f352602316f82303b)

Updates `Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml` from 18.21.3 to 18.23.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b...98bcfbe06aafffdc0e9a790f352602316f82303b)

Updates `Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment` from 18.21.3 to 18.23.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b...98bcfbe06aafffdc0e9a790f352602316f82303b)

Updates `github/gh-aw-actions/setup` from 0.86.1 to 0.86.2
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/8914f47b6c1bb8a802c7549f5ac1a81434b66403...6aab9e5b5c91c615506061f09bedd81a23babe3c)

Updates `Alfresco/alfresco-build-tools/.github/actions/git-latest-tag` from 18.21.3 to 18.23.0
- [Release notes](https://github.com/alfresco/alfresco-build-tools/releases)
- [Commits](https://github.com/alfresco/alfresco-build-tools/compare/5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b...98bcfbe06aafffdc0e9a790f352602316f82303b)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification
  dependency-version: 18.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml
  dependency-version: 18.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment
  dependency-version: 18.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/gh-aw-actions/setup
  dependency-version: 0.86.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag
  dependency-version: 18.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 12:57:23 +02:00
dependabot[bot] c032051b68 build(deps-dev): bump webpack from 5.109.0 to 5.109.2 (#12174)
Bumps [webpack](https://github.com/webpack/webpack) from 5.109.0 to 5.109.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.109.0...v5.109.2)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.109.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 12:55:35 +02:00
dependabot[bot] 5feb6d89ec build(deps-dev): bump the typescript-eslint group across 1 directory with 4 updates (#12173)
Bumps the typescript-eslint group with 4 updates in the / directory: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/typescript-estree](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-estree) and [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils).


Updates `@typescript-eslint/eslint-plugin` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/parser)

Updates `@typescript-eslint/typescript-estree` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/typescript-estree)

Updates `@typescript-eslint/utils` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/utils)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/typescript-estree"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: typescript-eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 12:55:07 +02:00
Dominik Iwanek d5772606f5 [PRODSEC-13780] - Fix Angular i18n: Cross-Site Scripting (XSS) via event-handler attributes (ADF/ACA/ADW) (#12167) 2026-08-17 11:36:12 +02:00
Mykyta Maliarchuk 3aa17e49fe [ACS-12332] Add checkout and cancel-checkout node support (#12133)
* [ACS-12332] Add checkout and cancel-checkout node support

* [ACS-12332] cr fix
2026-08-13 09:32:32 +02:00
Shivangi Shree 4931475950 [ACS-10175] Add logic to send notification only if the user adds labe… (#12141)
* [ACS-10175] Add logic to send notification only if the user adds label after creating pr

* [ACS-10175] Update label detection logic

* [ACS-10175] check if pr createdAt and label addedAt is different

* [ACS-10175] Add logic to send notification on adding A/N BDU label

* [ACS-10175] Add issues to permissions

* Make time difference 15 seconds

* [ACS-10175] Fix version

* [ACS-10175] Add condition checking

* [ACS-10175] CR fixes

* [ACS-10175] Add full sha
2026-08-12 23:37:31 +05:30
186 changed files with 8097 additions and 1957 deletions
+19 -1
View File
@@ -26,6 +26,24 @@ runs:
echo "TAG_NPM not set, aborting"
exit 1
fi
ADF_VERSION=$(npm view @alfresco/adf-core@${TAG_NPM} version)
# Retry to absorb npm registry propagation delay right after publish.
NPM_VIEW_RETRIES=24
NPM_VIEW_RETRY_DELAY=20
for attempt in $(seq 1 "$NPM_VIEW_RETRIES"); do
ADF_VERSION=$(npm view @alfresco/adf-core@${TAG_NPM} version 2>/dev/null) || true
if [[ -n $ADF_VERSION ]]; then
break
fi
if [[ $attempt -lt $NPM_VIEW_RETRIES ]]; then
sleep "$NPM_VIEW_RETRY_DELAY"
fi
done
if [[ -z $ADF_VERSION ]]; then
echo "Could not resolve @alfresco/adf-core@${TAG_NPM} version, aborting" >&2
exit 1
fi
echo "check bundle on TAG_NPM='${TAG_NPM}' and ADF_VERSION='${ADF_VERSION}'"
./scripts/github/build/npm-check-bundles.sh -v ${ADF_VERSION}
+2 -2
View File
@@ -26,7 +26,7 @@ runs:
shell: bash
run: echo "nx-github-actions" | sudo tee /etc/machine-id > /dev/null
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -35,7 +35,7 @@ runs:
- name: get latest tag sha
if: ${{ inputs.full-setup == 'true' }}
id: tag-sha
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@a297ff6c5bf20c667047db658dd23f60241b270a # v18.28.0
- name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag
+28 -13
View File
@@ -1,9 +1,9 @@
{
"entries": {
"github/gh-aw-actions/setup@v0.85.4": {
"github/gh-aw-actions/setup@v0.88.7": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.85.4",
"sha": "2709137ea6c5b0e19aa621454dc643ea8dc526b1"
"version": "v0.88.7",
"sha": "5e508589e03a7757a7e05b26e834292f5445bfb6"
}
},
"containers": {
@@ -12,30 +12,45 @@
"digest": "sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.14": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14",
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44",
"digest": "sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14",
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/squid:0.27.44": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.27.44",
"digest": "sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.8": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.8",
"digest": "sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.8@sha256:38bbea36cdb46a3c9d04d1db05e672966f5239b431a2022eb35881688e5721d8"
"ghcr.io/github/gh-aw-firewall/squid:0.28.14": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14",
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.18": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.18",
"digest": "sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.18@sha256:85b940556a8faa4e1fdbef124bfd75f2c4ebd855a10b88a1c3b6f3e97f6f1a53"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"
"digest": "sha256:87366cb93b06d7a4e3db08a705875efc027b6c394da336119e7a067abacbb39b",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:87366cb93b06d7a4e3db08a705875efc027b6c394da336119e7a067abacbb39b"
},
"ghcr.io/github/github-mcp-server:v1.8.0": {
"image": "ghcr.io/github/github-mcp-server:v1.8.0",
"digest": "sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"
"ghcr.io/github/github-mcp-server:v1.11.0": {
"image": "ghcr.io/github/github-mcp-server:v1.11.0",
"digest": "sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.11.0@sha256:fbec75de11c255213fa08d80fb166abe73d851fff631c51c0079872967720699"
}
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3.29.5
# Override language selection by uncommenting this and choosing your languages
with:
languages: javascript
@@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3.29.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -53,4 +53,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v3.29.5
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3.29.5
+35 -1
View File
@@ -7,6 +7,7 @@ on:
permissions:
pull-requests: read
issues: read
jobs:
notify-bdu:
@@ -18,8 +19,41 @@ jobs:
github.event.label.name == 'A/N BDU' &&
github.event.pull_request.state == 'open'
steps:
- name: Check if label was added after PR creation (with time threshold)
id: check_label_timing
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const prCreatedAt = new Date('${{ github.event.pull_request.created_at }}');
const timeline = await github.rest.issues.listEvents({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const labelEvent = timeline.data.reverse().find(event =>
event.event === 'labeled' &&
event.label.name === 'A/N BDU'
);
if (!labelEvent) {
core.setOutput('should_notify', 'false');
return;
}
const labelAddedAt = new Date(labelEvent.created_at);
const timeDiffSeconds = (labelAddedAt - prCreatedAt) / 1000;
if (timeDiffSeconds > 15) {
core.setOutput('should_notify', 'true');
} else {
core.setOutput('should_notify', 'false');
}
- name: Send Teams notification
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
if: steps.check_label_timing.outputs.should_notify == 'true'
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@a297ff6c5bf20c667047db658dd23f60241b270a # v18.28.0
with:
webhook-url: ${{ secrets.TEAMS_NOTIFICATION_ADF_BDU_WEBHOOK }}
skip_checkout: true
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: develop
token: ${{ steps.app-token.outputs.token }}
- name: Pull translations from Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with:
skip_ref_checkout: true
upload_sources: false
+2
View File
@@ -254,6 +254,8 @@ jobs:
name: "Unit Tests"
needs: [setup]
uses: ./.github/workflows/unit-test-workflow.yml
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
base_ref: ${{ github.base_ref || 'develop' }}
+2 -2
View File
@@ -125,7 +125,7 @@ jobs:
npm-check-bundle:
needs: [release-npm]
timeout-minutes: 15
timeout-minutes: 25
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
@@ -147,7 +147,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Push Source Files to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with:
upload_sources: true
upload_sources_args: --delete-obsolete
+22
View File
@@ -0,0 +1,22 @@
name: "SonarCloud Full Scan (develop)"
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
full-unit-tests-and-sonar-scan:
name: "Full Unit Tests + SonarCloud Scan"
uses: ./.github/workflows/unit-test-workflow.yml
secrets: inherit
with:
full: true
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@a297ff6c5bf20c667047db658dd23f60241b270a # v18.28.0
@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@5177eca5d3d71342d7f7e0a2a4d74cc16b1eeb1b # v18.21.3
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@a297ff6c5bf20c667047db658dd23f60241b270a # v18.28.0
with:
comment-identifier: supply-chain-review-instructions
comment-body: |
File diff suppressed because one or more lines are too long
+47 -18
View File
@@ -8,10 +8,9 @@ permissions:
contents: read
pull-requests: read
model: gpt-5-mini
engine:
id: copilot
model: gpt-5-mini
tools:
github:
toolsets: [context, pull_requests, repos]
@@ -24,17 +23,23 @@ network:
- api.osv.dev
- api.scorecard.dev
- search.maven.org
- api.github.com
- github.com
safe-outputs:
add-comment:
hide-older-comments: true
add-labels:
allowed: [security:low, security:medium, security:high]
issue-intent: false
remove-labels:
allowed: [security:low, security:medium, security:high]
submit-pull-request-review:
allowed-events: [COMMENT, REQUEST_CHANGES]
supersede-older-reviews: true
dismiss-pull-request-review:
source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7bc0fc6f4f11df6c065b57d4a6aa90d7ea362b2f
source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@599eebd2a1b84e76d540e41036520df3a64c7cbd
---
# Supply Chain Review
@@ -57,7 +62,7 @@ For each changed dependency extract:
- Old version (or mark as `NEW DEPENDENCY` if newly added)
- New version
If no dependency files were changed, post a brief PR comment stating that no dependency changes were detected and no review is needed, then stop.
If no dependency files were changed, post a brief PR comment stating that no dependency changes were detected, then go directly to Step 6 — treating this as LOW risk — to remove any stale `security:*` labels and submit the required pull request review, then stop.
## Step 1b — Filter Internal Dependencies
@@ -74,7 +79,7 @@ For each internal dependency found:
2. Record the package name (with `@` replaced by `(at)` for GitHub comment compatibility), ecosystem, old version, and new version in a separate "Internal Dependencies (Skipped)" list.
3. Continue with Step 2 only for the remaining external/public dependencies.
If ALL changed dependencies are internal, skip Steps 2-4 and proceed directly to Step 5, posting a report that lists the internal dependencies and notes that no external supply chain analysis was performed.
If ALL changed dependencies are internal, skip Steps 2-4 and proceed directly to Step 5 — treating this as LOW risk for Step 6 — posting a report that lists the internal dependencies and notes that no external supply chain analysis was performed.
## Step 2 — Collect Data for Each Dependency
@@ -268,7 +273,7 @@ Verify that the source repository URL in registry metadata points to the canonic
Assign a risk score (0-100) to each dependency using these guidelines:
| Priority | Signal | Typical Impact |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|
| Highest | Known CRITICAL/HIGH CVEs in new version, confirmed typosquatting, malicious code in diff, build provenance mismatch (tag points to different code than published artifact), tag mimicry on fork | 60+ points |
| High | Maintainer takeover pattern (publisher changed + old maintainers removed), dangerous install scripts, known compromised package, moved/recreated tag with different commit, provenance attestations removed from package that previously had them | 20-40 points |
| Medium | Low OpenSSF Scorecard (< 3), publisher changed (without full takeover), new install scripts, very recent publish (< 48h), obfuscated code in diff, unsigned lightweight tags on security-critical packages, absence of provenance on high-profile packages | 10-20 points |
@@ -304,9 +309,9 @@ GitHub enforces a maximum of 10 mentions per comment. Package names containing `
### Internal Dependencies (Skipped)
| Package | Ecosystem | Old Version | New Version | Reason |
|-------------------|-----------|-------------|-------------|---------------------------------|
| (at)hyland/core | npm | 3.1.0 | 3.2.0 | Internal ((at)hyland/* scope) |
| Package | Ecosystem | Old Version | New Version | Reason |
|-----------------|-----------|-------------|-------------|-------------------------------|
| (at)hyland/core | npm | 3.1.0 | 3.2.0 | Internal ((at)hyland/* scope) |
_These dependencies are internal packages not available on public registries. External API checks were skipped._
@@ -342,18 +347,42 @@ No suspicious patterns detected. Routine upgrade.
## Step 6 — Apply Label and Review Status
- First, remove any `security:low`, `security:medium`, or `security:high` labels already present on the PR from a previous review — this PR may have been reviewed before (e.g., after a new commit), and stale risk labels must not remain alongside the new one.
- Then apply a label to the PR based on the highest risk level found:
- `security:low` for LOW risk
- `security:medium` for MEDIUM risk
- `security:high` for HIGH or CRITICAL risk
- If the highest risk level is HIGH or CRITICAL, submit a pull request review requesting changes, with a summary of the critical findings.
- If the risk is MEDIUM, submit a pull request review as a comment, noting that human review is recommended.
- If the risk is LOW, do not submit a review — the PR comment is sufficient.
### 6a. Labels — order-independent update
Determine the single target label for the highest risk level found: `security:low`, `security:medium`, or `security:high`.
- Remove only the OTHER `security:*` labels (the ones that do NOT match the target) if present on the PR — this clears stale risk labels left by a previous review (e.g., after a new commit changed the risk level).
- Add the target label if it is not already present.
- **Never remove the target label itself.** Because the remove and add operations act on disjoint labels, the final state is correct regardless of which of the two safe-output calls (`add_labels` / `remove_labels`) happens to be processed first — do NOT rely on emitting them in a particular order, since that ordering is not guaranteed. (Do not, for example, remove all three `security:*` labels and then add the target back — if the removal is processed after the add, the target label would be stripped again, leaving the PR with no risk label at all.)
### 6b. Dismiss stale reviews from this workflow
Every review this workflow posts (see 6c) MUST start its body with the exact literal marker line `**Supply Chain Review**` as the first line, so future runs can recognize their own prior reviews.
Before posting the new review:
1. Fetch the PR's existing reviews (GitHub MCP `pull_requests` toolset).
2. Identify any review that is authored by this workflow's actor AND whose body starts with the `**Supply Chain Review**` marker AND is still in the `CHANGES_REQUESTED` state — that is a stale review from an earlier run of this same workflow (e.g., posted before the flagged dependency was fixed, downgraded, or removed).
3. For each such review, call `dismiss_pull_request_review` with its explicit numeric `review_id` (do NOT use `'auto'` — this repository may run other agentic workflows that also post as the same actor, and `'auto'` would dismiss their reviews too) and a justification of at least 20 characters (e.g., "Superseded by a newer Supply Chain Review run.").
Do this even though `submit-pull-request-review` is also configured with `supersede-older-reviews: true` — that setting is best-effort and may not always recognize the prior review, so the explicit dismissal above is the reliable mechanism and must always be attempted.
### 6c. Submit the review
**Always submit a pull request review — in every invocation, with no exceptions.** This is not conditional on risk level. Submit a review even when there are no dependency changes, when all dependencies are internal, or when risk is LOW — skipping it would mean a stale `REQUEST_CHANGES` review from an earlier run is never replaced or dismissed.
The review body must start with the `**Supply Chain Review**` marker line (see 6b), followed by the assessment:
- If the highest risk level is HIGH or CRITICAL, submit the review as **request changes**, with a summary of the critical findings.
- If the risk is MEDIUM, submit the review as a **comment**, noting that human review is recommended.
- If the risk is LOW (including when there are no dependency changes, or all changed dependencies are internal), submit the review as a **comment**, summarizing that no concerns were found and the PR comment has the full detail.
- **Never submit the review as an approval, under any circumstance** — this workflow only ever comments or requests changes; a human always makes the merge decision.
## Important Guidelines
- **Never approve or merge the PR** — all actions are advisory or blocking only. A human always makes the merge decision.
- **Never approve or merge the PR** — all actions are advisory or blocking only. A human always makes the merge decision. Every review this workflow submits must use the comment or request-changes event — never the approve event.
- **Always submit exactly one pull request review per invocation, regardless of outcome**, and always prefix its body with the `**Supply Chain Review**` marker — this is required so that a later run of this same workflow can find and dismiss it via `dismiss_pull_request_review` once it becomes stale (see Step 6b). Do not rely on `supersede-older-reviews` alone; it is best-effort.
- **Never remove the `security:*` label matching the current risk level** when clearing stale labels — only remove the other ones, so the final label state is correct no matter which safe-output call is processed first (see Step 6a).
- Be specific in findings — cite exact data (vulnerability ID, maintainer name, script content, file path, API response) rather than vague warnings.
- For Maven packages, adapt npm-specific checks appropriately (e.g., install scripts become build plugin analysis, maintainer metadata may be limited).
- When a package is a NEW dependency (no old version), pay extra attention to project health, name legitimacy, and install scripts since there is no historical baseline to compare against.
+65 -2
View File
@@ -2,12 +2,21 @@ name: "Unit Tests Workflow"
on:
workflow_call:
secrets:
SONAR_TOKEN:
description: 'Token for SonarCloud analysis'
required: false
inputs:
base_ref:
description: 'Base branch for affected calculation'
required: false
type: string
default: 'develop'
full:
description: 'Run the full (non-affected) test suite for every project instead of only affected ones'
required: false
type: boolean
default: false
jobs:
generate-affected-matrix:
@@ -30,9 +39,15 @@ jobs:
id: set-matrix
env:
BASE_REF: ${{ inputs.base_ref }}
FULL_RUN: ${{ inputs.full }}
run: |
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
if [ "$FULL_RUN" == "true" ]; then
echo "Running full (non-affected) test suite"
AFFECTED_UNIT=$(pnpm nx show projects --target=test --select=projects --plain --exclude=cli,stories,eslint-angular)
else
echo "Base ref is $BASE_REF"
AFFECTED_UNIT=$(pnpm nx show projects --affected --target=test --base=origin/$BASE_REF --head=HEAD --select=projects --plain --exclude=cli,stories,eslint-angular)
fi
echo "Affected projects for UNIT: $AFFECTED_UNIT"
if [ -z "$AFFECTED_UNIT" ]; then
@@ -74,8 +89,56 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=5120"
run: |
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
if-no-files-found: ignore
retention-days: 1
- name: Save nx cache
if: ${{ success() }}
uses: ./.github/actions/save-nx-cache
with:
cache-suffix: test-${{ matrix.project }}
sonarcloud:
name: "SonarCloud Scan"
runs-on: ubuntu-latest
needs: [generate-affected-matrix, unit-tests]
if: ${{ needs.generate-affected-matrix.outputs.hasProjects == 'true' && always() && needs.unit-tests.result != 'cancelled' }}
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
path: coverage-reports
- name: Merge coverage reports
run: |
mkdir -p coverage
echo "Artifact structure:"
find coverage-reports -type f -name 'lcov.info' 2>/dev/null || true
for dir in coverage-reports/coverage-*/; do
project_name=$(basename "$dir" | sed 's/^coverage-//')
lcov_file=$(find "$dir" -name 'lcov.info' -type f | head -1)
if [ -n "$lcov_file" ]; then
mkdir -p "coverage/${project_name}"
cp "$lcov_file" "coverage/${project_name}/lcov.info"
echo "Copied coverage for ${project_name}"
fi
done
echo "Coverage files found:"
find coverage -name 'lcov.info' -type f
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
@@ -2,7 +2,7 @@
Title: Search Chip Autocomplete Input component
Added: v6.1.0
Status: Active
Last reviewed: 2023-06-13
Last reviewed: 2026-09-09
---
# [Search Chip Autocomplete Input component](../../../lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts "Defined in search-chip-autocomplete-input.component.ts")
@@ -31,6 +31,7 @@ Represents an input with autocomplete options.
| preselectedOptions | `AutocompleteOption[]` | [] | Options which are selected from start |
| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`<void>` | | Observable that will listen to any reset event causing component to clear the chips and input |
| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones |
| loading | [`Observable`](https://rxjs.dev/guide/observable)`<boolean>` | `of(false)` | Stream driving a loading spinner shown inside the autocomplete panel while a new batch of options is being fetched. While it emits `true`, the options list is replaced by the spinner. |
| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. |
| compareOption | (option1: AutocompleteOption, option2: AutocompleteOption) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. |
| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. |
@@ -2,7 +2,7 @@
Title: Search Filter Autocomplete Chips component
Added: v6.1.0
Status: Active
Last reviewed: 2023-06-13
Last reviewed: 2026-09-09
---
# [Search Filter Autocomplete Chips component](../../../lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts "Defined in search-filter-autocomplete-chips.component.ts")
@@ -39,18 +39,61 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models
### Settings
| Name | Type | Description |
| ---- |----------|--------------------------------------------------------------------------------------------------------------------|
| field | `string` | Field to apply the query to. Required value |
| autocompleteOptions | `AutocompleteOption[]` | Predefined options for autocomplete |
| allowOnlyPredefinedValues | `boolean` | Specifies whether the input values should only be from predefined |
| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true |
| hideDefaultAction | `boolean` | Show/hide the widget actions. By default is false |
| Name | Type | Description |
| ------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| field | `string` | Field to apply the query to. Required value. See [Supported fields](#supported-fields) for the values that enable dynamically fetched options |
| label | `string` | Label displayed for the autocomplete input |
| autocompleteOptions | `AutocompleteOption[]` | Predefined options for autocomplete |
| allowOnlyPredefinedValues | `boolean` | Specifies whether the input values should only be from predefined |
| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true |
| hideDefaultAction | `boolean` | Show/hide the widget actions. By default is false |
### Supported fields
Besides using static `autocompleteOptions`, the `field` value can be set to one of the following to fetch options dynamically from the repository as the user types:
| `field` value | Source of options |
| --------------- | ----------------------------------------------------------- |
| `TAG` | Existing tags |
| `cm:categories` | Existing categories |
| `SITE` | Available sites (plus any predefined `autocompleteOptions`) |
| `ANCESTOR` | Folders matching the typed name (parent folder search) |
While a batch of options is being fetched for any of these fields, a loading spinner is shown inside the autocomplete panel until the results arrive. Only the results of the latest request are applied, so quickly changing the input does not display stale options.
## Details
This component allows the user to choose filter options for the search query.
See the [Search Chip Autocomplete Input component](search-chip-autocomplete-input.component.md) for more details.
### Parent folder search
Set `field` to `ANCESTOR` to let the user search for and select a parent folder to scope the search to. The following example also uses `label` to set the input label:
```json
{
"search": {
"categories": [
{
"id": "parentFolder",
"name": "Parent folder",
"enabled": true,
"component": {
"selector": "autocomplete-chips",
"settings": {
"allowUpdateOnChange": false,
"hideDefaultAction": true,
"allowOnlyPredefinedValues": false,
"field": "ANCESTOR",
"label": "Parent folder"
}
}
}
]
}
}
```
## See also
- [Search Configuration Guide](../../user-guide/search-configuration-guide.md)
+2 -1
View File
@@ -52,7 +52,8 @@ Contains the value and metadata for a field of a [`Form`](../../../lib/process-s
| columns | [`ContainerColumnModel`](../../../lib/core/src/lib/form/components/widgets/core/container-column.model.ts)\[] | \[] | Column definitions for a container field |
| rows | [`ContainerRowModel`](../../../lib/core/src/lib/form/components/widgets/core/container-row.model.ts)\[] | \[] | Row definitions for a repeatable section field |
| emptyOption | [`FormFieldOption`](../../../lib/core/src/lib/form/components/widgets/core/form-field-option.ts) | | Dropdown menu item to use when no option is chosen |
| validationSummary | string | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummary | [`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts) | | Error/information message added during field validation (see [`FormFieldValidator`](../../../lib/core/src/lib/form/components/widgets/core/form-field-validator.ts) interface) |
| validationSummaryChanges$ | Observable<[`ErrorMessageModel`](../../../lib/core/src/lib/form/components/widgets/core/error-message.model.ts)> | | Replays the current validation summary to subscribers and emits the completed summary after each validation |
## Details
+17
View File
@@ -18,6 +18,7 @@ Accesses and manipulates ACS document nodes using their node IDs.
- [Getting folder node contents](#getting-folder-node-contents)
- [Creating and updating nodes](#creating-and-updating-nodes)
- [Deleting and restoring nodes](#deleting-and-restoring-nodes)
- [Checking out nodes](#checking-out-nodes)
- [See also](#see-also)
## Class members
@@ -103,6 +104,16 @@ Accesses and manipulates ACS document nodes using their node IDs.
- _nodeId:_ `string` - ID of the target node
- _opts:_ `{ where?: string; includeSource?: boolean;} & NodesIncludeQuery & ContentPagingQuery` - additional options that API can take
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeAssociationPaging`](../../../lib/js-api/src/api/content-rest-api/docs/NodesApi.md#NodeAssociationPaging)`>` - List of node's parents.
- **checkoutNode**(nodeId: `string`, opts?: `NodesIncludeQuery`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>`<br/>
Checks out a file node for offline editing. Creates a private working copy and locks the original.
- _nodeId:_ `string` - ID of the file node to check out
- _opts:_ `NodesIncludeQuery` - (Optional) Additional query parameters (`include`, `fields`)
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>` - The working copy node
- **cancelCheckoutNode**(nodeId: `string`, opts?: `NodesIncludeQuery`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>`<br/>
Cancels a checkout. Accepts either the working copy or the original checked-out node. Deletes the working copy and unlocks the original.
- _nodeId:_ `string` - ID of the working copy or original checked-out node
- _opts:_ `NodesIncludeQuery` - (Optional) Additional query parameters (`include`, `fields`)
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/lib/js-api/src/api/content-rest-api/docs/NodeEntry.md)`>` - The original (unlocked) node
## Details
@@ -160,6 +171,12 @@ and
pages in the Alfresco JS API for further details and options. Note that you can also use the
[Deleted Nodes Api service](deleted-nodes-api.service.md) get a list of all items currently in the trashcan.
### Checking out nodes
Use `checkoutNode` to lock a file node for offline editing. This creates a private working copy and locks the original node so other users cannot modify it. On success, the Observable emits the working copy `NodeEntry`.
Use `cancelCheckoutNode` to discard the working copy and unlock the original. You can pass either the working copy node ID or the original node ID. On success, the Observable emits the original (unlocked) `NodeEntry`.
## See also
- [Deleted nodes api service](deleted-nodes-api.service.md)
+8 -7
View File
@@ -13,26 +13,27 @@ Accesses the Content Services Search API.
### Methods
- **getNodeQueryResults**(term: `string`, options?: [`SearchOptions`](../../../lib/content-services/src/lib/search/services/search.service.ts)): [`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/>
- **getNodeQueryResults**(term: `string`, options?: [`SearchOptions`](../../../lib/content-services/src/lib/search/services/search.service.ts)): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](../../../lib/js-api/src/api/content-rest-api/model/nodePaging.ts)`>`<br/>
Gets a list of nodes that match the given search criteria.
- _term:_ `string` - Term to search for
- _options:_ [`SearchOptions`](../../../lib/content-services/src/lib/search/services/search.service.ts) - (Optional) Options for delivery of the search 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 resulting from the search
- **search**(searchTerm: `string`, maxResults: `number`, skipCount: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](../../../lib/js-api/src/api/content-rest-api/model/nodePaging.ts)`>` - List of nodes resulting from the search
- **search**(searchTerm: `string`, maxResults: `number`, skipCount: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](../../../lib/js-api/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
Performs a search.
- _searchTerm:_ `string` - Term to search for
- _maxResults:_ `number` - Maximum number of items in the list of results
- _skipCount:_ `number` - Number of higher-ranked items to skip over in the list
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
- **searchByQueryBody**(queryBody: `SearchRequest`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](../../../lib/js-api/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
- **searchByQueryBody**(queryBody: `SearchRequest`, shouldEmit: `boolean` = `true`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](../../../lib/js-api/src/api/search-rest-api/docs/ResultSetPaging.md)`>`<br/>
Performs a search with its parameters supplied by a SearchRequest object.
- _queryBody:_ `SearchRequest` - Object containing the search parameters
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
- _shouldEmit:_ `boolean` - Whether the `dataLoaded` event should be emitted with the results. Set to `false` for auxiliary searches (for example populating autocomplete options) that should not notify the main results subscribers. Defaults to `true`
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`ResultSetPaging`](../../../lib/js-api/src/api/search-rest-api/docs/ResultSetPaging.md)`>` - List of search results
## Details
See the
[search method](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-search-rest-api/docs/SearchApi.md#search)
[search method](../../../lib/js-api/src/api/search-rest-api/docs/SearchApi.md#search)
in the Alfresco JS-API for the format of the query and returned data.
The [Search Configuration service](../services/search-configuration.service.md)
has a method to generate the QueryBody object used by `searchByQueryBody`. The properties of the
@@ -27,6 +27,7 @@ Lists all available process filters and allows to select a filter.
| appName | `string` | "" | (required) The application name |
| filterParam | `UserTaskFilterRepresentation` | | (optional) The filter to be selected by default |
| showIcons | `boolean` | false | (optional) Toggles showing an icon by the side of each filter |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
@@ -36,6 +36,7 @@ Shows all available filters.
| appName | `string` | "" | Display filters available to the current user for the application with the specified name. |
| filterParam | `FilterParamsModel` | | Parameters to use for the task filter cloud. If there is no match then the default filter (the first one in the list) is selected. |
| showIcons | `boolean` | false | Toggles display of the filter's icons. |
| useBatchedCounters | `boolean` | false | Get all the filter counters with one call to `POST /query/v1/count` (needs Activiti 8.7.0). Turn it on for both filter components. |
### Events
@@ -412,6 +412,8 @@ export interface SearchWidgetSettings {
allowOnlyPredefinedValues?: boolean;
/* allow the user to predefine autocomplete options */
autocompleteOptions?: AutocompleteOption[];
/* label that will be displayed for autocomplete input */
label?: string;
[indexer: string]: any;
}
+1 -1
View File
@@ -56,7 +56,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/content-services'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+7 -7
View File
@@ -12,14 +12,14 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/compiler": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/platform-browser-dynamic": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@alfresco/js-api": ">=10.0.0",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/adf-core": ">=9.0.0"
@@ -18,11 +18,19 @@
import { TestBed } from '@angular/core/testing';
import { RedirectAuthService } from '@alfresco/adf-core';
import { EMPTY, firstValueFrom, of } from 'rxjs';
import { JobIdBodyEntry, SizeDetails, SizeDetailsEntry } from '@alfresco/js-api';
import { JobIdBodyEntry, NodeEntry, SizeDetails, SizeDetailsEntry } from '@alfresco/js-api';
import { NodesApiService } from './nodes-api.service';
import { AlfrescoApiService } from '../../services/alfresco-api.service';
import { AlfrescoApiServiceMock } from '../../mock/alfresco-api.service.mock';
const fakeNodeEntry = {
entry: {
id: 'fake-node-id',
name: 'fake-file.txt',
nodeType: 'cm:content'
}
} as NodeEntry;
const fakeInitiateFolderSizeResponse: JobIdBodyEntry = {
entry: {
jobId: 'fake-job-id'
@@ -75,4 +83,20 @@ describe('NodesApiService', () => {
expect(nodesApiService.nodesApi.listParents).toHaveBeenCalledWith('fake-node-id', { include: ['path'], where: 'isPrimary=true' });
});
it('should call nodesApi.checkoutNode with the node ID and optional query params', async () => {
const opts = { include: ['path', 'allowableOperations'], fields: ['id', 'name'] };
spyOn(nodesApiService.nodesApi, 'checkoutNode').and.returnValue(Promise.resolve(fakeNodeEntry));
await firstValueFrom(nodesApiService.checkoutNode(fakeNodeEntry.entry.id, opts));
expect(nodesApiService.nodesApi.checkoutNode).toHaveBeenCalledWith(fakeNodeEntry.entry.id, opts);
});
it('should call nodesApi.cancelCheckoutNode with the node ID and optional query params', async () => {
const opts = { include: ['path'], fields: ['id'] };
spyOn(nodesApiService.nodesApi, 'cancelCheckoutNode').and.returnValue(Promise.resolve(fakeNodeEntry));
await firstValueFrom(nodesApiService.cancelCheckoutNode(fakeNodeEntry.entry.id, opts));
expect(nodesApiService.nodesApi.cancelCheckoutNode).toHaveBeenCalledWith(fakeNodeEntry.entry.id, opts);
});
});
@@ -293,6 +293,30 @@ export class NodesApiService {
return from(this.nodesApi.listParents(nodeId, opts));
}
/**
* Checks out a file node for offline editing.
* Creates a private working copy and locks the original.
*
* @param nodeId ID of the node to check out
* @param opts Optional query parameters (`include`, `fields`)
* @returns Observable emitting the working copy node
*/
checkoutNode(nodeId: string, opts?: NodesIncludeQuery): Observable<NodeEntry> {
return from(this.nodesApi.checkoutNode(nodeId, opts));
}
/**
* Cancels a checkout. Accepts either the working copy or the original node.
* Deletes the working copy and unlocks the original.
*
* @param nodeId ID of the working copy or the original checked-out node
* @param opts Optional query parameters (`include`, `fields`)
* @returns Observable emitting the original (unlocked) node
*/
cancelCheckoutNode(nodeId: string, opts?: NodesIncludeQuery): Observable<NodeEntry> {
return from(this.nodesApi.cancelCheckoutNode(nodeId, opts));
}
private randomNodeName(): string {
return `node_${Date.now()}`;
}
@@ -1,22 +1,23 @@
<mat-form-field class="adf-chip-list">
<mat-label>{{ placeholder | translate }}</mat-label>
<mat-chip-grid #chipList [attr.aria-label]="'SEARCH.FILTER.ARIA-LABEL.OPTIONS-SELECTION' | translate">
<mat-chip-row
class="adf-option-chips adf-autocomplete-added-option-chips"
*ngFor="let option of selectedOptions"
(removed)="remove(option)">
<span [title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath}) : undefined">
{{ option.value }}
</span>
<button
matChipRemove
class="adf-option-chips-delete-button adf-autocomplete-added-option-chips-delete-button"
[title]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
[attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
>
<mat-icon class="adf-option-chips-delete-icon adf-autocomplete-added-option-chips-delete-icon" adf-icon="close" />
</button>
</mat-chip-row>
@for (option of selectedOptions; track $index) {
<mat-chip-row
class="adf-option-chips adf-autocomplete-added-option-chips"
(removed)="remove(option)">
<span [title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath}) : undefined">
{{ option.value }}
</span>
<button
matChipRemove
class="adf-option-chips-delete-button adf-autocomplete-added-option-chips-delete-button"
[title]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
[attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value"
>
<mat-icon class="adf-option-chips-delete-icon adf-autocomplete-added-option-chips-delete-icon" adf-icon="close" />
</button>
</mat-chip-row>
}
<input
placeholder="{{ placeholder | translate }}"
aria-controls="adf-search-chip-autocomplete"
@@ -33,18 +34,33 @@
data-automation-id="adf-search-chip-autocomplete-input">
</mat-chip-grid>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" id="adf-search-chip-autocomplete"
(optionActivated)="activeAnyOption = true" (closed)="activeAnyOption = false">
<mat-option
*ngFor="let option of filteredOptions"
[value]="option"
(mousedown)=$event.preventDefault()
[disabled]="isOptionSelected(option)"
[attr.data-automation-id]="'option-' + option.value"
[title]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath || option.value }) : undefined"
class="adf-search-chip-autocomplete-added-option"
[ngClass]="isOptionSelected(option) && 'adf-autocomplete-added-option'"
>
{{ option.fullPath || option.value }}
(optionActivated)="activeAnyOption = true" (closed)="activeAnyOption = false" hideSingleSelectionIndicator>
@if (loading$ | async) {
<mat-option class="adf-search-chip-autocomplete-loading" disabled data-automation-id="adf-search-chip-autocomplete-loading">
<mat-progress-spinner mode="indeterminate" [diameter]="24" />
</mat-option>
} @else {
@for (option of filteredOptions; track $index) {
<mat-option
[value]="option"
(mousedown)=$event.preventDefault()
[disabled]="isOptionSelected(option)"
[attr.data-automation-id]="'option-' + option.value"
[attr.aria-label]="option.fullPath ? ('SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath || option.value }) : undefined"
class="adf-search-chip-autocomplete-added-option"
[ngClass]="isOptionSelected(option) && 'adf-autocomplete-added-option'"
>
<div class="adf-search-chip-autocomplete-added-option-container">
<span>{{ option.value }}</span>
@if (option.fullPath) {
<mat-icon
class="adf-info-icon"
[matTooltip]="'SEARCH.RESULTS.WILL_CONTAIN' | translate : { searchTerm: option.fullPath }"
adf-icon="info" />
}
</div>
</mat-option>
}
}
</mat-autocomplete>
</mat-form-field>
@@ -43,7 +43,32 @@ adf-search-chip-autocomplete-input {
}
}
.adf-search-chip-autocomplete-added-option {
#{ms.$mat-list-item-primary-text} {
width: 100%;
}
&-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.adf-info-icon#{ms.$mat-icon} {
margin-right: 0;
}
}
}
.adf-search-chip-autocomplete-added-option.adf-autocomplete-added-option {
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-primary);
}
.adf-search-chip-autocomplete-loading {
#{ms.$mat-list-item-primary-text} {
display: flex;
justify-content: center;
width: 100%;
}
}
@@ -18,19 +18,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatChipRemove } from '@angular/material/chips';
import { By } from '@angular/platform-browser';
import { Subject } from 'rxjs';
import { of, Subject } from 'rxjs';
import { SearchChipAutocompleteInputComponent } from './search-chip-autocomplete-input.component';
import { DebugElement, SimpleChanges } from '@angular/core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatChipHarness, MatChipGridHarness } from '@angular/material/chips/testing';
import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { UnitTestingUtils } from '@alfresco/adf-core';
describe('SearchChipAutocompleteInputComponent', () => {
let component: SearchChipAutocompleteInputComponent;
let fixture: ComponentFixture<SearchChipAutocompleteInputComponent>;
let loader: HarnessLoader;
let testingUtils: UnitTestingUtils;
const onResetSubject = new Subject<void>();
beforeEach(() => {
@@ -40,6 +44,7 @@ describe('SearchChipAutocompleteInputComponent', () => {
fixture = TestBed.createComponent(SearchChipAutocompleteInputComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
testingUtils = new UnitTestingUtils(fixture.debugElement, loader);
component = fixture.componentInstance;
component.onReset$ = onResetSubject.asObservable();
component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }];
@@ -121,6 +126,15 @@ describe('SearchChipAutocompleteInputComponent', () => {
return fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option'));
}
/**
* Force the autocomplete panel to open regardless of the current options
*/
function openAutocompletePanel() {
const trigger = testingUtils.getByDirective(MatAutocompleteTrigger).injector.get(MatAutocompleteTrigger);
trigger.openPanel();
fixture.detectChanges();
}
it('should assign preselected values to selected options on init', () => {
component.preselectedOptions = [{ value: 'option1' }];
component.ngOnInit();
@@ -293,13 +307,24 @@ describe('SearchChipAutocompleteInputComponent', () => {
expect((await getChipList()).length).toEqual(1);
});
it('should show full category path when fullPath provided', async () => {
it('should display the option value and render the info icon when fullPath is provided', () => {
component.filteredOptions = [{ id: 'test-id', value: 'test-value', fullPath: 'test-full-path' }];
enterNewInputValue('test-value');
const matOption = fixture.debugElement.query(By.css('.adf-search-chip-autocomplete-added-option')).nativeElement;
expect(matOption.textContent).toEqual(' test-full-path ');
const matOption = testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option').nativeElement;
expect(matOption.textContent).toContain('test-value');
expect(testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option .adf-info-icon')).toBeTruthy();
});
it('should not render the info icon when fullPath is not provided', () => {
component.filteredOptions = [{ id: 'test-id', value: 'test-value' }];
enterNewInputValue('test-value');
const matOption = testingUtils.getByCSS('.adf-search-chip-autocomplete-added-option').nativeElement;
expect(matOption.textContent).toContain('test-value');
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-added-option .adf-info-icon').length).toBe(0);
});
it('should emit input value when input changed', async () => {
@@ -316,6 +341,38 @@ describe('SearchChipAutocompleteInputComponent', () => {
expect(inputChangedSpy).not.toHaveBeenCalled();
});
describe('loading', () => {
it('should show a loading spinner in the autocomplete panel when loading emits true', async () => {
component.loading$ = of(true);
fixture.detectChanges();
openAutocompletePanel();
await fixture.whenStable();
expect(testingUtils.getAllByDirective(MatProgressSpinner).length).toBe(1);
});
it('should not render selectable options while loading emits true', async () => {
component.filteredOptions = [{ value: 'option1' }, { value: 'option2' }];
component.loading$ = of(true);
fixture.detectChanges();
openAutocompletePanel();
await fixture.whenStable();
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-added-option').length).toBe(0);
});
it('should render options and no spinner when loading emits false', async () => {
component.filteredOptions = [{ value: 'option1' }, { value: 'option2' }];
component.loading$ = of(false);
fixture.detectChanges();
openAutocompletePanel();
await fixture.whenStable();
expect((await getOptionElements()).length).toBe(2);
expect(testingUtils.getAllByCSS('.adf-search-chip-autocomplete-loading').length).toBe(0);
});
});
describe('isOptionSelected', () => {
beforeEach(() => {
component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }];
@@ -33,7 +33,7 @@ import { ENTER } from '@angular/cdk/keycodes';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
import { Observable, timer } from 'rxjs';
import { Observable, of, timer } from 'rxjs';
import { debounce, startWith, tap } from 'rxjs/operators';
import { AutocompleteOption } from '../../models/autocomplete-option.interface';
import { CommonModule } from '@angular/common';
@@ -41,10 +41,22 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { TranslatePipe } from '@ngx-translate/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IconModule } from '@alfresco/adf-core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'adf-search-chip-autocomplete-input',
imports: [CommonModule, MatFormFieldModule, MatChipsModule, TranslatePipe, IconModule, ReactiveFormsModule, MatAutocompleteModule],
imports: [
CommonModule,
MatFormFieldModule,
MatChipsModule,
TranslatePipe,
IconModule,
ReactiveFormsModule,
MatAutocompleteModule,
MatTooltipModule,
MatProgressSpinnerModule
],
templateUrl: './search-chip-autocomplete-input.component.html',
styleUrls: ['./search-chip-autocomplete-input.component.scss'],
encapsulation: ViewEncapsulation.None
@@ -68,6 +80,9 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnChanges {
@Input()
placeholder = 'SEARCH.FILTER.ACTIONS.ADD_OPTION';
@Input()
loading$ = of(false);
@Input()
compareOption?: (option1: AutocompleteOption, option2: AutocompleteOption) => boolean;
@@ -5,13 +5,17 @@
[allowOnlyPredefinedValues]="settings.allowOnlyPredefinedValues"
(inputChanged)="onInputChange($event)"
[compareOption]="optionComparator"
[placeholder]="settings?.label ?? 'SEARCH.FILTER.ACTIONS.ADD_OPTION'"
[loading$]="loading$"
(optionsChanged)="onOptionsChange($event)" />
<div class="adf-facet-buttons" *ngIf="!settings?.hideDefaultAction">
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-clear" (click)="reset()">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-apply" (click)="submitValues()">
{{ 'SEARCH.FILTER.ACTIONS.APPLY' | translate }}
</button>
</div>
@if (!settings?.hideDefaultAction) {
<div class="adf-facet-buttons">
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-clear" (click)="reset()">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
<button mat-button data-automation-id="adf-search-chip-autocomplete-btn-apply" (click)="submitValues()">
{{ 'SEARCH.FILTER.ACTIONS.APPLY' | translate }}
</button>
</div>
}
@@ -18,18 +18,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SearchFilterAutocompleteChipsComponent } from './search-filter-autocomplete-chips.component';
import { of, ReplaySubject } from 'rxjs';
import { of, ReplaySubject, Subject, throwError } from 'rxjs';
import { AutocompleteField, AutocompleteOption } from '../../models/autocomplete-option.interface';
import { TagService } from '../../../tag/services/tag.service';
import { SitesService } from '../../../common/services/sites.service';
import { SitePaging } from '@alfresco/js-api';
import { ResultSetPaging, SitePaging } from '@alfresco/js-api';
import { CategoryService } from '../../../category';
import { SearchService } from '../../services/search.service';
import { AppConfigService } from '@alfresco/adf-core';
import { SearchConfiguration } from '../../models/search-configuration.interface';
describe('SearchFilterAutocompleteChipsComponent', () => {
let component: SearchFilterAutocompleteChipsComponent;
let fixture: ComponentFixture<SearchFilterAutocompleteChipsComponent>;
let tagService: TagService;
let sitesService: SitesService;
let categoryService: CategoryService;
let searchService: SearchService;
let appConfig: AppConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -40,6 +46,9 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
component = fixture.componentInstance;
tagService = TestBed.inject(TagService);
sitesService = TestBed.inject(SitesService);
categoryService = TestBed.inject(CategoryService);
searchService = TestBed.inject(SearchService);
appConfig = TestBed.inject(AppConfigService);
component.id = 'test-id';
component.context = {
queryFragments: {
@@ -47,7 +56,10 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
},
filterRawParams: {},
populateFilters: new ReplaySubject(1),
execute: jasmine.createSpy('execute')
execute: jasmine.createSpy('execute'),
get wildcardsEnabled(): boolean {
return appConfig.get<boolean>('search-wildcards-enabled', true);
}
} as any;
component.settings = {
field: 'test',
@@ -240,6 +252,169 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
expect(searchSpy).toHaveBeenCalledWith('tag', { orderBy: 'tag', direction: 'asc' }, false, 0, 15);
});
describe('loading state', () => {
function categoriesResult(name: string): ResultSetPaging {
return { list: { pagination: {}, entries: [{ entry: { id: `${name}-id`, name, path: { name: '/a/b' } } }] } } as ResultSetPaging;
}
it('should be false initially', (done) => {
component.loading$.subscribe((loading) => {
expect(loading).toBeFalse();
done();
});
});
it('should toggle true while fetching and back to false once results arrive', () => {
component.settings.field = AutocompleteField.CATEGORIES;
const response$ = new Subject<ResultSetPaging>();
spyOn(categoryService, 'searchCategories').and.returnValue(response$.asObservable());
const emitted: boolean[] = [];
component.loading$.subscribe((loading) => emitted.push(loading));
component.onInputChange('mark');
expect(emitted).toEqual([false, true]);
response$.next(categoriesResult('Marketing'));
response$.complete();
expect(emitted).toEqual([false, true, false]);
});
it('should clear loading and emit empty options when the fetch fails', () => {
component.settings.field = AutocompleteField.CATEGORIES;
spyOn(categoryService, 'searchCategories').and.returnValue(throwError(() => new Error('failure')));
const loadingStates: boolean[] = [];
const optionResults: AutocompleteOption[][] = [];
component.loading$.subscribe((loading) => loadingStates.push(loading));
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
component.onInputChange('mark');
expect(loadingStates).toEqual([false, true, false]);
expect(optionResults[optionResults.length - 1]).toEqual([]);
});
it('should keep the stream alive after a failed fetch', () => {
component.settings.field = AutocompleteField.CATEGORIES;
const searchSpy = spyOn(categoryService, 'searchCategories').and.returnValues(
throwError(() => new Error('failure')),
of(categoriesResult('Marketing'))
);
const optionResults: AutocompleteOption[][] = [];
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
component.onInputChange('mark');
component.onInputChange('mark');
expect(searchSpy).toHaveBeenCalledTimes(2);
expect(optionResults[optionResults.length - 1]).toEqual([{ id: 'Marketing-id', value: 'Marketing', fullPath: 'Marketing' }]);
});
it('should ignore results from a superseded request', () => {
component.settings.field = AutocompleteField.CATEGORIES;
const firstResponse$ = new Subject<ResultSetPaging>();
const secondResponse$ = new Subject<ResultSetPaging>();
spyOn(categoryService, 'searchCategories').and.returnValues(firstResponse$.asObservable(), secondResponse$.asObservable());
const optionResults: AutocompleteOption[][] = [];
component.autocompleteOptions$.subscribe((options) => optionResults.push(options));
component.onInputChange('ma');
component.onInputChange('mark');
secondResponse$.next(categoriesResult('Fresh'));
secondResponse$.complete();
firstResponse$.next(categoriesResult('Stale'));
firstResponse$.complete();
expect(optionResults[optionResults.length - 1]).toEqual([{ id: 'Fresh-id', value: 'Fresh', fullPath: 'Fresh' }]);
expect(optionResults.some((result) => result.some((option) => option.value === 'Stale'))).toBeFalse();
});
it('should not trigger a fetch for a non-async field', () => {
component.settings.field = 'test';
const searchSpy = spyOn(categoryService, 'searchCategories');
component.onInputChange('mark');
expect(searchSpy).not.toHaveBeenCalled();
});
});
describe('PARENT_FOLDER field', () => {
const folderPaging: ResultSetPaging = {
list: {
pagination: {},
entries: [{ entry: { id: 'folder1', name: 'Documents', path: { name: '/Company Home/Sites/ws/folderA' } } }]
}
} as ResultSetPaging;
function mockWildcardsEnabled(enabled: boolean) {
spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? enabled : defaultValue));
}
beforeEach(() => {
component.settings.field = AutocompleteField.PARENT_FOLDER;
component.context.config = { filterQueries: [{ query: 'existing' }] } as SearchConfiguration;
});
it('should search folders and map results into options with full paths', (done) => {
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
component.onInputChange('doc');
component.autocompleteOptions$.subscribe((result) => {
expect(result).toEqual([{ id: 'folder1', value: 'Documents', fullPath: '/Company Home/Sites/ws/folderA/Documents' }]);
done();
});
});
it('should fall back to the folder name when the path name is empty', (done) => {
const folderWithoutPath: ResultSetPaging = {
list: {
pagination: {},
entries: [{ entry: { id: 'folder2', name: 'Documents', path: { name: '' } } }]
}
} as ResultSetPaging;
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderWithoutPath));
component.onInputChange('doc');
component.autocompleteOptions$.subscribe((result) => {
expect(result).toEqual([{ id: 'folder2', value: 'Documents', fullPath: 'Documents' }]);
done();
});
});
it('should build a folder-scoped query without emitting the dataLoaded event', () => {
mockWildcardsEnabled(true);
const searchSpy = spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
component.onInputChange('doc');
const [queryBody, shouldEmit] = searchSpy.calls.mostRecent().args;
expect(shouldEmit).toBeFalse();
expect(queryBody.query.query).toBe(`cm:name:"*doc*"`);
expect(queryBody.include).toEqual(['path']);
expect(queryBody.filterQueries).toEqual([{ query: 'existing' }, { query: "TYPE:'cm:folder'" }]);
});
it('should not mutate the shared context filter queries', () => {
spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
component.onInputChange('doc');
component.onInputChange('docs');
expect(component.context.config.filterQueries).toEqual([{ query: 'existing' }]);
});
it('should not wrap the search term with wildcards when wildcards are disabled', () => {
mockWildcardsEnabled(false);
component.context.config = {} as SearchConfiguration;
const searchSpy = spyOn(searchService, 'searchByQueryBody').and.returnValue(of(folderPaging));
component.onInputChange('doc');
const [queryBody] = searchSpy.calls.mostRecent().args;
expect(queryBody.query.query).toBe(`cm:name:"doc"`);
expect(queryBody.filterQueries).toEqual([{ query: "TYPE:'cm:folder'" }]);
});
it('should compose the query fragment using the node reference', () => {
component.selectedOptions = [{ id: 'folder1', value: 'Documents' }];
component.submitValues();
expect(component.context.queryFragments[component.id]).toBe('ANCESTOR:"workspace://SpacesStore/folder1"');
});
});
describe('optionComparator', () => {
it('should return false if either option is undefined', () => {
expect(component.optionComparator(undefined, { value: 'A' })).toBe(false);
@@ -16,8 +16,8 @@
*/
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { BehaviorSubject, Observable, of, ReplaySubject, Subject } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
@@ -31,6 +31,13 @@ import { TranslatePipe } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SitesService } from '../../../common/services/sites.service';
import { SearchService } from '../../services/search.service';
import { SearchRequest } from '@alfresco/js-api';
interface AutocompleteFetchState {
options: AutocompleteOption[] | null;
loading: boolean;
}
@Component({
selector: 'adf-search-filter-autocomplete-chips',
@@ -42,6 +49,18 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
private readonly tagService = inject(TagService);
private readonly categoryService = inject(CategoryService);
private readonly sitesService = inject(SitesService);
private readonly searchService = inject(SearchService);
private readonly loadingSubject$ = new BehaviorSubject<boolean>(false);
private readonly inputChange$ = new Subject<string>();
private readonly asyncFields: string[] = [
AutocompleteField.CATEGORIES,
AutocompleteField.TAG,
AutocompleteField.LOCATION,
AutocompleteField.PARENT_FOLDER
];
private readonly resetSubject$ = new Subject<void>();
private readonly autocompleteOptionsSubject$ = new BehaviorSubject<AutocompleteOption[]>([]);
private readonly destroyRef = inject(DestroyRef);
id: string;
settings?: SearchWidgetSettings;
@@ -51,13 +70,9 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
displayValue$ = new ReplaySubject<string>(1);
selectedOptions: AutocompleteOption[] = [];
enableChangeUpdate: boolean;
private readonly resetSubject$ = new Subject<void>();
reset$: Observable<void> = this.resetSubject$.asObservable();
private readonly autocompleteOptionsSubject$ = new BehaviorSubject<AutocompleteOption[]>([]);
autocompleteOptions$: Observable<AutocompleteOption[]> = this.autocompleteOptionsSubject$.asObservable();
private readonly destroyRef = inject(DestroyRef);
loading$ = this.loadingSubject$.asObservable();
constructor() {
this.options = new SearchFilterList<AutocompleteOption[]>();
@@ -71,6 +86,23 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
}
this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true;
}
this.inputChange$
.pipe(
switchMap((value) =>
this.fetchOptions(value).pipe(
map((options): AutocompleteFetchState => ({ options, loading: false })),
catchError((): Observable<AutocompleteFetchState> => of({ options: [], loading: false })),
startWith<AutocompleteFetchState>({ options: null, loading: true })
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ options, loading }) => {
this.loadingSubject$.next(loading);
if (options) {
this.autocompleteOptionsSubject$.next(options);
}
});
this.context.populateFilters
.asObservable()
.pipe(
@@ -121,12 +153,9 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
}
onInputChange(value: string) {
if (this.settings.field === AutocompleteField.CATEGORIES) {
this.searchForExistingCategories(value);
} else if (this.settings.field === AutocompleteField.TAG) {
this.searchForExistingTags(value);
} else if (this.settings.field === AutocompleteField.LOCATION) {
this.populateSitesOptions();
const field = this.settings?.field;
if (field && this.asyncFields.includes(field)) {
this.inputChange$.next(value);
}
}
@@ -148,6 +177,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
let queryFragments;
switch (this.settings.field) {
case AutocompleteField.CATEGORIES:
case AutocompleteField.PARENT_FOLDER:
queryFragments = this.selectedOptions.map((val) => `${this.settings.field}:"workspace://SpacesStore/${val.id}"`);
break;
case AutocompleteField.LOCATION:
@@ -165,14 +195,11 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
}
private setOptions() {
switch (this.settings.field) {
switch (this.settings?.field) {
case AutocompleteField.TAG:
this.autocompleteOptionsSubject$.next([]);
break;
case AutocompleteField.CATEGORIES:
this.autocompleteOptionsSubject$.next([]);
break;
case AutocompleteField.LOCATION:
case AutocompleteField.PARENT_FOLDER:
this.autocompleteOptionsSubject$.next([]);
break;
default:
@@ -180,44 +207,80 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
}
}
private searchForExistingCategories(searchTerm: string) {
this.categoryService.searchCategories(searchTerm, 0, 15).subscribe((existingCategoriesResult) => {
this.autocompleteOptionsSubject$.next(
private fetchOptions(searchTerm: string): Observable<AutocompleteOption[]> {
switch (this.settings?.field) {
case AutocompleteField.CATEGORIES:
return this.searchForExistingCategories(searchTerm);
case AutocompleteField.TAG:
return this.searchForExistingTags(searchTerm);
case AutocompleteField.LOCATION:
return this.getSitesOptions();
case AutocompleteField.PARENT_FOLDER:
return this.searchFolders(searchTerm);
default:
return of([]);
}
}
private searchForExistingCategories(searchTerm: string): Observable<AutocompleteOption[]> {
return this.categoryService.searchCategories(searchTerm, 0, 15).pipe(
map((existingCategoriesResult) =>
existingCategoriesResult.list.entries.map((rowEntry) => {
const path = rowEntry.entry.path.name.split('/').splice(3).join('/');
const fullPath = path ? `${path}/${rowEntry.entry.name}` : rowEntry.entry.name;
return { id: rowEntry.entry.id, value: rowEntry.entry.name, fullPath };
})
);
});
)
);
}
private searchForExistingTags(searchTerm: string) {
this.tagService.searchTags(searchTerm, { orderBy: 'tag', direction: 'asc' }, false, 0, 15).subscribe((existingTagsResult) => {
this.autocompleteOptionsSubject$.next(
private searchForExistingTags(searchTerm: string): Observable<AutocompleteOption[]> {
return this.tagService.searchTags(searchTerm, { orderBy: 'tag', direction: 'asc' }, false, 0, 15).pipe(
map((existingTagsResult) =>
existingTagsResult.list.entries.map((tag) => ({
id: tag.entry.id,
value: tag.entry.tag
}))
);
});
)
);
}
private populateSitesOptions(): void {
this.sitesService
.getSites()
.pipe(
map((sites) => {
const predefinedOptions = this.settings?.autocompleteOptions || [];
const sitesOptions = sites.list.entries
.filter((siteEntry) => siteEntry.entry.visibility === 'public' || siteEntry.entry?.role)
.map<AutocompleteOption>((siteEntry) => ({
id: siteEntry.entry.id,
value: siteEntry.entry.title
}));
return [...sitesOptions, ...predefinedOptions];
private getSitesOptions(): Observable<AutocompleteOption[]> {
return this.sitesService.getSites().pipe(
map((sites) => {
const predefinedOptions = this.settings?.autocompleteOptions || [];
const sitesOptions = sites.list.entries
.filter((siteEntry) => siteEntry.entry.visibility === 'public' || siteEntry.entry?.role)
.map<AutocompleteOption>((siteEntry) => ({
id: siteEntry.entry.id,
value: siteEntry.entry.title
}));
return [...sitesOptions, ...predefinedOptions];
})
);
}
private searchFolders(searchTerm: string): Observable<AutocompleteOption[]> {
const wildcard = this.context?.wildcardsEnabled ? '*' : '';
const filterQueries = [...(this.context?.config.filterQueries ?? []), { query: "TYPE:'cm:folder'" }];
const queryBody: SearchRequest = {
query: {
language: 'afts',
query: `cm:name:"${wildcard}${searchTerm}${wildcard}"`
},
include: ['path'],
filterQueries
};
return this.searchService.searchByQueryBody(queryBody, false).pipe(
map((folders) =>
folders.list.entries.map((folderEntry) => {
const fullPath = folderEntry.entry.path.name
? `${folderEntry.entry.path.name}/${folderEntry.entry.name}`
: folderEntry.entry.name;
return { id: folderEntry.entry.id, value: folderEntry.entry.name, fullPath };
})
)
.subscribe((options) => this.autocompleteOptionsSubject$.next(options));
);
}
}
@@ -1,6 +1,8 @@
<div class="adf-search-filter-menu-card">
<div class="adf-search-filter-title">
<ng-content select="filter-title" />
<h2 class="adf-search-filter-title-heading">
<ng-content select="filter-title" />
</h2>
<button mat-icon-button
class="adf-search-filter-title-action"
aria-hidden="false"
@@ -7,6 +7,11 @@
height: 32px;
font: var(--mat-sys-body-medium);
&-heading {
margin: 0;
font: inherit;
}
&-action {
float: right;
}
@@ -38,4 +38,11 @@ describe('SearchFilterMenuComponent', () => {
closeButton.click();
expect(spyCloseEvent).toHaveBeenCalled();
});
it('should expose the title as a heading', () => {
const heading = fixture.debugElement.nativeElement.querySelector('.adf-search-filter-title-heading');
expect(heading).not.toBeNull();
expect(heading.tagName).toBe('H2');
});
});
@@ -1,9 +1,9 @@
<div class="adf-search-logical-filter-container">
<div *ngFor="let field of fields" class="adf-search-input">
<mat-label data-automation-id="adf-search-input-label">{{('SEARCH.LOGICAL_SEARCH.' + field + '_LABEL') | translate}}</mat-label>
<mat-label [id]="`adf-search-input-label-${field}`" data-automation-id="adf-search-input-label">{{('SEARCH.LOGICAL_SEARCH.' + field + '_LABEL') | translate}}</mat-label>
<input type="text"
[(ngModel)]="searchCondition[LogicalSearchFields[field]]"
placeholder="{{ ('SEARCH.LOGICAL_SEARCH.' + field + '_HINT') | translate }}"
[attr.aria-label]="('SEARCH.LOGICAL_SEARCH.' + field + '_HINT') | translate"/>
[attr.aria-labelledby]="`adf-search-input-label-${field}`"/>
</div>
</div>
@@ -19,10 +19,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { LogicalSearchCondition, LogicalSearchFields, SearchLogicalFilterComponent } from './search-logical-filter.component';
import { ReplaySubject } from 'rxjs';
import { UnitTestingUtils } from '@alfresco/adf-core';
describe('SearchLogicalFilterComponent', () => {
let component: SearchLogicalFilterComponent;
let fixture: ComponentFixture<SearchLogicalFilterComponent>;
let unitTestingUtils: UnitTestingUtils;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -41,6 +43,7 @@ describe('SearchLogicalFilterComponent', () => {
execute: jasmine.createSpy('execute')
} as any;
component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false };
unitTestingUtils = new UnitTestingUtils(fixture.debugElement);
fixture.detectChanges();
});
@@ -204,4 +207,17 @@ describe('SearchLogicalFilterComponent', () => {
expect(component.searchCondition).toEqual({ matchAll: 'test', matchAny: 'test2', matchExact: '', exclude: '' });
expect(component.context.filterLoaded.next).toHaveBeenCalled();
});
describe('Accessibility', () => {
it('should use aria-labelledby attribute for input fields', () => {
const inputs = getInputs();
const fieldset = unitTestingUtils.getAllByDataAutomationId('adf-search-input-label');
inputs.forEach((input, index) => {
const fieldsetId = fieldset[index].nativeElement.getAttribute('id');
expect(input.getAttribute('aria-labelledby')).toBe(fieldsetId);
expect(input.hasAttribute('aria-label')).toBe(false);
});
});
});
});
@@ -25,7 +25,8 @@ export interface AutocompleteOption {
export const AutocompleteField = {
TAG: 'TAG',
CATEGORIES: 'cm:categories',
LOCATION: 'SITE'
LOCATION: 'SITE',
PARENT_FOLDER: 'ANCESTOR'
} as const;
export type AutocompleteField = (typeof AutocompleteField)[keyof typeof AutocompleteField];
@@ -31,6 +31,8 @@ export interface SearchWidgetSettings {
allowOnlyPredefinedValues?: boolean;
/* allow the user to predefine autocomplete options */
autocompleteOptions?: AutocompleteOption[];
/* label that will be displayed for autocomplete input */
label?: string;
[indexer: string]: any;
}
@@ -76,14 +76,21 @@ export class SearchService {
* Performs a search with its parameters supplied by a request object.
*
* @param queryBody Object containing the search parameters
* @param shouldEmit Should emit dataLoaded event
* @returns List of search results
*/
searchByQueryBody(queryBody: SearchRequest): Observable<ResultSetPaging> {
searchByQueryBody(queryBody: SearchRequest, shouldEmit = true): Observable<ResultSetPaging> {
const promise = this.searchApi.search(queryBody);
promise.then((nodePaging) => {
this.dataLoaded.next(nodePaging);
});
if (shouldEmit) {
promise
.then((nodePaging) => {
this.dataLoaded.next(nodePaging);
})
.catch(() => {
/* error is surfaced to subscribers through the returned Observable */
});
}
return from(promise);
}
@@ -32,11 +32,13 @@ export interface QaFeaturesHelperConfig {
helperExposeKeyOnDocument?: string;
}
export interface FlagChangesetValues {
current: any;
previous: any;
}
export interface FlagChangeset {
[key: string]: {
current: any;
previous: any;
};
[key: string]: FlagChangesetValues;
}
export interface WritableFlagChangeset {
@@ -0,0 +1,133 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs';
import { FeaturesServiceToken, IFeaturesService } from '../interfaces/features.interface';
import { MockFeatureFlags, provideMockFeatureFlags } from './features-service-mock.factory';
describe('provideMockFeatureFlags', () => {
let featureValue$: BehaviorSubject<boolean>;
const feature1 = 'feature1';
const setupFeatureService = (featureFlags: MockFeatureFlags | string | string[]): IFeaturesService => {
TestBed.configureTestingModule({
providers: [provideMockFeatureFlags(featureFlags)]
});
return TestBed.inject(FeaturesServiceToken);
};
beforeEach(() => {
featureValue$ = new BehaviorSubject(false);
});
it('should emit the updated value when an observable feature flag changes', async () => {
const featuresService = setupFeatureService({ [feature1]: featureValue$ });
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
featureValue$.next(true);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(false);
featureValue$.next(false);
expect(await firstValueFrom(featuresService.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(featuresService.isOff$(feature1))).toBe(true);
});
it('should not let a consumer change the feature flag value through the observable returned by isOn$', async () => {
const originalValue$ = new BehaviorSubject(true);
const service = setupFeatureService({ [feature1]: originalValue$ });
const isOn$ = service.isOn$(feature1) as Subject<boolean>;
expect(isOn$).not.toBe(originalValue$);
expect(() => isOn$.next(false)).toThrow();
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(originalValue$)).toBe(true);
});
it('should mock a string feature flag as enabled', async () => {
const service = setupFeatureService(feature1);
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should mock every feature flag in an array as enabled', async () => {
const feature2 = 'feature2';
const service = setupFeatureService([feature1, feature2]);
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null },
[feature2]: { current: true, previous: null }
});
});
it('should emit an empty changeset when no feature flags are provided', async () => {
const service = setupFeatureService({});
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should emit an empty changeset when an empty feature flag array is provided', async () => {
const service = setupFeatureService([]);
expect(await firstValueFrom(service.getFlags$())).toEqual({});
expect(await firstValueFrom(service.init())).toEqual({});
});
it('should return the expected state when the feature flag is true', async () => {
const service = setupFeatureService({ [feature1]: true });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: true, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(true);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(false);
});
it('should return the expected state when the feature flag is false', async () => {
const service = setupFeatureService({ [feature1]: false });
expect(await firstValueFrom(service.init())).toEqual({
[feature1]: { current: false, previous: null }
});
expect(await firstValueFrom(service.isOn$(feature1))).toBe(false);
expect(await firstValueFrom(service.isOff$(feature1))).toBe(true);
});
it('should resolve observable values in the complete feature flags result', async () => {
const service = setupFeatureService({ [feature1]: of(true) });
expect(await firstValueFrom(service.getFlags$())).toEqual({
[feature1]: { current: true, previous: null }
});
});
it('should throw when a feature flag has not been mocked', () => {
const featuresService = setupFeatureService({ [feature1]: true });
expect(() => featuresService.isOn$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
expect(() => featuresService.isOff$('missing-feature')).toThrowError(/missing-feature.*not mocked/);
});
});
@@ -15,11 +15,27 @@
* limitations under the License.
*/
import { of } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, IFeaturesService } from '../interfaces/features.interface';
import { of, Observable, map, combineLatest, take, defer } from 'rxjs';
import { FeaturesServiceToken, FlagChangeset, FlagChangesetValues, IFeaturesService } from '../interfaces/features.interface';
/**
* Feature flags to mock. A boolean sets a fixed value, an observable lets the test change the value over time.
*
* Observable flags must have a current value, use 'BehaviorSubject' or 'of(...)'.
* A bare 'Subject' has no value until it emits, and 'getFlags$()'/'init()' withhold the whole changeset
* until every observable flag has emitted at least once.
*/
export interface MockFeatureFlags {
[key: string]: boolean;
[key: string]: boolean | Observable<boolean>;
}
interface MockFlagChangesetValues extends FlagChangesetValues {
current: boolean | Observable<boolean>;
previous: null;
}
interface MockFlagChangeset extends FlagChangeset {
[key: string]: MockFlagChangesetValues;
}
const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
@@ -32,19 +48,72 @@ const assertFeatureFlag = (flagChangeset: FlagChangeset, key: string): void => {
}
};
const mockFeaturesService = (flagChangeset: FlagChangeset): IFeaturesService => ({
init: () => of(flagChangeset),
/**
* Calling 'pipe' on a 'Subject' returns an 'AnonymousSubject' that still writes through to the original 'Subject',
* so piping alone does not stop a consumer from pushing values into the mocked flag.
* 'defer' breaks that chain and gives back a plain, read only observable.
*
* @param value$ Observable feature flag value provided by the test
* @returns Observable that cannot be used to change the mocked value
*/
const toReadOnly = (value$: Observable<boolean>): Observable<boolean> => defer(() => value$);
const mockFeaturesService = (flagChangeset: MockFlagChangeset): IFeaturesService => ({
init: () => resolveFeatureFlagValues(flagChangeset).pipe(take(1)),
isOn$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
// In case of an observable, we do not want to return the original observable, so a consumer cannot 'next', 'error' or 'complete' it
return typeof featureFlagValue === 'boolean' ? of(featureFlagValue) : toReadOnly(featureFlagValue).pipe(map(Boolean));
},
isOff$: (key) => {
assertFeatureFlag(flagChangeset, key);
return of(!flagChangeset[key].current);
const featureFlagValue = flagChangeset[key].current;
return typeof featureFlagValue === 'boolean' ? of(!featureFlagValue) : toReadOnly(featureFlagValue).pipe(map((value) => !value));
},
getFlags$: () => of(flagChangeset)
getFlags$: () => resolveFeatureFlagValues(flagChangeset)
});
/**
* 'provideMockFeatureFlags' can receive observables, therefore we need to resolve these values
*
* @param mockFlagChangeset Mocked flag changeset
* @returns FlagChangeset
*/
const resolveFeatureFlagValues = (mockFlagChangeset: MockFlagChangeset): Observable<FlagChangeset> => {
// No FF provided, just return empty object
if (Object.keys(mockFlagChangeset).length === 0) {
return of({});
}
const resolveFeatureFlagValues$ = Object.entries(mockFlagChangeset).map(([featureKey, values]) => {
if (typeof values.current === 'boolean') {
return of([featureKey, { ...values }] as const);
}
// Value is observable, we need to resolve it
const observableValue$ = values.current;
const resolveFlagValue$ = observableValue$.pipe(map((resolvedValue) => [featureKey, { current: resolvedValue, previous: null }] as const));
return resolveFlagValue$;
});
return combineLatest(resolveFeatureFlagValues$).pipe(
map((resolvedFlags) => {
const resolvedFeatureFlag: FlagChangeset = {};
resolvedFlags.forEach(([key, values]) => {
resolvedFeatureFlag[key] = values;
});
return resolvedFeatureFlag;
})
);
};
const arrayToFlagChangeset = (featureFlags: string[]): FlagChangeset => {
const flagChangeset: FlagChangeset = {};
featureFlags.forEach((featureFlag) => {
@@ -62,6 +131,26 @@ const mockFeatureFlagsToFlagChangeset = (mockFeatureFlags: MockFeatureFlags) =>
return flagChangeset;
};
/**
* Mock the FeaturesService with the provided feature flags.
* A string or string[] sets every listed feature to true, a MockFeatureFlags object sets each value explicitly.
* Every flag the code under test asks for has to be mocked, otherwise 'isOn$'/'isOff$' throw.
*
* Use a 'BehaviorSubject' for a flag that changes during the test. A bare 'Subject' has no current value,
* so the flag stays silent and 'getFlags$()'/'init()' withhold the whole changeset, until it emits.
*
* @example
*
* const featureA$ = new BehaviorSubject(false);
*
* providers: [provideMockFeatureFlags('featureA')]
* providers: [provideMockFeatureFlags(['featureA', 'featureB'])]
* providers: [provideMockFeatureFlags({ featureA: true, featureB: false })]
* providers: [provideMockFeatureFlags({ featureA: featureA$ })]
*
* @param featureFlag The feature flag(s) to mock. Can be a single feature flag string, an array of feature flag strings, or a MockFeatureFlags object.
* @returns A provider object for the FeaturesServiceToken with the mocked feature flags.
*/
export const provideMockFeatureFlags = (featureFlag: MockFeatureFlags | string | string[]) => {
if (typeof featureFlag === 'string') {
featureFlag = [featureFlag];
+1 -1
View File
@@ -69,7 +69,7 @@ module.exports = function (config) {
coverageReporter: {
dir: join(__dirname, '../../coverage/core'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
reporters: [{ type: 'html' }, { type: 'lcov' }, { type: 'text-summary' }, { type: 'text-summary', subdir: '.', file: 'summary.txt' }],
check: {
global: {
statements: 75,
+5 -5
View File
@@ -23,13 +23,13 @@
},
"peerDependencies": {
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/common": ">=20.3.27",
"@angular/core": ">=20.3.27",
"@angular/forms": ">=20.3.27",
"@angular/material": ">=20.2.14",
"@angular/material-date-fns-adapter": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/platform-browser": ">=20.3.27",
"@angular/router": ">=20.3.27",
"@mat-datetimepicker/core": ">=12.0.1",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/js-api": ">=10.0.0",
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-with-rule copy</title><desc>Created with Sketch.</desc><g id="folder-with-rule-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-with-rules-active"><g id="folder-with-rules"><g id="Group"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#D9E021" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><polygon id="Fill-1-Copy" fill="#FFF" points="14.862 9 16.436 10.574 13.495 13.515 10.55 10.574 12.125 9 8 9 8 13.125 9.575 11.551 12.811 14.789 12.811 18 14.186 18 14.186 14.776 17.412 11.551 18.986 13.125 18.986 9"/></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-with-rule copy</title><desc>Created with Sketch.</desc><g id="folder-with-rule-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-with-rules-active"><g id="folder-with-rules"><g id="Group"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#2A7DE1" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><polygon id="Fill-1-Copy" fill="#FFF" points="14.862 9 16.436 10.574 13.495 13.515 10.55 10.574 12.125 9 8 9 8 13.125 9.575 11.551 12.811 14.789 12.811 18 14.186 18 14.186 14.776 17.412 11.551 18.986 13.125 18.986 9"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 861 B

After

Width:  |  Height:  |  Size: 861 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-shortcut-link copy</title><desc>Created with Sketch.</desc><g id="folder-shortcut-link-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-shortcut-link-active"><g id="folder-shortcut-link"><g id="Group-9"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Path-2-Copy" fill="#D9E021" d="M2.70933755,4.54327602 L2,5.924926 L2,18.8121399 L2.69466158,19.5150573 C2.88252616,19.7051546 3.13866895,19.8121399 3.40593302,19.8121399 L20.6045166,19.8121399 L21.3116234,19.1050331 C21.4991598,18.9174967 21.6045166,18.6631428 21.6045166,18.3979263 L21.6045166,7.00924449 L20.9015261,6.24697884 C20.7122055,6.04169503 20.4456711,5.924926 20.1664154,5.924926 L11.1733882,5.924926 C10.8510886,5.924926 10.548546,5.76958526 10.3607292,5.50766556 L9.57882415,4.41726045 C9.39100739,4.15534074 9.08846483,4 8.76616517,4 L3.59894601,4 C3.2240117,4 2.88057904,4.20973129 2.70933755,4.54327602 Z"/><path id="Fill-2" fill="#FFF" d="M15.75,10 L13.15,10 L13.15,11.235 L15.75,11.235 C16.8615,11.235 17.765,12.1385 17.765,13.25 C17.765,14.3615 16.8615,15.265 15.75,15.265 L13.15,15.265 L13.15,16.5 L15.75,16.5 C17.544,16.5 19,15.044 19,13.25 C19,11.456 17.544,10 15.75,10 L15.75,10 Z M9.9,13.9 L15.1,13.9 L15.1,12.6 L9.9,12.6 L9.9,13.9 Z M7.235,13.25 C7.235,12.1385 8.1385,11.235 9.25,11.235 L11.85,11.235 L11.85,10 L9.25,10 C7.456,10 6,11.456 6,13.25 C6,15.044 7.456,16.5 9.25,16.5 L11.85,16.5 L11.85,15.265 L9.25,15.265 C8.1385,15.265 7.235,14.3615 7.235,13.25 L7.235,13.25 Z"/></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>folder-shortcut-link copy</title><desc>Created with Sketch.</desc><g id="folder-shortcut-link-copy" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="systemicon/folder-shortcut-link-active"><g id="folder-shortcut-link"><g id="Group-9"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Path-2-Copy" fill="#2A7DE1" d="M2.70933755,4.54327602 L2,5.924926 L2,18.8121399 L2.69466158,19.5150573 C2.88252616,19.7051546 3.13866895,19.8121399 3.40593302,19.8121399 L20.6045166,19.8121399 L21.3116234,19.1050331 C21.4991598,18.9174967 21.6045166,18.6631428 21.6045166,18.3979263 L21.6045166,7.00924449 L20.9015261,6.24697884 C20.7122055,6.04169503 20.4456711,5.924926 20.1664154,5.924926 L11.1733882,5.924926 C10.8510886,5.924926 10.548546,5.76958526 10.3607292,5.50766556 L9.57882415,4.41726045 C9.39100739,4.15534074 9.08846483,4 8.76616517,4 L3.59894601,4 C3.2240117,4 2.88057904,4.20973129 2.70933755,4.54327602 Z"/><path id="Fill-2" fill="#FFF" d="M15.75,10 L13.15,10 L13.15,11.235 L15.75,11.235 C16.8615,11.235 17.765,12.1385 17.765,13.25 C17.765,14.3615 16.8615,15.265 15.75,15.265 L13.15,15.265 L13.15,16.5 L15.75,16.5 C17.544,16.5 19,15.044 19,13.25 C19,11.456 17.544,10 15.75,10 L15.75,10 Z M9.9,13.9 L15.1,13.9 L15.1,12.6 L9.9,12.6 L9.9,13.9 Z M7.235,13.25 C7.235,12.1385 8.1385,11.235 9.25,11.235 L11.85,11.235 L11.85,10 L9.25,10 C7.456,10 6,11.456 6,13.25 C6,15.044 7.456,16.5 9.25,16.5 L11.85,16.5 L11.85,15.265 L9.25,15.265 C8.1385,15.265 7.235,14.3615 7.235,13.25 L7.235,13.25 Z"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>filetype/folder</title><desc>Created with Sketch.</desc><g id="Symbols" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="filetype/folder"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#D9E021" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><g id="cog" fill="#FFF" fill-rule="nonzero" transform="translate(10.000000, 8.000000)"><path id="Shape" d="M8.97602103,5.70765953 L9.87354689,6.20360587 C9.97604967,6.2602388 10.0236259,6.37735544 9.98851764,6.48556081 C9.7553749,7.20414773 9.3573263,7.85333966 8.83672508,8.39253187 C8.75648164,8.47568691 8.62677806,8.49455592 8.52435171,8.4379753 L7.6274998,7.9421096 C7.25015532,8.25161622 6.81633302,8.49173291 6.34786835,8.65037397 L6.34786835,9.64194408 C6.34787008,9.75522045 6.26583319,9.85335946 6.15038149,9.87819349 C5.41418114,10.0364592 4.62349658,10.0446447 3.85014503,9.87833462 C3.73452139,9.85347581 3.65211059,9.7554317 3.65211059,9.6419844 L3.65211059,8.65037397 C3.18364144,8.49174219 2.74981745,8.25162456 2.37247914,7.9421096 L1.47562723,8.4379753 C1.37320088,8.49455592 1.24349729,8.47568691 1.16325386,8.39253187 C0.642673695,7.85333966 0.244604043,7.20414773 0.011461297,6.48556081 C-0.02364701,6.3773756 0.023929274,6.26025896 0.126432051,6.20360587 L1.02397897,5.70765953 C0.933828729,5.23983992 0.933828729,4.76018024 1.02397897,4.29236063 L0.126453112,3.79641429 C0.0239503348,3.73978137 -0.0236259492,3.62266472 0.0114823578,3.51445935 C0.244625104,2.79587243 0.642673695,2.1466805 1.16327492,1.60748829 C1.24351836,1.52433325 1.37322194,1.50546424 1.47564829,1.56204486 L2.3725002,2.05791056 C2.74984468,1.74840395 3.18366698,1.50828725 3.65213165,1.34964619 L3.65213165,0.358055922 C3.65212992,0.244779553 3.73416681,0.146640538 3.84961851,0.12180651 C4.58581886,-0.0364592244 5.37650342,-0.0446446879 6.14985497,0.121665382 C6.26547861,0.146524191 6.34788941,0.244568301 6.34788941,0.358015599 L6.34788941,1.34962603 C6.81635856,1.50825781 7.25018255,1.74837544 7.62752086,2.0578904 L8.52437277,1.5620247 C8.62679912,1.50544408 8.75650271,1.52431309 8.83674614,1.60746813 C9.3573263,2.14666034 9.75539596,2.79585227 9.9885387,3.51443919 C10.023647,3.6226244 9.97607073,3.73974104 9.87356795,3.79639413 L8.97602103,4.29234047 C9.06617149,4.76016684 9.06617149,5.23983316 8.97602103,5.70765953 Z M6.68486176,5 C6.68486176,4.11064738 5.92903278,3.38710079 5,3.38710079 C4.07096722,3.38710079 3.31513824,4.11064738 3.31513824,5 C3.31513824,5.88935262 4.07096722,6.61289921 5,6.61289921 C5.92903278,6.61289921 6.68486176,5.88935262 6.68486176,5 Z"/></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" version="1.1" viewBox="0 0 24 24"><title>filetype/folder</title><desc>Created with Sketch.</desc><g id="Symbols" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="filetype/folder"><polygon id="Fill-1" points="0 24 24 24 24 0 0 0"/><path id="Fill-2" fill="#2A7DE1" d="M10,4 L4,4 C2.895,4 2.01,4.895 2.01,6 L2,18 C2,19.105 2.895,20 4,20 L20,20 C21.105,20 22,19.105 22,18 L22,8 C22,6.895 21.105,6 20,6 L12,6 L10,4 Z"/><g id="cog" fill="#FFF" fill-rule="nonzero" transform="translate(10.000000, 8.000000)"><path id="Shape" d="M8.97602103,5.70765953 L9.87354689,6.20360587 C9.97604967,6.2602388 10.0236259,6.37735544 9.98851764,6.48556081 C9.7553749,7.20414773 9.3573263,7.85333966 8.83672508,8.39253187 C8.75648164,8.47568691 8.62677806,8.49455592 8.52435171,8.4379753 L7.6274998,7.9421096 C7.25015532,8.25161622 6.81633302,8.49173291 6.34786835,8.65037397 L6.34786835,9.64194408 C6.34787008,9.75522045 6.26583319,9.85335946 6.15038149,9.87819349 C5.41418114,10.0364592 4.62349658,10.0446447 3.85014503,9.87833462 C3.73452139,9.85347581 3.65211059,9.7554317 3.65211059,9.6419844 L3.65211059,8.65037397 C3.18364144,8.49174219 2.74981745,8.25162456 2.37247914,7.9421096 L1.47562723,8.4379753 C1.37320088,8.49455592 1.24349729,8.47568691 1.16325386,8.39253187 C0.642673695,7.85333966 0.244604043,7.20414773 0.011461297,6.48556081 C-0.02364701,6.3773756 0.023929274,6.26025896 0.126432051,6.20360587 L1.02397897,5.70765953 C0.933828729,5.23983992 0.933828729,4.76018024 1.02397897,4.29236063 L0.126453112,3.79641429 C0.0239503348,3.73978137 -0.0236259492,3.62266472 0.0114823578,3.51445935 C0.244625104,2.79587243 0.642673695,2.1466805 1.16327492,1.60748829 C1.24351836,1.52433325 1.37322194,1.50546424 1.47564829,1.56204486 L2.3725002,2.05791056 C2.74984468,1.74840395 3.18366698,1.50828725 3.65213165,1.34964619 L3.65213165,0.358055922 C3.65212992,0.244779553 3.73416681,0.146640538 3.84961851,0.12180651 C4.58581886,-0.0364592244 5.37650342,-0.0446446879 6.14985497,0.121665382 C6.26547861,0.146524191 6.34788941,0.244568301 6.34788941,0.358015599 L6.34788941,1.34962603 C6.81635856,1.50825781 7.25018255,1.74837544 7.62752086,2.0578904 L8.52437277,1.5620247 C8.62679912,1.50544408 8.75650271,1.52431309 8.83674614,1.60746813 C9.3573263,2.14666034 9.75539596,2.79585227 9.9885387,3.51443919 C10.023647,3.6226244 9.97607073,3.73974104 9.87356795,3.79639413 L8.97602103,4.29234047 C9.06617149,4.76016684 9.06617149,5.23983316 8.97602103,5.70765953 Z M6.68486176,5 C6.68486176,4.11064738 5.92903278,3.38710079 5,3.38710079 C4.07096722,3.38710079 3.31513824,4.11064738 3.31513824,5 C3.31513824,5.88935262 4.07096722,6.61289921 5,6.61289921 C5.92903278,6.61289921 6.68486176,5.88935262 6.68486176,5 Z"/></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -87,12 +87,26 @@ describe('DataTableRowComponent', () => {
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBeNull();
});
it('should not set tabindex when row is disabled', () => {
it('should set tabindex as focusable only with the arrow keys when row is enabled but not active', () => {
component.disabled = false;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBe('-1');
});
it('should set tabindex as focusable with the Tab key when row is enabled and active', () => {
component.disabled = false;
component.active = true;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBe('0');
});
it('should not set tabindex when row is disabled even if it is active', () => {
component.disabled = true;
component.active = true;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBeNull();
});
it('should focus element', () => {
expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(false);
component.disabled = false;
@@ -35,6 +35,8 @@ export class DataTableRowComponent implements FocusableOption {
@Input() disabled = true;
@Input() active = false;
@Output()
select: EventEmitter<any> = new EventEmitter<any>();
@@ -65,7 +67,10 @@ export class DataTableRowComponent implements FocusableOption {
@HostBinding('attr.tabindex')
get tabindex(): number | null {
return this.disabled ? null : 0;
if (this.disabled) {
return null;
}
return this.active ? 0 : -1;
}
@HostListener('keydown.space', ['$event'])
@@ -262,7 +262,9 @@
[class.adf-datatable-row__dragging]="isDraggingRow"
[attr.data-automation-id]="'datatable-row-' + idx"
(contextmenu)="markRowAsContextMenuSource(row)"
[disabled]="!(enableDragRows || multiselect)"
(focus)="onRowFocus(idx)"
[disabled]="!isRowKeyboardNavigable()"
[active]="isRowActive(idx)"
[attr.aria-description]="
(multiselect ? ('ADF-DATATABLE.ACCESSIBILITY.ROW_SELECTION' | translate) : '') +
(multiselect && enableDragRows ? '. ' : '') +
@@ -1881,10 +1881,10 @@ describe('Accessibility', () => {
const getBodyRows = (): DebugElement[] => testingUtils.getAllByCSS(rowSelector);
const expectRowsTabindex = (expected: string | null): void => {
const expectRowsTabindex = (expected: (string | null)[]): void => {
const rowElements = getBodyRows();
expect(rowElements.length).toBeGreaterThan(0);
expect(rowElements.every((row) => row.nativeElement.getAttribute('tabindex') === expected)).toBeTrue();
expect(rowElements.length).toBe(expected.length);
expect(rowElements.map((row) => row.nativeElement.getAttribute('tabindex'))).toEqual(expected);
};
const activateRow = (rowIndex: number): void => {
@@ -1907,24 +1907,42 @@ describe('Accessibility', () => {
dataTable.data = new ObjectDataTableAdapter([], [new ObjectDataColumn({ key: 'name' })]);
});
it('should set tabindex to null (disabled === true) on datatable-body rows when neither multiselect nor enableDragRows is enabled', () => {
it('should set tabindex to null (disabled === true) on datatable-body rows when rows cannot be selected nor dragged', () => {
dataTable.selectionMode = 'none';
setRows();
expectRowsTabindex(null);
expectRowsTabindex([null, null]);
});
it('should set tabindex to 0 (disabled === false) on datatable-body rows when multiselect is enabled', () => {
it('should make only the first row reachable with the Tab key when rows are selectable', () => {
setRows();
expectRowsTabindex(['0', '-1']);
});
it('should make only the first row reachable with the Tab key when multiselect is enabled', () => {
dataTable.multiselect = true;
setRows();
expectRowsTabindex('0');
expectRowsTabindex(['0', '-1']);
});
it('should set tabindex to 0 (disabled === false) on datatable-body rows when enableDragRows is enabled', () => {
it('should make only the first row reachable with the Tab key when enableDragRows is enabled', () => {
dataTable.enableDragRows = true;
setRows();
expectRowsTabindex('0');
expectRowsTabindex(['0', '-1']);
});
it('should move the tabindex to the active row', () => {
setRows();
dataTable.ngAfterViewInit();
activateRow(1);
testingUtils.setDebugElement(fixture.debugElement);
fixture.detectChanges();
expectRowsTabindex(['-1', '0']);
});
it('should focus next row on ArrowDown event', () => {
@@ -1954,6 +1972,17 @@ describe('Accessibility', () => {
expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-0');
});
it('should navigate between rows with the arrow keys in single selection mode', () => {
dataTable.selectionMode = 'single';
setRows();
dataTable.ngAfterViewInit();
activateRow(1);
dispatchKeyUp(event);
expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-0');
});
});
describe('Row cells focus management', () => {
@@ -347,6 +347,10 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
private readonly destroyRef = inject(DestroyRef);
private get rowsOffset(): number {
return this.isHeaderVisible() ? 1 : 0;
}
@HostListener('keyup', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (event.shiftKey && this.enableDragRows) {
@@ -640,7 +644,7 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
}
if (row) {
const rowIndex = this.data.getRows().indexOf(row) + (this.isHeaderVisible() ? 1 : 0);
const rowIndex = this.data.getRows().indexOf(row) + this.rowsOffset;
this.keyManager.setActiveItem(rowIndex);
const dataRowEvent = new DataRowEvent(row, mouseEvent, this);
@@ -923,6 +927,19 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
return this.selectionMode && this.selectionMode.toLowerCase() === 'multiple';
}
protected isRowKeyboardNavigable(): boolean {
return this.enableDragRows || this.multiselect || this.isSingleSelectionMode() || this.isMultiSelectionMode();
}
protected isRowActive(rowIndex: number): boolean {
const activeItemIndex = this.keyManager?.activeItemIndex ?? -1;
return activeItemIndex < this.rowsOffset ? rowIndex === 0 : activeItemIndex === rowIndex + this.rowsOffset;
}
protected onRowFocus(rowIndex: number): void {
this.keyManager?.updateActiveItem(rowIndex + this.rowsOffset);
}
getRowStyle(row: DataRow): string {
row.cssClass = row.cssClass ? row.cssClass : '';
this.rowStyleClass = this.rowStyleClass ? this.rowStyleClass : '';
@@ -67,19 +67,25 @@ const renderDateCell = (dateConfig: DateConfig, value: number | string | Date, t
};
const checkDisplayedDate = (expectedDate: string) => {
const displayedDate = testingUtils.getByCSS('span').nativeElement.textContent.trim();
const displayedDate = testingUtils.getByCSS('time').nativeElement.textContent.trim();
expect(displayedDate).toBeTruthy();
expect(displayedDate).toBe(expectedDate);
};
const checkDisplayedTooltip = (expectedTooltip: string) => {
const displayedTooltip = testingUtils.getByCSS('span').nativeElement.title;
const displayedTooltip = testingUtils.getByCSS('time').nativeElement.title;
expect(displayedTooltip).toBeTruthy();
expect(displayedTooltip).toBe(expectedTooltip);
};
const checkDatetimeAttribute = (expectedIso: string) => {
const datetime = testingUtils.getByCSS('time').nativeElement.getAttribute('datetime');
expect(datetime).toBe(expectedIso);
};
const configureTestingModule = (providers: any[]) => {
TestBed.configureTestingModule({
imports: [DateCellComponent],
@@ -141,7 +147,7 @@ describe('DateCellComponent', () => {
expect(component.config().locale).toEqual('en-US');
});
it('should display date and tooltip with defaules values if NO dateConfig or appConfig is provided', () => {
it('should display date and tooltip with default values if NO dateConfig or appConfig is provided', () => {
appConfigService.config = {
dateValues: {}
};
@@ -232,6 +238,16 @@ describe('DateCellComponent', () => {
renderDateCell(mockDateConfig, mockTimestamp);
checkDisplayedDate(expectedDate);
});
it('should render a datetime attribute with the full ISO date value', () => {
const mockDateConfig: DateConfig = {
format: 'short',
tooltipFormat: 'short'
};
renderDateCell(mockDateConfig, mockDate);
checkDatetimeAttribute(mockDate.toISOString());
});
});
describe('DateCellComponent locale', () => {
@@ -26,7 +26,9 @@ import { toSignal } from '@angular/core/rxjs-interop';
selector: 'adf-date-cell',
template: `
@if (formattedDate()) {
<span [title]="title()" class="adf-datatable-cell-value">{{ formattedDate() }}</span>
<time [attr.datetime]="isoDate()" [attr.aria-label]="title() || null" [title]="title()" class="adf-datatable-cell-value"
>{{ formattedDate() }}
</time>
}
`,
encapsulation: ViewEncapsulation.None,
@@ -55,6 +57,14 @@ export class DateCellComponent extends DataTableCellComponent implements OnInit
// Convert value$ observable to signal for reactive computation
private readonly dateValue = toSignal(this.value$);
// Computed signal that automatically formats the date to ISO string for datetime attribute
protected readonly isoDate = computed(() => {
const date = this.dateValue();
const parsed = new Date(date);
return isNaN(parsed.getTime()) ? null : parsed.toISOString();
});
// Computed signal that automatically formats the date based on value and config
protected readonly formattedDate = computed(() => {
const date = this.dateValue();
@@ -0,0 +1,76 @@
/*!
* @license
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { ElementRef } from '@angular/core';
import { DropZoneDirective } from './drop-zone.directive';
describe('DropZoneDirective', () => {
let directive: DropZoneDirective;
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
TestBed.configureTestingModule({
providers: [{ provide: ElementRef, useValue: new ElementRef(element) }]
});
directive = TestBed.runInInjectionContext(() => new DropZoneDirective());
directive.dropTarget = 'cell';
directive.ngOnInit();
});
it('should dispatch a namespaced custom event on dragenter while attached', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.dispatchEvent(new DragEvent('dragenter'));
expect(dispatched).toContain('cell-dragenter');
});
it('should not handle drag events after the directive is destroyed', () => {
const dispatched: string[] = [];
element.addEventListener('cell-dragenter', () => dispatched.push('cell-dragenter'));
element.addEventListener('cell-dragover', () => dispatched.push('cell-dragover'));
element.addEventListener('cell-drop', () => dispatched.push('cell-drop'));
directive.ngOnDestroy();
element.dispatchEvent(new DragEvent('dragenter'));
element.dispatchEvent(new DragEvent('dragover'));
element.dispatchEvent(new DragEvent('drop'));
expect(dispatched).toEqual([]);
});
it('should remove listeners using the same references that were added', () => {
const addSpy = spyOn(element, 'addEventListener').and.callThrough();
const removeSpy = spyOn(element, 'removeEventListener').and.callThrough();
directive.ngOnInit();
directive.ngOnDestroy();
const addedByEvent = new Map<string, EventListenerOrEventListenerObject>();
addSpy.calls.allArgs().forEach(([evt, fn]) => addedByEvent.set(evt as string, fn as EventListenerOrEventListenerObject));
removeSpy.calls.allArgs().forEach(([evt, fn]) => {
expect(fn).toBe(addedByEvent.get(evt as string));
});
});
});
@@ -36,6 +36,10 @@ export class DropZoneDirective implements OnInit, OnDestroy {
@Input()
dropColumn: DataColumn;
private readonly onDragEnterHandler = this.onDragEnter.bind(this);
private readonly onDragOverHandler = this.onDragOver.bind(this);
private readonly onDropHandler = this.onDrop.bind(this);
constructor() {
const elementRef = inject(ElementRef);
@@ -44,16 +48,16 @@ export class DropZoneDirective implements OnInit, OnDestroy {
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
this.element.addEventListener('dragenter', this.onDragEnter.bind(this));
this.element.addEventListener('dragover', this.onDragOver.bind(this));
this.element.addEventListener('drop', this.onDrop.bind(this));
this.element.addEventListener('dragenter', this.onDragEnterHandler);
this.element.addEventListener('dragover', this.onDragOverHandler);
this.element.addEventListener('drop', this.onDropHandler);
});
}
ngOnDestroy() {
this.element.removeEventListener('dragenter', this.onDragEnter);
this.element.removeEventListener('dragover', this.onDragOver);
this.element.removeEventListener('drop', this.onDrop);
this.element.removeEventListener('dragenter', this.onDragEnterHandler);
this.element.removeEventListener('dragover', this.onDragOverHandler);
this.element.removeEventListener('drop', this.onDropHandler);
}
onDragEnter(event: DragEvent) {
@@ -131,7 +131,10 @@ export class ResizableDirective implements OnInit, OnDestroy {
.pipe(filter(() => !!this.currentRect));
mouseDrag
.pipe(map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)))
.pipe(
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX + this.coverPadding)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((rectangle: BoundingRectangle) => {
if (this.resizing.observers.length > 0) {
this.zone.run(() => {
@@ -131,6 +131,28 @@ describe('ResizeHandleDirective', () => {
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function));
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function));
});
it('should unregister previous mouseup listener before registering a new one on repeated mousedown', () => {
const firstUnlistenMouseUp = jasmine.createSpy('firstUnlistenMouseUp');
const secondUnlistenMouseUp = jasmine.createSpy('secondUnlistenMouseUp');
let mouseUpCallCount = 0;
renderer.listen.and.callFake((_target: any, eventName: string, _callback: (event: MouseEvent) => void) => {
if (eventName === 'mouseup') {
mouseUpCallCount++;
return mouseUpCallCount === 1 ? firstUnlistenMouseUp : secondUnlistenMouseUp;
}
return () => {};
});
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).not.toHaveBeenCalled();
mousedownCallback(mouseEvent);
expect(firstUnlistenMouseUp).toHaveBeenCalled();
});
});
describe('keyboard resizing', () => {
@@ -86,6 +86,7 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
});
}
this.unlistenMouseUp?.();
this.unlistenMouseUp = this.renderer.listen('document', 'mouseup', (mouseUpEvent: MouseEvent) => {
this.onMouseup(mouseUpEvent);
});
@@ -96,7 +97,8 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
private onMouseup(event: MouseEvent): void {
this.unlistenMouseMove?.();
this.unlistenMouseMove = undefined;
this.unlistenMouseUp();
this.unlistenMouseUp?.();
this.unlistenMouseUp = undefined;
this.resizableContainer.mouseup.next(event);
}
@@ -98,7 +98,7 @@
[hidden]="!currentRootElement?.isVisible"
>
<adf-repeat-widget [element]="currentRootElement" [isEditor]="false">
@for (row of currentRootElement.field.rows; track row; let rowIndex = $index) {
@for (row of currentRootElement.field.rows; track row.id; let rowIndex = $index) {
@let hasMultipleRows = currentRootElement.field.rows.length > 1;
<div
class="adf-grid-list-container"
@@ -125,11 +125,11 @@
}
</div>
<section class="adf-grid-list-column-view">
@for (column of row.columns; track column; let columnIndex = $index) {
@for (column of row.columns; track column.id; let columnIndex = $index) {
<div
class="adf-grid-list-single-column"
[style.width.%]="getColumnWidth(currentRootElement, row.columns, columnIndex)">
@for (field of column?.fields; track field) {
@for (field of column?.fields; track field.id) {
@if (field.type === 'section') {
<adf-form-section [field]="field"/>
} @else {
@@ -20,6 +20,12 @@
.mat-mdc-form-field-infix {
width: auto;
}
.adf-form-field-status-slot {
display: block;
height: 40px;
box-sizing: border-box;
}
}
.alfresco-tabs-widget {
@@ -27,6 +33,7 @@
.adf-form-tab-content {
margin-top: 1em;
padding-bottom: 3px;
}
.adf-form-tab-group {
@@ -40,7 +47,7 @@
.adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
margin-bottom: 35px;
margin-bottom: 0;
}
.adf-grid-list {
@@ -265,7 +272,7 @@
}
&-error-messages-container {
min-height: 35px;
height: 40px;
}
&-error-messages-container-visible {
@@ -17,7 +17,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../testing';
import { FormRulesManager } from '../models/form-rules.model';
import { FORM_RULES_MANAGER, FormRulesManager } from '../models/form-rules.model';
import { FormRulesEvent } from '../events/form-rules.event';
import { FormRenderingService } from '../services/form-rendering.service';
import { FormService } from '../services/form.service';
import { FormRendererComponent } from './form-renderer.component';
@@ -52,6 +53,7 @@ import {
import { FormFieldModel, FormModel, TextWidgetComponent } from './widgets';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { filter } from 'rxjs/operators';
import { WidgetVisibilityService } from '../services/widget-visibility.service';
const typeIntoInput = (testingUtils: UnitTestingUtils, selector: string, message: string) => {
@@ -137,6 +139,38 @@ describe('Form Renderer Component', () => {
});
describe('visibility refresh on form rules', () => {
const buildFormWithRequiredField = (startVisible: boolean = true): FormModel => {
const form = new FormModel(
{
id: 'required-field-form',
fields: [
{
id: 'container',
type: 'container',
numberOfColumns: 1,
fields: { 1: [{ id: 'requiredText', type: 'text', name: 'Required text', required: true }] }
}
]
},
undefined,
false,
formService
);
if (!startVisible) {
form.changeFieldVisibility('requiredText', false);
form.validateForm();
}
return form;
};
const hideFieldOnFieldValueChanged = (form: FormModel, fieldId: string, visible: boolean): void => {
formService.formRulesEvent
.pipe(filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === form.id))
.subscribe(() => form.changeFieldVisibility(fieldId, visible));
};
it('should refresh visibility when a fieldValueChanged rule event fires for the same form', () => {
const form = formService.parseForm(textWidgetVisibility.formRepresentation);
formRendererComponent.formDefinition = form;
@@ -176,6 +210,80 @@ describe('Form Renderer Component', () => {
expect(refreshVisibilitySpy).not.toHaveBeenCalled();
});
it('should revalidate the form when a rule hides an empty required field', () => {
const form = buildFormWithRequiredField();
formRendererComponent.formDefinition = form;
hideFieldOnFieldValueChanged(form, 'requiredText', false);
fixture.detectChanges();
expect(form.isValid).toBe(false, 'form should start invalid with a visible empty required field');
formService.formRulesEvent.next({ type: 'fieldValueChanged', form } as any);
expect(form.isValid).toBe(true, 'hiding the required field should re-enable the outcome');
});
it('should revalidate the form when a rule reveals an empty required field', () => {
const form = buildFormWithRequiredField(false);
formRendererComponent.formDefinition = form;
hideFieldOnFieldValueChanged(form, 'requiredText', true);
fixture.detectChanges();
expect(form.isValid).toBe(true, 'form should start valid while the required field is hidden');
formService.formRulesEvent.next({ type: 'fieldValueChanged', form } as any);
expect(form.isValid).toBe(false, 'revealing the empty required field should disable the outcome');
});
it('should revalidate the form when a rule hides a group containing an empty required field', () => {
const form = new FormModel(
{
id: 'required-group-form',
fields: [
{
id: 'requiredGroup',
type: 'group',
params: {},
numberOfColumns: 1,
fields: { 1: [{ id: 'textInGroup', type: 'text', name: 'Text', required: true }] }
}
]
},
undefined,
false,
formService
);
form.enableParentVisibilityCheck = true;
form.getFormFields().forEach((field) => (field.checkParentVisibilityForValidation = true));
form.validateForm();
formRendererComponent.formDefinition = form;
hideFieldOnFieldValueChanged(form, 'requiredGroup', false);
fixture.detectChanges();
expect(form.isValid).toBe(false, 'form should start invalid while the group is visible');
formService.formRulesEvent.next({ type: 'fieldValueChanged', form } as any);
expect(form.isValid).toBe(true, 'hiding the group should exempt its required child');
});
it('should revalidate the form for rule events other than fieldValueChanged', () => {
const form = buildFormWithRequiredField();
formRendererComponent.formDefinition = form;
formService.formRulesEvent
.pipe(filter((event) => event?.type === 'onRowCountChanged' && event.form?.id === form.id))
.subscribe(() => form.changeFieldVisibility('requiredText', false));
fixture.detectChanges();
expect(form.isValid).toBe(false, 'form should start invalid with a visible empty required field');
formService.formRulesEvent.next({ type: 'onRowCountChanged', form } as any);
expect(form.isValid).toBe(true, 'row-count driven rules should also recompute validity');
});
});
describe('Display Date Widget ', () => {
@@ -1225,3 +1333,67 @@ describe('Form Renderer Component', () => {
});
});
});
class HideOnFieldValueChangedRulesManager extends FormRulesManager<{ targetFieldId: string }> {
protected getRules(): { targetFieldId: string } {
return { targetFieldId: 'requiredText' };
}
protected handleRuleEvent(event: FormRulesEvent, rules: { targetFieldId: string }): void {
if (event.type === 'fieldValueChanged') {
this.formModel.changeFieldVisibility(rules.targetFieldId, false);
}
}
}
describe('Form Renderer Component form rule ordering', () => {
let fixture: ComponentFixture<FormRendererComponent<any>>;
let formService: FormService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [FormRendererComponent],
providers: [
{
provide: FORM_RULES_MANAGER,
useFactory: (service: FormService) => new HideOnFieldValueChangedRulesManager(service),
deps: [FormService]
}
]
});
fixture = TestBed.createComponent(FormRendererComponent<any>);
formService = TestBed.inject(FormService);
});
afterEach(() => {
fixture.destroy();
});
it('should revalidate after a real rules manager hides an empty required field', () => {
const form = new FormModel(
{
id: 'rule-ordering-form',
fields: [
{
id: 'container',
type: 'container',
numberOfColumns: 1,
fields: { 1: [{ id: 'requiredText', type: 'text', name: 'Required text', required: true }] }
}
]
},
undefined,
false,
formService
);
fixture.componentInstance.formDefinition = form;
fixture.detectChanges();
expect(form.isValid).toBe(false, 'form should start invalid with a visible empty required field');
formService.formRulesEvent.next({ type: 'fieldValueChanged', form } as any);
expect(form.isValid).toBe(true, 'the rule-driven hide must be reflected in form validity');
});
});
@@ -156,10 +156,15 @@ export class FormRendererComponent<T> implements OnInit, OnDestroy {
this.formService.formRulesEvent
.pipe(
filter((event) => event?.type === 'fieldValueChanged' && event.form?.id === this.formDefinition?.id),
filter((event) => !!event?.type && event.form?.id === this.formDefinition?.id),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.visibilityService.refreshVisibility(this.formDefinition));
.subscribe((event) => {
if (event.type === 'fieldValueChanged') {
this.visibilityService.refreshVisibility(this.formDefinition);
}
this.formDefinition?.validateForm();
});
}
ngOnDestroy() {
@@ -5,7 +5,7 @@
&-single-column {
display: flex;
flex-wrap: inherit;
align-items: center;
align-items: flex-start;
gap: 1%;
@include flex.layout-bp(lt-md) {
@@ -10,7 +10,11 @@
>
</div>
<div class="adf-amount-widget-container">
<mat-form-field class="adf-amount-widget__input adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-amount-widget__input adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{field.name | translate }}</mat-label> }
@if(!enableDisplayBasedOnLocale) {
<span matTextPrefix class="adf-amount-widget__prefix-spacing">{{ currency }}&nbsp;</span>
@@ -32,12 +36,13 @@
(blur)="amountWidgetOnBlur()"
/>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -23,13 +23,14 @@ import { FormModel } from '../core/form.model';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { of } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { FormService } from '../../../services/form.service';
import { FormFieldEvent } from '../../../events/form-field.event';
import { TranslationService } from '../../../../translation/translation.service';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { TranslateService } from '@ngx-translate/core';
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
@@ -394,6 +395,76 @@ describe('AmountWidgetComponent - rendering', () => {
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
});
describe('when validation runs without amount interaction', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}"
}
}
}
};
let amountField: FormFieldModel;
let form: FormModel;
beforeEach(async () => {
const translateService = TestBed.inject(TranslateService);
translateService.setTranslation('en', validatorTranslations);
await firstValueFrom(translateService.use('en'));
form = new FormModel({ taskId: '<id>' }, undefined, false, formService);
amountField = new FormFieldModel(form, {
id: 'amount-id',
type: FormFieldTypes.AMOUNT,
value: 1,
minValue: '10'
});
form.fieldsCache = [amountField];
amountField.validate();
fixture.componentRef.setInput('field', amountField);
fixture.detectChanges();
});
it('should render updated parameters after direct revalidation', async () => {
const formField = await testingUtils.formField.get();
let errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be less than 10");
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
amountField.validate();
fixture.detectChanges();
errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
it('should render updated parameters after sibling field revalidation', async () => {
const siblingField = new FormFieldModel(form, {
id: 'sibling-id',
type: FormFieldTypes.TEXT,
value: 'before'
});
form.fieldsCache = [amountField, siblingField];
amountField.value = 10;
amountField.minValue = '1';
amountField.maxValue = '5';
siblingField.value = 'after';
form.onFormFieldChanged(siblingField);
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors[0]).toContain("Can't be greater than 5");
expect(errors[0]).not.toContain('{{');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -25,6 +25,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -121,7 +122,9 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.subscribeToFieldChanges();
this.setInitialValues();
this.initErrorStateMatcher();
this.updateTranslateParameters();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
}
@@ -143,7 +146,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
}
}
this.markAsTouched();
this.updateTranslateParameters();
}
amountWidgetOnFocus(): void {
@@ -163,7 +165,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field);
this.markAsTouched();
this.updateTranslateParameters();
}
setInitialValues(): void {
@@ -188,7 +189,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value;
}
this.updateTranslateParameters();
});
}
@@ -208,12 +208,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field?.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -16,10 +16,9 @@
<span class="adf-asterisk" [style.visibility]="isRequired() ? 'visible' : 'hidden'">*</span>
</mat-checkbox>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
[error]="field.validationSummary"
[required]="isInvalidFieldRequired() && isTouched() ? ('FORM.FIELD.REQUIRED' | translate) : ''"
/>
</div>
</div>
@@ -17,7 +17,7 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgClass, NgIf } from '@angular/common';
import { NgClass } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -47,7 +47,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent, NgIf],
imports: [NgClass, MatCheckboxModule, FormsModule, TranslatePipe, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class CheckboxWidgetComponent extends WidgetComponent {}
@@ -17,24 +17,36 @@
export class ErrorMessageModel {
message: string = '';
attributes: Map<string, string> = null;
attributes: Map<string, string> = new Map();
constructor(obj?: any) {
this.message = obj?.message || '';
this.attributes = obj?.attributes || new Map();
if (obj?.attributes) {
this.attributes = obj.attributes;
}
}
isActive(): boolean {
return !!this.message;
}
getAttributesAsJsonObj() {
const result = {};
getAttributesAsJsonObj(): Record<string, string> {
const result: Record<string, string> = {};
if (this.attributes.size > 0) {
this.attributes.forEach((value, key) => {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
});
}
return result;
}
}
export const getValidationSummaryTranslationParameters = (validationSummary?: ErrorMessageModel): Record<string, string> => {
if (validationSummary?.isActive()) {
return validationSummary.getAttributesAsJsonObj();
}
return {};
};
@@ -17,9 +17,10 @@
import { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event';
import { firstValueFrom, map, Subject, take, timeout } from 'rxjs';
import { firstValueFrom, map, skip, Subject, take, timeout } from 'rxjs';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from './error-message.model';
import { FormFieldTypes } from './form-field-types';
import { RequiredFieldValidator } from './form-field-validator';
import { MinValueFieldValidator, RequiredFieldValidator } from './form-field-validator';
import { FormFieldModel } from './form-field.model';
import { FormModel } from './form.model';
@@ -36,6 +37,58 @@ describe('FormFieldModel', () => {
expect(model.json).toBe(json);
});
it('should return an isolated authored value snapshot', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const model = new FormFieldModel(new FormModel(), { id: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue });
const snapshot = model.authoredValue as typeof authoredValue;
snapshot.blocks[0].data.text = 'changed';
expect((model.authoredValue as typeof authoredValue).blocks[0].data.text).toBe('${field.name}');
expect(authoredValue.blocks[0].data.text).toBe('${field.name}');
});
it('should not capture authored values for other field types', () => {
const model = new FormFieldModel(new FormModel(), { id: 'json', type: FormFieldTypes.JSON, value: { content: 'value' } });
expect(model.authoredValue).toBeUndefined();
});
it('should return undefined for authored values that cannot be cloned', () => {
const circularValue: { self?: unknown } = {};
circularValue.self = circularValue;
const circularValueModel = new FormFieldModel(new FormModel(), {
id: 'circular',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: circularValue
});
const bigintValueModel = new FormFieldModel(new FormModel(), {
id: 'bigint',
type: FormFieldTypes.DISPLAY_RICH_TEXT,
value: BigInt(1)
});
expect(circularValueModel.authoredValue).toBeUndefined();
expect(bigintValueModel.authoredValue).toBeUndefined();
});
it('should preserve authored value when form data overrides the field value', () => {
const authoredValue = { blocks: [{ data: { text: '${field.name}' } }] };
const savedValue = { blocks: [{ data: { text: 'John' } }] };
const form = new FormModel(
{
fields: [{ id: 'richText', name: 'richText', type: FormFieldTypes.DISPLAY_RICH_TEXT, value: authoredValue }]
},
{ richText: savedValue }
);
const model = form.getFieldById('richText');
expect(model.value).toEqual(savedValue);
expect(model.authoredValue).toEqual(authoredValue);
expect(model.authoredValue).not.toBe(authoredValue);
});
it('should setup with json config', () => {
const json = {
fieldType: '<fieldType>',
@@ -1222,6 +1275,57 @@ describe('FormFieldModel', () => {
});
});
describe('validation summary changes', () => {
const createField = (): FormFieldModel => {
const form = new FormModel();
form.fieldValidators = [new MinValueFieldValidator()];
return new FormFieldModel(form, {
id: 'number-field',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: '10'
});
};
it('should replay an inactive validation summary before the first validation', async () => {
const field = new FormFieldModel(new FormModel());
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary).toEqual(jasmine.any(ErrorMessageModel));
expect(validationSummary.isActive()).toBe(false);
});
it('should replay the completed validation summary when subscribing after validation', async () => {
const field = createField();
field.validate();
const validationSummary = await firstValueFrom(field.validationSummaryChanges$);
expect(validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(validationSummary.attributes.get('minValue')).toBe('10');
});
it('should emit completed summaries when validation state changes', async () => {
const field = createField();
const invalidSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const invalidSummary = await invalidSummaryPromise;
field.value = 10;
const validSummaryPromise = firstValueFrom(field.validationSummaryChanges$.pipe(skip(1)));
field.validate();
const validSummary = await validSummaryPromise;
expect(invalidSummary.message).toBe('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
expect(invalidSummary.attributes.get('minValue')).toBe('10');
expect(validSummary.isActive()).toBe(false);
expect(validSummary.attributes.size).toBe(0);
});
});
it('should fail validation for readOnly required display-external-property field with null value', () => {
const form = new FormModel();
const field = new FormFieldModel(form, {
@@ -2056,3 +2160,35 @@ describe('FormFieldTypes', () => {
});
});
});
describe('ErrorMessageModel', () => {
it('should initialize empty attributes when attributes are omitted', () => {
const errorMessage = new ErrorMessageModel();
expect(errorMessage.attributes).toEqual(new Map());
});
it('should retain provided attributes', () => {
const attributes = new Map([['minValue', '10']]);
const errorMessage = new ErrorMessageModel({ attributes });
expect(errorMessage.attributes).toBe(attributes);
});
});
describe('getValidationSummaryTranslationParameters', () => {
it('should return validation attributes when the summary is active', () => {
const validationSummary = new ErrorMessageModel({
message: 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN',
attributes: new Map([['minValue', '10']])
});
expect(getValidationSummaryTranslationParameters(validationSummary)).toEqual({ minValue: '10' });
});
it('should return empty parameters when the summary is inactive or omitted', () => {
expect(getValidationSummaryTranslationParameters(new ErrorMessageModel())).toEqual({});
expect(getValidationSummaryTranslationParameters()).toEqual({});
});
});
@@ -29,6 +29,7 @@ import { VariableConfig } from './form-field-variable-options';
import { DataColumn } from '../../../../datatable/data/data-column.model';
import { DateFnsUtils } from '../../../../common';
import { isValid as isValidDate } from 'date-fns';
import { Observable, ReplaySubject } from 'rxjs';
import { ContainerRowModel } from './container-row.model';
import { RepeatableSectionModel, ROW_ID_PREFIX, TEMPLATE_ROW_ID } from './repeatable-section.model';
import { formFieldRuleHandler } from './handlers/form-field-rule.handler';
@@ -38,12 +39,35 @@ export type FieldOptionType = 'rest' | 'manual' | 'variable';
export type FieldSelectionType = 'single' | 'multiple';
export type FieldAlignmentType = 'vertical' | 'horizontal';
interface ValidationSummaryChangesState {
subject: ReplaySubject<ErrorMessageModel>;
observable: Observable<ErrorMessageModel>;
}
const validationSummaryChangesByField = new WeakMap<FormFieldModel, ValidationSummaryChangesState>();
const isJsonPrimitive = (value: unknown): value is null | string | number | boolean =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
const cloneJsonCompatibleValue = (value: unknown): unknown => {
if (value === undefined || isJsonPrimitive(value)) {
return value;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return undefined;
}
};
// Maps to FormFieldRepresentation
export class FormFieldModel extends FormWidgetModel {
private _value: string;
private _readOnly: boolean = false;
private _isValid: boolean = true;
private _required: boolean = false;
private readonly _authoredValue: unknown;
readonly defaultDateFormat: string = 'D-M-YYYY';
readonly defaultDateTimeFormat: string = 'D-M-YYYY hh:mm A';
@@ -110,7 +134,21 @@ export class FormFieldModel extends FormWidgetModel {
// util members
emptyOption: FormFieldOption;
validationSummary: ErrorMessageModel;
validationSummary: ErrorMessageModel = new ErrorMessageModel();
get validationSummaryChanges$(): Observable<ErrorMessageModel> {
const existingState = validationSummaryChangesByField.get(this);
if (existingState) {
return existingState.observable;
}
const subject = new ReplaySubject<ErrorMessageModel>(1);
const observable = subject.asObservable();
validationSummaryChangesByField.set(this, { subject, observable });
subject.next(this.validationSummary);
return observable;
}
get value(): any {
return this._value;
@@ -123,6 +161,10 @@ export class FormFieldModel extends FormWidgetModel {
}
}
get authoredValue(): unknown {
return cloneJsonCompatibleValue(this._authoredValue);
}
get readOnly(): boolean {
if (this.form?.readOnly) {
return true;
@@ -173,16 +215,19 @@ export class FormFieldModel extends FormWidgetModel {
for (const validator of validators) {
if (!validator.validate(this)) {
this._isValid = false;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
}
this._isValid = true;
validationSummaryChangesByField.get(this)?.subject.next(this.validationSummary);
return this._isValid;
}
constructor(form: any, json?: any, parent?: RepeatableSectionModel) {
super(form, json);
this._authoredValue = json?.type === FormFieldTypes.DISPLAY_RICH_TEXT ? cloneJsonCompatibleValue(json.value) : undefined;
if (json) {
this.fieldType = json.fieldType;
this.id = this.getId(json.id, parent);
@@ -221,7 +266,6 @@ export class FormFieldModel extends FormWidgetModel {
this.enableFractions = json.enableFractions;
this.currency = json.currency;
this.dateDisplayFormat = json.dateDisplayFormat || this.getDefaultDateFormat(json);
this.validationSummary = new ErrorMessageModel();
this.tooltip = json.tooltip || '';
this.selectionType = json.selectionType;
this.alignmentType = json.alignmentType;
@@ -20,4 +20,5 @@ export interface FormVariableModel {
name: string;
type: string;
value?: any;
runtimeSet?: boolean;
}
@@ -600,6 +600,33 @@ describe('FormModel', () => {
const missing = form.getProcessVariableValue('missing');
expect(missing).toBeUndefined();
});
it('should return zero process variable value instead of the form default', () => {
const formWithZero = new FormModel({
variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }],
processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }]
});
expect(formWithZero.getProcessVariableValue('amount')).toBe(0);
});
it('should return false process variable value instead of the form default', () => {
const formWithFalse = new FormModel({
variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }],
processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }]
});
expect(formWithFalse.getProcessVariableValue('flag')).toBe(false);
});
it('should return empty string process variable value instead of the form default', () => {
const formWithEmpty = new FormModel({
variables: [{ id: 'text-var', name: 'text', type: 'string', value: 'default' }],
processVariables: [{ name: 'variables.text', value: '', type: 'string' }]
});
expect(formWithEmpty.getProcessVariableValue('text')).toBe('');
});
});
describe('add values not present', () => {
@@ -307,22 +307,17 @@ export class FormModel implements ProcessFormModel {
* @returns process variable value
*/
getProcessVariableValue(name: string): any {
let value;
if (this.processVariables?.length) {
const names = [`variables.${name}`, name];
const processVariable = this.processVariables.find((entry) => names.includes(entry.name));
if (processVariable) {
value = this.parseValue(processVariable.type, processVariable.value);
return this.parseValue(processVariable.type, processVariable.value);
}
}
if (!value) {
value = this.getDefaultFormVariableValue(name);
}
return value;
return this.getDefaultFormVariableValue(name);
}
protected parseValue(type: string, value: any): any {
@@ -535,9 +530,20 @@ export class FormModel implements ProcessFormModel {
const variable = this.getFormVariable(variableId);
if (variable) {
variable.value = value;
variable.runtimeSet = true;
}
}
/**
* Checks whether a form variable has been given a value at runtime, for example by a form rule.
*
* @param identifier The `name` or `id` value
* @returns `true` when the value was set at runtime rather than coming from the form definition
*/
isVariableSetAtRuntime(identifier: string): boolean {
return !!this.getFormVariable(identifier)?.runtimeSet;
}
private loadInjectedFieldValidators(injectedFieldValidators: FormFieldValidator[]): void {
this.fieldValidators = injectedFieldValidators ? [...FORM_FIELD_VALIDATORS, ...injectedFieldValidators] : [...FORM_FIELD_VALIDATORS];
}
@@ -10,6 +10,7 @@
</div>
<div class="adf-date-time-widget-container">
<mat-form-field class="adf-date-time-widget adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-left-label-input-datepicker]="field.leftLabels"
[floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || field?.required) && !field.leftLabels) {
@@ -38,11 +39,12 @@
[timeInterval]="5"
[disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -1,7 +1,7 @@
<div class="{{ field.className }} date-widget-container" id="data-widget"
[class.adf-invalid]="dateInputControl.invalid && dateInputControl.touched"
[class.adf-readonly]="field.readOnly">
<mat-form-field class="adf-date-widget adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-date-widget adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
<mat-label class="adf-label"
[id]="field.id + '-label'"
[attr.for]="field.id">
@@ -22,11 +22,12 @@
[startAt]="startAt"
[disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
@@ -9,7 +9,7 @@
</div>
<div class="adf-decimal-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if ( (field.name || field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">{{ field.name | translate }}</mat-label> }
<input matInput
class="adf-input"
@@ -25,12 +25,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()" />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormService } from '../../../services/form.service';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { DecimalWidgetComponent } from './decimal.component';
import { TranslateService } from '@ngx-translate/core';
describe('DecimalComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
INVALID_DECIMAL_PRECISION: 'Precision {{ precision }}'
}
}
}
};
let loader: HarnessLoader;
let widget: DecimalWidgetComponent;
let fixture: ComponentFixture<DecimalWidgetComponent>;
@@ -107,6 +119,62 @@ describe('DecimalComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'decimal-id',
type: FormFieldTypes.DECIMAL,
value: 1,
minValue: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render decimal precision when programmatic revalidation fails', async () => {
field.value = 1.234;
field.minValue = '1';
field.precision = 2;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Precision 2');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -16,13 +16,15 @@
*/
import { NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -46,19 +48,21 @@ import { WidgetComponent } from '../widget.component';
export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {};
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onDecimalFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -67,12 +71,4 @@ export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -10,12 +10,16 @@
}
}
error-widget {
display: block;
}
.adf-error {
display: flex;
align-items: center;
&-widget-container {
height: auto;
height: 40px;
}
&-animate {
@@ -1,6 +1,6 @@
.adf-hyperlink-widget {
padding: 0.4375em 0;
border-top: 0.8438em solid transparent;
margin-bottom: 40px;
a {
color: var(--mat-sys-primary);
@@ -7,6 +7,7 @@
<mat-form-field
floatPlaceholder="never"
class="adf-form-field-input"
subscriptSizing="dynamic"
[class.adf-has-counter]="field.maxLength > 0"
[floatLabel]="field.placeholder ? 'always' : null"
>
@@ -31,14 +32,16 @@
>
</textarea>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
@if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>}
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
} @else if (field.maxLength > 0) {
<mat-hint class="adf-multiline-hint">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
<mat-hint class="adf-multiline-hint adf-form-field-status-slot">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
} @else {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
</div>
@@ -43,6 +43,6 @@
@include mixins.adf-error-icon;
}
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
margin-bottom: 44px;
.adf-container-widget .adf-multiline-text-widget mat-form-field.adf-form-field-input.adf-has-counter {
margin-bottom: 20px;
}
@@ -25,8 +25,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('MultilineTextWidgetComponentComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: MultilineTextWidgetComponentComponent;
let fixture: ComponentFixture<MultilineTextWidgetComponentComponent>;
@@ -109,6 +120,48 @@ describe('MultilineTextWidgetComponentComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'multiline-text-id',
type: FormFieldTypes.MULTILINE_TEXT,
value: 'text',
minLength: 10
});
field.validate();
field.form.showAllValidationErrors = true;
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when is required', () => {
beforeEach(() => {
widget.field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
@@ -27,6 +27,7 @@ import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { isObservable } from 'rxjs';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -56,6 +57,9 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
ngOnInit(): void {
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
if (this.enableCustomMessage != null) {
if (isObservable(this.enableCustomMessage)) {
this.enableCustomMessage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((enabled: boolean) => {
@@ -73,12 +77,10 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onMultilineTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -87,12 +89,4 @@ export class MultilineTextWidgetComponentComponent extends WidgetComponent imple
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -9,7 +9,7 @@
</label>
</div>
<div class="adf-number-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="field.placeholder ? 'always' : null">
<mat-form-field class="adf-form-field-input" subscriptSizing="dynamic" [floatLabel]="field.placeholder ? 'always' : null">
@if( (field.name || this.field?.required) && !field.leftLabels) {
<mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
@@ -30,12 +30,13 @@
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()">
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
</mat-form-field>
</div>
</div>
@@ -22,8 +22,20 @@ import { UnitTestingUtils } from '../../../../testing';
import { FormFieldModel, FormFieldTypes, FormModel } from '../core';
import { NumberWidgetComponent } from './number.widget';
import { DecimalNumberPipe } from '../../../../pipes';
import { TranslateService } from '@ngx-translate/core';
describe('NumberWidgetComponent', () => {
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
NOT_LESS_THAN: "Can't be less than {{ minValue }}",
NOT_GREATER_THAN: "Can't be greater than {{ maxValue }}",
NO_LONGER_THAN: 'Maximum length {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: NumberWidgetComponent;
let fixture: ComponentFixture<NumberWidgetComponent>;
@@ -175,6 +187,60 @@ describe('NumberWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(new FormModel({ taskId: '<id>' }), {
id: 'number-id',
type: FormFieldTypes.NUMBER,
value: 1,
minValue: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum value in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be less than 10");
});
it('should render the updated maximum value when programmatic revalidation fails', async () => {
field.value = 10;
field.minValue = '1';
field.maxValue = '5';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("Can't be greater than 5");
});
it('should render the maximum length when programmatic revalidation fails', async () => {
field.value = 12345678901;
field.minValue = '1';
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum length 10');
});
});
describe('when form model has left labels', () => {
it('should have left labels classes on leftLabels true', async () => {
widget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id', leftLabels: true }), {
@@ -18,7 +18,8 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common';
import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,6 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { DecimalNumberPipe } from '../../../../pipes';
import { getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -53,6 +55,7 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
translateParameters: Record<string, string> = {};
private readonly decimalNumberPipe = inject(DecimalNumberPipe);
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
if (this.field.readOnly) {
@@ -61,11 +64,13 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
this.displayValue = this.field.value;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
protected onNumberChange(value: string) {
@@ -74,7 +79,6 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
}
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -83,12 +87,4 @@ export class NumberWidgetComponent extends WidgetComponent implements OnInit {
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -9,7 +9,8 @@
}
&-row-action {
margin-left: 10px;
margin-inline-start: 10px;
margin-block-end: 35px;
}
&-row-limit {
@@ -8,7 +8,11 @@
</label>
</div>
<div class="adf-text-widget-container">
<mat-form-field class="adf-form-field-input" [floatLabel]="placeholder ? 'always' : null">
<mat-form-field
class="adf-form-field-input"
subscriptSizing="dynamic"
[floatLabel]="placeholder ? 'always' : null"
>
@if ( (field.name || this.field?.required) && !field.leftLabels) { <mat-label class="adf-label" [attr.for]="field.id">
{{ field.name | translate }}
</mat-label>
@@ -30,7 +34,7 @@
(paste)="onPaste($event)"
(blur)="onBlur()">
@if (!fieldStatusTemplate && (maxLengthPasteError.isActive() || field.validationSummary?.message || (isInvalidFieldRequired() && isTouched()))) {
<mat-error>
<mat-error class="adf-form-field-status-slot">
<mat-icon class="adf-error-icon" adf-icon="error_outline" />
<span class="adf-error-text">
@if (maxLengthPasteError.isActive()) {
@@ -43,6 +47,9 @@
</span>
</mat-error>
}
@if (!fieldStatusTemplate) {
<mat-hint class="adf-form-field-status-slot" aria-hidden="true" />
}
</mat-form-field>
<ng-container *ngTemplateOutlet="maxLengthPasteError.isActive() && fieldStatusTemplate ? maxLengthPasteErrorTemplate : (fieldStatusTemplate ?? null); context: { $implicit: this }" />
<ng-template #maxLengthPasteErrorTemplate>
@@ -27,9 +27,20 @@ import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
describe('TextWidgetComponent', () => {
const form = new FormModel({ taskId: 'fake-task-id' });
const validatorTranslations = {
FORM: {
FIELD: {
VALIDATOR: {
AT_LEAST_LONG: 'Minimum {{ minLength }}',
NO_LONGER_THAN: 'Maximum {{ maxLength }}'
}
}
}
};
let loader: HarnessLoader;
let widget: TextWidgetComponent;
@@ -64,6 +75,47 @@ describe('TextWidgetComponent', () => {
});
});
describe('when validation runs without widget interaction', () => {
let field: FormFieldModel;
beforeEach(() => {
const translateService = TestBed.inject(TranslateService);
translateService.use('en').subscribe();
translateService.setTranslation('en', validatorTranslations);
field = new FormFieldModel(form, {
id: 'text-id',
type: FormFieldTypes.TEXT,
value: 'text',
minLength: 10
});
field.validate();
fixture.componentRef.setInput('field', field);
fixture.detectChanges();
});
it('should render the minimum length in the message when initial validation fails', async () => {
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Minimum 10');
});
it('should render the updated maximum length when programmatic revalidation fails', async () => {
field.value = 'too long';
field.minLength = 1;
field.maxLength = 5;
field.validate();
fixture.detectChanges();
const formField = await testingUtils.formField.get();
const errors = await formField.getTextErrors();
expect(errors.length).toBe(1);
expect(errors[0]).toContain('Maximum 5');
});
});
describe('when template is ready', () => {
describe('and no mask is configured on text element', () => {
it('should raise ngModelChange event', async () => {
@@ -19,6 +19,7 @@
import { NgIf, NgTemplateOutlet } from '@angular/common';
import { Component, Directive, inject, InjectionToken, Input, TemplateRef, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -26,7 +27,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { WidgetComponent } from '../widget.component';
import { ErrorMessageModel } from '../core/error-message.model';
import { ErrorMessageModel, getValidationSummaryTranslationParameters } from '../core/error-message.model';
import { FormattableTextWidgetComponent } from '../core/formattable-text.widget';
import { DEFAULT_TEXT_MAX_LENGTH } from '../core/form-field-validator';
import { InputMaskDirective } from './text-mask.component';
@@ -97,6 +98,9 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
this.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
}
this.initErrorStateMatcher();
this.field.validationSummaryChanges$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((validationSummary) => {
this.translateParameters = getValidationSummaryTranslationParameters(validationSummary);
});
}
onPaste(event: ClipboardEvent): void {
@@ -125,12 +129,10 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onTextFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
@@ -143,14 +145,6 @@ export class TextWidgetComponent extends FormattableTextWidgetComponent {
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
private getLengthAfterPaste(input: HTMLInputElement, pastedValue: string): number {
const value = input.value ?? '';
const selectionStart = input.selectionStart ?? value.length;
@@ -18,5 +18,5 @@
export class TaskProcessVariableModel {
id?: string;
type?: string;
value: string;
value: any;
}

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