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
86 changed files with 4663 additions and 1048 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@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # 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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # 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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v3.29.5
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v3.29.5
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
- name: Send Teams notification
if: steps.check_label_timing.outputs.should_notify == 'true'
uses: Alfresco/alfresco-build-tools/.github/actions/send-teams-notification@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
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@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with:
skip_ref_checkout: true
upload_sources: false
+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@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3.0.2
with:
upload_sources: true
upload_sources_args: --delete-obsolete
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
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@da99ab845e78301fcb0680d16cbc185a40a1938c # v18.24.1
- 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.
+3 -3
View File
@@ -91,7 +91,7 @@ jobs:
xvfb-run --auto-servernum pnpm nx run ${{ matrix.project }}:test
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-${{ matrix.project }}
path: coverage/${{ matrix.project }}/lcov.info
@@ -117,7 +117,7 @@ jobs:
with:
fetch-depth: 0
- name: Download all coverage artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
path: coverage-reports
@@ -138,7 +138,7 @@ jobs:
echo "Coverage files found:"
find coverage -name 'lcov.info' -type f
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 # v5.1.0
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)
+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,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,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];
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) {
@@ -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 {
@@ -33,6 +33,7 @@
.adf-form-tab-content {
margin-top: 1em;
padding-bottom: 3px;
}
.adf-form-tab-group {
@@ -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() {
@@ -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];
}
@@ -18,5 +18,5 @@
export class TaskProcessVariableModel {
id?: string;
type?: string;
value: string;
value: any;
}
@@ -1002,4 +1002,169 @@ describe('WidgetVisibilityService', () => {
expect(textField.isVisible).toBe(true);
});
});
describe('Visibility calculation from form variables', () => {
const hiddenWhileRequestor = new WidgetVisibilityModel({
leftType: 'variable',
leftValue: 'person_type',
operator: '!=',
rightType: 'value',
rightValue: 'Requestor',
nextConditionOperator: '',
nextCondition: null
});
const requestorProcessVariables = [{ id: 'variables.person_type', value: 'Requestor' }];
let formJson: any;
let form: FormModel;
beforeEach(() => {
formJson = {
id: 'person-type-form',
variables: [{ id: 'person-type-var', name: 'person_type', value: null }],
processVariables: [{ name: 'variables.person_type', value: 'Requestor' }]
};
form = new FormModel(formJson);
service.cleanProcessVariable();
});
it('should resolve a variable from the form when the cached process variables no longer hold it', () => {
service.refreshVisibility(form, requestorProcessVariables);
service.refreshVisibility(new FormModel({ id: 'another-form' }), [{ id: 'variables.other', value: 'other' }]);
expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false);
});
it('should resolve a variable from the form when the refreshed data omits it', () => {
service.refreshVisibility(form, requestorProcessVariables);
service.refreshVisibility(form, [{ id: 'processOutput', value: 'result' }]);
expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(false);
});
it('should prefer a process variable over the value defined in the form definition', () => {
const formWithDefault = new FormModel({
id: 'person-type-form-with-default',
variables: [{ id: 'person-type-var', name: 'person_type', value: 'Approver' }],
processVariables: [{ name: 'variables.person_type', value: 'Requestor' }]
});
expect(service.getVariableValue(formWithDefault, 'person_type', requestorProcessVariables)).toBe('Requestor');
});
it('should prefer a variable changed at runtime over a process variable of the same name', () => {
service.refreshVisibility(form, requestorProcessVariables);
form.changeVariableValue('person-type-var', 'Approver');
service.refreshVisibility(form, requestorProcessVariables);
expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true);
});
it('should prefer a variable cleared at runtime over a process variable of the same name', () => {
service.refreshVisibility(form, requestorProcessVariables);
form.changeVariableValue('person-type-var', '');
service.refreshVisibility(form, requestorProcessVariables);
expect(service.evaluateVisibility(form, hiddenWhileRequestor)).toBe(true);
});
it('should keep a variable changed at runtime when the form is rebuilt from the same definition', () => {
service.refreshVisibility(form, requestorProcessVariables);
form.changeVariableValue('person-type-var', 'Approver');
const rebuiltForm = new FormModel(formJson);
service.refreshVisibility(rebuiltForm, [{ id: 'processOutput', value: 'result' }]);
expect(service.evaluateVisibility(rebuiltForm, hiddenWhileRequestor)).toBe(true);
});
it('should not convert the type of a variable changed at runtime', () => {
const numericForm = new FormModel({ id: 'numeric-form', variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }] });
const booleanForm = new FormModel({ id: 'boolean-form', variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }] });
service.refreshVisibility(numericForm);
service.refreshVisibility(booleanForm);
numericForm.changeVariableValue('amount-var', 5);
booleanForm.changeVariableValue('flag-var', false);
service.refreshVisibility(numericForm);
service.refreshVisibility(booleanForm);
expect(service.getVariableValue(numericForm, 'amount')).toBe(5);
expect(service.getVariableValue(booleanForm, 'flag', [])).toBe(false);
});
it('should evaluate a numeric variable changed at runtime without string comparison', () => {
const numericForm = new FormModel({
id: 'numeric-visibility-form',
variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 0 }]
});
const amountOverTen = new WidgetVisibilityModel({
leftType: 'variable',
leftValue: 'amount',
operator: '>',
rightType: 'value',
rightValue: '10',
nextConditionOperator: '',
nextCondition: null
});
service.refreshVisibility(numericForm);
numericForm.changeVariableValue('amount-var', 5);
service.refreshVisibility(numericForm);
expect(service.evaluateVisibility(numericForm, amountOverTen)).toBe(false);
});
it('should resolve a zero process variable from the form when the refreshed data omits it', () => {
const zeroForm = new FormModel({
id: 'zero-form',
variables: [{ id: 'amount-var', name: 'amount', type: 'integer', value: 99 }],
processVariables: [{ name: 'variables.amount', value: 0, type: 'integer' }]
});
const amountIsZero = new WidgetVisibilityModel({
leftType: 'variable',
leftValue: 'amount',
operator: '==',
rightType: 'value',
rightValue: '0',
nextConditionOperator: '',
nextCondition: null
});
service.refreshVisibility(zeroForm, [{ id: 'variables.amount', value: 0 }]);
service.refreshVisibility(zeroForm, [{ id: 'processOutput', value: 'result' }]);
expect(service.getVariableValue(zeroForm, 'amount', [])).toBe(0);
expect(service.evaluateVisibility(zeroForm, amountIsZero)).toBe(true);
});
it('should resolve a false process variable from the form when the refreshed data omits it', () => {
const falseForm = new FormModel({
id: 'false-form',
variables: [{ id: 'flag-var', name: 'flag', type: 'boolean', value: true }],
processVariables: [{ name: 'variables.flag', value: false, type: 'boolean' }]
});
const flagIsFalse = new WidgetVisibilityModel({
leftType: 'variable',
leftValue: 'flag',
operator: '==',
rightType: 'value',
rightValue: 'false',
nextConditionOperator: '',
nextCondition: null
});
service.refreshVisibility(falseForm, [{ id: 'variables.flag', value: false }]);
service.refreshVisibility(falseForm, [{ id: 'processOutput', value: 'result' }]);
expect(service.getVariableValue(falseForm, 'flag', [])).toBe(false);
expect(service.evaluateVisibility(falseForm, flagIsFalse)).toBe(true);
});
});
});
@@ -29,7 +29,7 @@ import { FormService } from './form.service';
export class WidgetVisibilityService {
private readonly formService = inject(FormService);
private processVarList: TaskProcessVariableModel[];
private processVarList: TaskProcessVariableModel[] = [];
private form: FormModel;
public refreshVisibility(form: FormModel, processVarList?: TaskProcessVariableModel[]) {
@@ -110,7 +110,7 @@ export class WidgetVisibilityService {
}
}
getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string {
getLeftValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any {
let leftValue = '';
if (visibilityObj.leftType === WidgetTypeEnum.variable) {
leftValue = this.getVariableValue(form, visibilityObj.leftValue, this.processVarList);
@@ -124,7 +124,7 @@ export class WidgetVisibilityService {
return leftValue;
}
getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): string {
getRightValue(form: FormModel, visibilityObj: WidgetVisibilityModel): any {
let valueFound = '';
if (visibilityObj.rightType === WidgetTypeEnum.variable) {
@@ -270,20 +270,21 @@ export class WidgetVisibilityService {
return field.id && fieldToFind ? field.id.toUpperCase() === fieldToFind.toUpperCase() : false;
}
public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[]): string {
const processVariableValue = this.getProcessVariableValue(name, processVarList);
const variableDefaultValue = form.getDefaultFormVariableValue(name);
public getVariableValue(form: FormModel, name: string, processVarList: TaskProcessVariableModel[] = []): any {
if (form.isVariableSetAtRuntime(name)) {
return form.getDefaultFormVariableValue(name);
}
return processVariableValue === undefined ? variableDefaultValue : processVariableValue;
const processVariableValue = this.getProcessVariableValue(name, processVarList);
return processVariableValue === undefined ? form.getProcessVariableValue(name) : processVariableValue;
}
private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): string {
if (processVarList) {
const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`);
private getProcessVariableValue(name: string, processVarList: TaskProcessVariableModel[]): any {
const processVariable = processVarList.find((variable) => variable.id === name || variable.id === `variables.${name}`);
if (processVariable) {
return processVariable.value;
}
if (processVariable) {
return processVariable.value;
}
return undefined;
}
@@ -106,6 +106,14 @@
<mat-icon adf-icon="zoom_out" />
</button>
<button id="viewer-rotate-page-button"
title="{{ 'ADF_VIEWER.ARIA.ROTATE' | translate }}"
attr.aria-label="{{ 'ADF_VIEWER.ARIA.ROTATE' | translate }}"
mat-icon-button
(click)="rotatePage()">
<mat-icon adf-icon="rotate_left" />
</button>
<button id="viewer-scale-page-button"
role="button" aria-pressed="true"
title="{{ 'ADF_VIEWER.ARIA.FIT_PAGE' | translate }}"
@@ -114,6 +122,5 @@
(click)="pageFit()">
<mat-icon adf-icon="zoom_out_map" />
</button>
</adf-toolbar>
</div>
@@ -461,8 +461,10 @@ describe('Test PdfViewer - User interaction', () => {
let component: PdfViewerComponent;
let testingUtils: UnitTestingUtils;
let pdfViewerSpy: jasmine.Spy;
let pageViewMock: { width: number; height: number; scale: number; rotation: number; update: jasmine.Spy };
beforeEach(fakeAsync(() => {
pageViewMock = { width: 100, height: 100, scale: 1, rotation: 0, update: jasmine.createSpy() };
pdfViewerSpy = jasmine.createSpy('PDFViewer').and.returnValue({
setDocument: jasmine.createSpy().and.returnValue({
loadingTask: () => ({
@@ -479,7 +481,7 @@ describe('Test PdfViewer - User interaction', () => {
update: jasmine.createSpy(),
currentScaleValue: 1,
_currentPageNumber: 1,
_pages: [{ width: 100, height: 100, scale: 1 }]
_pages: [pageViewMock]
});
TestBed.configureTestingModule({
@@ -617,6 +619,30 @@ describe('Test PdfViewer - User interaction', () => {
}), 300);
});
describe('Rotation', () => {
it('should rotate only the current page counter-clockwise by 90 degrees', () => {
testingUtils.clickByCSS('#viewer-rotate-page-button');
expect(pageViewMock.update).toHaveBeenCalledWith({ rotation: 270 });
});
it('should wrap rotation back to 0 after a full turn', () => {
pageViewMock.rotation = 90;
testingUtils.clickByCSS('#viewer-rotate-page-button');
expect(pageViewMock.update).toHaveBeenCalledWith({ rotation: 0 });
});
it('should recompute the document overflow after rotation', () => {
spyOn(component, 'setDocumentOverflow');
testingUtils.clickByCSS('#viewer-rotate-page-button');
expect(component.setDocumentOverflow).toHaveBeenCalled();
});
});
describe('Resize interaction', () => {
it('should resize event trigger setScaleUpdatePages', () => {
spyOn(component, 'onResize');
@@ -577,6 +577,24 @@ export class PdfViewerComponent implements OnChanges, OnDestroy {
this.setScaleUpdatePages(newScale);
}
/**
* Rotate the currently displayed page 90 degrees counter-clockwise.
*
* Only the current page is affected; every other page keeps its own rotation.
* The angle wraps around, so four consecutive calls return the page to its
* original orientation (0 → 270 → 180 → 90 → 0). The document overflow state
* is recomputed afterwards, since rotation swaps the page width and height.
*/
rotatePage() {
if (this.pdfViewer) {
const pageView = this.pdfViewer._pages[this.pdfViewer._currentPageNumber - 1];
if (pageView) {
pageView.update({ rotation: (pageView.rotation + 270) % 360 });
this.setDocumentOverflow();
}
}
}
/**
* load the previous page
*/
+3 -3
View File
@@ -11,9 +11,9 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
},
"dependencies": {
"chart.js": "^4.3.0",
"ng2-charts": "^4.1.1",
"raphael": ">=2.3.0"
"chart.js": "4.5.1",
"ng2-charts": "9.0.0",
"raphael": "2.3.0"
},
"peerDependencies": {
"@angular/common": ">=20.3.27",
@@ -22,7 +22,7 @@ import { AnalyticsService } from '../services/analytics.service';
import { CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NgChartsModule } from 'ng2-charts';
import { BaseChartDirective } from 'ng2-charts';
import { TranslatePipe } from '@ngx-translate/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
@@ -34,7 +34,7 @@ import { AnalyticsReportHeatMapComponent } from './analytics-report-heat-map.com
CommonModule,
MatButtonModule,
MatIconModule,
NgChartsModule,
BaseChartDirective,
TranslatePipe,
MatCheckboxModule,
FormsModule,
@@ -2575,3 +2575,126 @@ describe('FormCloudComponent — runtime state preservation on data refresh', ()
});
});
});
describe('FormCloudComponent — form variable visibility on data refresh', () => {
let fixture: ComponentFixture<FormCloudComponent>;
let formComponent: FormCloudComponent;
let visibilityService: WidgetVisibilityService;
/** field is hidden while person_type is Requestor */
const personTypeFormJson = {
id: 'person-type-form',
name: 'Person Type Form',
variables: [{ id: 'person-type-var', name: 'person_type', value: null }],
fields: [
{
fieldType: 'ContainerRepresentation',
id: 'container1',
name: 'Container',
type: 'container',
tab: null,
numberOfColumns: 1,
fields: {
1: [
{
fieldType: 'FormFieldRepresentation',
id: 'conditionalField',
name: 'Conditional Field',
type: 'multiline-text',
value: null,
required: false,
readOnly: false,
visibilityCondition: {
leftType: 'variable',
leftValue: 'person_type',
operator: '!=',
rightType: 'value',
rightValue: 'Requestor',
nextConditionOperator: '',
nextCondition: null
},
params: { existingColspan: 1, maxColspan: 1 }
}
]
}
}
]
};
const requestorVariables = () => [new TaskVariableCloud({ name: 'variables.person_type', value: 'Requestor' })];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule, NoopAuthModule, FormCloudComponent],
providers: [
{ provide: VersionCompatibilityService, useValue: {} },
{ provide: FormRenderingService, useClass: CloudFormRenderingService }
]
});
const apiService = TestBed.inject(AlfrescoApiService);
spyOn(apiService, 'getInstance').and.returnValue(mockOauth2Auth);
visibilityService = TestBed.inject(WidgetVisibilityService);
visibilityService.cleanProcessVariable();
fixture = TestBed.createComponent(FormCloudComponent);
formComponent = fixture.componentInstance;
formComponent.formCloudRepresentationJSON = new FormCloudRepresentation(JSON.parse(JSON.stringify(personTypeFormJson)));
formComponent.formCloudRepresentationJSON.processVariables = requestorVariables();
formComponent.data = requestorVariables();
formComponent.form = formComponent.parseForm(formComponent.formCloudRepresentationJSON);
visibilityService.refreshVisibility(formComponent.form, formComponent.data);
fixture.detectChanges();
});
it('should keep the field hidden when the refreshed data omits the variable', () => {
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse();
const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
const change = new SimpleChange(formComponent.data, partialData, false);
formComponent.data = partialData;
formComponent.ngOnChanges({ data: change });
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeFalse();
});
it('should show the field when the refreshed data changes the variable', () => {
const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })];
const change = new SimpleChange(formComponent.data, approverData, false);
formComponent.data = approverData;
formComponent.ngOnChanges({ data: change });
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
});
it('should keep a variable changed by a form rule when the refreshed data omits it', () => {
formComponent.form.changeVariableValue('person-type-var', 'Approver');
const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
const change = new SimpleChange(formComponent.data, partialData, false);
formComponent.data = partialData;
formComponent.ngOnChanges({ data: change });
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
});
it('should keep the latest received variable value across a following partial refresh', () => {
const approverData = [new TaskVariableCloud({ name: 'variables.person_type', value: 'Approver' })];
formComponent.data = approverData;
formComponent.ngOnChanges({ data: new SimpleChange(requestorVariables(), approverData, false) });
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
const partialData = [new TaskVariableCloud({ name: 'processOutput', value: 'result' })];
formComponent.data = partialData;
formComponent.ngOnChanges({ data: new SimpleChange(approverData, partialData, false) });
expect(formComponent.form.getFieldById('conditionalField').isVisible).toBeTrue();
});
});
@@ -601,6 +601,8 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
private refreshFormData(previousData: TaskVariableCloud[] = []) {
const snapshot = this.snapshotRuntimeState();
this.mergeProcessVariables(this.data ?? []);
this.form = this.parseForm(this.formCloudRepresentationJSON);
if (!this.form) {
return;
@@ -610,13 +612,36 @@ export class FormCloudComponent extends FormBaseComponent implements OnChanges,
this.restoreRuntimeState(this.form, snapshot, changedFieldIds);
this.setCheckParentVisibilityForValidationOnFields();
this.visibilityService.refreshVisibility(this.form);
this.visibilityService.refreshVisibility(this.form, this.data);
this.form.validateForm();
this.onFormLoaded(this.form);
this.formService.formRulesEvent.next(new FormRulesEvent('dataRefreshed', new FormEvent(this.form)));
this.onFormDataRefreshed(this.form);
}
/**
* Keeps the process variables on the stored representation up to date with the latest data, so that a
* variable omitted by a later partial refresh still resolves to the most recent value received.
*
* @param updates Variables received on the latest data refresh
*/
private mergeProcessVariables(updates: TaskVariableCloud[]): void {
if (!this.formCloudRepresentationJSON) {
return;
}
const existing: TaskVariableCloud[] = this.formCloudRepresentationJSON.processVariables ?? [];
const byName = new Map<string, TaskVariableCloud>();
for (const variable of [...existing, ...updates]) {
if (variable?.name) {
byName.set(variable.name, variable);
}
}
this.formCloudRepresentationJSON.processVariables = Array.from(byName.values());
}
private snapshotRuntimeState(): Map<string, FormFieldRuntimeState> {
const snapshot = new Map<string, FormFieldRuntimeState>();
if (!this.form) {
@@ -0,0 +1,55 @@
/*!
* @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.
*/
export const FilterCounterEntityType = {
TASK: 'TASK',
PROCESS_INSTANCE: 'PROCESS_INSTANCE'
} as const;
export type FilterCounterEntityType = (typeof FilterCounterEntityType)[keyof typeof FilterCounterEntityType];
export interface FilterCountersQuerySort {
field: string;
direction: string;
isProcessVariable: boolean;
}
export interface FilterCountersQuery {
requestId: string;
status?: string[];
assignee?: string[];
sort?: FilterCountersQuerySort;
[criteria: string]: unknown;
}
export type FilterCountersRequest = {
[entityType in FilterCounterEntityType]?: FilterCountersQuery[];
};
export interface FilterCounterCandidate {
key?: string | null;
showCounter?: boolean;
}
export type FilterCounters = {
[entityType in FilterCounterEntityType]?: { [requestId: string]: number };
};
export interface FilterCountersResult {
counters: { [filterKey: string]: number };
batched: boolean;
}
@@ -16,15 +16,15 @@
*/
import { Component, SimpleChange } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
import { first, of, Subject, throwError } from 'rxjs';
import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service';
import { ProcessFiltersCloudComponent } from './process-filters-cloud.component';
import { By } from '@angular/platform-browser';
import { PROCESS_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service';
import { mockProcessFilters } from '../../mock/process-filters-cloud.mock';
import { AppConfigService, AppConfigServiceMock } from '@alfresco/adf-core';
import { AppConfigService, AppConfigServiceMock, NoopAuthModule } from '@alfresco/adf-core';
import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service';
import { ApolloTestingModule } from 'apollo-angular/testing';
import { HarnessLoader } from '@angular/cdk/testing';
@@ -32,39 +32,39 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatIconHarness } from '@angular/material/icon/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType, FilterCountersResult } from '../../../../models/filter-counters-cloud.model';
import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
class DummyComponent {}
const ProcessFilterCloudServiceMock = {
getProcessFilters: () => of(mockProcessFilters),
getProcessNotificationSubscription: () => of([]),
filterKeyToBeRefreshed$: of(mockProcessFilters[0].key)
};
describe('ProcessFiltersCloudComponent', () => {
let processFilterService: ProcessFilterCloudService;
let filterCountersService: FilterCountersCloudService;
let processListService: ProcessListCloudService;
let component: ProcessFiltersCloudComponent;
let fixture: ComponentFixture<ProcessFiltersCloudComponent>;
let getProcessFiltersSpy: jasmine.Spy;
let getProcessNotificationSubscriptionSpy: jasmine.Spy;
let getFilterCountersSpy: jasmine.Spy;
let refreshFilterCountersSpy: jasmine.Spy;
let getProcessCounterSpy: jasmine.Spy;
let loader: HarnessLoader;
let router: Router;
const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => {
TestBed.configureTestingModule({
imports: [ProcessFiltersCloudComponent, ApolloTestingModule],
imports: [NoopAuthModule, ProcessFiltersCloudComponent, ApolloTestingModule],
providers: [
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: AppConfigService, useClass: AppConfigServiceMock },
{
provide: ProcessListCloudService,
useValue: {
getProcessCounter: () => of(10),
getProcessListCount: () => of(10)
}
},
ProcessListCloudService,
{ provide: ProcessFilterCloudService, useValue: ProcessFilterCloudServiceMock },
provideRouter([{ path: 'process-list-cloud', component: DummyComponent }]),
{
@@ -88,11 +88,16 @@ describe('ProcessFiltersCloudComponent', () => {
component.searchApiMethod = searchApiMethod;
processFilterService = TestBed.inject(ProcessFilterCloudService);
filterCountersService = TestBed.inject(FilterCountersCloudService);
processListService = TestBed.inject(ProcessListCloudService);
TestBed.inject(ActivatedRoute);
router = TestBed.inject(Router);
await RouterTestingHarness.create();
getProcessFiltersSpy = spyOn(processFilterService, 'getProcessFilters').and.returnValue(of(mockProcessFilters));
getProcessNotificationSubscriptionSpy = spyOn(processFilterService, 'getProcessNotificationSubscription').and.returnValue(of([]));
getProcessFiltersSpy = spyOn(filterCountersService, 'getProcessFilters').and.returnValue(of(mockProcessFilters));
getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue(of({ counters: {}, batched: true }));
refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters');
getProcessCounterSpy = spyOn(processListService, 'getProcessCounter').and.returnValue(of(10));
spyOn(processListService, 'getProcessListCount').and.returnValue(of(10));
};
const bindAppName = async (appName = 'my-app-1') => {
@@ -463,17 +468,98 @@ describe('ProcessFiltersCloudComponent', () => {
expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy();
});
it('should call fetchProcessFilterCounter only if filter.showCounter is true', () => {
const filterWithCounter = { ...mockProcessFilters[0], showCounter: true };
const filterWithoutCounter = { ...mockProcessFilters[1], showCounter: false };
const fetchSpy = spyOn<any>(component, 'fetchProcessFilterCounter').and.returnValue(of(42));
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true });
const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false });
getProcessCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getProcessCounterSpy).toHaveBeenCalledTimes(1);
expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status);
});
describe('Batched counters', () => {
beforeEach(() => {
getProcessFiltersSpy.and.returnValue(
of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true })))
);
});
it('should read the counters of the process filters of the bound app', async () => {
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
});
it('should not ask for the batched count endpoint by default', async () => {
await bindAppName('mock-app-name');
expect(component.useBatchedCounters).toBeFalse();
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
});
it('should ask for the batched count endpoint when the input is set', async () => {
fixture.componentRef.setInput('useBatchedCounters', true);
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true);
});
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
});
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName('mock-app-name');
expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses');
});
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName('mock-app-name');
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
expect(component.counters['FakeRunningProcesses']).toBe(10);
});
it('should resolve the counters of the filters the batch left out on their own', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']);
});
it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
getProcessCounterSpy.and.throwError('the query of the filter cannot be built');
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
expect(component.counters['completed-processes']).toBe(0);
});
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName('mock-app-name');
component.onFilterClick(mockProcessFilters[1]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name');
});
});
describe('Notifications config', () => {
@@ -507,39 +593,436 @@ describe('ProcessFiltersCloudComponent', () => {
expect(component.notificationDebounceTime).toBe(5000);
});
it('should debounce notification subscription using the configured debounce time', fakeAsync(() => {
const notifications$ = new Subject<TaskCloudEngineEvent[]>();
getProcessNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable());
it('should keep the counters in sync with the counters stream', fakeAsync(() => {
const counters$ = new Subject<FilterCountersResult>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters');
notifications$.next([]);
tick(1000);
expect(updateFilterCountersSpy).not.toHaveBeenCalled();
tick(2000);
expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1);
counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true });
expect(component.counters['FakeRunningProcesses']).toBe(7);
flush();
}));
});
describe('Highlight Selected Filter', () => {
it('should make subscription', async () => {
const allProcessesFilterKey = mockProcessFilters[0].key;
const allProcessesFilterId = mockProcessFilters[0].id;
it('should apply active CSS class on filter click', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getProcessNotificationSubscriptionSpy).toHaveBeenCalled();
let link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.getAttribute('href')).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`);
link.click();
fixture.detectChanges();
await fixture.whenStable();
expect(router.url).toBe(`/process-list-cloud?filterId=${allProcessesFilterId}`);
link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.classList).toContain('adf-active');
});
it('should not make subscription when notifications are disabled', async () => {
const appConfigService = TestBed.inject(AppConfigService);
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => (key === 'notifications' ? false : defaultValue));
it('should add aria-current attribute with value "page" to the active filter', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getProcessNotificationSubscriptionSpy).not.toHaveBeenCalled();
const link = fixture.debugElement.query(By.css(`[data-automation-id="${allProcessesFilterKey}_filter"]`)).nativeElement;
expect(link.getAttribute('aria-current')).toBe('page');
});
it('should not have aria-current attribute when filter is not active', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
const link = fixture.debugElement.query(By.css(`[data-automation-id="${mockProcessFilters[1].key}_filter"]`)).nativeElement;
expect(link.getAttribute('aria-current')).toBeNull();
});
});
});
describe('searchApiMethod set to POST', () => {
beforeEach(async () => {
await configureTestingModule('POST');
});
it('should attach specific icon for each filter if hasIcon is true', async () => {
await bindAppName();
component.showIcons = true;
fixture.detectChanges();
await fixture.whenStable();
expect(component.filters.length).toBe(3);
const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' }));
expect(filterIcons.length).toBe(3);
expect(await filterIcons[0].getName()).toContain('adjust');
expect(await filterIcons[1].getName()).toContain('inbox');
expect(await filterIcons[2].getName()).toContain('done');
});
it('should not attach icons for each filter if hasIcon is false', async () => {
component.showIcons = false;
await bindAppName();
const filterIcons = await loader.getAllHarnesses(MatIconHarness.with({ selector: '[data-automation-id="adf-filter-icon"]' }));
expect(filterIcons.length).toBe(0);
});
it('should display the filters', async () => {
await bindAppName();
component.showIcons = true;
fixture.detectChanges();
await fixture.whenStable();
const filters = fixture.debugElement.queryAll(By.css('.adf-process-filters__entry'));
expect(component.filters.length).toBe(3);
expect(filters.length).toBe(3);
expect(filters[0].nativeElement.innerText).toContain('FakeAllProcesses');
expect(filters[1].nativeElement.innerText).toContain('FakeRunningProcesses');
expect(filters[2].nativeElement.innerText).toContain('FakeCompletedProcesses');
expect(Object.keys(component.counters).length).toBe(3);
});
it('should emit success with the filters when filters are loaded', async () => {
const successSpy = spyOn(component.success, 'emit');
await bindAppName();
expect(successSpy).toHaveBeenCalledWith(mockProcessFilters);
expect(component.filters).toBeDefined();
expect(component.filters[0].name).toEqual('FakeAllProcesses');
expect(component.filters[1].name).toEqual('FakeRunningProcesses');
expect(component.filters[2].name).toEqual('FakeCompletedProcesses');
expect(Object.keys(component.counters).length).toBe(3);
});
it('should not select any filter as default', async () => {
await bindAppName();
expect(component.currentFilter).toBeUndefined();
});
it('should filterClicked emit when a filter is clicked from the UI', async () => {
const filterClickedSpy = spyOn(component.filterClicked, 'emit');
await bindAppName();
const filterButton = fixture.debugElement.nativeElement.querySelector(`[data-automation-id="${mockProcessFilters[0].key}_filter"]`);
filterButton.click();
fixture.detectChanges();
await fixture.whenStable();
expect(component.currentFilter).toEqual(mockProcessFilters[0]);
expect(filterClickedSpy).toHaveBeenCalledWith(mockProcessFilters[0]);
});
});
describe('API agnostic', () => {
beforeEach(async () => {
await configureTestingModule('GET');
});
it('should emit an error with a bad response', async () => {
getProcessFiltersSpy.and.returnValue(throwError('wrong request'));
let lastValue: any;
component.error.subscribe((err) => (lastValue = err));
await bindAppName();
expect(lastValue).toBeDefined();
});
it('should not select any process filter if filter input does not exist', async () => {
const change = new SimpleChange(null, { name: 'nonexistentFilter' }, true);
fixture.detectChanges();
await fixture.whenStable();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toBeUndefined();
});
it('should select the filter based on the input by name param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { name: 'FakeRunningProcesses' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[1]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[1]);
});
it('should select the filter based on the input by key param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { key: 'completed-processes' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should select the filter based on the input by index param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { index: 2 }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should select the filter based on the input by id param', async () => {
const filterSelectedSpy = spyOn(component.filterSelected, 'emit');
const change = new SimpleChange(null, { id: '12' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(mockProcessFilters[2]);
expect(filterSelectedSpy).toHaveBeenCalledWith(mockProcessFilters[2]);
});
it('should reset the filter when the param is undefined', () => {
const change = new SimpleChange(mockProcessFilters[0], undefined, false);
component.currentFilter = mockProcessFilters[0];
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toEqual(undefined);
});
it('should not emit a filter clicked event when a filter is selected through the filterParam input (filterClicked emits only through a UI click action)', async () => {
const filterClickedSpy = spyOn(component.filterClicked, 'emit');
const change = new SimpleChange(null, { id: '10' }, true);
await bindAppName();
component.ngOnChanges({ filterParam: change });
expect(component.currentFilter).toBe(mockProcessFilters[0]);
expect(filterClickedSpy).not.toHaveBeenCalled();
});
it('should reload filters by appName on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = 'my-app-1';
const change = new SimpleChange(null, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).toHaveBeenCalledWith(appName);
});
it('should not reload filters by appName null on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = null;
const change = new SimpleChange(undefined, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).not.toHaveBeenCalledWith(appName);
});
it('should reload filters by app name on binding changes', () => {
spyOn(component, 'getFilters').and.stub();
const appName = 'fake-app-name';
const change = new SimpleChange(null, appName, true);
component.ngOnChanges({ appName: change });
expect(component.getFilters).toHaveBeenCalledWith(appName);
});
it('should return the current filter after one is selected', () => {
const filter = mockProcessFilters[1];
component.filters = mockProcessFilters;
expect(component.currentFilter).toBeUndefined();
component.selectFilter({ id: filter.id });
expect(component.getCurrentFilter()).toBe(filter);
});
it('should remove key from set of updated filters when received refreshed filter key', async () => {
const filterKeyTest = 'filter-key-test';
component.updatedFiltersSet.add(filterKeyTest);
expect(component.updatedFiltersSet.size).toBe(1);
processFilterService.filterKeyToBeRefreshed$ = of(filterKeyTest);
fixture.detectChanges();
expect(component.updatedFiltersSet.has(filterKeyTest)).toBeFalsy();
});
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[1], showCounter: true });
const filterWithoutCounter = new ProcessFilterCloudModel({ ...mockProcessFilters[2], showCounter: false });
getProcessCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(getProcessCounterSpy).toHaveBeenCalledTimes(1);
expect(getProcessCounterSpy).toHaveBeenCalledWith(filterWithCounter.appName, filterWithCounter.status);
});
describe('Batched counters', () => {
beforeEach(() => {
getProcessFiltersSpy.and.returnValue(
of(mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true })))
);
});
it('should read the counters of the process filters of the bound app', async () => {
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
});
it('should not ask for the batched count endpoint by default', async () => {
await bindAppName('mock-app-name');
expect(component.useBatchedCounters).toBeFalse();
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
});
it('should ask for the batched count endpoint when the input is set', async () => {
fixture.componentRef.setInput('useBatchedCounters', true);
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, true);
});
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
});
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName('mock-app-name');
expect(updatedFilterSpy).toHaveBeenCalledWith('FakeRunningProcesses');
});
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName('mock-app-name');
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
expect(component.counters['FakeRunningProcesses']).toBe(10);
});
it('should resolve the counters of the filters the batch left out on their own', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
expect(getProcessCounterSpy.calls.allArgs().map(([, status]) => status)).toEqual([null, 'COMPLETED']);
});
it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { FakeRunningProcesses: 9 }, batched: true }));
getProcessCounterSpy.and.throwError('the query of the filter cannot be built');
await bindAppName('mock-app-name');
expect(component.counters['FakeRunningProcesses']).toBe(9);
expect(component.counters['completed-processes']).toBe(0);
});
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName('mock-app-name');
component.onFilterClick(mockProcessFilters[1]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('mock-app-name');
});
});
describe('Notifications config', () => {
it('should read enableNotifications and notificationDebounceTime from app config on init', () => {
const appConfigService = TestBed.inject(AppConfigService);
const getSpy = spyOn(appConfigService, 'get').and.callThrough();
fixture.detectChanges();
expect(getSpy).toHaveBeenCalledWith('notifications', true);
expect(getSpy).toHaveBeenCalledWith('notificationDebounceTime', 3000);
});
it('should default notificationDebounceTime to 3000 when not set in app config', () => {
fixture.detectChanges();
expect(component.notificationDebounceTime).toBe(3000);
});
it('should use notificationDebounceTime from app config', () => {
const appConfigService: AppConfigService = TestBed.inject(AppConfigService);
spyOn(appConfigService, 'get').and.callFake((key: string, defaultValue: any) => {
if (key === 'notificationDebounceTime') {
return 5000;
}
return defaultValue;
});
fixture.detectChanges();
expect(component.notificationDebounceTime).toBe(5000);
});
it('should keep the counters in sync with the counters stream', fakeAsync(() => {
const counters$ = new Subject<FilterCountersResult>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
counters$.next({ counters: { FakeRunningProcesses: 7 }, batched: true });
expect(component.counters['FakeRunningProcesses']).toBe(7);
flush();
}));
it('should resolve the counters one filter at a time when the batched endpoint is not available', fakeAsync(() => {
const counters$ = new Subject<FilterCountersResult>();
getFilterCountersSpy.and.returnValue(counters$.asObservable());
component.appName = 'mock-app-name';
fixture.detectChanges();
component.filters = mockProcessFilters.map((filter) => new ProcessFilterCloudModel({ ...filter, showCounter: true }));
getProcessCounterSpy.calls.reset();
counters$.next({ counters: {}, batched: false });
expect(getProcessCounterSpy).toHaveBeenCalledTimes(3);
flush();
}));
});
describe('Highlight Selected Filter', () => {
it('should read the counters of the bound app', async () => {
component.enableNotifications = true;
await bindAppName('mock-app-name');
expect(getFilterCountersSpy).toHaveBeenCalledWith('mock-app-name', FilterCounterEntityType.PROCESS_INSTANCE, false);
});
it('should emit filter key when filter counter is set for first time', () => {
@@ -16,14 +16,16 @@
*/
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { EMPTY, Observable } from 'rxjs';
import { combineLatest, defer, EMPTY, Observable, of, Subscription } from 'rxjs';
import { ProcessFilterCloudService } from '../../services/process-filter-cloud.service';
import { ProcessFilterCloudModel } from '../../models/process-filter-cloud.model';
import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core';
import { FilterParamsModel } from '../../../../task/task-filters/models/filter-cloud.model';
import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
import { ProcessListCloudService } from '../../../process-list/services/process-list-cloud.service';
import { ProcessFilterCloudAdapter } from '../../../process-list/models/process-cloud-query-request.model';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { TranslatePipe } from '@ngx-translate/core';
import { AsyncPipe } from '@angular/common';
@@ -43,10 +45,21 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
@Input()
appName: string = '';
/** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count */
/**
* (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the process count.
*
*/
@Input()
searchApiMethod: 'GET' | 'POST' = 'GET';
/**
* (optional) Resolves the counters of the task and the process filters with a single call to
* `POST /query/v1/count`. Both filter components have to
* ask for it, otherwise the counters are resolved one filter at a time.
*/
@Input()
useBatchedCounters = false;
/** (optional) The filter to be selected by default */
@Input()
filterParam: FilterParamsModel;
@@ -79,27 +92,31 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
currentFilter?: ProcessFilterCloudModel;
filters: ProcessFilterCloudModel[] = [];
counters: { [key: string]: number } = {};
enableNotifications = true;
notificationDebounceTime = 3000;
currentFiltersValues: { [key: string]: number } = {};
updatedFiltersSet = new Set<string>();
enableNotifications = true;
notificationDebounceTime = 3000;
private filtersLoadedFor?: string;
private countersSubscription?: Subscription;
private countersFilters$?: Observable<ProcessFilterCloudModel[]>;
private batchedCounters = true;
private readonly destroyRef = inject(DestroyRef);
private readonly processFilterCloudService = inject(ProcessFilterCloudService);
private readonly translationService = inject(TranslationService);
private readonly appConfigService = inject(AppConfigService);
private readonly processListCloudService = inject(ProcessListCloudService);
private readonly filterCountersCloudService = inject(FilterCountersCloudService);
private readonly activatedRoute = inject(ActivatedRoute);
protected readonly currentRouteFilterId = toSignal(this.activatedRoute.queryParamMap.pipe(map((params) => params.get('filterId'))));
ngOnInit() {
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
this.initProcessNotification();
this.getFilterKeysAfterExternalRefreshing();
}
@@ -110,6 +127,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.getFilters(appName.currentValue);
} else if (filter && filter.currentValue !== filter.previousValue) {
this.selectFilterAndEmit(filter.currentValue);
} else if (changes['useBatchedCounters'] && !changes['useBatchedCounters'].firstChange && this.filtersLoadedFor) {
this.loadFilterCounters(this.filtersLoadedFor);
}
}
@@ -120,8 +139,8 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
const filters$ = this.processFilterCloudService.getProcessFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true }));
this.filters$ = filters$.pipe(catchError(() => EMPTY));
const filters$ = this.filterCountersCloudService.getProcessFilters(appName);
this.filters$ = filters$.pipe(catchError(() => of([])));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
@@ -130,19 +149,25 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
this.success.emit(res);
this.updateFilterCounters();
},
error: (err: any) => {
error: (err: unknown) => {
this.error.emit(err);
}
});
this.countersFilters$ = filters$;
this.loadFilterCounters(appName);
}
/**
* Initialize counter collection for filters
*/
initFilterCounters() {
this.filters.forEach((filter) => (this.counters[filter.key] = 0));
initFilterCounters(): void {
this.filters.forEach((filter) => {
if (filter.key) {
this.counters[filter.key] = 0;
}
});
}
/**
@@ -167,20 +192,6 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
); // fallback to preserve the previous behavior
}
/**
* Check equality of the filter names by translating the given name strings
*
* @param name1 source name
* @param name2 target name
* @returns `true` if filter names are equal, otherwise `false`
*/
private checkFilterNamesEquality(name1: string, name2: string): boolean {
const translatedName1 = this.translationService.instant(name1);
const translatedName2 = this.translationService.instant(name2);
return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase();
}
/**
* Selects and emits the given filter
*
@@ -213,7 +224,7 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
if (filter) {
this.selectFilter(filter);
this.filterClicked.emit(this.currentFilter);
this.updateFilterCounter(this.currentFilter);
this.refreshFilterCounter(this.currentFilter);
this.updatedFiltersSet.delete(filter.key);
} else {
this.currentFilter = undefined;
@@ -247,6 +258,83 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
return this.filters === undefined || (this.filters && this.filters.length === 0);
}
isActiveFilter(filter: ProcessFilterCloudModel): boolean {
return this.currentFilter.name === filter.name;
}
/**
* @deprecated does nothing: the counters keep themselves in sync. Removed in ADF 10.0.0.
*/
initProcessNotification(): void {}
/**
* Iterate over filters and update counters
*
* @deprecated counts one filter at a time. Removed in ADF 10.0.0.
*/
updateFilterCounters(): void {
this.filters.forEach((filter) => this.updateFilterCounter(filter));
}
/**
* Get current value for filter and check if value has changed
*
* @param filter filter
* @deprecated counts one filter at a time. Removed in ADF 10.0.0.
*/
updateFilterCounter(filter: ProcessFilterCloudModel): void {
const filterKey = filter?.showCounter ? filter.key : undefined;
if (!filterKey) {
return;
}
defer(() => this.fetchProcessFilterCounter(filter))
.pipe(
catchError(() => EMPTY),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((counter) => {
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue };
this.updatedFilter.emit(filterKey);
this.updatedFiltersSet.add(filterKey);
}
}
/**
* Get filer key when filter was refreshed by external action
*
*/
getFilterKeysAfterExternalRefreshing(): void {
this.processFilterCloudService.filterKeyToBeRefreshed$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((filterKey: string) => this.updatedFiltersSet.delete(filterKey));
}
isFilterUpdated(filterName: string): boolean {
return this.updatedFiltersSet.has(filterName);
}
/**
* Check equality of the filter names by translating the given name strings
*
* @param name1 source name
* @param name2 target name
* @returns `true` if filter names are equal, otherwise `false`
*/
private checkFilterNamesEquality(name1: string, name2: string): boolean {
const translatedName1 = this.translationService.instant(name1);
const translatedName2 = this.translationService.instant(name2);
return translatedName1.toLocaleLowerCase() === translatedName2.toLocaleLowerCase();
}
/**
* Reset the filters
*/
@@ -255,76 +343,53 @@ export class ProcessFiltersCloudComponent implements OnInit, OnChanges {
this.currentFilter = undefined;
}
isActiveFilter(filter: ProcessFilterCloudModel): boolean {
return this.currentFilter.name === filter.name;
}
initProcessNotification(): void {
if (this.appName && this.enableNotifications) {
this.processFilterCloudService
.getProcessNotificationSubscription(this.appName)
.pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.updateFilterCounters();
});
}
}
/**
* Iterate over filters and update counters
*/
updateFilterCounters(): void {
this.filters.forEach((filter: ProcessFilterCloudModel) => {
this.updateFilterCounter(filter);
});
}
/**
* Get current value for filter and check if value has changed
*
* @param filter filter
*/
updateFilterCounter(filter: ProcessFilterCloudModel): void {
if (!filter?.showCounter) {
private loadFilterCounters(appName: string): void {
if (!this.countersFilters$) {
return;
}
this.fetchProcessFilterCounter(filter)
.pipe(
tap((filterCounter) => {
this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter);
})
)
.subscribe((data) => {
this.counters = {
...this.counters,
[filter.key]: data
};
this.countersSubscription?.unsubscribe();
this.countersSubscription = combineLatest([
this.countersFilters$.pipe(catchError(() => of([]))),
this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, this.useBatchedCounters)
])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(([, { counters, batched }]) => {
this.batchedCounters = batched;
if (batched) {
this.applyFilterCounters(counters);
} else {
this.updateFilterCounters();
}
});
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number): void {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues[filterKey] = filterValue;
this.updatedFilter.emit(filterKey);
this.updatedFiltersSet.add(filterKey);
}
}
private applyFilterCounters(counters: { [filterKey: string]: number }): void {
this.filters.forEach((filter) => {
const filterKey = filter?.showCounter ? filter.key : undefined;
if (!filterKey) {
return;
}
isFilterUpdated(filterName: string): boolean {
return this.updatedFiltersSet.has(filterName);
}
const counter = counters[filterKey];
if (counter === undefined) {
this.updateFilterCounter(filter);
return;
}
/**
* Get filer key when filter was refreshed by external action
*
*/
getFilterKeysAfterExternalRefreshing(): void {
this.processFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => {
this.updatedFiltersSet.delete(filterKey);
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
}
private refreshFilterCounter(filter?: ProcessFilterCloudModel): void {
if (this.batchedCounters) {
this.filterCountersCloudService.refreshFilterCounters(this.appName);
} else if (filter) {
this.updateFilterCounter(filter);
}
}
private fetchProcessFilterCounter(filter: ProcessFilterCloudModel): Observable<number> {
return this.searchApiMethod === 'POST'
? this.processListCloudService.getProcessListCount(new ProcessFilterCloudAdapter(filter))
@@ -404,6 +404,12 @@ export class ProcessFilterCloudService {
];
}
/**
* @deprecated use FilterCountersCloudService.getEngineEvents instead.
*
* @param appName Name of the target app
* @returns Process engine events
*/
getProcessNotificationSubscription(appName: string): Observable<TaskCloudEngineEvent[]> {
return this.notificationCloudService
.makeGQLQuery(appName, PROCESS_EVENT_SUBSCRIPTION_QUERY)
@@ -100,7 +100,7 @@ export class ProcessListCloudService extends BaseCloudService {
);
}
protected buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } {
buildQueryData(requestNode: ProcessListRequestModel): { [key: string]: any } {
const queryData: { [key: string]: any } = {
name: requestNode.name,
id: requestNode.id,
@@ -0,0 +1,651 @@
/*!
* @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 { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core';
import { BehaviorSubject, combineLatest, firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
import { ApolloTestingModule } from 'apollo-angular/testing';
import { FilterCountersCloudService } from './filter-counters-cloud.service';
import { NotificationCloudService } from './notification-cloud.service';
import { LocalPreferenceCloudService } from './local-preference-cloud.service';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from './cloud-token.service';
import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service';
import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service';
import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model';
import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model';
import {
FilterCounterEntityType,
FilterCounters,
FilterCountersQuery,
FilterCountersRequest,
FilterCountersResult
} from '../models/filter-counters-cloud.model';
import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model';
import { FetchResult } from '@apollo/client/core';
type EngineEventsResult = FetchResult<{ engineEvents?: TaskCloudEngineEvent[] }>;
interface CountEndpoint {
post: (url: string, request: FilterCountersRequest) => Observable<FilterCounters>;
}
describe('FilterCountersCloudService', () => {
let service: FilterCountersCloudService;
let notificationCloudService: NotificationCloudService;
let appConfigService: AppConfigService;
let taskEvents$: Subject<EngineEventsResult>;
let processEvents$: Subject<EngineEventsResult>;
let makeGQLQuerySpy: jasmine.Spy;
let postSpy: jasmine.Spy;
let getTaskListFiltersSpy: jasmine.Spy;
let getProcessFiltersSpy: jasmine.Spy;
const countRequest = (): FilterCountersRequest => postSpy.calls.mostRecent().args[1];
const countUrl = (): string => postSpy.calls.mostRecent().args[0];
const countQueries = (entityType: FilterCounterEntityType): FilterCountersQuery[] => countRequest()[entityType] ?? [];
const countRequestIds = (entityType: FilterCounterEntityType): string[] => countQueries(entityType).map((query) => query.requestId);
const countersMock: FilterCounters = {
TASK: { 'my-tasks': 5, 'queued-tasks': 0 },
PROCESS_INSTANCE: { 'running-processes': 5 }
};
const taskFilter = (filter: Partial<TaskFilterCloudModel>) =>
new TaskFilterCloudModel({ appName: 'mock-app', sort: 'createdDate', order: 'DESC', ...filter });
const processFilter = (filter: Partial<ProcessFilterCloudModel>) =>
new ProcessFilterCloudModel({ appName: 'mock-app', sort: 'startDate', order: 'DESC', ...filter });
const taskFiltersMock = [
taskFilter({ key: 'my-tasks', status: 'ASSIGNED', assignee: 'mock-user', showCounter: true }),
taskFilter({ key: 'queued-tasks', status: 'CREATED', showCounter: true }),
taskFilter({ key: 'completed-tasks', status: 'COMPLETED', showCounter: false })
];
const processFiltersMock = [
processFilter({ key: 'running-processes', status: 'RUNNING', showCounter: true }),
processFilter({ key: 'all-processes', status: '', showCounter: false })
];
const engineEvents = (eventType: string): EngineEventsResult => ({
data: { engineEvents: [{ eventType, entity: {} } as TaskCloudEngineEvent] }
});
const emitTaskEvent = (eventType = 'TASK_CREATED') => taskEvents$.next(engineEvents(eventType));
const emitProcessEvent = (eventType = 'PROCESS_STARTED') => processEvents$.next(engineEvents(eventType));
const counters = (entityType: FilterCounterEntityType, appName = 'mock-app') =>
firstValueFrom(service.getFilterCounters(appName, entityType, true));
const taskCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.TASK, appName);
const processCounters = (appName = 'mock-app') => counters(FilterCounterEntityType.PROCESS_INSTANCE, appName);
const bothCounters = (appName = 'mock-app') =>
firstValueFrom(
combineLatest([
service.getFilterCounters(appName, FilterCounterEntityType.TASK, true),
service.getFilterCounters(appName, FilterCounterEntityType.PROCESS_INSTANCE, true)
])
);
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopAuthModule, ApolloTestingModule],
providers: [
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }
]
});
service = TestBed.inject(FilterCountersCloudService);
notificationCloudService = TestBed.inject(NotificationCloudService);
appConfigService = TestBed.inject(AppConfigService);
appConfigService.config.bpmHost = 'https://fake-bpm-host.com';
taskEvents$ = new Subject<EngineEventsResult>();
processEvents$ = new Subject<EngineEventsResult>();
makeGQLQuerySpy = spyOn(notificationCloudService, 'makeGQLQuery');
makeGQLQuerySpy.and.callFake((_appName: string, query: string) =>
(query.includes('TASK_CREATED') ? taskEvents$ : processEvents$).asObservable()
);
postSpy = spyOn(service as unknown as CountEndpoint, 'post').and.returnValue(of(countersMock));
getTaskListFiltersSpy = spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock));
getProcessFiltersSpy = spyOn(TestBed.inject(ProcessFilterCloudService), 'getProcessFilters').and.returnValue(of(processFiltersMock));
});
describe('getTaskFilters / getProcessFilters', () => {
it('should load the filters of every entity type', async () => {
expect(await firstValueFrom(service.getTaskFilters('mock-app'))).toEqual(taskFiltersMock);
expect(await firstValueFrom(service.getProcessFilters('mock-app'))).toEqual(processFiltersMock);
});
it('should load the filters of an app once for concurrent subscribers', async () => {
await firstValueFrom(combineLatest([service.getTaskFilters('mock-app'), service.getTaskFilters('mock-app')]));
await firstValueFrom(combineLatest([service.getProcessFilters('mock-app'), service.getProcessFilters('mock-app')]));
expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1);
expect(getProcessFiltersSpy).toHaveBeenCalledTimes(1);
});
it('should load the filters of every app', async () => {
await firstValueFrom(service.getTaskFilters('mock-app'));
await firstValueFrom(service.getTaskFilters('other-app'));
expect(getTaskListFiltersSpy.calls.allArgs()).toEqual([['mock-app'], ['other-app']]);
});
it('should share the filters with the batched count request', async () => {
const subscription = service.getTaskFilters('mock-app').subscribe();
await taskCounters();
subscription.unsubscribe();
expect(getTaskListFiltersSpy).toHaveBeenCalledTimes(1);
});
it('should propagate the error of the filters that fail to load', async () => {
getTaskListFiltersSpy.and.returnValue(throwError(() => new Error('filters failed')));
await expectAsync(firstValueFrom(service.getTaskFilters('mock-app'))).toBeRejectedWithError('filters failed');
});
});
describe('getFilterCounters', () => {
it('should return EMPTY when appName is not set', () => {
let completed = false;
service.getFilterCounters('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) });
expect(completed).toBeTrue();
expect(postSpy).not.toHaveBeenCalled();
});
it('should resolve the counters of both entity types with a single request', async () => {
expect(await bothCounters()).toEqual([
{ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true },
{ counters: { 'running-processes': 5 }, batched: true }
]);
expect(postSpy).toHaveBeenCalledTimes(1);
});
it('should call the batched count endpoint of the app', async () => {
await taskCounters();
expect(countUrl()).toBe('https://fake-bpm-host.com/mock-app/query/v1/count');
});
it('should identify the query of every filter by the key of the filter', async () => {
await bothCounters();
expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['running-processes']);
});
it('should send the criteria of every filter along with its request id', async () => {
await taskCounters();
expect(countQueries(FilterCounterEntityType.TASK)[0]).toEqual({
requestId: 'my-tasks',
status: ['ASSIGNED'],
assignee: ['mock-user'],
sort: { field: 'createdDate', direction: 'desc', isProcessVariable: false }
});
});
it('should not send the filters without a counter enabled', async () => {
await taskCounters();
expect(countRequestIds(FilterCounterEntityType.TASK)).not.toContain('completed-tasks');
});
it('should send the query of a filter targeting every status', async () => {
getProcessFiltersSpy.and.returnValue(of([processFilter({ key: 'all-processes', status: '', showCounter: true })]));
await processCounters();
expect(countRequestIds(FilterCounterEntityType.PROCESS_INSTANCE)).toEqual(['all-processes']);
});
it('should omit an entity type without filters with a counter enabled', async () => {
getProcessFiltersSpy.and.returnValue(of([]));
await bothCounters();
expect(countRequest().PROCESS_INSTANCE).toBeUndefined();
});
it('should leave out a filter the query cannot be built for', async () => {
getTaskListFiltersSpy.and.returnValue(
of([taskFilter({ key: 'broken', status: 'ASSIGNED', showCounter: true, sort: undefined, order: undefined }), taskFiltersMock[1]])
);
await taskCounters();
expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['queued-tasks']);
});
it('should leave out a filter without a key, since it holds no request id', async () => {
getProcessFiltersSpy.and.returnValue(of([processFilter({ key: null, status: 'RUNNING', showCounter: true })]));
expect(await processCounters()).toEqual({ counters: {}, batched: true });
expect(postSpy).not.toHaveBeenCalled();
});
it('should resolve the counters of an entity type when the filters of the other one fail to load', async () => {
getProcessFiltersSpy.and.returnValue(throwError(() => new Error('filters failed')));
await bothCounters();
expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
expect(countRequest().PROCESS_INSTANCE).toBeUndefined();
});
it('should resolve no counter when no filter has a counter enabled', async () => {
getTaskListFiltersSpy.and.returnValue(of([]));
getProcessFiltersSpy.and.returnValue(of([]));
expect(await taskCounters()).toEqual({ counters: {}, batched: true });
expect(postSpy).not.toHaveBeenCalled();
});
describe('when the batched count endpoint is not available', () => {
it('should report the counters as not batched', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
expect(await taskCounters()).toEqual({ counters: {}, batched: false });
});
it('should not call the endpoint again for the same app', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
await taskCounters();
expect(await processCounters()).toEqual({ counters: {}, batched: false });
expect(postSpy).toHaveBeenCalledTimes(1);
});
it('should keep calling the endpoint of the apps that do hold it', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 404 })));
await taskCounters();
postSpy.and.returnValue(of(countersMock));
expect(await taskCounters('other-app')).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
});
it('should keep calling the endpoint after a transient failure', async () => {
postSpy.and.returnValue(throwError(() => ({ status: 500 })));
expect(await taskCounters()).toEqual({ counters: {}, batched: false });
postSpy.and.returnValue(of(countersMock));
expect(await taskCounters()).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
expect(postSpy).toHaveBeenCalledTimes(2);
});
});
});
describe('batched counters opted in by the filter components', () => {
it('should not call the batched count endpoint when it was not asked for', async () => {
const result = await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK));
expect(result).toEqual({ counters: {}, batched: false });
expect(postSpy).not.toHaveBeenCalled();
});
it('should not load the filters when the batched count endpoint was not asked for', async () => {
await firstValueFrom(service.getFilterCounters('mock-app', FilterCounterEntityType.TASK));
expect(getTaskListFiltersSpy).not.toHaveBeenCalled();
});
it('should call the batched count endpoint when every entity type on screen asked for it', async () => {
await bothCounters();
expect(postSpy).toHaveBeenCalledTimes(1);
});
it('should not call the batched count endpoint when one entity type on screen did not ask for it', fakeAsync(() => {
const results: FilterCountersResult[] = [];
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe();
tick(0);
expect(postSpy).not.toHaveBeenCalled();
expect(results).toEqual([{ counters: {}, batched: false }]);
}));
it('should call the batched count endpoint once the entity type that opted out leaves the screen', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
const processSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, false).subscribe();
tick(0);
processSubscription.unsubscribe();
service.refreshFilterCounters('mock-app');
tick(0);
expect(postSpy).toHaveBeenCalledTimes(1);
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
}));
});
describe('counters scoped to the entity types on screen', () => {
it('should send the queries of the entity type on screen alone', async () => {
await taskCounters();
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
});
it('should not load the filters of an entity type that is not on screen', async () => {
await taskCounters();
expect(getTaskListFiltersSpy).toHaveBeenCalled();
expect(getProcessFiltersSpy).not.toHaveBeenCalled();
});
it('should send the queries of both entity types when both are on screen', async () => {
await bothCounters();
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]);
});
it('should resolve the counters again when an entity type joins the ones on screen', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK]);
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
expect(postSpy).toHaveBeenCalledTimes(2);
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.TASK, FilterCounterEntityType.PROCESS_INSTANCE]);
}));
it('should stop covering an entity type once its counters hold no subscriber', fakeAsync(() => {
const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
taskSubscription.unsubscribe();
service.refreshFilterCounters('mock-app');
tick(0);
expect(Object.keys(countRequest())).toEqual([FilterCounterEntityType.PROCESS_INSTANCE]);
}));
});
describe('teardown', () => {
it('should close the engine event subscription once the counters hold no subscriber', fakeAsync(() => {
const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
}));
it('should keep the engine event subscription while another subscriber holds the same entity type', fakeAsync(() => {
const subscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
subscription.unsubscribe();
emitTaskEvent();
tick(3000);
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledTimes(2);
}));
it('should release the filters subscription once nothing reads them', fakeAsync(() => {
const filters$ = new BehaviorSubject(taskFiltersMock);
getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
const subscriptions = [
service.getTaskFilters('mock-app').subscribe(),
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe()
];
tick(0);
expect(filters$.observed).toBeTrue();
subscriptions.forEach((subscription) => subscription.unsubscribe());
expect(filters$.observed).toBeFalse();
}));
it('should resolve the counters again for a subscriber that comes after a full teardown', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe().unsubscribe();
tick(0);
postSpy.calls.reset();
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
});
describe('refreshFilterCounters', () => {
it('should resolve the counters again with a single request', fakeAsync(() => {
const results: FilterCountersResult[] = [];
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
service.refreshFilterCounters('mock-app');
tick(0);
expect(postSpy).toHaveBeenCalledTimes(2);
expect(results.length).toBe(2);
}));
it('should not resolve the counters of an app without subscribers', fakeAsync(() => {
service.refreshFilterCounters('mock-app');
tick(0);
expect(postSpy).not.toHaveBeenCalled();
}));
});
describe('when only one of the two filter families is wired', () => {
const configureTasksOnly = () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [NoopAuthModule, ApolloTestingModule],
providers: [{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService }]
});
const tasksOnlyService = TestBed.inject(FilterCountersCloudService);
TestBed.inject(AppConfigService).config.bpmHost = 'https://fake-bpm-host.com';
spyOn(TestBed.inject(NotificationCloudService), 'makeGQLQuery').and.returnValue(new Subject<EngineEventsResult>().asObservable());
spyOn(TestBed.inject(TaskFilterCloudService), 'getTaskListFilters').and.returnValue(of(taskFiltersMock));
postSpy = spyOn(tasksOnlyService as unknown as CountEndpoint, 'post').and.returnValue(of(countersMock));
return tasksOnlyService;
};
it('should resolve the counters of the wired family', async () => {
const tasksOnlyService = configureTasksOnly();
const result = await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true));
expect(result).toEqual({ counters: { 'my-tasks': 5, 'queued-tasks': 0 }, batched: true });
});
it('should leave the filters of the family that is not wired out of the request', async () => {
const tasksOnlyService = configureTasksOnly();
await firstValueFrom(tasksOnlyService.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true));
expect(countRequestIds(FilterCounterEntityType.TASK)).toEqual(['my-tasks', 'queued-tasks']);
expect(countRequest().PROCESS_INSTANCE).toBeUndefined();
});
});
describe('getEngineEvents', () => {
it('should return EMPTY when appName is not set', () => {
let completed = false;
service.getEngineEvents('', FilterCounterEntityType.TASK).subscribe({ complete: () => (completed = true) });
expect(completed).toBeTrue();
expect(makeGQLQuerySpy).not.toHaveBeenCalled();
});
it('should subscribe to the events of the task entity type alone', () => {
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
const [appName, query] = makeGQLQuerySpy.calls.mostRecent().args;
expect(appName).toBe('mock-app');
expect(query).toContain('TASK_CREATED');
expect(query).not.toContain('PROCESS_STARTED');
});
it('should subscribe to the events of the process entity type alone', () => {
service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
const [, query] = makeGQLQuerySpy.calls.mostRecent().args;
expect(query).toContain('PROCESS_STARTED');
expect(query).not.toContain('TASK_CREATED');
});
it('should open a single subscription for multiple subscribers of the same entity type', () => {
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
});
it('should open a separate subscription per entity type', () => {
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
service.getEngineEvents('mock-app', FilterCounterEntityType.PROCESS_INSTANCE).subscribe();
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
});
it('should open a separate subscription per app', () => {
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe();
service.getEngineEvents('other-app', FilterCounterEntityType.TASK).subscribe();
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(2);
});
it('should emit the debounced batch of events', fakeAsync(() => {
const batches: TaskCloudEngineEvent[][] = [];
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe((events) => batches.push(events));
emitTaskEvent('TASK_CREATED');
emitTaskEvent('TASK_ASSIGNED');
tick(3000);
expect(batches.length).toBe(1);
expect(batches[0][0].eventType).toBe('TASK_ASSIGNED');
}));
it('should debounce the events using the configured debounce time', fakeAsync(() => {
spyOnProperty(service, 'notificationDebounceTime', 'get').and.returnValue(5000);
let emitted = false;
service.getEngineEvents('mock-app', FilterCounterEntityType.TASK).subscribe(() => (emitted = true));
emitTaskEvent();
tick(3000);
expect(emitted).toBeFalse();
tick(2000);
expect(emitted).toBeTrue();
}));
});
describe('counters driven by the engine events', () => {
it('should make a single count request for a batch of events of both entity types', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
postSpy.calls.reset();
emitTaskEvent();
emitProcessEvent();
tick(3000);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
it('should make a single count request for the events of both entity types arriving apart', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
postSpy.calls.reset();
emitTaskEvent();
tick(1000);
emitProcessEvent();
tick(3000);
expect(postSpy).toHaveBeenCalledTimes(1);
}));
it('should emit the counters resolved for the batch of events', fakeAsync(() => {
const results: FilterCountersResult[] = [];
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe((result) => results.push(result));
tick(0);
postSpy.and.returnValue(of({ TASK: { 'my-tasks': 9 } }));
emitTaskEvent();
tick(3000);
expect(results.length).toBe(2);
expect(results[1]).toEqual({ counters: { 'my-tasks': 9 }, batched: true });
}));
it('should not subscribe to the events of an entity type that is not on screen', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
expect(makeGQLQuerySpy).toHaveBeenCalledTimes(1);
expect(makeGQLQuerySpy.calls.mostRecent().args[1]).toContain('TASK_CREATED');
}));
it('should not resolve the counters again on the events of an entity type that is not on screen', fakeAsync(() => {
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(0);
postSpy.calls.reset();
emitProcessEvent();
tick(3000);
expect(postSpy).not.toHaveBeenCalled();
}));
it('should stop resolving the counters on the events of an entity type that left the screen', fakeAsync(() => {
const taskSubscription = service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
service.getFilterCounters('mock-app', FilterCounterEntityType.PROCESS_INSTANCE, true).subscribe();
tick(0);
taskSubscription.unsubscribe();
postSpy.calls.reset();
emitTaskEvent();
tick(3000);
expect(postSpy).not.toHaveBeenCalled();
}));
it('should not subscribe to the engine events when notifications are disabled', fakeAsync(() => {
appConfigService.config.notifications = false;
service.getFilterCounters('mock-app', FilterCounterEntityType.TASK, true).subscribe();
tick(3000);
expect(makeGQLQuerySpy).not.toHaveBeenCalled();
expect(postSpy).toHaveBeenCalledTimes(1);
}));
});
});
@@ -0,0 +1,375 @@
/*!
* @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 { inject, Injectable, Injector } from '@angular/core';
import { asapScheduler, combineLatest, defer, EMPTY, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, debounceTime, finalize, map, shareReplay, switchMap, take } from 'rxjs/operators';
import { BaseCloudService } from './base-cloud.service';
import { NotificationCloudService } from './notification-cloud.service';
import { TaskCloudEngineEvent } from '../models/engine-event-cloud.model';
import { TaskFilterCloudService } from '../task/task-filters/services/task-filter-cloud.service';
import { ProcessFilterCloudService } from '../process/process-filters/services/process-filter-cloud.service';
import { TaskListCloudService } from '../task/task-list/services/task-list-cloud.service';
import { ProcessListCloudService } from '../process/process-list/services/process-list-cloud.service';
import { TaskFilterCloudAdapter } from '../models/filter-cloud-model';
import { TaskFilterCloudModel } from '../task/task-filters/models/filter-cloud.model';
import { ProcessFilterCloudModel } from '../process/process-filters/models/process-filter-cloud.model';
import { ProcessFilterCloudAdapter } from '../process/process-list/models/process-cloud-query-request.model';
import {
FilterCounterCandidate,
FilterCounterEntityType,
FilterCounters,
FilterCountersQuery,
FilterCountersRequest,
FilterCountersResult
} from '../models/filter-counters-cloud.model';
const BATCHED_COUNTERS_UNAVAILABLE_STATUSES = [404, 501];
interface FilterCountersFilters {
[FilterCounterEntityType.TASK]: TaskFilterCloudModel[];
[FilterCounterEntityType.PROCESS_INSTANCE]: ProcessFilterCloudModel[];
}
interface EngineEventsData {
engineEvents?: TaskCloudEngineEvent[];
}
const ENGINE_EVENTS_SUBSCRIPTION_QUERIES: Record<FilterCounterEntityType, string> = {
[FilterCounterEntityType.TASK]: `
subscription {
engineEvents(eventType: [
TASK_COMPLETED
TASK_ASSIGNED
TASK_ACTIVATED
TASK_SUSPENDED
TASK_CANCELLED
TASK_CREATED
]) {
eventType
entity
}
}
`,
[FilterCounterEntityType.PROCESS_INSTANCE]: `
subscription {
engineEvents(eventType: [
PROCESS_CANCELLED
PROCESS_COMPLETED
PROCESS_CREATED
PROCESS_RESUMED
PROCESS_SUSPENDED
PROCESS_STARTED
]) {
eventType
entity
}
}
`
};
@Injectable({ providedIn: 'root' })
export class FilterCountersCloudService extends BaseCloudService {
private readonly notificationCloudService = inject(NotificationCloudService);
private readonly taskListCloudService = inject(TaskListCloudService);
private readonly processListCloudService = inject(ProcessListCloudService);
private readonly injector = inject(Injector);
private readonly eventsPerEntityType = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly rawEventsPerEntityType = new Map<string, Observable<TaskCloudEngineEvent[]>>();
private readonly recountPerApp = new Map<string, Subject<void>>();
private readonly eventRecountPerApp = new Map<string, Subject<void>>();
private readonly activeEntityTypesPerApp = new Map<string, Set<FilterCounterEntityType>>();
private readonly subscribersPerEntityType = new Map<string, number>();
private readonly batchedCountersPerEntityType = new Map<string, boolean>();
private readonly eventSubscriptionsPerEntityType = new Map<string, Subscription>();
private readonly appsWithoutBatchedCounters = new Set<string>();
private readonly taskFiltersPerApp = new Map<string, Observable<TaskFilterCloudModel[]>>();
private readonly processFiltersPerApp = new Map<string, Observable<ProcessFilterCloudModel[]>>();
private readonly countersPerApp = new Map<string, Observable<{ counters: FilterCounters; batched: boolean }>>();
get notificationDebounceTime(): number {
return this.appConfigService.get('notificationDebounceTime', 3000);
}
getTaskFilters(appName: string): Observable<TaskFilterCloudModel[]> {
return this.shareFilters(this.taskFiltersPerApp, appName, () => this.injector.get(TaskFilterCloudService).getTaskListFilters(appName));
}
getProcessFilters(appName: string): Observable<ProcessFilterCloudModel[]> {
return this.shareFilters(this.processFiltersPerApp, appName, () => this.injector.get(ProcessFilterCloudService).getProcessFilters(appName));
}
getFilterCounters(appName: string, entityType: FilterCounterEntityType, batchedCounters = false): Observable<FilterCountersResult> {
if (!appName) {
return EMPTY;
}
return defer(() => {
this.activateEntityType(appName, entityType, batchedCounters);
return this.getCounters(appName);
}).pipe(
map(({ counters, batched }) => ({ counters: counters[entityType] ?? {}, batched })),
finalize(() => this.deactivateEntityType(appName, entityType))
);
}
refreshFilterCounters(appName: string): void {
this.recount(appName);
}
getEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable<TaskCloudEngineEvent[]> {
if (!appName) {
return EMPTY;
}
const key = this.entityTypeKey(appName, entityType);
let events$ = this.eventsPerEntityType.get(key);
if (!events$) {
events$ = this.rawEngineEvents(appName, entityType).pipe(
debounceTime(this.notificationDebounceTime),
shareReplay({ bufferSize: 1, refCount: true })
);
this.eventsPerEntityType.set(key, events$);
}
return events$;
}
private rawEngineEvents(appName: string, entityType: FilterCounterEntityType): Observable<TaskCloudEngineEvent[]> {
const key = this.entityTypeKey(appName, entityType);
let events$ = this.rawEventsPerEntityType.get(key);
if (!events$) {
events$ = defer(() =>
this.notificationCloudService.makeGQLQuery<EngineEventsData>(appName, ENGINE_EVENTS_SUBSCRIPTION_QUERIES[entityType])
).pipe(
map((result) => result.data?.engineEvents ?? []),
catchError(() => EMPTY),
shareReplay({ bufferSize: 1, refCount: true })
);
this.rawEventsPerEntityType.set(key, events$);
}
return events$;
}
private get notificationsEnabled(): boolean {
return this.appConfigService.get('notifications', true);
}
private activateEntityType(appName: string, entityType: FilterCounterEntityType, batchedCounters: boolean): void {
const key = this.entityTypeKey(appName, entityType);
const subscribers = (this.subscribersPerEntityType.get(key) ?? 0) + 1;
this.subscribersPerEntityType.set(key, subscribers);
if (subscribers > 1) {
return;
}
this.batchedCountersPerEntityType.set(key, batchedCounters);
const activeEntityTypes = this.activeEntityTypes(appName);
const joinsResolvedCounters = activeEntityTypes.size > 0;
activeEntityTypes.add(entityType);
if (this.notificationsEnabled) {
this.eventSubscriptionsPerEntityType.set(
key,
this.rawEngineEvents(appName, entityType).subscribe(() => this.eventRecountTrigger(appName).next())
);
}
if (joinsResolvedCounters) {
this.recount(appName);
}
}
private deactivateEntityType(appName: string, entityType: FilterCounterEntityType): void {
const key = this.entityTypeKey(appName, entityType);
const subscribers = (this.subscribersPerEntityType.get(key) ?? 1) - 1;
if (subscribers > 0) {
this.subscribersPerEntityType.set(key, subscribers);
return;
}
this.subscribersPerEntityType.delete(key);
this.batchedCountersPerEntityType.delete(key);
this.activeEntityTypes(appName).delete(entityType);
this.eventSubscriptionsPerEntityType.get(key)?.unsubscribe();
this.eventSubscriptionsPerEntityType.delete(key);
}
private activeEntityTypes(appName: string): Set<FilterCounterEntityType> {
let activeEntityTypes = this.activeEntityTypesPerApp.get(appName);
if (!activeEntityTypes) {
activeEntityTypes = new Set<FilterCounterEntityType>();
this.activeEntityTypesPerApp.set(appName, activeEntityTypes);
}
return activeEntityTypes;
}
private entityTypeKey(appName: string, entityType: FilterCounterEntityType): string {
return `${appName}|${entityType}`;
}
private recount(appName: string): void {
this.recountTrigger(appName).next();
}
private getFiltersForCounters(appName: string): Observable<FilterCountersFilters> {
const activeEntityTypes = this.activeEntityTypes(appName);
return combineLatest({
[FilterCounterEntityType.TASK]: activeEntityTypes.has(FilterCounterEntityType.TASK)
? this.getTaskFilters(appName).pipe(catchError(() => of([])))
: of([]),
[FilterCounterEntityType.PROCESS_INSTANCE]: activeEntityTypes.has(FilterCounterEntityType.PROCESS_INSTANCE)
? this.getProcessFilters(appName).pipe(catchError(() => of([])))
: of([])
});
}
private shareFilters<T>(cache: Map<string, Observable<T[]>>, appName: string, loadFilters: () => Observable<T[]>): Observable<T[]> {
let filters$ = cache.get(appName);
if (!filters$) {
filters$ = defer(loadFilters).pipe(shareReplay({ bufferSize: 1, refCount: true }));
cache.set(appName, filters$);
}
return filters$;
}
private getCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> {
let counters$ = this.countersPerApp.get(appName);
if (!counters$) {
counters$ = this.recounts(appName).pipe(
switchMap(() => this.resolveCounters(appName)),
shareReplay({ bufferSize: 1, refCount: true })
);
this.countersPerApp.set(appName, counters$);
}
return counters$;
}
private resolveCounters(appName: string): Observable<{ counters: FilterCounters; batched: boolean }> {
if (!this.batchedCountersEnabled(appName) || this.appsWithoutBatchedCounters.has(appName)) {
return of({ counters: {}, batched: false });
}
return this.getFiltersForCounters(appName).pipe(
take(1),
switchMap((filters) => this.fetchFilterCounters(appName, this.buildRequest(filters))),
map((counters) => ({ counters, batched: true })),
catchError((error) => {
if (BATCHED_COUNTERS_UNAVAILABLE_STATUSES.includes(error?.status)) {
this.appsWithoutBatchedCounters.add(appName);
}
return of({ counters: {}, batched: false });
})
);
}
private batchedCountersEnabled(appName: string): boolean {
const activeEntityTypes = [...this.activeEntityTypes(appName)];
return (
activeEntityTypes.length > 0 &&
activeEntityTypes.every((entityType) => this.batchedCountersPerEntityType.get(this.entityTypeKey(appName, entityType)))
);
}
private recounts(appName: string): Observable<unknown> {
return merge(
merge(of(undefined), this.recountTrigger(appName)).pipe(debounceTime(0, asapScheduler)),
this.eventRecountTrigger(appName).pipe(debounceTime(this.notificationDebounceTime))
);
}
private recountTrigger(appName: string): Subject<void> {
let recount$ = this.recountPerApp.get(appName);
if (!recount$) {
recount$ = new Subject<void>();
this.recountPerApp.set(appName, recount$);
}
return recount$;
}
private eventRecountTrigger(appName: string): Subject<void> {
let eventRecount$ = this.eventRecountPerApp.get(appName);
if (!eventRecount$) {
eventRecount$ = new Subject<void>();
this.eventRecountPerApp.set(appName, eventRecount$);
}
return eventRecount$;
}
private buildRequest(filters: FilterCountersFilters): FilterCountersRequest {
const request: FilterCountersRequest = {};
const taskQueries = this.buildQueries(filters[FilterCounterEntityType.TASK], (filter) =>
this.taskListCloudService.buildQueryData(new TaskFilterCloudAdapter(filter))
);
if (taskQueries.length) {
request[FilterCounterEntityType.TASK] = taskQueries;
}
const processQueries = this.buildQueries(filters[FilterCounterEntityType.PROCESS_INSTANCE], (filter) =>
this.processListCloudService.buildQueryData(new ProcessFilterCloudAdapter(filter))
);
if (processQueries.length) {
request[FilterCounterEntityType.PROCESS_INSTANCE] = processQueries;
}
return request;
}
private buildQueries<T extends FilterCounterCandidate>(
filters: T[],
buildQuery: (filter: T) => Omit<FilterCountersQuery, 'requestId'>
): FilterCountersQuery[] {
return (filters ?? [])
.filter((filter) => filter?.showCounter && this.isCounterBatched(filter))
.map((filter) => {
try {
return { ...buildQuery(filter), requestId: filter.key as string };
} catch {
return undefined;
}
})
.filter((query): query is FilterCountersQuery => !!query);
}
private fetchFilterCounters(appName: string, request: FilterCountersRequest): Observable<FilterCounters> {
if (!Object.keys(request).length) {
return of({});
}
const queryUrl = `${this.getBasePath(appName)}/query/v1/count`;
return this.post<FilterCountersRequest, FilterCounters>(queryUrl, request).pipe(map((counters) => counters || {}));
}
private isCounterBatched(filter: FilterCounterCandidate): boolean {
return !!filter?.key;
}
}
@@ -15,8 +15,9 @@
* limitations under the License.
*/
import { gql } from '@apollo/client/core';
import { FetchResult, gql } from '@apollo/client/core';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { WebSocketService } from './web-socket.service';
@Injectable({
providedIn: 'root'
@@ -24,8 +25,8 @@ import { WebSocketService } from './web-socket.service';
export class NotificationCloudService {
private readonly webSocketService = inject(WebSocketService);
makeGQLQuery(appName: string, gqlQuery: string) {
return this.webSocketService.getSubscription({
makeGQLQuery<T = unknown>(appName: string, gqlQuery: string): Observable<FetchResult<T>> {
return this.webSocketService.getSubscription<T>({
apolloClientName: appName,
wsUrl: `${appName}/notifications`,
httpUrl: `${appName}/notifications/v2/ws/graphql`,
@@ -17,6 +17,7 @@
export * from './base-cloud.service';
export * from './cloud-token.service';
export * from './filter-counters-cloud.service';
export * from './form-fields.interfaces';
export * from './local-preference-cloud.service';
export * from './notification-cloud.service';
@@ -16,18 +16,39 @@
*/
import { TestBed } from '@angular/core/testing';
import { Injectable } from '@angular/core';
import { Apollo, gql } from 'apollo-angular';
import { lastValueFrom, of, Subject } from 'rxjs';
import { WebSocketService } from './web-socket.service';
import { SubscriptionOptions } from '@apollo/client/core';
import { ApolloLink, execute, FetchResult, Observable as ApolloObservable, SubscriptionOptions } from '@apollo/client/core';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthenticationService, AppConfigService } from '@alfresco/adf-core';
import { Client, ClientOptions, Sink, SubscribePayload } from 'graphql-ws';
import { HttpLink } from 'apollo-angular/http';
@Injectable()
class TestWebSocketService extends WebSocketService {
public capturedOnError: (() => void) | undefined;
protected override createWsClient(clientOptions: ClientOptions): Client {
this.capturedOnError = clientOptions.on?.error as (() => void) | undefined;
return {
on: () => () => undefined,
subscribe: (_payload: SubscribePayload, _sink: Sink) => () => undefined,
async *iterate() {},
terminate: () => undefined,
dispose: () => undefined
};
}
}
describe('WebSocketService', () => {
let service: WebSocketService;
let service: TestWebSocketService;
const onLogoutSubject: Subject<void> = new Subject<void>();
const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed']);
const apolloMock = jasmine.createSpyObj('Apollo', ['use', 'createNamed', 'removeClient']);
const httpLinkMock = jasmine.createSpyObj('HttpLink', ['create']);
beforeEach(() => {
TestBed.configureTestingModule({
@@ -37,6 +58,14 @@ describe('WebSocketService', () => {
provide: Apollo,
useValue: apolloMock
},
{
provide: WebSocketService,
useClass: TestWebSocketService
},
{
provide: HttpLink,
useValue: httpLinkMock
},
{
provide: AppConfigService,
useValue: {
@@ -52,13 +81,15 @@ describe('WebSocketService', () => {
}
]
});
service = TestBed.inject(WebSocketService);
service = TestBed.inject(WebSocketService) as TestWebSocketService;
apolloMock.use.and.returnValues(undefined, { subscribe: () => of({}) });
});
afterEach(() => {
apolloMock.use.calls.reset();
apolloMock.createNamed.calls.reset();
apolloMock.removeClient.calls.reset();
httpLinkMock.create.calls.reset();
});
it('should not create a new Apollo client if it is already in use', async () => {
@@ -95,7 +126,7 @@ describe('WebSocketService', () => {
const apolloClientName = 'testClient';
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
apolloMock.createNamed.and.callFake((_, options) => {
apolloMock.createNamed.and.callFake((_: any, options: { headers: {} }) => {
headers = options.headers;
});
@@ -105,4 +136,74 @@ describe('WebSocketService', () => {
expect(apolloMock.createNamed).toHaveBeenCalled();
expect(headers).toEqual(expectedHeaders);
});
it('should recreate the subscription client when the websocket connection errors', async () => {
const apolloClientName = 'testClient';
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
const wsOptions = { apolloClientName, wsUrl: 'testUrl', subscriptionOptions };
await lastValueFrom(service.getSubscription(wsOptions));
expect(apolloMock.createNamed).toHaveBeenCalledTimes(1);
expect(apolloMock.removeClient).not.toHaveBeenCalled();
if (!service.capturedOnError) {
fail('Expected websocket error handler to be registered');
return;
}
service.capturedOnError();
expect(apolloMock.removeClient).toHaveBeenCalledWith(apolloClientName);
expect(apolloMock.createNamed).toHaveBeenCalledTimes(2);
expect(apolloMock.createNamed).toHaveBeenCalledWith(apolloClientName, jasmine.any(Object));
});
it('should retry the operation when a GraphQL error is unauthenticated', async () => {
const apolloClientName = 'testClient';
const subscriptionOptions: SubscriptionOptions = { query: gql(`subscription {testQuery}`) };
const wsOptions = { apolloClientName, wsUrl: 'testUrl', httpUrl: 'testHttpUrl', subscriptionOptions };
const expectedResult: FetchResult = { data: { retried: true } };
let createdLink: ApolloLink | undefined;
let requestCount = 0;
httpLinkMock.create.and.returnValue(
new ApolloLink(
() =>
new ApolloObservable<FetchResult>((observer) => {
requestCount++;
if (requestCount === 1) {
observer.next({
errors: [{ message: 'Unauthorized', extensions: { code: 'UNAUTHENTICATED' } }]
});
} else {
observer.next(expectedResult);
}
observer.complete();
})
)
);
apolloMock.createNamed.and.callFake((_clientName: any, options: { link: ApolloLink | undefined }) => {
createdLink = options.link;
});
await lastValueFrom(service.getSubscription(wsOptions));
if (!createdLink) {
fail('Expected Apollo link to be created');
return;
}
const result = await new Promise<FetchResult>((resolve, reject) => {
execute(createdLink!, { query: gql(`query { testQuery }`) }).subscribe({
next: resolve,
error: reject
});
});
expect(requestCount).toBe(2);
expect(result).toEqual(expectedResult);
});
});
@@ -15,10 +15,9 @@
* limitations under the License.
*/
import { createClient } from 'graphql-ws';
import { Client, ClientOptions, createClient } from 'graphql-ws';
import { inject, Injectable } from '@angular/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { WebSocketLink } from '@apollo/client/link/ws';
import {
DefaultContext,
FetchResult,
@@ -56,9 +55,8 @@ export class WebSocketService {
private readonly authService = inject(AuthenticationService);
private readonly appConfigService = inject(AppConfigService);
private readonly subscriptionProtocol: 'graphql-ws' | 'transport-ws' = 'graphql-ws';
private wsLink: GraphQLWsLink | WebSocketLink;
private httpLinkHandler: HttpLinkHandler;
private wsLink!: GraphQLWsLink;
private httpLinkHandler: HttpLinkHandler | undefined;
public getSubscription<T>(options: serviceOptions): Observable<FetchResult<T>> {
const { apolloClientName, subscriptionOptions } = options;
@@ -110,8 +108,7 @@ export class WebSocketService {
operation.setContext(({ headers }: DefaultContext) => ({
headers: {
...headers,
...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
Authorization: `Bearer ${this.authService.getToken()}`
}
}));
return forward(operation);
@@ -120,8 +117,8 @@ export class WebSocketService {
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.extensions && error.extensions['code'] === 'UNAUTHENTICATED') {
authLink(operation, forward);
if (error.extensions?.['code'] === 'UNAUTHENTICATED') {
return authLink(operation, forward);
}
}
}
@@ -129,6 +126,8 @@ export class WebSocketService {
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
return undefined;
});
const retryLink = new RetryLink({
@@ -145,8 +144,7 @@ export class WebSocketService {
this.apollo.createNamed(options.apolloClientName, {
headers: {
...(this.subscriptionProtocol === 'graphql-ws' && { Authorization: `Bearer ${this.authService.getToken()}` }),
...(this.subscriptionProtocol === 'transport-ws' && { 'X-Authorization': `Bearer ${this.authService.getToken()}` })
Authorization: `Bearer ${this.authService.getToken()}`
},
link: from([authLink, retryLink, errorLink, link]),
cache: new InMemoryCache({ merge: true } as InMemoryCacheConfig)
@@ -155,22 +153,28 @@ export class WebSocketService {
private createGraphQLWsLink(options: serviceOptions): void {
this.wsLink = new GraphQLWsLink(
createClient({
this.createWsClient({
url: this.createWsUrl(options.wsUrl) + '/v2/ws/graphql',
connectionParams: () => ({
Authorization: 'Bearer ' + this.authService.getToken()
}),
on: {
error: () => {
this.apollo.removeClient(options.apolloClientName);
this.initSubscriptions(options);
}
error: () => this.reconnect(options)
},
lazy: true
})
);
}
protected createWsClient(clientOptions: ClientOptions): Client {
return createClient(clientOptions);
}
private reconnect(options: serviceOptions): void {
this.apollo.removeClient(options.apolloClientName);
this.initSubscriptions(options);
}
private createHttpLinkHandler(options: serviceOptions): void {
this.httpLinkHandler = options.httpUrl
? this.httpLink.create({
@@ -17,10 +17,10 @@
import { AppConfigService, NoopAuthModule } from '@alfresco/adf-core';
import { Component, SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing';
import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { first, of, Subject, throwError } from 'rxjs';
import { TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { first, NEVER, of, Subject, throwError } from 'rxjs';
import { PROCESS_FILTERS_SERVICE_TOKEN, TASK_FILTERS_SERVICE_TOKEN } from '../../../../services/cloud-token.service';
import { LocalPreferenceCloudService } from '../../../../services/local-preference-cloud.service';
import { defaultTaskFiltersMock, fakeGlobalFilter, taskNotifications } from '../../mock/task-filters-cloud.mock';
import { TaskFilterCloudService } from '../../services/task-filter-cloud.service';
@@ -35,6 +35,9 @@ import { TaskFilterCloudModel } from '../../models/filter-cloud.model';
import { MatIconHarness } from '@angular/material/icon/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
@Component({ selector: 'adf-cloud-dummy', template: '' })
class DummyComponent {}
@@ -50,7 +53,10 @@ describe('TaskFiltersCloudComponent', () => {
let getTaskFilterCounterSpy: jasmine.Spy;
let getTaskListFiltersSpy: jasmine.Spy;
let getTaskListCountSpy: jasmine.Spy;
let getTaskNotificationSubscriptionSpy: jasmine.Spy;
let getEngineEventsSpy: jasmine.Spy;
let filterCountersService: FilterCountersCloudService;
let getFilterCountersSpy: jasmine.Spy;
let refreshFilterCountersSpy: jasmine.Spy;
let router: Router;
const configureTestingModule = async (searchApiMethod: 'GET' | 'POST') => {
@@ -58,6 +64,7 @@ describe('TaskFiltersCloudComponent', () => {
imports: [NoopAuthModule, TaskFiltersCloudComponent, ApolloTestingModule],
providers: [
{ provide: TASK_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
{ provide: PROCESS_FILTERS_SERVICE_TOKEN, useClass: LocalPreferenceCloudService },
provideRouter([{ path: 'task-list-cloud', component: DummyComponent }]),
{
provide: ActivatedRoute,
@@ -76,10 +83,15 @@ describe('TaskFiltersCloudComponent', () => {
});
taskFilterService = TestBed.inject(TaskFilterCloudService);
taskListService = TestBed.inject(TaskListCloudService);
filterCountersService = TestBed.inject(FilterCountersCloudService);
getTaskFilterCounterSpy = spyOn(taskFilterService, 'getTaskFilterCounter').and.returnValue(of(11));
getTaskListCountSpy = spyOn(taskListService, 'getTaskListCount').and.returnValue(of(11));
getTaskNotificationSubscriptionSpy = spyOn(taskFilterService, 'getTaskNotificationSubscription').and.returnValue(of(taskNotifications));
getTaskListFiltersSpy = spyOn(taskFilterService, 'getTaskListFilters').and.returnValue(of(fakeGlobalFilter));
getEngineEventsSpy = spyOn(filterCountersService, 'getEngineEvents').and.returnValue(of(taskNotifications));
getTaskListFiltersSpy = spyOn(filterCountersService, 'getTaskFilters').and.returnValue(of(fakeGlobalFilter));
getFilterCountersSpy = spyOn(filterCountersService, 'getFilterCounters').and.returnValue(
of({ counters: { 'fake-involved-tasks': 11 }, batched: true })
);
refreshFilterCountersSpy = spyOn(filterCountersService, 'refreshFilterCounters');
appConfigService = TestBed.inject(AppConfigService);
@@ -261,7 +273,7 @@ describe('TaskFiltersCloudComponent', () => {
expect(updatedFilterCounters.length).toBe(0);
});
it('should update filter counter when filter is selected', async () => {
it('should refresh the filter counters when a filter is selected', async () => {
component.showIcons = true;
await bindAppName();
@@ -269,7 +281,7 @@ describe('TaskFiltersCloudComponent', () => {
filterButton.click();
fixture.detectChanges();
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
describe('Notifications config', () => {
@@ -306,30 +318,33 @@ describe('TaskFiltersCloudComponent', () => {
});
it('should not subscribe to notifications when appName is missing', () => {
getTaskNotificationSubscriptionSpy.calls.reset();
getEngineEventsSpy.calls.reset();
component.appName = '';
fixture.detectChanges();
expect(getTaskNotificationSubscriptionSpy).not.toHaveBeenCalled();
expect(getEngineEventsSpy).not.toHaveBeenCalled();
});
it('should debounce notification subscription using the configured debounce time', fakeAsync(() => {
const notifications$ = new Subject<typeof taskNotifications>();
getTaskNotificationSubscriptionSpy.and.returnValue(notifications$.asObservable());
it('should subscribe to the notifications of the bound app', () => {
component.appName = 'my-app-1';
fixture.detectChanges();
const updateFilterCountersSpy = spyOn(component, 'updateFilterCounters');
expect(getEngineEventsSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK);
});
notifications$.next(taskNotifications);
tick(1000);
expect(updateFilterCountersSpy).not.toHaveBeenCalled();
it('should emit the events of the debounced batch', fakeAsync(() => {
const events$ = new Subject<TaskCloudEngineEvent[]>();
getEngineEventsSpy.and.returnValue(events$.asObservable());
const filterCounterUpdatedSpy = spyOn(component.filterCounterUpdated, 'emit');
component.appName = 'my-app-1';
tick(2000);
expect(updateFilterCountersSpy).toHaveBeenCalledTimes(1);
fixture.detectChanges();
events$.next(taskNotifications);
expect(filterCounterUpdatedSpy).toHaveBeenCalledWith(taskNotifications);
flush();
}));
});
@@ -438,7 +453,7 @@ describe('TaskFiltersCloudComponent', () => {
expect(updatedFilterCounters.length).toBe(0);
});
it('should update filter counter when filter is selected', async () => {
it('should refresh the filter counters when a filter is selected', async () => {
await bindAppName();
const filterButton = await loader.getHarness(
@@ -446,6 +461,14 @@ describe('TaskFiltersCloudComponent', () => {
);
await filterButton.click();
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
it('should resolve the counters with the POST method when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
expect(getTaskListCountSpy).toHaveBeenCalledWith(new TaskFilterCloudAdapter(fakeGlobalFilter[0]));
});
});
@@ -658,17 +681,137 @@ describe('TaskFiltersCloudComponent', () => {
expect(component.updatedCountersSet.has(fakeFilterKey)).toBe(true);
});
it('should call fetchTaskFilterCounter only if filter.showCounter is true', () => {
it('should resolve the counter only of the filters with a counter enabled', () => {
const filterWithCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[0], showCounter: true });
const filterWithoutCounter = new TaskFilterCloudModel({ ...defaultTaskFiltersMock[1], showCounter: false });
const fetchSpy = spyOn<any>(component, 'fetchTaskFilterCounter').and.returnValue(of(42));
getTaskFilterCounterSpy.calls.reset();
component.filters = [filterWithCounter, filterWithoutCounter];
component.updateFilterCounters();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(filterWithCounter);
expect(fetchSpy).not.toHaveBeenCalledWith(filterWithoutCounter);
expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(filterWithCounter);
});
describe('Batched counters', () => {
it('should read the counters without waiting for the filters', async () => {
getTaskListFiltersSpy.and.returnValue(NEVER);
await bindAppName();
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
});
it('should hold the counters until the filters they belong to arrive', async () => {
const filters$ = new Subject<TaskFilterCloudModel[]>();
getTaskListFiltersSpy.and.returnValue(filters$.asObservable());
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
await bindAppName();
expect(component.counters['fake-involved-tasks']).toBeUndefined();
filters$.next(fakeGlobalFilter);
fixture.detectChanges();
expect(component.counters['fake-involved-tasks']).toBe(9);
});
it('should read the counters of the task filters of the bound app', async () => {
await bindAppName();
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
});
it('should not ask for the batched count endpoint by default', async () => {
await bindAppName();
expect(component.useBatchedCounters).toBeFalse();
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, false);
});
it('should ask for the batched count endpoint when the input is set', async () => {
fixture.componentRef.setInput('useBatchedCounters', true);
await bindAppName();
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true);
});
it('should read the counters again when the input changes', async () => {
await bindAppName();
getFilterCountersSpy.calls.reset();
fixture.componentRef.setInput('useBatchedCounters', true);
fixture.detectChanges();
await fixture.whenStable();
expect(getFilterCountersSpy).toHaveBeenCalledWith('my-app-1', FilterCounterEntityType.TASK, true);
});
it('should hold the counters resolved by the batched count request', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
await bindAppName();
expect(component.counters['fake-involved-tasks']).toBe(9);
});
it('should emit the filters whose counter changed', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 9 }, batched: true }));
const updatedFilterSpy = spyOn(component.updatedFilter, 'emit');
await bindAppName();
expect(updatedFilterSpy).toHaveBeenCalledWith('fake-involved-tasks');
});
it('should resolve the counter of a filter the batch left out on its own', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: true }));
await bindAppName();
expect(getTaskFilterCounterSpy).toHaveBeenCalledWith(fakeGlobalFilter[0]);
expect(component.counters['fake-involved-tasks']).toBe(11);
});
it('should keep the counters of the other filters when one counter cannot be resolved', async () => {
getTaskListFiltersSpy.and.returnValue(of([fakeGlobalFilter[0], { ...fakeGlobalFilter[1], showCounter: true }]));
getFilterCountersSpy.and.returnValue(of({ counters: { 'fake-involved-tasks': 4 }, batched: true }));
getTaskFilterCounterSpy.and.throwError('the query of the filter cannot be built');
await bindAppName();
expect(component.counters['fake-involved-tasks']).toBe(4);
expect(component.counters['fake-my-task1']).toBe(0);
});
it('should resolve the counters one filter at a time when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
expect(getTaskFilterCounterSpy).toHaveBeenCalled();
expect(component.counters['fake-involved-tasks']).toBe(11);
});
it('should refresh the counters of every filter when a filter is clicked', async () => {
await bindAppName();
component.onFilterClick(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).toHaveBeenCalledWith('my-app-1');
});
it('should refresh the counter of the clicked filter alone when the batched endpoint is not available', async () => {
getFilterCountersSpy.and.returnValue(of({ counters: {}, batched: false }));
await bindAppName();
getTaskFilterCounterSpy.calls.reset();
component.onFilterClick(fakeGlobalFilter[0]);
expect(refreshFilterCountersSpy).not.toHaveBeenCalled();
expect(getTaskFilterCounterSpy).toHaveBeenCalledTimes(1);
});
});
describe('Highlight Selected Filter', () => {
@@ -16,16 +16,18 @@
*/
import { Component, EventEmitter, inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { EMPTY, Observable } from 'rxjs';
import { combineLatest, defer, EMPTY, Observable, of, Subscription } from 'rxjs';
import { TaskFilterCloudService } from '../../services/task-filter-cloud.service';
import { FilterParamsModel, TaskFilterCloudModel } from '../../models/filter-cloud.model';
import { AppConfigService, IconModule, TranslationService } from '@alfresco/adf-core';
import { catchError, debounceTime, map, shareReplay, tap } from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
import { BaseTaskFiltersCloudComponent } from '../base-task-filters-cloud.component';
import { TaskDetailsCloudModel } from '../../../models/task-details-cloud.model';
import { TaskCloudEngineEvent } from '../../../../models/engine-event-cloud.model';
import { TaskListCloudService } from '../../../task-list/services/task-list-cloud.service';
import { TaskFilterCloudAdapter } from '../../../../models/filter-cloud-model';
import { FilterCountersCloudService } from '../../../../services/filter-counters-cloud.service';
import { FilterCounterEntityType } from '../../../../models/filter-counters-cloud.model';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
@@ -42,10 +44,21 @@ import { AsyncPipe } from '@angular/common';
export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent implements OnInit, OnChanges {
protected readonly TASKS_ROUTE = '/task-list-cloud';
/** (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count. */
/**
* (optional) From Activiti 8.7.0 forward, use the 'POST' method to get the task count.
*
*/
@Input()
searchApiMethod: 'GET' | 'POST' = 'GET';
/**
* (optional) Resolves the counters of the task and the process filters with a single call to
* `POST /query/v1/count`. Both filter components have to
* ask for it, otherwise the counters are resolved one filter at a time.
*/
@Input()
useBatchedCounters = false;
/** Emitted when a filter is being selected based on the filterParam input. */
@Output()
filterSelected = new EventEmitter<TaskFilterCloudModel>();
@@ -69,9 +82,13 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
notificationDebounceTime = 3000;
currentFiltersValues: { [key: string]: number } = {};
private filtersLoadedFor?: string;
private countersSubscription?: Subscription;
private countersFilters$?: Observable<TaskFilterCloudModel[]>;
private batchedCounters = true;
private readonly taskFilterCloudService = inject(TaskFilterCloudService);
private readonly taskListCloudService = inject(TaskListCloudService);
private readonly filterCountersCloudService = inject(FilterCountersCloudService);
private readonly translationService = inject(TranslationService);
private readonly appConfigService = inject(AppConfigService);
private readonly activatedRoute = inject(ActivatedRoute);
@@ -80,6 +97,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
ngOnInit() {
this.enableNotifications = this.appConfigService.get('notifications', true);
this.notificationDebounceTime = this.appConfigService.get('notificationDebounceTime', 3000);
if (!this.filtersLoadedFor) {
this.getFilters(this.appName);
}
@@ -94,6 +112,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.getFilters(appName.currentValue);
} else if (filter && filter.currentValue !== filter.previousValue) {
this.selectFilterAndEmit(filter.currentValue);
} else if (changes['useBatchedCounters'] && !changes['useBatchedCounters'].firstChange && this.filtersLoadedFor) {
this.loadFilterCounters(this.filtersLoadedFor);
}
}
@@ -104,8 +124,8 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
*/
getFilters(appName: string): void {
this.filtersLoadedFor = appName;
const filters$ = this.taskFilterCloudService.getTaskListFilters(appName).pipe(shareReplay({ bufferSize: 1, refCount: true }));
this.filters$ = filters$.pipe(catchError(() => EMPTY));
const filters$ = this.filterCountersCloudService.getTaskFilters(appName);
this.filters$ = filters$.pipe(catchError(() => of([])));
filters$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (res) => {
@@ -113,13 +133,15 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
this.filters = res || [];
this.initFilterCounters();
this.selectFilterAndEmit(this.filterParam);
this.updateFilterCounters();
this.success.emit(res);
},
error: (err) => {
this.error.emit(err);
}
});
this.countersFilters$ = filters$;
this.loadFilterCounters(appName);
}
/**
@@ -131,55 +153,47 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
/**
* Iterate over filters and update counters
*
* @deprecated counts one filter at a time. Removed in ADF 10.0.0.
*/
updateFilterCounters(): void {
this.filters.forEach((filter: TaskFilterCloudModel) => this.updateFilterCounter(filter));
this.filters.forEach((filter) => this.updateFilterCounter(filter));
}
/**
* Get current value for filter and check if value has changed
*
* @param filter filter
* @deprecated counts one filter at a time. Removed in ADF 10.0.0.
*/
updateFilterCounter(filter: TaskFilterCloudModel): void {
if (!filter?.showCounter) {
return;
}
this.fetchTaskFilterCounter(filter)
defer(() => this.fetchTaskFilterCounter(filter))
.pipe(
tap((filterCounter) => {
this.checkIfFilterValuesHasBeenUpdated(filter.key, filterCounter);
})
catchError(() => EMPTY),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((data) => {
this.counters = {
...this.counters,
[filter.key]: data
};
.subscribe((counter) => {
this.checkIfFilterValuesHasBeenUpdated(filter.key, counter);
this.counters = { ...this.counters, [filter.key]: counter };
});
}
private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable<number> {
return this.searchApiMethod === 'POST'
? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
: this.taskFilterCloudService.getTaskFilterCounter(filter);
}
initFilterCounterNotifications() {
initFilterCounterNotifications(): void {
if (!this.appName) {
return;
}
if (this.enableNotifications) {
this.taskFilterCloudService
.getTaskNotificationSubscription(this.appName)
.pipe(debounceTime(this.notificationDebounceTime), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
result.forEach((taskEvent) => {
this.checkFilterCounter(taskEvent.entity);
});
this.updateFilterCounters();
this.filterCounterUpdated.emit(result);
if (this.enableNotifications) {
this.filterCountersCloudService
.getEngineEvents(this.appName, FilterCounterEntityType.TASK)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((events) => {
events.forEach((taskEvent) => this.checkFilterCounter(taskEvent.entity));
this.filterCounterUpdated.emit(events);
});
} else {
this.counters = {};
@@ -240,7 +254,7 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
onFilterClick(filter: FilterParamsModel) {
if (filter) {
this.selectFilter(filter);
this.updateFilterCounter(this.currentFilter);
this.refreshFilterCounter(this.currentFilter);
this.filterClicked.emit(this.currentFilter);
this.updatedCountersSet.delete(filter.key);
} else {
@@ -267,17 +281,9 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
return this.filters === undefined || (this.filters && this.filters.length === 0);
}
/**
* Reset the filters properties
*/
private resetFilter() {
this.filters = [];
this.currentFilter = undefined;
}
checkIfFilterValuesHasBeenUpdated(filterKey: string, filterValue: number) {
if (this.currentFiltersValues[filterKey] === undefined || this.currentFiltersValues[filterKey] !== filterValue) {
this.currentFiltersValues[filterKey] = filterValue;
this.currentFiltersValues = { ...this.currentFiltersValues, [filterKey]: filterValue };
this.updatedFilter.emit(filterKey);
this.updatedCountersSet.add(filterKey);
}
@@ -288,8 +294,69 @@ export class TaskFiltersCloudComponent extends BaseTaskFiltersCloudComponent imp
*
*/
getFilterKeysAfterExternalRefreshing(): void {
this.taskFilterCloudService.filterKeyToBeRefreshed$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filterKey: string) => {
this.updatedCountersSet.delete(filterKey);
this.taskFilterCloudService.filterKeyToBeRefreshed$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((filterKey: string) => this.updatedCountersSet.delete(filterKey));
}
private loadFilterCounters(appName: string): void {
if (!this.countersFilters$) {
return;
}
this.countersSubscription?.unsubscribe();
this.countersSubscription = combineLatest([
this.countersFilters$.pipe(catchError(() => of([]))),
this.filterCountersCloudService.getFilterCounters(appName, FilterCounterEntityType.TASK, this.useBatchedCounters)
])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(([, { counters, batched }]) => {
this.batchedCounters = batched;
if (batched) {
this.applyFilterCounters(counters);
} else {
this.updateFilterCounters();
}
});
}
private applyFilterCounters(counters: { [filterKey: string]: number }): void {
this.filters.forEach((filter) => {
const filterKey = filter?.showCounter ? filter.key : undefined;
if (!filterKey) {
return;
}
const counter = counters[filterKey];
if (counter === undefined) {
this.updateFilterCounter(filter);
return;
}
this.checkIfFilterValuesHasBeenUpdated(filterKey, counter);
this.counters = { ...this.counters, [filterKey]: counter };
});
}
private fetchTaskFilterCounter(filter: TaskFilterCloudModel): Observable<number> {
return this.searchApiMethod === 'POST'
? this.taskListCloudService.getTaskListCount(new TaskFilterCloudAdapter(filter))
: this.taskFilterCloudService.getTaskFilterCounter(filter);
}
/**
* Reset the filters properties
*/
private resetFilter() {
this.filters = [];
this.currentFilter = undefined;
}
private refreshFilterCounter(filter: TaskFilterCloudModel): void {
if (this.batchedCounters) {
this.filterCountersCloudService.refreshFilterCounters(this.appName);
} else {
this.updateFilterCounter(filter);
}
}
}
@@ -361,6 +361,11 @@ export class TaskFilterCloudService extends BaseCloudService {
];
}
/**
* @deprecated use FilterCountersCloudService.getEngineEvents instead.
* @param appName Name of the target app
* @returns Task engine events
*/
getTaskNotificationSubscription(appName: string): Observable<TaskCloudEngineEvent[]> {
return this.notificationCloudService
.makeGQLQuery(appName, TASK_EVENT_SUBSCRIPTION_QUERY)
@@ -135,7 +135,7 @@ export class TaskListCloudService extends BaseCloudService implements TaskListCl
return this.post<object, number>(queryUrl, queryData).pipe(map((response) => response || 0));
}
protected buildQueryData(requestNode: TaskListRequestModel) {
buildQueryData(requestNode: TaskListRequestModel) {
const queryData: any = {
id: requestNode.id,
parentId: requestNode.parentId,
@@ -33,6 +33,7 @@ export * from './lib/models/application-version.model';
export * from './lib/models/engine-event-cloud.model';
export * from './lib/models/task-cloud.model';
export * from './lib/models/filter-cloud-model';
export * from './lib/models/filter-counters-cloud.model';
export * from './lib/models/task-list-sorting.model';
export * from './lib/models/process-instance-variable.model';
export * from './lib/models/variable-definition';
+7 -7
View File
@@ -57,7 +57,7 @@
"@ngx-translate/core": "17.0.0",
"angular-oauth2-oidc": "19.0.0",
"apollo-angular": "11.0.0",
"chart.js": "4.4.4",
"chart.js": "4.5.1",
"cropperjs": "1.6.2",
"date-fns": "2.30.0",
"dotenv-expand": "5.1.0",
@@ -66,7 +66,7 @@
"graphql-ws": "6.0.8",
"material-icons": "1.13.14",
"minimatch": "10.2.6",
"ng2-charts": "4.1.1",
"ng2-charts": "9.0.0",
"pdfjs-dist": "5.1.91",
"raphael": "2.3.0",
"rxjs": "7.8.2",
@@ -98,10 +98,10 @@
"@types/jasminewd2": "2.0.13",
"@types/node": "26.1.1",
"@types/sinon": "22.0.0",
"@typescript-eslint/eslint-plugin": "8.67.0",
"@typescript-eslint/parser": "8.67.0",
"@typescript-eslint/typescript-estree": "8.67.0",
"@typescript-eslint/utils": "8.67.0",
"@typescript-eslint/eslint-plugin": "8.70.0",
"@typescript-eslint/parser": "8.70.0",
"@typescript-eslint/typescript-estree": "8.70.0",
"@typescript-eslint/utils": "8.70.0",
"ajv": "8.20.0",
"dotenv": "16.4.7",
"eslint": "10.8.0",
@@ -131,7 +131,7 @@
"react-dom": "19.2.6",
"rimraf": "6.1.3",
"sass-loader": "16.0.8",
"semver": "7.6.3",
"semver": "7.8.5",
"sinon": "22.1.0",
"spdx-license-list": "6.11.0",
"storybook": "10.4.0",
+173 -254
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,6 +12,7 @@ overrides:
"brace-expansion@5.0.8": "5.0.9"
"brace-expansion@<1.1.18": "1.1.18"
"socket.io-parser@4.2.6": 4.2.7
"browserslist@>=4.28.0 <4.28.8": "4.28.8"
svgo: 4.0.2
shell-quote: 1.9.0
adm-zip: 0.6.0
@@ -26,6 +27,7 @@ overrides:
webpack-dev-server: '>=6.0.0'
uuid: '>=11.1.1'
undici: 8.9.0
'@xmldom/xmldom@<0.8.15': 0.8.15
allowBuilds:
'@parcel/watcher': true
+34 -8
View File
@@ -34,6 +34,26 @@ error_out() {
# bg: 40 black, 41 red, 44 blue, 45 purple
}
# Retry to absorb npm registry propagation delay right after publish.
NPM_RETRIES=24
NPM_RETRY_DELAY=20
npm_pack_version() {
local spec=$1
local attempt
for attempt in $(seq 1 "$NPM_RETRIES"); do
echo "📦 npm pack attempt $attempt/$NPM_RETRIES: $spec"
if npm pack "$spec"; then
return 0
fi
if [ "$attempt" -lt "$NPM_RETRIES" ]; then
echo "⏳ Attempt $attempt/$NPM_RETRIES failed, retrying in ${NPM_RETRY_DELAY}s..."
sleep "$NPM_RETRY_DELAY"
fi
done
return 1
}
rm -rf temp
mkdir temp
cd temp
@@ -42,12 +62,14 @@ for PACKAGE in ${projects[@]}
do
mkdir $PACKAGE
cd $PACKAGE
PKG_VERSION=""
# Handle js-api differently - increase major version by 1
if [ $PACKAGE == 'js-api' ]; then
if [ $VERSION == 'alpha' ] || [ $VERSION == 'beta' ] || [ $VERSION == 'latest' ]; then
# For tag versions, we need to get the current version and increment
CURRENT_VERSION=$(npm view @alfresco/$PACKAGE@$VERSION version)
FALLBACK_VERSION=$CURRENT_VERSION
MAJOR_VERSION=$(echo $CURRENT_VERSION | cut -d'.' -f1)
NEXT_MAJOR=$((MAJOR_VERSION + 1))
# Keep the rest of the version string
@@ -55,6 +77,7 @@ do
PACKAGE_VERSION="${NEXT_MAJOR}.${REST_VERSION}"
else
# For specific versions, just increment the major number
FALLBACK_VERSION=$VERSION
MAJOR_VERSION=$(echo $VERSION | cut -d'.' -f1)
NEXT_MAJOR=$((MAJOR_VERSION + 1))
REST_VERSION=$(echo $VERSION | cut -d'.' -f2-)
@@ -65,13 +88,17 @@ do
fi
# Try the calculated package version first
PKG_VERSION=$(npm view @alfresco/$PACKAGE@$PACKAGE_VERSION version 2>/dev/null)
# If that fails for js-api, try the original version
if [ -z "$PKG_VERSION" ] && [ $PACKAGE == 'js-api' ]; then
echo "Warning: js-api@$PACKAGE_VERSION not found, trying @$VERSION"
PACKAGE_VERSION=$VERSION
PKG_VERSION=$(npm view @alfresco/$PACKAGE@$PACKAGE_VERSION version 2>/dev/null)
if npm_pack_version '@alfresco/'$PACKAGE@$PACKAGE_VERSION; then
PKG_VERSION=$PACKAGE_VERSION
elif [ $PACKAGE == 'js-api' ]; then
# If that fails, try the original (already resolved) version.
# FALLBACK_VERSION, not $VERSION directly, since npm pack writes the
# tarball under the resolved version, not the tag it was requested with.
echo "Warning: js-api@$PACKAGE_VERSION not found, trying @$FALLBACK_VERSION"
PACKAGE_VERSION=$FALLBACK_VERSION
if [ -n "$PACKAGE_VERSION" ] && npm_pack_version '@alfresco/'$PACKAGE@$PACKAGE_VERSION; then
PKG_VERSION=$PACKAGE_VERSION
fi
fi
# If still no version found, exit with error
@@ -82,7 +109,6 @@ do
echo "Inspecting: $PACKAGE@$PKG_VERSION"
npm pack '@alfresco/'$PACKAGE@$PACKAGE_VERSION
tar zxf 'alfresco-'$PACKAGE-$PKG_VERSION.tgz
if [ $PACKAGE == 'js-api' ]; then