Merge branch 'develop' into copilot/explain-time-url-authentication

This commit is contained in:
Eugenio Romano
2026-07-03 05:12:15 +02:00
committed by GitHub
75 changed files with 3098 additions and 693 deletions
-31
View File
@@ -1,31 +0,0 @@
{
"entries": {
},
"containers": {
"ghcr.io/github/gh-aw-firewall/agent:0.25.58": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.25.58",
"digest": "sha256:a316a2c021accba8a9ea194c75466b0c4a166be6ac783bf8c2d0afd73373dec2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.58@sha256:a316a2c021accba8a9ea194c75466b0c4a166be6ac783bf8c2d0afd73373dec2"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58",
"digest": "sha256:43a5cdbe4e1156920dcdaab26d6c6761777d5c6bdc572d7d07b11739fb34c749",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.58@sha256:43a5cdbe4e1156920dcdaab26d6c6761777d5c6bdc572d7d07b11739fb34c749"
},
"ghcr.io/github/gh-aw-firewall/squid:0.25.58": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.25.58",
"digest": "sha256:558682b7b6313a5443cbb3d702899823bd732f991c8b52db6b5b8066abefe7a1",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.58@sha256:558682b7b6313a5443cbb3d702899823bd732f991c8b52db6b5b8066abefe7a1"
},
"ghcr.io/github/gh-aw-mcpg:v0.3.22": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.3.22",
"digest": "sha256:ce5c6f5461b077af0d8e8eb1763436e85153f8e9531117d58a7bdb23de71f00a",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.3.22@sha256:ce5c6f5461b077af0d8e8eb1763436e85153f8e9531117d58a7bdb23de71f00a"
},
"ghcr.io/github/github-mcp-server:v1.1.0": {
"image": "ghcr.io/github/github-mcp-server:v1.1.0",
"digest": "sha256:71b07d9abecb83b4a2595bcd8ccb35f9a0166361a12335f9e16da1ef07172029",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.1.0@sha256:71b07d9abecb83b4a2595bcd8ccb35f9a0166361a12335f9e16da1ef07172029"
}
}
}
+4 -4
View File
@@ -47,14 +47,15 @@ jobs:
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch' if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
name: "Setup" name: "Setup"
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: ./.github/actions/setup - uses: ./.github/actions/setup
with:
enable-node-modules-cache: false
- name: install - name: install
run: | run: |
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
@@ -73,6 +74,7 @@ jobs:
id-token: write # Required for OIDC id-token: write # Required for OIDC
contents: read contents: read
packages: write packages: write
actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -80,8 +82,6 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- id: setup - id: setup
uses: ./.github/actions/setup uses: ./.github/actions/setup
with:
enable-node-modules-cache: false
- id: set-dryrun - id: set-dryrun
uses: ./.github/actions/enable-dryrun uses: ./.github/actions/enable-dryrun
with: with:
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -10,7 +10,7 @@ permissions:
engine: engine:
id: copilot id: copilot
model: gpt-5.4-nano model: gpt-5-mini
tools: tools:
github: github:
@@ -30,9 +30,11 @@ safe-outputs:
hide-older-comments: true hide-older-comments: true
add-labels: add-labels:
allowed: [security:low, security:medium, security:high] allowed: [security:low, security:medium, security:high]
remove-labels:
allowed: [security:low, security:medium, security:high]
submit-pull-request-review: submit-pull-request-review:
source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@52467f0241079de71fe14591f97bdec7555ab545 source: Alfresco/alfresco-build-tools/.github/workflows/supply-chain-review.md@7479d99410acb8b63f78a5b17abb3b8ebec3a66d
--- ---
# Supply Chain Review # Supply Chain Review
@@ -336,7 +338,8 @@ No suspicious patterns detected. Routine upgrade.
## Step 6 — Apply Label and Review Status ## Step 6 — Apply Label and Review Status
- Apply a label to the PR based on the highest risk level found: - 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:low` for LOW risk
- `security:medium` for MEDIUM risk - `security:medium` for MEDIUM risk
- `security:high` for HIGH or CRITICAL risk - `security:high` for HIGH or CRITICAL risk
@@ -46,9 +46,9 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models
| field | string | Field to apply the query fragment to. Required value | | field | string | Field to apply the query fragment to. Required value |
| pattern | string | Regular expression pattern to restrict the format of the input text | | pattern | string | Regular expression pattern to restrict the format of the input text |
| placeholder | string | Text displayed in the widget when the input string is empty | | placeholder | string | Text displayed in the widget when the input string is empty |
| searchSuffix | string | Text to append always in the search of a string | | searchSuffix | string | Text to append in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). |
| searchPrefix | string | Text to prepend always in the search of a string | | searchPrefix | string | Text to prepend in the search of a string. Only applied when wildcard matching is enabled (the `search-wildcards-enabled` app config flag, default `true`). |
| allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true. | | allowUpdateOnChange | `boolean` | Enable/Disable firing the search update when the text changes. Defaults to `false`; when disabled the search runs only when the user submits the value. |
| hideDefaultAction | boolean | Show/hide the widget actions. By default is false. | | hideDefaultAction | boolean | Show/hide the widget actions. By default is false. |
## Details ## Details
@@ -115,8 +115,8 @@ that will be used when performing the actual query.
Every query fragment is stored and retrieved using its widget `id`. Every query fragment is stored and retrieved using its widget `id`.
It is your responsibility to format the query correctly. It is your responsibility to format the query correctly.
Once your change to the query is finished, update the context and call the `update` method Once your change to the query is finished, update the context and call the `execute` method
to inform other components about the change: to rebuild and run the query so the results reflect your change:
```ts ```ts
@Component({...}) @Component({...})
@@ -126,12 +126,15 @@ export class MyComponent implements SearchWidget, OnInit {
onUIChanged() { onUIChanged() {
this.context.queryFragments[this.id] = `some query`; this.context.queryFragments[this.id] = `some query`;
this.context.update(); void this.context.execute();
} }
} }
``` ```
> **Note:** Earlier versions called `this.context.update()` here. The `update()` method and the
> `updated` stream have been removed; call `this.context.execute()` directly instead.
When executed, your fragment will be injected into the resulting query based on the category order in the application configuration file. When executed, your fragment will be injected into the resulting query based on the category order in the application configuration file.
```text ```text
@@ -2,7 +2,7 @@
Title: Search Query Builder service Title: Search Query Builder service
Added: v2.3.0 Added: v2.3.0
Status: Active Status: Active
Last reviewed: 2019-03-19 Last reviewed: 2026-06-29
--- ---
# [Search Query Builder service](../../../lib/content-services/src/lib/search/services/search-query-builder.service.ts "Defined in search-query-builder.service.ts") # [Search Query Builder service](../../../lib/content-services/src/lib/search/services/search-query-builder.service.ts "Defined in search-query-builder.service.ts")
@@ -11,6 +11,17 @@ Stores information from all the custom search and faceted search widgets, compil
## Class members ## Class members
### Properties
| Name | Type | Description |
| ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| userQuery | `string` | The raw query string typed by the user. Setting it stores the value, records it in `filterRawParams` and recomputes `parsedQuery` according to the current `searchMode`. |
| parsedQuery | `string` (read-only) | The query derived from `userQuery`. In `regular` mode the user terms are expanded against the configured fields (see `app:fields`) and optionally wildcarded; in `formula` mode it is identical to `userQuery`. |
| searchMode | `'regular' \| 'formula'` | Controls how `userQuery` is turned into `parsedQuery`. `regular` (default) parses the user input into a field query; `formula` uses the user input verbatim as an AFTS expression. |
| selectedConfigurationId | `string` | Id of the currently active search configuration. Setting it also records the value in `filterRawParams`. |
| encodedQuery | `string` (read-only) | The Base64-encoded `filterRawParams`, produced by `encodeQuery()` and written to the `q` route query parameter. |
| wildcardsEnabled | `boolean` (read-only) | Reads the `search-wildcards-enabled` app config flag (default `true`). When enabled, query terms are suffixed with `*` so partial matches are returned. |
### Methods ### Methods
- **addFilterQuery**(query: `string`)<br/> - **addFilterQuery**(query: `string`)<br/>
@@ -25,9 +36,10 @@ Stores information from all the custom search and faceted search widgets, compil
- **Returns** `SearchRequest` - The finished query - **Returns** `SearchRequest` - The finished query
- **encodeQuery**()<br/> - **encodeQuery**()<br/>
Encodes query shards stored in `filterRawParams` property. Encodes query shards stored in `filterRawParams` property.
- **execute**(queryBody?: `SearchRequest`)<br/> - **execute**(updateQueryParams: `boolean` = `true`, queryBody?: `SearchRequest`)<br/>
Builds and executes the current query. Builds and executes the current query, then emits the result on the `executed` stream.
- _queryBody:_ `SearchRequest` - (Optional) - _updateQueryParams:_ `boolean` - (Optional) When `true` (default) the encoded query is written to the `q` route query parameter. Pass `false` to execute without updating the URL.
- _queryBody:_ `SearchRequest` - (Optional) Pre-built query to execute instead of building one from the current state.
- **getDefaultConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined`<br/> - **getDefaultConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined`<br/>
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined` - - **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)`|undefined` -
@@ -70,6 +82,11 @@ Stores information from all the custom search and faceted search widgets, compil
- **Returns** `boolean` - - **Returns** `boolean` -
- **isOperator**(input: `string`): `boolean`<br/>
Checks whether the supplied string is a logical `AND` or `OR` operator. Used when parsing a multi-word user query in `regular` search mode.
- _input:_ `string` - String to check
- **Returns** `boolean` - `true` if the trimmed string is `AND` or `OR`, otherwise `false`
- **loadConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)<br/> - **loadConfiguration**(): [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts)<br/>
- **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts) - - **Returns** [`SearchConfiguration`](../../../lib/content-services/src/lib/search/models/search-configuration.interface.ts) -
@@ -86,7 +103,10 @@ Stores information from all the custom search and faceted search widgets, compil
Removes an existing bucket from a field. Removes an existing bucket from a field.
- _field:_ [`FacetField`](../../../lib/content-services/src/lib/search/models/facet-field.interface.ts) - The target field - _field:_ [`FacetField`](../../../lib/content-services/src/lib/search/models/facet-field.interface.ts) - The target field
- _bucket:_ [`FacetFieldBucket`](../../../lib/content-services/src/lib/search/models/facet-field-bucket.interface.ts) - Bucket to remove - _bucket:_ [`FacetFieldBucket`](../../../lib/content-services/src/lib/search/models/facet-field-bucket.interface.ts) - Bucket to remove
- **resetToDefaults**()<br/> - **resetToDefaults**(withNavigate: `boolean` = `false`, resetUserQuery: `boolean` = `true`)<br/>
Resets the query builder back to the default search configuration.
- _withNavigate:_ `boolean` - (Optional) When `true`, clears the `q` route query parameter while resetting. Defaults to `false`.
- _resetUserQuery:_ `boolean` - (Optional) When `true` (default), the `userQuery` and its parsed form are cleared. Pass `false` to keep the current user query while resetting the rest of the options.
- **search**(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/> - **search**(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/>
@@ -97,14 +117,13 @@ Stores information from all the custom search and faceted search widgets, compil
- _scope:_ `RequestScope` - - _scope:_ `RequestScope` -
- **update**(queryBody?: `SearchRequest`)<br/>
Builds the current query and triggers the `updated` event.
- _queryBody:_ `SearchRequest` - (Optional)
- **updateSearchQueryParams**() <br/> - **updateSearchQueryParams**() <br/>
Encodes the query and navigates to existing search route adding encoded query as a search param. Encodes the query and navigates to existing search route adding encoded query as a search param.
- **updateSelectedConfiguration**(index: `number`)<br/> - **updateSelectedConfiguration**(id: `string`, resetFilters: `boolean` = `true`, shouldExecute: `boolean` = `true`)<br/>
Switches the active search configuration to the one matching the supplied id (only relevant when multiple configurations are provided).
- _index:_ `number` - - _id:_ `string` - Id of the configuration to select
- _resetFilters:_ `boolean` - (Optional) When `true` (default), the current search options are reset before applying the new configuration. Pass `false` to keep them.
- _shouldExecute:_ `boolean` - (Optional) When `true` (default), the query is executed immediately after switching configuration.
## Details ## Details
@@ -127,10 +146,6 @@ You can use custom widgets to populate and edit the following parts of the resul
```ts ```ts
constructor(queryBuilder: SearchQueryBuilderService) { constructor(queryBuilder: SearchQueryBuilderService) {
queryBuilder.updated.subscribe(query => {
this.queryBuilder.execute();
});
queryBuilder.executed.subscribe(data => { queryBuilder.executed.subscribe(data => {
this.onDataLoaded(data); this.onDataLoaded(data);
}); });
@@ -138,6 +153,29 @@ constructor(queryBuilder: SearchQueryBuilderService) {
} }
``` ```
To run a search, build the query state (for example by setting `userQuery` or by letting a
search widget update `queryFragments`) and then call `execute()`. The result is delivered
through the `executed` stream.
```ts
this.queryBuilder.userQuery = 'invoice';
void this.queryBuilder.execute();
```
> **Note:** Earlier versions exposed an `updated` stream and an `update()` method that built the
> query and emitted it so that a subscriber could call `execute()`. Both have been removed; build
> the query state and call `execute()` directly instead.
### Search modes
The builder supports two search modes, selected through the `searchMode` property:
- **regular** (default) - the text in `userQuery` is treated as user input and parsed into a
field query. The query is split into terms, each term is matched against the fields listed in
the `app:fields` search configuration entry (falling back to `cm:name`), and a `*` wildcard is
appended when wildcards are enabled. Bare `AND`/`OR` words are preserved as operators.
- **formula** - the text in `userQuery` is used verbatim as an [AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression, allowing callers that already build their own query syntax to bypass parsing.
> **Note:** From ADF 3.0.0, the query contains the `"facetFormat": "V2"` parameter so that all the responses have the same structure whether they come from search queries containing facetFields, facetQueries, grouped facetQueries or facetIntervals. > **Note:** From ADF 3.0.0, the query contains the `"facetFormat": "V2"` parameter so that all the responses have the same structure whether they come from search queries containing facetFields, facetQueries, grouped facetQueries or facetIntervals.
## Runtime Configuration ## Runtime Configuration
+44
View File
@@ -13,6 +13,50 @@ The authType parameter specifies the authentication method, with BASIC and OAUTH
"authType": "OAUTH" "authType": "OAUTH"
} }
``` ```
## Session Timeout
ADF can track user activity and show a countdown dialog before logging out an idle authenticated session. Register the feature with `provideSessionTimeout` in your application providers:
```ts
import { ApplicationConfig } from '@angular/core';
import { provideSessionTimeout } from '@alfresco/adf-core';
export const appConfig: ApplicationConfig = {
providers: [
provideSessionTimeout({
enabled: true,
idleTimeoutMs: 30 * 60 * 1000,
dialogTimeoutMs: 60 * 1000
})
]
};
```
The same values can be configured in `app.config.json` with the `sessionTimeout` key:
```json
{
"sessionTimeout": {
"enabled": true,
"idleTimeoutMs": 1800000,
"dialogTimeoutMs": 60000
}
}
```
Registering `provideSessionTimeout` enables the feature: `enabled` defaults to `true`, so the countdown is active unless you set `enabled` to `false` in `app.config.json` or in the options passed to `provideSessionTimeout`. The default idle timeout is 30 minutes, and the default dialog timeout is 60 seconds. Values passed to `provideSessionTimeout` take precedence over values from `app.config.json`.
Applications can defer startup until another async condition is enabled:
```ts
provideSessionTimeout({
startWhen: () => featureService.isOn$('session-timeout-feature')
});
```
Whether the user clicks **Log out** in the countdown dialog or leaves it unanswered until the countdown elapses, the normal logout flow runs and the user is redirected to the configured IdP/login page. The same logout is broadcast to other tabs so every session ends together.
# OAuth2 Configuration # OAuth2 Configuration
OAuth2 is a protocol that allows the application to authorize operations without exposing user credentials. The configuration includes several parameters essential for setting up OAuth2 authentication. OAuth2 is a protocol that allows the application to authorize operations without exposing user credentials. The configuration includes several parameters essential for setting up OAuth2 authentication.
+2 -3
View File
@@ -90,7 +90,6 @@ export class YourSearchComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.queryBuilder.resetToDefaults(); this.queryBuilder.resetToDefaults();
this.queryBuilder.updated.subscribe(() => void this.queryBuilder.execute());
this.queryBuilder.executed.subscribe((data) => { this.queryBuilder.executed.subscribe((data) => {
this.queryBuilder.paging.skipCount = 0; this.queryBuilder.paging.skipCount = 0;
this.onSearchResultLoaded(data); this.onSearchResultLoaded(data);
@@ -103,7 +102,7 @@ export class YourSearchComponent implements OnInit {
onSearchQueryChanged(string: string) { onSearchQueryChanged(string: string) {
this.queryBuilder.userQuery = decodeURIComponent(string); this.queryBuilder.userQuery = decodeURIComponent(string);
this.queryBuilder.update(); void this.queryBuilder.execute();
} }
async onPaginationChanged(pagination: Pagination) { async onPaginationChanged(pagination: Pagination) {
@@ -111,7 +110,7 @@ export class YourSearchComponent implements OnInit {
maxItems: pagination.maxItems, maxItems: pagination.maxItems,
skipCount: pagination.skipCount skipCount: pagination.skipCount
}; };
this.queryBuilder.update(); void this.queryBuilder.execute();
} }
} }
``` ```
@@ -16,6 +16,7 @@ This page describes how you can configure the search configuration.
- [Steps Involved In Search Configuration](#steps-involved-in-search-configuration) - [Steps Involved In Search Configuration](#steps-involved-in-search-configuration)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Extra fields and filter queries](#extra-fields-and-filter-queries) - [Extra fields and filter queries](#extra-fields-and-filter-queries)
- [Search modes and wildcards](#search-modes-and-wildcards)
- [Sorting](#sorting) - [Sorting](#sorting)
- [Categories and widgets](#categories-and-widgets) - [Categories and widgets](#categories-and-widgets)
- [Facet Fields](#facet-fields) - [Facet Fields](#facet-fields)
@@ -267,6 +268,44 @@ settings:
Note that the entries of the `filterQueries` array are joined using the `AND` operator. Note that the entries of the `filterQueries` array are joined using the `AND` operator.
### Search modes and wildcards
When a user types free text into a search input, the [Search Query Builder Service](../../content-services/services/search-query-builder.service.md) turns that text (its `userQuery`) into the final query according to the configured *search mode*:
- **regular** (default) - the user input is parsed into a field query. Each term is matched
against the fields listed in the `app:fields` array (falling back to `cm:name` when it is not
set), and a `*` wildcard is appended to each term when wildcards are enabled. Words that are
exactly `AND` or `OR` are preserved as logical operators.
- **formula** - the user input is passed through verbatim as an
[AFTS](https://docs.alfresco.com/content-services/latest/develop/search-api/) expression. Use
this mode when the caller already builds valid query syntax.
The `app:fields` entry lists the fields used to expand a `regular` user query:
```json
{
"search": {
...
"app:fields": ["cm:name", "cm:title", "cm:description"]
...
}
}
```
For example, with the configuration above and wildcards enabled, the user query `report` is
expanded to `(cm:name:"report*" OR cm:title:"report*" OR cm:description:"report*")`.
Wildcard matching is controlled by the top-level `search-wildcards-enabled` flag in
`app.config.json` (default `true`). When set to `false`, terms are matched exactly and the
trailing `*` is not added (this also disables the `searchPrefix`/`searchSuffix` of the
[Search text component](../content-services/components/search-text.component.md)):
```json
{
"search-wildcards-enabled": false
}
```
### Sorting ### Sorting
The Sorting configuration section consists of two blocks: The Sorting configuration section consists of two blocks:
@@ -26,7 +26,6 @@ import { DocumentListComponent } from '../../document-list/components/document-l
import { CustomResourcesService } from '../../document-list/services/custom-resources.service'; import { CustomResourcesService } from '../../document-list/services/custom-resources.service';
import { NodeEntryEvent, ShareDataRow } from '../../document-list'; import { NodeEntryEvent, ShareDataRow } from '../../document-list';
import { SearchQueryBuilderService } from '../../search'; import { SearchQueryBuilderService } from '../../search';
import { mockSearchRequest } from '../../mock/search-query.mock';
import { SitesService } from '../../common/services/sites.service'; import { SitesService } from '../../common/services/sites.service';
import { NodesApiService } from '../../common/services/nodes-api.service'; import { NodesApiService } from '../../common/services/nodes-api.service';
import { UnitTestingUtils } from '../../../../../core/src/lib/testing/unit-testing-utils'; import { UnitTestingUtils } from '../../../../../core/src/lib/testing/unit-testing-utils';
@@ -161,43 +160,70 @@ describe('ContentNodeSelectorPanelComponent', () => {
}); });
it('should the user query get updated when the user types in the search input', fakeAsync(() => { it('should the user query get updated when the user types in the search input', fakeAsync(() => {
const updateSpy = spyOn(searchQueryBuilderService, 'update');
typeToSearchBox('search-term'); typeToSearchBox('search-term');
tick(debounceSearch); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(updateSpy).toHaveBeenCalled(); expect(searchSpy).toHaveBeenCalled();
expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)'); expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)');
expect(component.searchTerm).toEqual('search-term'); expect(component.searchTerm).toEqual('search-term');
})); }));
it('should set the query builder search mode to formula when the user types in the search input', fakeAsync(() => {
typeToSearchBox('search-term');
tick(debounceSearch);
fixture.detectChanges();
expect(searchQueryBuilderService.searchMode).toEqual('formula');
}));
it('should add the wildcard suffix to the user query when wildcards are enabled', fakeAsync(() => {
spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(true);
typeToSearchBox('search-term');
tick(debounceSearch);
fixture.detectChanges();
expect(searchQueryBuilderService.userQuery).toEqual('(search-term*)');
}));
it('should NOT add the wildcard suffix to the user query when wildcards are disabled', fakeAsync(() => {
spyOnProperty(searchQueryBuilderService, 'wildcardsEnabled', 'get').and.returnValue(false);
typeToSearchBox('search-term');
tick(debounceSearch);
fixture.detectChanges();
expect(searchQueryBuilderService.userQuery).toEqual('(search-term)');
}));
it('should perform a search when the search request gets updated and it is defined', fakeAsync(() => { it('should perform a search when the search request gets updated and it is defined', fakeAsync(() => {
typeToSearchBox('search-term'); typeToSearchBox('search-term');
tick(debounceSearch); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); expect(searchSpy).toHaveBeenCalledWith(false);
})); }));
it('should NOT perform a search and clear the results when the search request gets updated and it is NOT defined', async () => { it('should NOT perform a search and clear the results when the search input is empty', fakeAsync(() => {
spyOn(component, 'clearSearch'); spyOn(component, 'clearSearch');
searchQueryBuilderService.userQuery = ''; typeToSearchBox('');
searchQueryBuilderService.update();
tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable();
expect(searchSpy).not.toHaveBeenCalled(); expect(searchSpy).not.toHaveBeenCalled();
expect(component.clearSearch).toHaveBeenCalled(); expect(component.clearSearch).toHaveBeenCalled();
}); }));
it('should reset the search term when clicking the clear icon', async () => { it('should reset the search term when clicking the clear icon', async () => {
component.searchTerm = 'search-term'; component.searchTerm = 'search-term';
searchQueryBuilderService.userQuery = 'search-term'; searchQueryBuilderService.userQuery = 'search-term';
spyOn(component, 'clearSearch'); spyOn(component, 'clear').and.callThrough();
fixture.detectChanges(); fixture.detectChanges();
const clearIcon = getSearchIcon('clear'); const clearIcon = getSearchIcon('clear');
@@ -207,7 +233,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(searchQueryBuilderService.userQuery).toEqual(''); expect(searchQueryBuilderService.userQuery).toEqual('');
expect(component.searchTerm).toEqual(''); expect(component.searchTerm).toEqual('');
expect(component.clearSearch).toHaveBeenCalled(); expect(component.clear).toHaveBeenCalled();
}); });
it('should load the results by calling the search api on search change', fakeAsync(() => { it('should load the results by calling the search api on search change', fakeAsync(() => {
@@ -216,22 +242,21 @@ describe('ContentNodeSelectorPanelComponent', () => {
tick(debounceSearch); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); expect(searchSpy).toHaveBeenCalledWith(false);
})); }));
it('should the query include the show files filterQuery', fakeAsync(() => { it('should the query include the show files filterQuery', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
component.showFilesInResult = true; component.showFilesInResult = true;
typeToSearchBox('search-term'); typeToSearchBox('search-term');
const expectedRequest = mockSearchRequest; const expectedRequest = `TYPE:'cm:folder' OR TYPE:'cm:content'`;
expectedRequest.filterQueries.push({
query: `TYPE:'cm:folder' OR TYPE:'cm:content'`
});
tick(debounceSearch); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); expect(searchSpy).toHaveBeenCalledWith(false);
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
})); }));
it('should reset the currently chosen node in case of starting a new search', fakeAsync(() => { it('should reset the currently chosen node in case of starting a new search', fakeAsync(() => {
@@ -253,6 +278,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
}); });
it('should perform a search when selecting a site with the correct query', fakeAsync(() => { it('should perform a search when selecting a site with the correct query', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
typeToSearchBox('search-term'); typeToSearchBox('search-term');
tick(debounceSearch); tick(debounceSearch);
@@ -261,14 +287,15 @@ describe('ContentNodeSelectorPanelComponent', () => {
component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry); component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry);
const expectedRequest = mockSearchRequest; const expectedRequest = `ANCESTOR:'workspace://SpacesStore/namek'`;
expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/namek'` }];
expect(searchSpy.calls.count()).toBe(2); expect(searchSpy.calls.count()).toBe(2);
expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); expect(searchSpy).toHaveBeenCalledWith(false);
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
})); }));
it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => { it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging; component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging;
component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node; component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node;
fixture.detectChanges(); fixture.detectChanges();
@@ -281,17 +308,12 @@ describe('ContentNodeSelectorPanelComponent', () => {
component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry); component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry);
const expectedRequest = mockSearchRequest; const expectedRequest = `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'`;
expectedRequest.filterQueries = [
{
query: `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId' OR ANCESTOR:'workspace://SpacesStore/09876543testId'`
}
];
expect(searchSpy).toHaveBeenCalled(); expect(searchSpy).toHaveBeenCalled();
expect(searchSpy.calls.count()).toBe(2); expect(searchSpy.calls.count()).toBe(2);
expect(searchSpy).toHaveBeenCalledWith(false, mockSearchRequest); expect(searchSpy).toHaveBeenCalledWith(false);
expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
})); }));
it('should get the corresponding node ids on search when a known alias is selected from dropdown', fakeAsync(() => { it('should get the corresponding node ids on search when a known alias is selected from dropdown', fakeAsync(() => {
@@ -395,7 +417,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(component.clear).toHaveBeenCalled(); expect(component.clear).toHaveBeenCalled();
})); }));
it('should clear the search field, nodes and chosenNode when clicking on the X (clear) icon', async () => { it('should reset the search term and user query when clearing the search', async () => {
component.chosenNode = [entry]; component.chosenNode = [entry];
component.nodePaging = { component.nodePaging = {
@@ -404,32 +426,31 @@ describe('ContentNodeSelectorPanelComponent', () => {
} }
}; };
component.searchTerm = 'piccolo'; component.searchTerm = 'piccolo';
searchQueryBuilderService.userQuery = 'piccolo';
component.showingSearchResults = true; component.showingSearchResults = true;
component.clear(); component.clear();
expect(component.searchTerm).toBe(''); expect(component.searchTerm).toBe('');
expect(component.nodePaging).toEqual(null); expect(searchQueryBuilderService.userQuery).toBe('');
expect(component.chosenNode).toBeNull(); expect(searchSpy).toHaveBeenCalledWith(false);
expect(component.showingSearchResults).toBeFalsy();
}); });
it('should the query restrict the search to the currentFolderId in case is defined', fakeAsync(() => { it('should the query restrict the search to the currentFolderId in case is defined', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
component.currentFolderId = 'my-root-id'; component.currentFolderId = 'my-root-id';
component.restrictRootToCurrentFolderId = true; component.restrictRootToCurrentFolderId = true;
component.ngOnInit(); component.ngOnInit();
typeToSearchBox('search-term'); typeToSearchBox('search-term');
tick(debounceSearch); tick(debounceSearch);
const expectedRequest = mockSearchRequest; const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-root-id'`;
expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-root-id'` }];
expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); expect(searchSpy).toHaveBeenCalledWith(false);
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
})); }));
it('should emit showingSearch event with true while searching', async () => { it('should emit showingSearch event with true while searching', async () => {
searchQueryBuilderService.userQuery = 'mock-search-term';
searchQueryBuilderService.update();
spyOn(customResourcesService, 'hasCorrespondingNodeIds').and.returnValue(true); spyOn(customResourcesService, 'hasCorrespondingNodeIds').and.returnValue(true);
const showingSearchSpy = spyOn(component.showingSearch, 'emit'); const showingSearchSpy = spyOn(component.showingSearch, 'emit');
@@ -454,8 +475,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(showingSearchSpy).toHaveBeenCalledWith(false); expect(showingSearchSpy).toHaveBeenCalledWith(false);
})); }));
it('should emit showingResults event with false when clicking on the X (clear) icon', async () => { it('should re-run the query with an empty user query when clicking on the X (clear) icon', async () => {
const showingSearchSpy = spyOn(component.showingSearch, 'emit');
component.chosenNode = [entry]; component.chosenNode = [entry];
component.nodePaging = { component.nodePaging = {
@@ -464,17 +484,16 @@ describe('ContentNodeSelectorPanelComponent', () => {
} }
}; };
component.searchTerm = 'piccolo'; component.searchTerm = 'piccolo';
component.showingSearchResults = true; searchQueryBuilderService.userQuery = 'piccolo';
component.clear(); component.clear();
expect(component.showingSearchResults).toBe(false); expect(component.searchTerm).toBe('');
expect(showingSearchSpy).toHaveBeenCalledWith(false); expect(searchQueryBuilderService.userQuery).toBe('');
expect(searchSpy).toHaveBeenCalledWith(false);
}); });
it('should emit showingResults event with false if search api fails', async () => { it('should emit showingResults event with false if search api fails', async () => {
searchQueryBuilderService.userQuery = 'mock-search-term';
searchQueryBuilderService.update();
getCorrespondingNodeIdsSpy.and.throwError('Failed'); getCorrespondingNodeIdsSpy.and.throwError('Failed');
const showingSearchSpy = spyOn(component.showingSearch, 'emit'); const showingSearchSpy = spyOn(component.showingSearch, 'emit');
await searchQueryBuilderService.execute(true, { query: { query: 'search' } }); await searchQueryBuilderService.execute(true, { query: { query: 'search' } });
@@ -488,15 +507,16 @@ describe('ContentNodeSelectorPanelComponent', () => {
}); });
it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => { it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
searchQueryBuilderService.userQuery = 'search-term*'; searchQueryBuilderService.userQuery = 'search-term*';
component.currentFolderId = 'my-root-id'; component.currentFolderId = 'my-root-id';
component.restrictRootToCurrentFolderId = true; component.restrictRootToCurrentFolderId = true;
component.siteChanged({ entry: { guid: 'my-site-id' } } as SiteEntry); component.siteChanged({ entry: { guid: 'my-site-id' } } as SiteEntry);
const expectedRequest = mockSearchRequest; const expectedRequest = `ANCESTOR:'workspace://SpacesStore/my-site-id'`;
expectedRequest.filterQueries = [{ query: `ANCESTOR:'workspace://SpacesStore/my-site-id'` }];
expect(searchSpy).toHaveBeenCalledWith(false, expectedRequest); expect(searchSpy).toHaveBeenCalledWith(false);
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
}); });
it('should restrict the breadcrumb to the currentFolderId in case restrictedRoot is true', async () => { it('should restrict the breadcrumb to the currentFolderId in case restrictedRoot is true', async () => {
@@ -563,10 +583,12 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(searchSpy.calls.count()).toBe(2); expect(searchSpy.calls.count()).toBe(2);
component.clear(); typeToSearchBox('');
tick(debounceSearch);
expect(component.searchTerm).toBe(''); expect(component.searchTerm).toBe('');
expect(component.folderIdToShow).toBe('namek'); expect(component.folderIdToShow).toBe('namek');
flush();
})); }));
it('should show the current folder content instead of search results if search was not performed', async () => { it('should show the current folder content instead of search results if search was not performed', async () => {
@@ -638,25 +660,23 @@ describe('ContentNodeSelectorPanelComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]');
expect(documentList).not.toBeNull(); expect(documentList).not.toBeNull();
expect(component.hasValidQuery).toEqual(true);
expect(documentList.componentInstance.currentFolderId).toBeNull(); expect(documentList.componentInstance.currentFolderId).toBeNull();
done(); done();
}, 300); }, 300);
}); });
it('should not show the result list when results are returned but there is no search term typed', (done) => { it('should not run a search nor show the result list when there is no search term typed', fakeAsync(() => {
searchQueryBuilderService.userQuery = ''; spyOn(component, 'clearSearch').and.callThrough();
searchQueryBuilderService.update();
setTimeout(() => { typeToSearchBox('');
triggerSearchResults(fakeResultSetPaging); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(component.hasValidQuery).toEqual(false); expect(searchSpy).not.toHaveBeenCalled();
expect(component.showingSearchResults).toEqual(false); expect(component.clearSearch).toHaveBeenCalled();
done(); expect(component.showingSearchResults).toEqual(false);
}, 300); flush();
}); }));
it('should highlight the results when search was performed in the next timeframe', (done) => { it('should highlight the results when search was performed in the next timeframe', (done) => {
typeToSearchBox('My'); typeToSearchBox('My');
@@ -673,7 +693,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
}, 300); }, 300);
}); });
it('should show the default text instead of result list if search was cleared', (done) => { it('should reset the search term and re-run an empty query when the clear button is clicked', (done) => {
typeToSearchBox(); typeToSearchBox();
setTimeout(() => { setTimeout(() => {
@@ -683,12 +703,13 @@ describe('ContentNodeSelectorPanelComponent', () => {
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
const clearButton = getSearchIcon('clear'); const clearButton = getSearchIcon('clear');
expect(clearButton).not.toBeNull(); expect(clearButton).not.toBeNull();
searchSpy.calls.reset();
clearButton.triggerEventHandler('click', {}); clearButton.triggerEventHandler('click', {});
fixture.detectChanges(); fixture.detectChanges();
const documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); expect(component.searchTerm).toBe('');
expect(documentList).not.toBeNull(); expect(searchQueryBuilderService.userQuery).toBe('');
expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); expect(searchSpy).toHaveBeenCalledWith(false);
done(); done();
}); });
}, 300); }, 300);
@@ -709,20 +730,20 @@ describe('ContentNodeSelectorPanelComponent', () => {
flush(); flush();
})); }));
it('should set the folderIdToShow to the default "currentFolderId" if siteId is undefined', (done) => { it('should reset the folder to the selected site and fall back to currentFolderId when site is undefined', () => {
component.siteChanged({ entry: { guid: 'Kame-Sennin Muten Roshi' } } as SiteEntry); component.siteChanged({ entry: { guid: 'Kame-Sennin Muten Roshi' } } as SiteEntry);
component.resetFolderToShow();
fixture.detectChanges(); fixture.detectChanges();
let documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); let documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]');
expect(documentList.componentInstance.currentFolderId).toBe('Kame-Sennin Muten Roshi'); expect(documentList.componentInstance.currentFolderId).toBe('Kame-Sennin Muten Roshi');
component.siteChanged({ entry: { guid: undefined } } as SiteEntry); component.siteChanged({ entry: { guid: undefined } } as SiteEntry);
component.resetFolderToShow();
fixture.detectChanges(); fixture.detectChanges();
documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]'); documentList = testingUtils.getByCSS('[data-automation-id="content-node-selector-document-list"]');
expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku'); expect(documentList.componentInstance.currentFolderId).toBe('cat-girl-nuku-nuku');
done();
}); });
describe('Pagination "Load more" button', () => { describe('Pagination "Load more" button', () => {
@@ -752,7 +773,7 @@ describe('ContentNodeSelectorPanelComponent', () => {
}); });
it('should set its loading state to true to perform a new search', async () => { it('should set its loading state to true to perform a new search', async () => {
component.prepareDialogForNewSearch(mockSearchRequest); component.prepareDialogForNewSearch();
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
@@ -764,13 +785,14 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(paginationLoading).not.toBeNull(); expect(paginationLoading).not.toBeNull();
}); });
it('Should infinite pagination target be null when we use it for search ', fakeAsync(() => { it('Should keep the document list as infinite pagination target while searching', fakeAsync(() => {
component.showingSearchResults = true; component.showingSearchResults = true;
typeToSearchBox('shenron'); typeToSearchBox('shenron');
tick(debounceSearch); tick(debounceSearch);
fixture.detectChanges(); fixture.detectChanges();
expect(component.target).toBeNull(); expect(component.target).toEqual(component.documentList);
flush();
})); }));
it('Should infinite pagination target be present when search finish', () => { it('Should infinite pagination target be present when search finish', () => {
@@ -270,7 +270,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
it('should not show the breadcrumb if search was performed as last action', async () => { it('should not show the breadcrumb if search was performed as last action', async () => {
searchQueryBuilderService.userQuery = 'mock-search-term'; searchQueryBuilderService.userQuery = 'mock-search-term';
searchQueryBuilderService.update();
triggerSearchResults(fakeResultSetPaging); triggerSearchResults(fakeResultSetPaging);
fixture.detectChanges(); fixture.detectChanges();
@@ -291,7 +290,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
it('should show the breadcrumb in search results for a valid node selection', async () => { it('should show the breadcrumb in search results for a valid node selection', async () => {
searchQueryBuilderService.userQuery = 'mock-search-term'; searchQueryBuilderService.userQuery = 'mock-search-term';
searchQueryBuilderService.update();
triggerSearchResults(fakeResultSetPaging); triggerSearchResults(fakeResultSetPaging);
const chosenNode = new Node({ path: { elements: [{ name: 'one' }] } }); const chosenNode = new Node({ path: { elements: [{ name: 'one' }] } });
@@ -306,7 +304,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
it('should show the breadcrumb in search results even for an invalid node selection', async () => { it('should show the breadcrumb in search results even for an invalid node selection', async () => {
component.isSelectionValid = (node: Node) => node.isFile; component.isSelectionValid = (node: Node) => node.isFile;
searchQueryBuilderService.userQuery = 'mock-search-term'; searchQueryBuilderService.userQuery = 'mock-search-term';
searchQueryBuilderService.update();
triggerSearchResults(fakeResultSetPaging); triggerSearchResults(fakeResultSetPaging);
const chosenNode = new Node({ path: { elements: [{ name: 'fake-path' }] }, isFile: false, isFolder: true }); const chosenNode = new Node({ path: { elements: [{ name: 'fake-path' }] }, isFile: false, isFolder: true });
@@ -33,7 +33,7 @@ import {
} from '@alfresco/adf-core'; } from '@alfresco/adf-core';
import { FileUploadCompleteEvent, FileUploadDeleteEvent, NodesApiService, SitesService, UploadService } from '../../common'; import { FileUploadCompleteEvent, FileUploadDeleteEvent, NodesApiService, SitesService, UploadService } from '../../common';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SearchRequest, SiteEntry, SitePaging } from '@alfresco/js-api'; import { Node, NodeEntry, NodePaging, Pagination, RequestScope, SiteEntry, SitePaging } from '@alfresco/js-api';
import { DocumentListComponent } from '../../document-list/components/document-list.component'; import { DocumentListComponent } from '../../document-list/components/document-list.component';
import { RowFilter } from '../../document-list/data/row-filter.model'; import { RowFilter } from '../../document-list/data/row-filter.model';
import { ImageResolver } from '../../document-list/data/image-resolver.model'; import { ImageResolver } from '../../document-list/data/image-resolver.model';
@@ -292,7 +292,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
folderIdToShow: string | null = null; folderIdToShow: string | null = null;
breadcrumbFolderTitle: string | null = null; breadcrumbFolderTitle: string | null = null;
startSiteGuid: string | null = null; startSiteGuid: string | null = null;
hasValidQuery: boolean = false;
showHeader = ShowHeaderMode.Never; showHeader = ShowHeaderMode.Never;
@ViewChild(InfinitePaginationComponent, { static: true }) @ViewChild(InfinitePaginationComponent, { static: true })
@@ -334,26 +333,16 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
.pipe(debounceTime(this.debounceSearch), takeUntilDestroyed(this.destroyRef)) .pipe(debounceTime(this.debounceSearch), takeUntilDestroyed(this.destroyRef))
.subscribe((searchValue: string) => { .subscribe((searchValue: string) => {
this.searchTerm = searchValue; this.searchTerm = searchValue;
this.queryBuilderService.userQuery = searchValue.length > 0 ? `${searchValue}*` : searchValue; if (this.searchTerm) {
this.queryBuilderService.update(); this.executeSearch(searchValue);
} else {
this.resetFolderToShow();
this.clearSearch();
}
}); });
this.queryBuilderService.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((searchRequest) => {
if (searchRequest) {
this.hasValidQuery = true;
this.prepareDialogForNewSearch(searchRequest);
this.queryBuilderService.execute(false, searchRequest);
} else {
this.hasValidQuery = false;
this.resetFolderToShow();
this.clearSearch();
}
});
this.queryBuilderService.executed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results: NodePaging) => { this.queryBuilderService.executed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((results: NodePaging) => {
if (this.hasValidQuery) { this.showSearchResults(results);
this.showSearchResults(results);
}
}); });
this.userPreferencesService this.userPreferencesService
@@ -451,7 +440,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.siteId = chosenSite.entry.guid; this.siteId = chosenSite.entry.guid;
this.setTitleIfCustomSite(chosenSite); this.setTitleIfCustomSite(chosenSite);
this.siteChange.emit(chosenSite.entry.title); this.siteChange.emit(chosenSite.entry.title);
this.queryBuilderService.update(); this.executeSearch(this.searchTerm);
} }
/** /**
@@ -473,14 +462,8 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
/** /**
* Prepares the dialog for a new search * Prepares the dialog for a new search
*
* @param searchRequest request options
*/ */
prepareDialogForNewSearch(searchRequest: SearchRequest): void { prepareDialogForNewSearch(): void {
this.target = searchRequest ? null : this.documentList;
if (this.target) {
this.infinitePaginationComponent.reset();
}
this.folderIdToShow = null; this.folderIdToShow = null;
this.preselectedNodes = []; this.preselectedNodes = [];
this.loadingSearchResults = true; this.loadingSearchResults = true;
@@ -494,7 +477,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
clear(): void { clear(): void {
this.searchTerm = ''; this.searchTerm = '';
this.queryBuilderService.userQuery = ''; this.queryBuilderService.userQuery = '';
this.queryBuilderService.update(); this.executeSearch(this.searchTerm);
} }
/** /**
@@ -619,7 +602,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.queryBuilderService.paging.skipCount = pagination.skipCount; this.queryBuilderService.paging.skipCount = pagination.skipCount;
if (this.searchTerm.length > 0) { if (this.searchTerm.length > 0) {
this.queryBuilderService.update(); this.executeSearch(this.searchTerm);
} }
} }
@@ -696,4 +679,12 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
skipCount: this.DEFAULT_PAGINATION.skipCount skipCount: this.DEFAULT_PAGINATION.skipCount
}; };
} }
private executeSearch(searchValue: string): void {
this.prepareDialogForNewSearch();
this.queryBuilderService.searchMode = 'formula';
const wildcardSuffix = this.queryBuilderService.wildcardsEnabled ? '*' : '';
this.queryBuilderService.userQuery = searchValue.length > 0 ? `(${searchValue}${wildcardSuffix})` : searchValue;
this.queryBuilderService.execute(false);
}
} }
@@ -23,6 +23,8 @@ import { SimpleChange } from '@angular/core';
import { SearchHeaderQueryBuilderService } from './../../../search/services/search-header-query-builder.service'; import { SearchHeaderQueryBuilderService } from './../../../search/services/search-header-query-builder.service';
import { FilterHeaderComponent } from './filter-header.component'; import { FilterHeaderComponent } from './filter-header.component';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { SearchCategory } from '@alfresco/adf-content-services';
import { NodePaging } from '@alfresco/js-api';
describe('FilterHeaderComponent', () => { describe('FilterHeaderComponent', () => {
let fixture: ComponentFixture<FilterHeaderComponent>; let fixture: ComponentFixture<FilterHeaderComponent>;
@@ -150,7 +152,7 @@ describe('FilterHeaderComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(Object.keys(queryBuilder.filterRawParams).length).toBe(0); expect(queryBuilder.filterRawParams['name']).toBeUndefined();
component.value = { name: 'pinocchio' }; component.value = { name: 'pinocchio' };
const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true);
@@ -158,11 +160,41 @@ describe('FilterHeaderComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(Object.keys(queryBuilder.filterRawParams).length).toBe(1);
expect(queryBuilder.filterRawParams['name']).toBe('pinocchio'); expect(queryBuilder.filterRawParams['name']).toBe('pinocchio');
expect(queryBuilder.queryFragments['name']).toBe('pinocchio'); expect(queryBuilder.queryFragments['name']).toBe('pinocchio');
}); });
it('should build a wildcard field query fragment for the queryName filter when wildcards are enabled', async () => {
spyOn(queryBuilder, 'setCurrentRootFolderId');
spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false);
spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(true);
queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory];
component.value = { queryName: 'pinocchio' };
const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true);
component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange });
fixture.detectChanges();
await fixture.whenStable();
expect(queryBuilder.filterRawParams['queryName']).toBe('pinocchio');
expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'*pinocchio*'`);
});
it('should build a non-wildcard field query fragment for the queryName filter when wildcards are disabled', async () => {
spyOn(queryBuilder, 'setCurrentRootFolderId');
spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false);
spyOnProperty(queryBuilder, 'wildcardsEnabled', 'get').and.returnValue(false);
queryBuilder.categories = [{ id: 'queryName', component: { settings: { field: 'cm:name' } } } as SearchCategory];
component.value = { queryName: 'pinocchio' };
const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true);
component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange });
fixture.detectChanges();
await fixture.whenStable();
expect(queryBuilder.queryFragments['queryName']).toBe(`cm:name:'pinocchio'`);
});
it('should emit filterSelection when a filter is changed', (done) => { it('should emit filterSelection when a filter is changed', (done) => {
spyOn(queryBuilder, 'getActiveFilters').and.returnValue([{ key: 'name', value: 'pinocchio' }]); spyOn(queryBuilder, 'getActiveFilters').and.returnValue([{ key: 'name', value: 'pinocchio' }]);
@@ -193,7 +225,7 @@ describe('FilterHeaderComponent', () => {
it('should emit searchResultsReady when search query builder executes', (done) => { it('should emit searchResultsReady when search query builder executes', (done) => {
fixture.detectChanges(); // Initialize component (triggers ngOnInit) fixture.detectChanges(); // Initialize component (triggers ngOnInit)
const mockNodePaging: any = { list: { entries: [] } }; const mockNodePaging: NodePaging = { list: { entries: [] } };
component.searchResultsReady.subscribe((nodePaging) => { component.searchResultsReady.subscribe((nodePaging) => {
expect(nodePaging).toBe(mockNodePaging); expect(nodePaging).toBe(mockNodePaging);
@@ -115,9 +115,16 @@ export class FilterHeaderComponent implements OnInit, OnChanges {
const operator = this.searchFilterQueryBuilder.getOperatorForFilterId(key) || 'OR'; const operator = this.searchFilterQueryBuilder.getOperatorForFilterId(key) || 'OR';
this.searchFilterQueryBuilder.filterRawParams[key] = this.value[key]; this.searchFilterQueryBuilder.filterRawParams[key] = this.value[key];
this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key]) if (key === 'queryName') {
? this.value[key].join(` ${operator} `) const filterConfig = this.searchFilterQueryBuilder.categories.find((category) => category.id === key);
: this.value[key]; const wildcardSuffix = this.searchFilterQueryBuilder.wildcardsEnabled ? '*' : '';
this.searchFilterQueryBuilder.queryFragments[key] =
`${filterConfig.component.settings.field}:'${wildcardSuffix}${this.value[key]}${wildcardSuffix}'`;
} else {
this.searchFilterQueryBuilder.queryFragments[key] = Array.isArray(this.value[key])
? this.value[key].join(` ${operator} `)
: this.value[key];
}
}); });
} }
this.searchFilterQueryBuilder.setCurrentRootFolderId(currentFolderId); this.searchFilterQueryBuilder.setCurrentRootFolderId(currentFolderId);
@@ -44,7 +44,7 @@ describe('SearchCheckListComponent', () => {
queryFragments: {}, queryFragments: {},
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy() execute: jasmine.createSpy('execute')
} as any; } as any;
}); });
@@ -134,7 +134,7 @@ describe('SearchCheckListComponent', () => {
component.reset(); component.reset();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.filterRawParams[component.id]).toBeUndefined();
}); });
@@ -226,7 +226,7 @@ describe('SearchCheckListComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.update).not.toHaveBeenCalled(); expect(component.context.execute).not.toHaveBeenCalled();
}); });
it('should populate filter state when populate filters event has been observed', () => { it('should populate filter state when populate filters event has been observed', () => {
@@ -107,7 +107,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
this.clearOptions(); this.clearOptions();
if (this.id && this.context && this.enableChangeUpdate) { if (this.id && this.context && this.enableChangeUpdate) {
this.updateDisplayValue(); this.updateDisplayValue();
this.context.update(); this.context.execute();
} }
} }
@@ -128,7 +128,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
if (this.id && this.context) { if (this.id && this.context) {
this.updateDisplayValue(); this.updateDisplayValue();
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -178,7 +178,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
this.context.queryFragments[this.id] = query; this.context.queryFragments[this.id] = query;
this.updateDisplayValue(); this.updateDisplayValue();
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -309,6 +309,13 @@ describe('SearchChipAutocompleteInputComponent', () => {
expect(inputChangedSpy).toHaveBeenCalledOnceWith('test-value'); expect(inputChangedSpy).toHaveBeenCalledOnceWith('test-value');
}); });
it('should not emit input value when input is empty', async () => {
const inputChangedSpy = spyOn(component.inputChanged, 'emit');
enterNewInputValue('');
await fixture.whenStable();
expect(inputChangedSpy).not.toHaveBeenCalled();
});
describe('isOptionSelected', () => { describe('isOptionSelected', () => {
beforeEach(() => { beforeEach(() => {
component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }]; component.autocompleteOptions = [{ value: 'option1' }, { value: 'option2' }];
@@ -108,8 +108,10 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnChanges {
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe((value: string) => { .subscribe((value: string) => {
this.filteredOptions = this.filter(this.autocompleteOptions, value); if (value) {
this.inputChanged.emit(value); this.filteredOptions = this.filter(this.autocompleteOptions, value);
this.inputChanged.emit(value);
}
}); });
this.onReset$?.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.reset()); this.onReset$?.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.reset());
this.selectedOptions = this.preselectedOptions ?? []; this.selectedOptions = this.preselectedOptions ?? [];
@@ -79,7 +79,7 @@ describe('SearchDateRangeTabbedComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { component.settings = {
hideDefaultAction: false, hideDefaultAction: false,
@@ -210,7 +210,7 @@ describe('SearchDateRangeTabbedComponent', () => {
expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(anyMockDate); expect(component.context.filterRawParams[component.id].modifiedDate).toEqual(anyMockDate);
}); });
it('should trigger context.update() when values are submitted', () => { it('should trigger context.execute() when values are submitted', () => {
component.onDateRangedValueChanged(betweenMockData, 'createdDate'); component.onDateRangedValueChanged(betweenMockData, 'createdDate');
component.onDateRangedValueChanged(inLastMockData, 'modifiedDate'); component.onDateRangedValueChanged(inLastMockData, 'modifiedDate');
fixture.detectChanges(); fixture.detectChanges();
@@ -221,7 +221,7 @@ describe('SearchDateRangeTabbedComponent', () => {
`createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` + `createdDate:['${formatISO(startOfDay(betweenMockData.betweenStartDate))}' TO '${formatISO(endOfDay(betweenMockData.betweenEndDate))}']` +
` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`; ` AND modifiedDate:['${formatISO(startOfDay(inLastStartDate))}' TO '${formatISO(endOfToday())}']`;
expect(component.context.queryFragments['dateRange']).toEqual(query); expect(component.context.queryFragments['dateRange']).toEqual(query);
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should clear values and search filter when widget is reset', (done) => { it('should clear values and search filter when widget is reset', (done) => {
@@ -231,7 +231,7 @@ describe('SearchDateRangeTabbedComponent', () => {
expect(component.combinedDisplayValue).toBe(''); expect(component.combinedDisplayValue).toBe('');
expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.displayValue$.next).toHaveBeenCalledWith('');
expect(component.context.queryFragments['dateRange']).toEqual(''); expect(component.context.queryFragments['dateRange']).toEqual('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
component.fields.forEach((field) => expect(component.context.filterRawParams[field]).toBeUndefined()); component.fields.forEach((field) => expect(component.context.filterRawParams[field]).toBeUndefined());
done(); done();
}); });
@@ -138,7 +138,7 @@ export class SearchDateRangeTabbedComponent implements SearchWidget, OnInit {
this.context.queryFragments[this.id] = this.combinedQuery; this.context.queryFragments[this.id] = this.combinedQuery;
this.displayValue$.next(this.combinedDisplayValue); this.displayValue$.next(this.combinedDisplayValue);
if (this.id && this.context && updateContext) { if (this.id && this.context && updateContext) {
this.context.update(); this.context.execute();
} }
} }
onDateRangedValueChanged(value: Partial<SearchDateRange>, field: string) { onDateRangedValueChanged(value: Partial<SearchDateRange>, field: string) {
@@ -46,7 +46,7 @@ describe('SearchDatetimeRangeComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { field: 'cm:created' }; component.settings = { field: 'cm:created' };
}); });
@@ -128,7 +128,7 @@ describe('SearchDatetimeRangeComponent', () => {
component.reset(); component.reset();
expect(component.context.queryFragments.createdDatetimeRange).toEqual(''); expect(component.context.queryFragments.createdDatetimeRange).toEqual('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should update the query in UTC format when values change', async () => { it('should update the query in UTC format when values change', async () => {
@@ -150,7 +150,7 @@ describe('SearchDatetimeRangeComponent', () => {
expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); expect(component.context.queryFragments[component.id]).toEqual(expectedQuery);
expect(component.context.filterRawParams[component.id]).toEqual({ start: expectedFromDate, end: expectedToDate }); expect(component.context.filterRawParams[component.id]).toEqual({ start: expectedFromDate, end: expectedToDate });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should be able to update the query in UTC format from a GMT format', async () => { it('should be able to update the query in UTC format from a GMT format', async () => {
@@ -175,7 +175,7 @@ describe('SearchDatetimeRangeComponent', () => {
expect(startDate).toContain('2021-02-24'); expect(startDate).toContain('2021-02-24');
expect(endDate).toContain('2021-02-28'); expect(endDate).toContain('2021-02-28');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
// Verify the query structure is correct without hardcoding exact timezone values // Verify the query structure is correct without hardcoding exact timezone values
const query = component.context.queryFragments[component.id]; const query = component.context.queryFragments[component.id];
@@ -171,7 +171,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit {
filterParam.end = end; filterParam.end = end;
this.updateDisplayValue(); this.updateDisplayValue();
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -242,7 +242,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit {
private updateQuery() { private updateQuery() {
if (this.id && this.context) { if (this.id && this.context) {
this.updateDisplayValue(); this.updateDisplayValue();
this.context.update(); this.context.execute();
} }
} }
@@ -44,7 +44,7 @@ describe('SearchFacetFieldComponent', () => {
}); });
it('should update bucket model and query builder on facet toggle', () => { it('should update bucket model and query builder on facet toggle', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough(); spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough();
const event: any = { checked: true }; const event: any = { checked: true };
@@ -57,12 +57,12 @@ describe('SearchFacetFieldComponent', () => {
expect(bucket.checked).toBeTruthy(); expect(bucket.checked).toBeTruthy();
expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
}); });
it('should update bucket model and query builder on facet un-toggle', () => { it('should update bucket model and query builder on facet un-toggle', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false }; const event: any = { checked: false };
@@ -75,12 +75,12 @@ describe('SearchFacetFieldComponent', () => {
component.onToggleBucket(event, facetField, bucket); component.onToggleBucket(event, facetField, bucket);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, bucket);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
}); });
it('should unselect facet query and update builder', () => { it('should unselect facet query and update builder', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false }; const event: any = { checked: false };
@@ -94,17 +94,17 @@ describe('SearchFacetFieldComponent', () => {
expect(query.checked).toEqual(false); expect(query.checked).toEqual(false);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, query); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(facetField.field, query);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled(); expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
}); });
it('should update query builder only when has bucket to unselect', () => { it('should update query builder only when has bucket to unselect', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
const field: FacetField = { field: 'f1', label: 'f1' }; const field: FacetField = { field: 'f1', label: 'f1' };
component.onToggleBucket({ checked: true } as any, field, null); component.onToggleBucket({ checked: true } as any, field, null);
expect(queryBuilder.update).not.toHaveBeenCalled(); expect(queryBuilder.execute).not.toHaveBeenCalled();
}); });
it('should allow to to reset selected buckets', () => { it('should allow to to reset selected buckets', () => {
@@ -166,7 +166,7 @@ describe('SearchFacetFieldComponent', () => {
}); });
it('should update query builder upon resetting buckets', () => { it('should update query builder upon resetting buckets', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
const buckets: FacetFieldBucket[] = [ const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' }, { label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
@@ -183,6 +183,6 @@ describe('SearchFacetFieldComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
component.resetSelectedBuckets(field); component.resetSelectedBuckets(field);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
}); });
}); });
@@ -69,7 +69,7 @@ export class SearchFacetFieldComponent implements FacetWidget {
this.searchFacetFiltersService.updateSelectedBuckets(); this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) { if (this.canUpdateOnChange) {
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
} }
@@ -81,7 +81,7 @@ export class SearchFacetFieldComponent implements FacetWidget {
this.searchFacetFiltersService.updateSelectedBuckets(); this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) { if (this.canUpdateOnChange) {
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
} }
@@ -101,7 +101,7 @@ export class SearchFacetFieldComponent implements FacetWidget {
} }
this.searchFacetFiltersService.updateSelectedBuckets(); this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) { if (this.canUpdateOnChange) {
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
} }
@@ -125,11 +125,11 @@ export class SearchFacetFieldComponent implements FacetWidget {
reset(): void { reset(): void {
this.resetSelectedBuckets(this.field); this.resetSelectedBuckets(this.field);
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
submitValues(): void { submitValues(): void {
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
@@ -47,7 +47,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { component.settings = {
field: 'test', field: 'test',
@@ -102,7 +102,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
clearBtn.click(); clearBtn.click();
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.selectedOptions).toEqual([]); expect(component.selectedOptions).toEqual([]);
expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.displayValue$.next).toHaveBeenCalledWith('');
expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.filterRawParams[component.id]).toBeUndefined();
@@ -116,7 +116,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => {
applyBtn.click(); applyBtn.click();
fixture.detectChanges(); fixture.detectChanges();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"'); expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"');
expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]); expect(component.context.filterRawParams[component.id]).toEqual([{ value: 'option2' }, { value: 'option1' }]);
@@ -111,7 +111,6 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
this.selectedOptions = selectedOptions; this.selectedOptions = selectedOptions;
if (this.enableChangeUpdate) { if (this.enableChangeUpdate) {
this.updateQuery(); this.updateQuery();
this.context.update();
} }
} }
@@ -160,7 +159,7 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI
} }
this.context.queryFragments[this.id] = queryFragments.join(' OR '); this.context.queryFragments[this.id] = queryFragments.join(' OR ');
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -33,7 +33,7 @@ describe('SearchFacetTabbedContentComponent', () => {
let queryBuilder: SearchQueryBuilderService; let queryBuilder: SearchQueryBuilderService;
let searchFacetService: SearchFacetFiltersService; let searchFacetService: SearchFacetFiltersService;
let loader: HarnessLoader; let loader: HarnessLoader;
let queryBuilderUpdateSpy: jasmine.Spy; let queryBuilderExecuteSpy: jasmine.Spy;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -44,7 +44,7 @@ describe('SearchFacetTabbedContentComponent', () => {
component = fixture.componentInstance; component = fixture.componentInstance;
queryBuilder = TestBed.inject(SearchQueryBuilderService); queryBuilder = TestBed.inject(SearchQueryBuilderService);
searchFacetService = TestBed.inject(SearchFacetFiltersService); searchFacetService = TestBed.inject(SearchFacetFiltersService);
queryBuilderUpdateSpy = spyOn(queryBuilder, 'update').and.stub(); queryBuilderExecuteSpy = spyOn(queryBuilder, 'execute').and.stub();
const facet1: FacetField = { type: 'field', label: 'field', field: 'field', buckets: new SearchFilterList() }; const facet1: FacetField = { type: 'field', label: 'field', field: 'field', buckets: new SearchFilterList() };
const facet2: FacetField = { type: 'field', label: 'field2', field: 'field2', buckets: new SearchFilterList() }; const facet2: FacetField = { type: 'field', label: 'field2', field: 'field2', buckets: new SearchFilterList() };
@@ -185,7 +185,7 @@ describe('SearchFacetTabbedContentComponent', () => {
spyOn(searchFacetService, 'updateSelectedBuckets').and.callThrough(); spyOn(searchFacetService, 'updateSelectedBuckets').and.callThrough();
component.submitValues(); component.submitValues();
expect(component.submitValues).toHaveBeenCalled(); expect(component.submitValues).toHaveBeenCalled();
expect(queryBuilderUpdateSpy).toHaveBeenCalled(); expect(queryBuilderExecuteSpy).toHaveBeenCalled();
expect(component.updateDisplayValue).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled();
expect(searchFacetService.updateSelectedBuckets).toHaveBeenCalled(); expect(searchFacetService.updateSelectedBuckets).toHaveBeenCalled();
}); });
@@ -193,12 +193,12 @@ describe('SearchFacetTabbedContentComponent', () => {
it('should update search query and display value on reset', () => { it('should update search query and display value on reset', () => {
spyOn(component, 'updateDisplayValue').and.callThrough(); spyOn(component, 'updateDisplayValue').and.callThrough();
component.reset(); component.reset();
expect(queryBuilderUpdateSpy).toHaveBeenCalled(); expect(queryBuilderExecuteSpy).toHaveBeenCalled();
expect(component.updateDisplayValue).toHaveBeenCalled(); expect(component.updateDisplayValue).toHaveBeenCalled();
}); });
it('should not call queryBuilder.update on options change', () => { it('should not call queryBuilder.execute on options change', () => {
component.onOptionsChange([{ value: 'test' }], 'field'); component.onOptionsChange([{ value: 'test' }], 'field');
expect(queryBuilderUpdateSpy).not.toHaveBeenCalled(); expect(queryBuilderExecuteSpy).not.toHaveBeenCalled();
}); });
}); });
@@ -111,14 +111,14 @@ export class SearchFacetTabbedContentComponent implements OnInit, OnChanges, Fac
this.resetSubject$.next(); this.resetSubject$.next();
this.updateUserFacetBuckets(); this.updateUserFacetBuckets();
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
submitValues() { submitValues() {
this.updateUserFacetBuckets(); this.updateUserFacetBuckets();
this.searchFacetFiltersService.updateSelectedBuckets(); this.searchFacetFiltersService.updateSelectedBuckets();
this.updateDisplayValue(); this.updateDisplayValue();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
optionComparator(option1: AutocompleteOption, option2: AutocompleteOption): boolean { optionComparator(option1: AutocompleteOption, option2: AutocompleteOption): boolean {
@@ -41,7 +41,7 @@ describe('SearchFacetChipComponent', () => {
fixture = TestBed.createComponent(SearchFacetChipComponent); fixture = TestBed.createComponent(SearchFacetChipComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
queryBuilder = TestBed.inject(SearchQueryBuilderService); queryBuilder = TestBed.inject(SearchQueryBuilderService);
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
component.field = { type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() }; component.field = { type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() };
fixture.detectChanges(); fixture.detectChanges();
@@ -55,7 +55,7 @@ describe('SearchFacetChipComponent', () => {
const applyButton = await menu.getHarness(MatButtonHarness.with({ selector: '#apply-filter-button' })); const applyButton = await menu.getHarness(MatButtonHarness.with({ selector: '#apply-filter-button' }));
await applyButton.click(); await applyButton.click();
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
}); });
it('should update search query on cancel click', async () => { it('should update search query on cancel click', async () => {
@@ -65,7 +65,7 @@ describe('SearchFacetChipComponent', () => {
const cancelButton = await menu.getHarness(MatButtonHarness.with({ selector: '#cancel-filter-button' })); const cancelButton = await menu.getHarness(MatButtonHarness.with({ selector: '#cancel-filter-button' }));
await cancelButton.click(); await cancelButton.click();
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
}); });
it('should display arrow down icon and not disable the chip when items are loaded', async () => { it('should display arrow down icon and not disable the chip when items are loaded', async () => {
@@ -223,7 +223,7 @@ describe('SearchFilterChipsComponent', () => {
}); });
it('should update query builder upon resetting selected queries', async () => { it('should update query builder upon resetting selected queries', async () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const queryResponse = { const queryResponse = {
@@ -247,7 +247,7 @@ describe('SearchFilterChipsComponent', () => {
facetField.resetSelectedBuckets(queryResponse); facetField.resetSelectedBuckets(queryResponse);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) {
expect(entry.checked).toEqual(false); expect(entry.checked).toEqual(false);
@@ -276,7 +276,7 @@ describe('SearchFilterChipsComponent', () => {
}); });
it('should be update the search query when name changed', async () => { it('should be update the search query when name changed', async () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
appConfigService.config.search = searchFilter; appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults(); queryBuilder.resetToDefaults();
@@ -290,7 +290,7 @@ describe('SearchFilterChipsComponent', () => {
const inputElement = fixture.debugElement.query(By.css('[data-automation-id="search-field-Name"] input')); const inputElement = fixture.debugElement.query(By.css('[data-automation-id="search-field-Name"] input'));
inputElement.triggerEventHandler('change', { target: { value: '*' } }); inputElement.triggerEventHandler('change', { target: { value: '*' } });
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
queryBuilder.executed.next(mockSearchResult); queryBuilder.executed.next(mockSearchResult);
@@ -404,7 +404,7 @@ describe('SearchFilterChipsComponent', () => {
queryBuilder.executed.next(mockSearchResult); queryBuilder.executed.next(mockSearchResult);
fixture.detectChanges(); fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`)); const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`));
searchChip.triggerEventHandler('click', { stopPropagation: () => null }); searchChip.triggerEventHandler('click', { stopPropagation: () => null });
@@ -441,7 +441,7 @@ describe('SearchFilterChipsComponent', () => {
await filteredMenu[0].check(); await filteredMenu[0].check();
expect(await filteredMenu[0].getLabelText()).toEqual('Extra Small (10239)'); expect(await filteredMenu[0].getLabelText()).toEqual('Extra Small (10239)');
expect(queryBuilder.update).toHaveBeenCalledTimes(1); expect(queryBuilder.execute).toHaveBeenCalledTimes(1);
}); });
}); });
}); });
@@ -45,7 +45,7 @@ describe('SearchWidgetChipComponent', () => {
queryBuilder = TestBed.inject(SearchQueryBuilderService); queryBuilder = TestBed.inject(SearchQueryBuilderService);
fixture = TestBed.createComponent(SearchWidgetChipComponent); fixture = TestBed.createComponent(SearchWidgetChipComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
component.category = simpleCategories[1]; component.category = simpleCategories[1];
fixture.detectChanges(); fixture.detectChanges();
@@ -60,7 +60,7 @@ describe('SearchWidgetChipComponent', () => {
const applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); const applyButton = fixture.debugElement.query(By.css('#apply-filter-button'));
applyButton.triggerEventHandler('click', {}); applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
}); });
it('should update search query on cancel click', async () => { it('should update search query on cancel click', async () => {
@@ -69,7 +69,7 @@ describe('SearchWidgetChipComponent', () => {
const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button')); const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button'));
applyButton.triggerEventHandler('click', {}); applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
}); });
it('should display arrow down icon', async () => { it('should display arrow down icon', async () => {
@@ -240,7 +240,7 @@ describe('SearchFilterComponent', () => {
}); });
it('should update query builder upon resetting selected queries', () => { it('should update query builder upon resetting selected queries', () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough(); spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const queryResponse = { const queryResponse = {
@@ -258,7 +258,7 @@ describe('SearchFilterComponent', () => {
facetField.resetSelectedBuckets(queryResponse); facetField.resetSelectedBuckets(queryResponse);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3); expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3);
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) { for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) {
expect(entry.checked).toEqual(false); expect(entry.checked).toEqual(false);
@@ -320,7 +320,7 @@ describe('SearchFilterComponent', () => {
}); });
it('should be update the search query when name changed', async () => { it('should be update the search query when name changed', async () => {
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
appConfigService.config.search = searchFilter; appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults(); queryBuilder.resetToDefaults();
@@ -333,7 +333,7 @@ describe('SearchFilterComponent', () => {
const inputElement = fixture.debugElement.query(By.css('[data-automation-id="expansion-panel-Name"] input')); const inputElement = fixture.debugElement.query(By.css('[data-automation-id="expansion-panel-Name"] input'));
inputElement.triggerEventHandler('change', { target: { value: '*' } }); inputElement.triggerEventHandler('change', { target: { value: '*' } });
expect(queryBuilder.update).toHaveBeenCalled(); expect(queryBuilder.execute).toHaveBeenCalled();
queryBuilder.executed.next(mockSearchResult); queryBuilder.executed.next(mockSearchResult);
fixture.detectChanges(); fixture.detectChanges();
@@ -441,7 +441,7 @@ describe('SearchFilterComponent', () => {
queryBuilder.executed.next(mockSearchResult); queryBuilder.executed.next(mockSearchResult);
fixture.detectChanges(); fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
const panel = await loader.getHarness( const panel = await loader.getHarness(
MatExpansionPanelHarness.with({ MatExpansionPanelHarness.with({
@@ -470,7 +470,7 @@ describe('SearchFilterComponent', () => {
expect(labels).toEqual(stepOne); expect(labels).toEqual(stepOne);
await checkboxes[0].check(); await checkboxes[0].check();
expect(queryBuilder.update).toHaveBeenCalledTimes(1); expect(queryBuilder.execute).toHaveBeenCalledTimes(1);
}); });
it('should preserve the filter state if other fields edited', async () => { it('should preserve the filter state if other fields edited', async () => {
@@ -481,7 +481,7 @@ describe('SearchFilterComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
queryBuilder.executed.next(mockSearchResult); queryBuilder.executed.next(mockSearchResult);
fixture.detectChanges(); fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub(); spyOn(queryBuilder, 'execute').and.stub();
const inputElement = fixture.debugElement.query(By.css(`${panel1Selector} input`)); const inputElement = fixture.debugElement.query(By.css(`${panel1Selector} input`));
inputElement.nativeElement.value = 'my'; inputElement.nativeElement.value = 'my';
@@ -511,7 +511,7 @@ describe('SearchFilterComponent', () => {
const checkedOption = await panel1.getHarness(MatCheckboxHarness.with({ checked: true })); const checkedOption = await panel1.getHarness(MatCheckboxHarness.with({ checked: true }));
expect(await checkedOption.getLabelText()).toBe('my1 (806)'); expect(await checkedOption.getLabelText()).toBe('my1 (806)');
expect(queryBuilder.update).toHaveBeenCalledTimes(2); expect(queryBuilder.execute).toHaveBeenCalledTimes(2);
}); });
it('should reset the query fragments when reset All is clicked', () => { it('should reset the query fragments when reset All is clicked', () => {
@@ -38,7 +38,7 @@ describe('SearchLogicalFilterComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false }; component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false };
fixture.detectChanges(); fixture.detectChanges();
@@ -136,7 +136,7 @@ describe('SearchLogicalFilterComponent', () => {
spyOn(component.displayValue$, 'next'); spyOn(component.displayValue$, 'next');
component.reset(); component.reset();
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.getCurrentValue()).toEqual({ matchAll: '', matchAny: '', exclude: '', matchExact: '' }); expect(component.getCurrentValue()).toEqual({ matchAll: '', matchAny: '', exclude: '', matchExact: '' });
expect(component.displayValue$.next).toHaveBeenCalledWith(''); expect(component.displayValue$.next).toHaveBeenCalledWith('');
expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue());
@@ -145,7 +145,7 @@ describe('SearchLogicalFilterComponent', () => {
it('should form correct query from match all field', () => { it('should form correct query from match all field', () => {
enterNewPhrase(' test1 test2 ', 0); enterNewPhrase(' test1 test2 ', 0);
component.submitValues(); component.submitValues();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe('((field1:"test1" AND field1:"test2") OR (field2:"test1" AND field2:"test2"))'); expect(component.context.queryFragments[component.id]).toBe('((field1:"test1" AND field1:"test2") OR (field2:"test1" AND field2:"test2"))');
expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue());
}); });
@@ -153,7 +153,7 @@ describe('SearchLogicalFilterComponent', () => {
it('should form correct query from match any field', () => { it('should form correct query from match any field', () => {
enterNewPhrase(' test3 test4', 1); enterNewPhrase(' test3 test4', 1);
component.submitValues(); component.submitValues();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe('((field1:"test3" OR field1:"test4") OR (field2:"test3" OR field2:"test4"))'); expect(component.context.queryFragments[component.id]).toBe('((field1:"test3" OR field1:"test4") OR (field2:"test3" OR field2:"test4"))');
expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue());
}); });
@@ -161,7 +161,7 @@ describe('SearchLogicalFilterComponent', () => {
it('should form correct query from exclude field', () => { it('should form correct query from exclude field', () => {
enterNewPhrase('test5 test6 ', 2); enterNewPhrase('test5 test6 ', 2);
component.submitValues(); component.submitValues();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe( expect(component.context.queryFragments[component.id]).toBe(
'((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))' '((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))'
); );
@@ -171,7 +171,7 @@ describe('SearchLogicalFilterComponent', () => {
it('should form correct query from match exact field and trim it', () => { it('should form correct query from match exact field and trim it', () => {
enterNewPhrase(' test7 test8 ', 3); enterNewPhrase(' test7 test8 ', 3);
component.submitValues(); component.submitValues();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe('((field1:"test7 test8") OR (field2:"test7 test8"))'); expect(component.context.queryFragments[component.id]).toBe('((field1:"test7 test8") OR (field2:"test7 test8"))');
expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue());
}); });
@@ -186,7 +186,7 @@ describe('SearchLogicalFilterComponent', () => {
const subQuery2 = '((field1:"test2") OR (field2:"test2"))'; const subQuery2 = '((field1:"test2") OR (field2:"test2"))';
const subQuery3 = '((NOT field1:"test3") AND (NOT field2:"test3"))'; const subQuery3 = '((NOT field1:"test3") AND (NOT field2:"test3"))';
const subQuery4 = '((field1:"test4") OR (field2:"test4"))'; const subQuery4 = '((field1:"test4") OR (field2:"test4"))';
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery4} AND ${subQuery3}`); expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery4} AND ${subQuery3}`);
expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue()); expect(component.context.filterRawParams[component.id]).toEqual(component.getCurrentValue());
}); });
@@ -128,7 +128,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit {
}); });
this.context.queryFragments[this.id] = query; this.context.queryFragments[this.id] = query;
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} else { } else {
this.reset(updateContext); this.reset(updateContext);
@@ -154,7 +154,7 @@ export class SearchLogicalFilterComponent implements SearchWidget, OnInit {
this.clearSearchInputs(); this.clearSearchInputs();
this.context.filterRawParams[this.id] = this.searchCondition; this.context.filterRawParams[this.id] = this.searchCondition;
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -37,7 +37,7 @@ describe('SearchNumberRangeComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
}); });
@@ -64,7 +64,7 @@ describe('SearchNumberRangeComponent', () => {
component.reset(); component.reset();
expect(component.context.queryFragments.contentSize).toEqual(''); expect(component.context.queryFragments.contentSize).toEqual('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.filterRawParams[component.id]).toBeUndefined();
}); });
@@ -82,7 +82,7 @@ describe('SearchNumberRangeComponent', () => {
const expectedQuery = 'cm:content.size:[10 TO 20]'; const expectedQuery = 'cm:content.size:[10 TO 20]';
expect(component.context.queryFragments[component.id]).toEqual(expectedQuery); expect(component.context.queryFragments[component.id]).toEqual(expectedQuery);
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
expect(component.context.filterRawParams[component.id].from).toEqual('10'); expect(component.context.filterRawParams[component.id].from).toEqual('10');
expect(component.context.filterRawParams[component.id].to).toEqual('20'); expect(component.context.filterRawParams[component.id].to).toEqual('20');
}); });
@@ -123,7 +123,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
filterParam.from = model.from; filterParam.from = model.from;
filterParam.to = model.to; filterParam.to = model.to;
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -178,7 +178,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
this.context.filterRawParams[this.id] = undefined; this.context.filterRawParams[this.id] = undefined;
this.updateDisplayValue(); this.updateDisplayValue();
if (this.enableChangeUpdate && updateContext) { if (this.enableChangeUpdate && updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -186,7 +186,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
reset(updateContext = true) { reset(updateContext = true) {
this.clear(); this.clear();
if (this.id && this.context && updateContext) { if (this.id && this.context && updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -72,7 +72,7 @@ describe('SearchPropertiesComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
}); });
@@ -208,7 +208,7 @@ describe('SearchPropertiesComponent', () => {
component.submitValues(); component.submitValues();
expect(component.displayValue$.next).not.toHaveBeenCalled(); expect(component.displayValue$.next).not.toHaveBeenCalled();
expect(component.context.update).not.toHaveBeenCalled(); expect(component.context.execute).not.toHaveBeenCalled();
}); });
it('should not search when context is not set', () => { it('should not search when context is not set', () => {
@@ -227,7 +227,7 @@ describe('SearchPropertiesComponent', () => {
fileExtensions: undefined, fileExtensions: undefined,
fileSizeCondition: { fileSize: null, fileSizeOperator: FileSizeOperator.AT_LEAST, fileSizeUnit: FileSizeUnit.KB } fileSizeCondition: { fileSize: null, fileSizeOperator: FileSizeOperator.AT_LEAST, fileSizeUnit: FileSizeUnit.KB }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by at least KB by default when any size is typed', () => { it('should search by at least KB by default when any size is typed', () => {
@@ -246,7 +246,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.KB fileSizeUnit: FileSizeUnit.KB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by at most MB after selecting proper options', () => { it('should search by at most MB after selecting proper options', () => {
@@ -271,7 +271,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.MB fileSizeUnit: FileSizeUnit.MB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by exactly GB after selecting proper options', () => { it('should search by exactly GB after selecting proper options', () => {
@@ -296,7 +296,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.GB fileSizeUnit: FileSizeUnit.GB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by single file type', () => { it('should search by single file type', () => {
@@ -314,7 +314,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.KB fileSizeUnit: FileSizeUnit.KB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by multiple file types', () => { it('should search by multiple file types', () => {
@@ -331,7 +331,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.KB fileSizeUnit: FileSizeUnit.KB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should search by file size and type', () => { it('should search by file size and type', () => {
@@ -351,7 +351,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.KB fileSizeUnit: FileSizeUnit.KB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
}); });
@@ -439,7 +439,7 @@ describe('SearchPropertiesComponent', () => {
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.filterRawParams[component.id]).toBeUndefined();
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
}); });
@@ -484,7 +484,7 @@ describe('SearchPropertiesComponent', () => {
fileSizeUnit: FileSizeUnit.MB fileSizeUnit: FileSizeUnit.MB
} }
}); });
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
}); });
@@ -206,7 +206,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear
this.context.queryFragments[this.id] = ''; this.context.queryFragments[this.id] = '';
this.context.filterRawParams[this.id] = undefined; this.context.filterRawParams[this.id] = undefined;
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
this.reset$.next(); this.reset$.next();
@@ -272,7 +272,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear
this.displayValue$.next(displayedValue); this.displayValue$.next(displayedValue);
this.context.queryFragments[this.id] = query; this.context.queryFragments[this.id] = query;
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
@@ -43,7 +43,7 @@ describe('SearchRadioComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { options: sizeOptions } as any; component.settings = { options: sizeOptions } as any;
}); });
@@ -93,6 +93,15 @@ describe('SearchRadioComponent', () => {
expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[0].value); expect(component.context.filterRawParams[component.id]).toBe(sizeOptions[0].value);
}); });
it('should call context.execute when reset is called', async () => {
const group = await loader.getHarness(MatRadioGroupHarness);
await group.checkRadioButton({ selector: `[data-automation-id="search-radio-${sizeOptions[2].name}"]` });
component.reset();
expect(component.context.execute).toHaveBeenCalled();
});
it('should populate filter state when populate filters event has been observed', async () => { it('should populate filter state when populate filters event has been observed', async () => {
component.context.filterLoaded = new ReplaySubject(1); component.context.filterLoaded = new ReplaySubject(1);
spyOn(component.context.filterLoaded, 'next').and.stub(); spyOn(component.context.filterLoaded, 'next').and.stub();
@@ -114,7 +114,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
this.setValue(this.value); this.setValue(this.value);
this.updateDisplayValue(); this.updateDisplayValue();
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
@@ -129,7 +129,6 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
this.context.filterRawParams[this.id] = newValue; this.context.filterRawParams[this.id] = newValue;
if (this.enableChangeUpdate) { if (this.enableChangeUpdate) {
this.updateDisplayValue(); this.updateDisplayValue();
this.context.update();
} }
} }
@@ -164,7 +163,7 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
this.setValue(initialValue); this.setValue(initialValue);
this.updateDisplayValue(); this.updateDisplayValue();
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -36,7 +36,7 @@ describe('SearchSliderComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute')
} as any; } as any;
component.settings = { component.settings = {
field: 'field1', field: 'field1',
@@ -64,7 +64,7 @@ describe('SearchSliderComponent', () => {
component.onChangedHandler(); component.onChangedHandler();
expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]'); expect(component.context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]');
expect(component.context.filterRawParams[component.id]).toEqual(10); expect(component.context.filterRawParams[component.id]).toEqual(10);
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
component.value = 20; component.value = 20;
component.onChangedHandler(); component.onChangedHandler();
@@ -81,7 +81,7 @@ describe('SearchSliderComponent', () => {
expect(component.value).toBe(10); expect(component.value).toBe(10);
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.filterRawParams[component.id]).toBe(null); expect(component.context.filterRawParams[component.id]).toBe(null);
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should reset to 0 if min not provided', () => { it('should reset to 0 if min not provided', () => {
@@ -93,7 +93,7 @@ describe('SearchSliderComponent', () => {
expect(component.value).toBe(0); expect(component.value).toBe(0);
expect(component.context.queryFragments['slider']).toBe(''); expect(component.context.queryFragments['slider']).toBe('');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should populate filter state when populate filters event has been observed', async () => { it('should populate filter state when populate filters event has been observed', async () => {
@@ -135,7 +135,7 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`; this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`;
} }
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -22,11 +22,13 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatInputHarness } from '@angular/material/input/testing'; import { MatInputHarness } from '@angular/material/input/testing';
import { MatButtonHarness } from '@angular/material/button/testing'; import { MatButtonHarness } from '@angular/material/button/testing';
import { ReplaySubject } from 'rxjs'; import { ReplaySubject } from 'rxjs';
import { AppConfigService } from '@alfresco/adf-core';
describe('SearchTextComponent', () => { describe('SearchTextComponent', () => {
let loader: HarnessLoader; let loader: HarnessLoader;
let fixture: ComponentFixture<SearchTextComponent>; let fixture: ComponentFixture<SearchTextComponent>;
let component: SearchTextComponent; let component: SearchTextComponent;
let appConfig: AppConfigService;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -34,6 +36,7 @@ describe('SearchTextComponent', () => {
}); });
fixture = TestBed.createComponent(SearchTextComponent); fixture = TestBed.createComponent(SearchTextComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
appConfig = TestBed.inject(AppConfigService);
component.id = 'text'; component.id = 'text';
component.settings = { component.settings = {
pattern: `cm:name:'(.*?)'`, pattern: `cm:name:'(.*?)'`,
@@ -46,7 +49,10 @@ describe('SearchTextComponent', () => {
}, },
filterRawParams: {}, filterRawParams: {},
populateFilters: new ReplaySubject(1), populateFilters: new ReplaySubject(1),
update: jasmine.createSpy('update') execute: jasmine.createSpy('execute'),
get wildcardsEnabled(): boolean {
return appConfig.get<boolean>('search-wildcards-enabled', true);
}
} as any; } as any;
loader = TestbedHarnessEnvironment.loader(fixture); loader = TestbedHarnessEnvironment.loader(fixture);
@@ -67,7 +73,10 @@ describe('SearchTextComponent', () => {
expect(component.value).toEqual(''); expect(component.value).toEqual('');
}); });
it('should update query builder on change', () => { it('should update query builder on change when change updates are enabled', () => {
component.settings.allowUpdateOnChange = true;
fixture.detectChanges();
component.onChangedHandler({ component.onChangedHandler({
target: { target: {
value: 'top-secret.doc' value: 'top-secret.doc'
@@ -77,10 +86,27 @@ describe('SearchTextComponent', () => {
expect(component.value).toBe('top-secret.doc'); expect(component.value).toBe('top-secret.doc');
expect(component.context.queryFragments[component.id]).toBe(`cm:name:'top-secret.doc'`); expect(component.context.queryFragments[component.id]).toBe(`cm:name:'top-secret.doc'`);
expect(component.context.filterRawParams[component.id]).toBe('top-secret.doc'); expect(component.context.filterRawParams[component.id]).toBe('top-secret.doc');
expect(component.context.update).toHaveBeenCalled(); expect(component.context.execute).toHaveBeenCalled();
}); });
it('should reset query builder', () => { it('should not update query builder on change when change updates are disabled', () => {
fixture.detectChanges();
component.onChangedHandler({
target: {
value: 'top-secret.doc'
}
});
expect(component.value).toBe('top-secret.doc');
expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.execute).not.toHaveBeenCalled();
});
it('should reset query builder when change updates are enabled', () => {
component.settings.allowUpdateOnChange = true;
fixture.detectChanges();
component.onChangedHandler({ component.onChangedHandler({
target: { target: {
value: 'top-secret.doc' value: 'top-secret.doc'
@@ -125,13 +151,21 @@ describe('SearchTextComponent', () => {
expect(component.context.filterRawParams[component.id]).toBeUndefined(); expect(component.context.filterRawParams[component.id]).toBeUndefined();
}); });
it('should update query with startValue on init, if provided', () => { it('should set value from startValue on init, if provided', () => {
component.startValue = 'mock-start-value'; component.startValue = 'mock-start-value';
fixture.detectChanges(); fixture.detectChanges();
expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`);
expect(component.value).toBe('mock-start-value'); expect(component.value).toBe('mock-start-value');
expect(component.context.update).toHaveBeenCalled(); });
it('should build the query fragment from the current value when submitted', () => {
component.startValue = 'mock-start-value';
fixture.detectChanges();
component.submitValues();
expect(component.context.queryFragments[component.id]).toBe(`cm:name:'mock-start-value'`);
expect(component.context.execute).toHaveBeenCalled();
}); });
it('should parse value and set query context as blank, and not call query update, if no start value was provided', () => { it('should parse value and set query context as blank, and not call query update, if no start value was provided', () => {
@@ -141,7 +175,7 @@ describe('SearchTextComponent', () => {
expect(component.context.queryFragments[component.id]).toBe(''); expect(component.context.queryFragments[component.id]).toBe('');
expect(component.value).toBe('secret.pdf'); expect(component.value).toBe('secret.pdf');
expect(component.context.update).not.toHaveBeenCalled(); expect(component.context.execute).not.toHaveBeenCalled();
}); });
it('should populate filter state when populate filters event has been observed', async () => { it('should populate filter state when populate filters event has been observed', async () => {
@@ -157,4 +191,30 @@ describe('SearchTextComponent', () => {
expect(component.context.filterRawParams[component.id]).toBe('secret.pdf'); expect(component.context.filterRawParams[component.id]).toBe('secret.pdf');
expect(component.context.filterLoaded.next).toHaveBeenCalled(); expect(component.context.filterLoaded.next).toHaveBeenCalled();
}); });
it('should add the search prefix and suffix to the query fragment when wildcards are enabled', () => {
spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? true : defaultValue));
component.settings.searchPrefix = '*';
component.settings.searchSuffix = '*';
fixture.detectChanges();
component.onChangedHandler({ target: { value: 'secret' } });
component.submitValues();
expect(component.context.wildcardsEnabled).toBeTrue();
expect(component.context.queryFragments[component.id]).toBe(`cm:name:'*secret*'`);
});
it('should NOT add the search prefix and suffix to the query fragment when wildcards are disabled', () => {
spyOn(appConfig, 'get').and.callFake((key: string, defaultValue?: any) => (key === 'search-wildcards-enabled' ? false : defaultValue));
component.settings.searchPrefix = '*';
component.settings.searchSuffix = '*';
fixture.detectChanges();
component.onChangedHandler({ target: { value: 'secret' } });
component.submitValues();
expect(component.context.wildcardsEnabled).toBeFalse();
expect(component.context.queryFragments[component.id]).toBe(`cm:name:'secret'`);
});
}); });
@@ -48,7 +48,7 @@ export class SearchTextComponent implements SearchWidget, OnInit {
context: SearchQueryBuilderService; context: SearchQueryBuilderService;
startValue: string; startValue: string;
isActive = false; isActive = false;
enableChangeUpdate = true; enableChangeUpdate = false;
displayValue$ = new ReplaySubject<string>(1); displayValue$ = new ReplaySubject<string>(1);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -121,7 +121,7 @@ export class SearchTextComponent implements SearchWidget, OnInit {
if (this.context?.queryFragments && this.settings?.field) { if (this.context?.queryFragments && this.settings?.field) {
this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : ''; this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : '';
if (updateContext) { if (updateContext) {
this.context.update(); this.context.execute();
} }
} }
} }
@@ -141,14 +141,13 @@ export class SearchTextComponent implements SearchWidget, OnInit {
setValue(value: string) { setValue(value: string) {
this.value = value; this.value = value;
this.displayValue$.next(this.value); this.displayValue$.next(this.value);
this.submitValues();
} }
private getSearchPrefix(): string { private getSearchPrefix(): string {
return this.settings.searchPrefix ? this.settings.searchPrefix : ''; return this.settings.searchPrefix && this.context.wildcardsEnabled ? this.settings.searchPrefix : '';
} }
private getSearchSuffix(): string { private getSearchSuffix(): string {
return this.settings.searchSuffix ? this.settings.searchSuffix : ''; return this.settings.searchSuffix && this.context.wildcardsEnabled ? this.settings.searchSuffix : '';
} }
} }
@@ -69,6 +69,12 @@ describe('BaseQueryBuilderService', () => {
{ ...mockSearchConfig, id: 'config-3', name: 'Config 3', default: false } { ...mockSearchConfig, id: 'config-3', name: 'Config 3', default: false }
]; ];
const configureAppConfig = (wildcards?: boolean): void => {
(appConfig.get as jasmine.Spy).and.callFake((key: string, defaultValue?: any) =>
key === 'search-wildcards-enabled' ? (wildcards ?? defaultValue) : false
);
};
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
@@ -89,19 +95,218 @@ describe('BaseQueryBuilderService', () => {
}); });
describe('userQuery', () => { describe('userQuery', () => {
it('should set userQuery with parentheses', () => { it('should set userQuery to raw value without wrapping', () => {
service.userQuery = 'test query'; service.userQuery = 'test query';
expect(service.userQuery).toBe('(test query)'); expect(service.userQuery).toBe('test query');
}); });
it('should trim userQuery', () => { it('should store userQuery in filterRawParams', () => {
service.userQuery = ' test query '; service.userQuery = 'test';
expect(service.userQuery).toBe('(test query)'); expect(service.filterRawParams['userQuery']).toBe('test');
}); });
it('should return empty string for null input', () => { it('should set parsedQuery when userQuery is set in regular mode', () => {
service.userQuery = null; service.searchMode = 'regular';
service.userQuery = 'hello';
expect(service.parsedQuery).toBe('((cm:name:"hello*"))');
});
it('should set parsedQuery equal to userQuery in formula mode', () => {
service.searchMode = 'formula';
service.userQuery = '(cm:name:"test*")';
expect(service.parsedQuery).toBe('(cm:name:"test*")');
});
it('should clear userQuery and its raw param when set to empty', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'something';
service.userQuery = '';
expect(service.userQuery).toBe(''); expect(service.userQuery).toBe('');
expect(service.filterRawParams['userQuery']).toBe('');
});
});
describe('parsedQuery', () => {
it('should wrap a single term with field query and parentheses in regular mode', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello';
expect(service.parsedQuery).toContain('cm:name:"hello*"');
});
it('should join multiple terms with AND in regular mode when no operator present', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello world';
expect(service.parsedQuery).toContain(' AND ');
});
it('should preserve AND/OR operators in regular mode when present in query', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello AND world';
expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))');
});
it('should store parsedQuery in filterRawParams', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello';
expect(service.filterRawParams['parsedQuery']).toBe(service.parsedQuery);
});
it('should not add wildcard suffix when wildcards are disabled', () => {
configureAppConfig(false);
service.searchMode = 'regular';
service.userQuery = 'hello';
expect(service.parsedQuery).toBe('((cm:name:"hello"))');
});
it('should build the exact single-term parsed query against the default cm:name field', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello';
expect(service.parsedQuery).toBe('((cm:name:"hello*"))');
});
it('should join each multi-term word with AND wrapped in parentheses', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello world';
expect(service.parsedQuery).toBe('((cm:name:"hello*") AND (cm:name:"world*"))');
});
it('should keep explicit operators as separators between terms', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello OR world';
expect(service.parsedQuery).toBe('((cm:name:"hello*") OR (cm:name:"world*"))');
});
it('should parse each term against every configured app:fields entry', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.config = { id: 'test-config', categories: [], 'app:fields': ['cm:name', 'cm:title'] } as any;
service.userQuery = 'hello';
expect(service.parsedQuery).toBe('((cm:name:"hello*" OR cm:title:"hello*"))');
});
it('should escape double quotes in the user query term', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'he"llo';
expect(service.parsedQuery).toBe('((cm:name:"he\\"llo*"))');
});
it('should escape backslashes in the user query term', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'he\\llo';
expect(service.parsedQuery).toBe('((cm:name:"he\\\\llo*"))');
});
it('should clear parsedQuery and its raw param when userQuery is set to empty', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'hello';
service.userQuery = '';
expect(service.parsedQuery).toBe('');
expect(service.filterRawParams['parsedQuery']).toBe('');
});
});
describe('searchMode', () => {
it('should default to regular mode', () => {
expect(service.searchMode).toBe('regular');
});
it('should store searchMode in filterRawParams', () => {
service.searchMode = 'formula';
expect(service.filterRawParams['searchMode']).toBe('formula');
});
it('should update filterRawParams when changed back to regular', () => {
service.searchMode = 'formula';
service.searchMode = 'regular';
expect(service.filterRawParams['searchMode']).toBe('regular');
});
it('should recompute parsedQuery when switching search mode', () => {
configureAppConfig(true);
service.searchMode = 'formula';
service.userQuery = 'hello';
expect(service.parsedQuery).toBe('hello');
service.searchMode = 'regular';
expect(service.parsedQuery).toBe('((cm:name:"hello*"))');
});
});
describe('wildcardsEnabled', () => {
it('should return true by default', () => {
configureAppConfig();
expect(service.wildcardsEnabled).toBe(true);
});
it('should return false when config disables wildcards', () => {
configureAppConfig(false);
expect(service.wildcardsEnabled).toBe(false);
});
});
describe('isOperator', () => {
it('should return true for AND', () => {
expect(service.isOperator('AND')).toBeTrue();
});
it('should return true for OR', () => {
expect(service.isOperator('OR')).toBeTrue();
});
it('should return true for AND with surrounding spaces', () => {
expect(service.isOperator(' AND ')).toBeTrue();
});
it('should return false for regular word', () => {
expect(service.isOperator('hello')).toBeFalse();
});
it('should return false for empty string', () => {
expect(service.isOperator('')).toBeFalse();
});
it('should return false for null/undefined', () => {
expect(service.isOperator(null)).toBeFalse();
expect(service.isOperator(undefined)).toBeFalse();
});
});
describe('selectedConfigurationId', () => {
it('should store selectedConfigurationId in filterRawParams when set', () => {
(appConfig.get as jasmine.Spy).and.returnValue(mockMultipleConfigs);
service.resetToDefaults();
service.selectedConfigurationId = 'config-2';
expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2');
});
});
describe('encodedQuery', () => {
it('should return encoded query after encodeQuery is called', () => {
service.userQuery = 'test';
service.encodeQuery();
expect(service.encodedQuery).toBeTruthy();
});
it('should return a base64 encoded string of filterRawParams', () => {
service.userQuery = 'test';
service.encodeQuery();
const decoded = new TextDecoder().decode(Uint8Array.from(atob(service.encodedQuery), (char) => char.charCodeAt(0)));
const parsed = JSON.parse(decoded);
expect(parsed['userQuery']).toBe('test');
}); });
}); });
@@ -215,15 +420,27 @@ describe('BaseQueryBuilderService', () => {
expect(service.buildQuery()).toBeNull(); expect(service.buildQuery()).toBeNull();
}); });
it('should build query with userQuery', () => { it('should build query with parsedQuery in regular mode', () => {
configureAppConfig(true);
service.searchMode = 'regular';
service.userQuery = 'test'; service.userQuery = 'test';
const query = service.buildQuery(); const query = service.buildQuery();
expect(query).toBeTruthy(); expect(query).toBeTruthy();
expect(query.query.query).toBe('(test)'); expect(query.query.query).toContain('cm:name:"test*"');
});
it('should build query using userQuery directly in formula mode', () => {
service.searchMode = 'formula';
service.userQuery = '(cm:name:"test*")';
const query = service.buildQuery();
expect(query).toBeTruthy();
expect(query.query.query).toBe('(cm:name:"test*")');
}); });
it('should include scope in query when set', () => { it('should include scope in query when set', () => {
service.searchMode = 'formula';
service.userQuery = 'test'; service.userQuery = 'test';
service.setScope({ locations: 'nodes' }); service.setScope({ locations: 'nodes' });
const query = service.buildQuery(); const query = service.buildQuery();
@@ -233,6 +450,7 @@ describe('BaseQueryBuilderService', () => {
it('should include default includes when none configured', () => { it('should include default includes when none configured', () => {
service.config = { id: 'test-config', categories: [] }; service.config = { id: 'test-config', categories: [] };
service.searchMode = 'formula';
service.userQuery = 'test'; service.userQuery = 'test';
const query = service.buildQuery(); const query = service.buildQuery();
@@ -241,29 +459,25 @@ describe('BaseQueryBuilderService', () => {
}); });
}); });
describe('update', () => { describe('getFinalQuery', () => {
it('should emit updated event with built query', (done) => { it('should skip query fragments that are empty match-all objects', () => {
service.searchMode = 'formula';
service.userQuery = 'test'; service.userQuery = 'test';
service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }];
service.queryFragments['cat1'] = { matchAll: '', matchAny: '', matchExact: '', exclude: '' };
service.updated.subscribe((query) => { const query = service.buildQuery();
expect(query.query.query).toBe('(test)'); expect(query.query.query).toBe('test');
done();
});
service.update();
}); });
it('should emit updated event with provided query body', (done) => { it('should include non-empty query fragments', () => {
const customQuery = { service.searchMode = 'formula';
query: { query: 'custom query', language: 'afts' } service.userQuery = 'test';
}; service.categories = [{ id: 'cat1', name: 'Cat1', enabled: true, expanded: false, component: { selector: 'test', settings: undefined } }];
service.queryFragments['cat1'] = 'cm:name:"hello"';
service.updated.subscribe((query) => { const query = service.buildQuery();
expect(query.query.query).toBe('custom query'); expect(query.query.query).toContain('cm:name:"hello"');
done();
});
service.update(customQuery);
}); });
}); });
@@ -450,79 +664,24 @@ describe('BaseQueryBuilderService', () => {
expect(service.execute).toHaveBeenCalled(); expect(service.execute).toHaveBeenCalled();
}); });
});
describe('populateFilters and selectedConfiguration restoration', () => { it('should not call execute when shouldExecute is false', async () => {
beforeEach(() => {
(appConfig.get as jasmine.Spy<<T>(key: string, defaultValue?: T) => T>).and.returnValue(mockMultipleConfigs);
service.resetToDefaults();
});
it('should restore selectedConfiguration from populateFilters', (done) => {
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
spyOn(service, 'execute');
service.configUpdated.subscribe((config) => { service.updateSelectedConfiguration('config-2', true, false);
expect(config.name).toBe('Config 3');
done();
});
service.populateFilters.next({ selectedConfigurationId: 'config-3', someOtherFilter: 'value' }); expect(service.execute).not.toHaveBeenCalled();
}); });
it('should reset to default configuration when populateFilters has no selectedConfigurationId', (done) => { it('should not reset search options when resetFilters is false', () => {
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging)); spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging));
service.updateSelectedConfiguration('config-3'); service.queryFragments['someFilter'] = 'some value';
service.updateSelectedConfiguration('config-2', false, false);
setTimeout(() => { expect(service.queryFragments['someFilter']).toBe('some value');
service.configUpdated.subscribe((config) => {
expect(config.name).toBe('Config 1');
done();
});
service.populateFilters.next({ someOtherFilter: 'value' });
}, 0);
});
it('should not change configuration when populateFilters is empty', () => {
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy');
service.configUpdated.subscribe(configUpdatedSpy);
configUpdatedSpy.calls.reset();
service.populateFilters.next({});
expect(configUpdatedSpy).not.toHaveBeenCalled();
});
it('should not change configuration when same configuration is already selected', (done) => {
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
spyOn(service.searchApi, 'search').and.returnValue(Promise.resolve({ list: { entries: [] } } as ResultSetPaging));
service.updateSelectedConfiguration('config-2');
setTimeout(() => {
const configUpdatedSpy = jasmine.createSpy('configUpdatedSpy');
service.configUpdated.subscribe(configUpdatedSpy);
service.populateFilters.next({ selectedConfigurationId: 'config-2' });
setTimeout(() => {
expect(configUpdatedSpy).not.toHaveBeenCalled();
done();
}, 0);
}, 0);
});
it('should update filterRawParams when restoring configuration from populateFilters', (done) => {
service.configUpdated.subscribe(() => {
expect(service.filterRawParams['selectedConfigurationId']).toBe('config-2');
done();
});
service.populateFilters.next({ selectedConfigurationId: 'config-2' });
}); });
}); });
@@ -577,5 +736,17 @@ describe('BaseQueryBuilderService', () => {
expect(service.categories.length).toBe(1); expect(service.categories.length).toBe(1);
expect(service.categories[0].id).toBe('cat1'); expect(service.categories[0].id).toBe('cat1');
}); });
it('should reset userQuery when resetUserQuery is true (default)', () => {
service.userQuery = 'some query';
service.resetToDefaults(false, true);
expect(service.userQuery).toBe('');
});
it('should preserve userQuery when resetUserQuery is false', () => {
service.userQuery = 'some query';
service.resetToDefaults(false, false);
expect(service.userQuery).toBe('some query');
});
}); });
}); });
@@ -54,9 +54,6 @@ export abstract class BaseQueryBuilderService {
/* Stream that emits the event each time when search filter finishes loading initial value */ /* Stream that emits the event each time when search filter finishes loading initial value */
filterLoaded = new Subject<void>(); filterLoaded = new Subject<void>();
/* Stream that emits the query before search whenever user search */
updated = new Subject<SearchRequest>();
/* Stream that emits the results whenever user search */ /* Stream that emits the results whenever user search */
executed = new Subject<ResultSetPaging>(); executed = new Subject<ResultSetPaging>();
@@ -85,11 +82,13 @@ export abstract class BaseQueryBuilderService {
sorting: SearchSortingDefinition[] = []; sorting: SearchSortingDefinition[] = [];
sortingOptions: SearchSortingDefinition[] = []; sortingOptions: SearchSortingDefinition[] = [];
private encodedQuery: string; private _encodedQuery: string;
private scope: RequestScope; private scope: RequestScope;
private selectedConfigurationId: string; private _selectedConfigurationId: string;
private _userQuery = '';
private _queryFragments: { [id: string]: string } = {}; private _queryFragments: { [id: string]: string } = {};
private _parsedQuery: string;
private _userQuery: string;
private _searchMode: 'regular' | 'formula';
private readonly selectedConfigurationKey = 'selectedConfigurationId'; private readonly selectedConfigurationKey = 'selectedConfigurationId';
private readonly queryFragmentsHandler: ProxyHandler<{ [key: string]: any }> = { private readonly queryFragmentsHandler: ProxyHandler<{ [key: string]: any }> = {
@@ -111,13 +110,45 @@ export abstract class BaseQueryBuilderService {
this.queryFragmentsUpdate.next(this._queryFragments); this.queryFragmentsUpdate.next(this._queryFragments);
} }
get encodedQuery(): string {
return this._encodedQuery;
}
get wildcardsEnabled(): boolean {
return this.appConfig.get<boolean>('search-wildcards-enabled', true);
}
get userQuery(): string { get userQuery(): string {
return this._userQuery; return this._userQuery;
} }
set userQuery(value: string) { set userQuery(value: string) {
value = (value || '').trim(); this._userQuery = value;
this._userQuery = value ? `(${value})` : ''; this.filterRawParams['userQuery'] = value;
this.setParsedQuery();
}
get parsedQuery(): string {
return this._parsedQuery;
}
get searchMode(): 'regular' | 'formula' {
return this._searchMode;
}
set searchMode(value: 'regular' | 'formula') {
this._searchMode = value;
this.filterRawParams['searchMode'] = value;
this.setParsedQuery();
}
get selectedConfigurationId(): string {
return this._selectedConfigurationId;
}
set selectedConfigurationId(value: string) {
this._selectedConfigurationId = value;
this.filterRawParams[this.selectedConfigurationKey] = value;
} }
config: SearchConfiguration = { config: SearchConfiguration = {
@@ -132,17 +163,17 @@ export abstract class BaseQueryBuilderService {
protected readonly appConfig: AppConfigService, protected readonly appConfig: AppConfigService,
protected readonly alfrescoApiService: AlfrescoApiService protected readonly alfrescoApiService: AlfrescoApiService
) { ) {
this.searchMode = 'regular';
this.userQuery = '';
this.resetToDefaults(); this.resetToDefaults();
this._queryFragments = this.createQueryFragmentsProxy({}); this._queryFragments = this.createQueryFragmentsProxy({});
this.populateFilters.subscribe((filters) => this.handleSelectedConfigurationChange(filters));
} }
public abstract loadConfiguration(): SearchConfiguration | SearchConfiguration[]; public abstract loadConfiguration(): SearchConfiguration | SearchConfiguration[];
public abstract isFilterServiceActive(): boolean; public abstract isFilterServiceActive(): boolean;
public resetToDefaults(withNavigate = false) { public resetToDefaults(withNavigate = false, resetUserQuery = true) {
if (withNavigate) { if (withNavigate) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { q: null }, queryParams: { q: null },
@@ -151,7 +182,7 @@ export abstract class BaseQueryBuilderService {
}); });
} }
const currentConfig = this.getDefaultConfiguration(); const currentConfig = this.getDefaultConfiguration();
this.resetSearchOptions(); this.resetSearchOptions(resetUserQuery);
this.configUpdated.next(currentConfig); this.configUpdated.next(currentConfig);
this.searchForms.next(this.getSearchFormDetails()); this.searchForms.next(this.getSearchFormDetails());
this.setUpSearchConfiguration(currentConfig); this.setUpSearchConfiguration(currentConfig);
@@ -170,23 +201,26 @@ export abstract class BaseQueryBuilderService {
return configurations; return configurations;
} }
public updateSelectedConfiguration(id: string): void { public updateSelectedConfiguration(id: string, resetFilters = true, shouldExecute = true): void {
const currentConfig = this.loadConfiguration(); const currentConfig = this.loadConfiguration();
if (Array.isArray(currentConfig)) { if (Array.isArray(currentConfig)) {
const selectedConfig = currentConfig.find((config) => config.id === id); const selectedConfig = currentConfig.find((config) => config.id === id);
if (selectedConfig) { if (selectedConfig) {
if (resetFilters) {
this.resetSearchOptions(false);
}
this.selectedConfigurationId = id; this.selectedConfigurationId = id;
this.searchForms.next(this.getSearchFormDetails()); this.searchForms.next(this.getSearchFormDetails());
this.resetSearchOptions();
this.setUpSearchConfiguration(selectedConfig); this.setUpSearchConfiguration(selectedConfig);
this.filterRawParams[this.selectedConfigurationKey] = id;
this.configUpdated.next(selectedConfig); this.configUpdated.next(selectedConfig);
this.execute(); if (shouldExecute) {
this.execute(true);
}
} }
} }
} }
private resetSearchOptions(): void { private resetSearchOptions(resetUserQuery = true): void {
this.categories = []; this.categories = [];
this.queryFragments = {}; this.queryFragments = {};
this.filterQueries = []; this.filterQueries = [];
@@ -194,8 +228,10 @@ export abstract class BaseQueryBuilderService {
this.sortingOptions = []; this.sortingOptions = [];
this.resetUserFacetBucket(); this.resetUserFacetBucket();
this.scope = null; this.scope = null;
this.filterRawParams = {}; if (resetUserQuery) {
this._userQuery = ''; this.userQuery = '';
}
this.resetFilterRawParams(resetUserQuery);
this.populateFilters.next({}); this.populateFilters.next({});
} }
@@ -229,6 +265,7 @@ export abstract class BaseQueryBuilderService {
this.categories = (this.config.categories || []).filter((category) => category.enabled); this.categories = (this.config.categories || []).filter((category) => category.enabled);
this.filterQueries = this.config.filterQueries || []; this.filterQueries = this.config.filterQueries || [];
this.userFacetBuckets = {}; this.userFacetBuckets = {};
this.userQuery = this.filterRawParams['userQuery'] || '';
if (this.config.sorting) { if (this.config.sorting) {
this.sorting = this.config.sorting.defaults || []; this.sorting = this.config.sorting.defaults || [];
this.sortingOptions = this.config.sorting.options || []; this.sortingOptions = this.config.sorting.options || [];
@@ -355,16 +392,6 @@ export abstract class BaseQueryBuilderService {
return this.scope; return this.scope;
} }
/**
* Builds the current query and triggers the `updated` event.
*
* @param queryBody query settings
*/
update(queryBody?: SearchRequest): void {
const query = queryBody ? queryBody : this.buildQuery();
this.updated.next(query);
}
/** /**
* Builds and executes the current query. * Builds and executes the current query.
* *
@@ -552,12 +579,9 @@ export abstract class BaseQueryBuilderService {
protected getFinalQuery(): string { protected getFinalQuery(): string {
let query = ''; let query = '';
if (this.userQuery) {
this.filterRawParams['userQuery'] = this.userQuery;
}
this.categories.forEach((facet) => { this.categories.forEach((facet) => {
const customQuery = this.queryFragments[facet.id]; const customQuery = this.queryFragments[facet.id];
if (customQuery) { if (customQuery && JSON.stringify(customQuery) !== JSON.stringify({ matchAll: '', matchAny: '', matchExact: '', exclude: '' })) {
if (query.length > 0) { if (query.length > 0) {
query += ' AND '; query += ' AND ';
} }
@@ -565,7 +589,8 @@ export abstract class BaseQueryBuilderService {
} }
}); });
let result = [this.userQuery, query].filter((entry) => entry).join(' AND '); const parsedQuery = this.searchMode === 'regular' ? this.parsedQuery : this.userQuery;
let result = [parsedQuery, query].filter((entry) => entry).join(' AND ');
if (this.userFacetBuckets) { if (this.userFacetBuckets) {
Object.keys(this.userFacetBuckets).forEach((key) => { Object.keys(this.userFacetBuckets).forEach((key) => {
@@ -626,10 +651,10 @@ export abstract class BaseQueryBuilderService {
*/ */
encodeQuery() { encodeQuery() {
try { try {
this.encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams)))); this._encodedQuery = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(this.filterRawParams))));
} catch (error) { } catch (error) {
console.error('Failed to encode query parameters:', error); console.error('Failed to encode query parameters:', error);
this.encodedQuery = ''; this._encodedQuery = '';
} }
} }
@@ -640,7 +665,7 @@ export abstract class BaseQueryBuilderService {
this.encodeQuery(); this.encodeQuery();
this.router.navigate([], { this.router.navigate([], {
relativeTo: this.activatedRoute, relativeTo: this.activatedRoute,
queryParams: { q: this.encodedQuery }, queryParams: { q: this._encodedQuery },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
} }
@@ -653,50 +678,71 @@ export abstract class BaseQueryBuilderService {
*/ */
async navigateToSearch(query: string, searchUrl: string) { async navigateToSearch(query: string, searchUrl: string) {
this.userQuery = query; this.userQuery = query;
this.encodeQuery();
await this.execute(); await this.execute();
await this.router.navigate([searchUrl], { await this.router.navigate([searchUrl], {
queryParams: { q: this.encodedQuery }, queryParams: { q: this._encodedQuery },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
} }
/**
* Checks if string is an AND or OR operator
*
* @param input string to check if it is an operator
* @returns boolean
*/
isOperator(input: string): boolean {
if (input) {
const operators = ['AND', 'OR'];
return operators.includes(input.trim());
}
return false;
}
private createQueryFragmentsProxy(target: { [key: string]: any }): { [key: string]: any } { private createQueryFragmentsProxy(target: { [key: string]: any }): { [key: string]: any } {
return new Proxy(target, this.queryFragmentsHandler); return new Proxy(target, this.queryFragmentsHandler);
} }
private setSelectedConfiguration(id: string): void { private setParsedQuery() {
const currentConfig = this.loadConfiguration(); if (!this.userQuery) {
if (Array.isArray(currentConfig)) { this._parsedQuery = '';
const selectedConfig = currentConfig.find((config) => config.id === id); this.filterRawParams['parsedQuery'] = '';
if (selectedConfig) {
this.selectedConfigurationId = id;
this.searchForms.next(this.getSearchFormDetails());
this.setUpSearchConfiguration(selectedConfig);
this.filterRawParams[this.selectedConfigurationKey] = id;
this.configUpdated.next(selectedConfig);
}
}
}
private handleSelectedConfigurationChange(filters: { [key: string]: string }): void {
if (Object.keys(filters ?? {}).length === 0) {
return; return;
} }
if (this.searchMode === 'formula') {
const newSelectedConfig = filters?.[this.selectedConfigurationKey]; this._parsedQuery = this.userQuery;
if (newSelectedConfig) {
if (newSelectedConfig !== this.selectedConfigurationId) {
this.setSelectedConfiguration(newSelectedConfig);
}
} else { } else {
const configurations = this.loadConfiguration(); const words = this.userQuery.split(/\s+/);
if (Array.isArray(configurations)) { if (words.length > 1) {
const defaultConfig = configurations.find((config) => config.default); const separator = words.some(this.isOperator) ? ' ' : ' AND ';
if (defaultConfig && this.selectedConfigurationId !== defaultConfig.id) { this._parsedQuery = '(' + words.map((term) => (this.isOperator(term) ? term : this.parseTermByFields(term))).join(separator) + ')';
this.setSelectedConfiguration(defaultConfig.id); } else {
} this._parsedQuery = '(' + this.parseTermByFields(this.userQuery) + ')';
} }
} }
this.filterRawParams['parsedQuery'] = this._parsedQuery;
}
private parseTermByFields(term: string): string {
const suffix = this.wildcardsEnabled ? '*' : '';
const fields = this.config['app:fields'] || ['cm:name'];
const escapedTerm = this.escapeQueryTerm(term);
return '(' + fields.map((field) => `${field}:"${escapedTerm}${suffix}"`).join(' OR ') + ')';
}
private escapeQueryTerm(term: string): string {
return term.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
private resetFilterRawParams(resetUserQuery = true) {
this.filterRawParams = {
userQuery: resetUserQuery ? '' : this.userQuery,
parsedQuery: resetUserQuery ? '' : this.parsedQuery,
searchMode: this.searchMode,
[this.selectedConfigurationKey]: this.selectedConfigurationId,
logic: { matchAll: '', matchAny: '', matchExact: '', exclude: '' }
};
} }
} }
@@ -679,7 +679,7 @@ describe('SearchFacetFiltersService', () => {
it('should reset filters and call resetToDefaults', () => { it('should reset filters and call resetToDefaults', () => {
const resetToDefaultsSpy = spyOn(queryBuilder, 'resetToDefaults'); const resetToDefaultsSpy = spyOn(queryBuilder, 'resetToDefaults');
const updateSpy = spyOn(queryBuilder, 'update'); const executeSpy = spyOn(queryBuilder, 'execute');
const responseFacets = [{ field: 'field1', label: null }]; const responseFacets = [{ field: 'field1', label: null }];
const selectedBuckets = [{ field: { field: 'field1', label: null }, bucket: { label: 'bucket1', count: 1, filterQuery: 'q1' } }]; const selectedBuckets = [{ field: { field: 'field1', label: null }, bucket: { label: 'bucket1', count: 1, filterQuery: 'q1' } }];
@@ -695,8 +695,8 @@ describe('SearchFacetFiltersService', () => {
searchFacetFiltersService.reset(); searchFacetFiltersService.reset();
expect(resetToDefaultsSpy).toHaveBeenCalled(); expect(resetToDefaultsSpy).toHaveBeenCalledWith(true, false);
expect(updateSpy).toHaveBeenCalled(); expect(executeSpy).toHaveBeenCalled();
expect(searchFacetFiltersService.responseFacets).toEqual([]); expect(searchFacetFiltersService.responseFacets).toEqual([]);
expect(searchFacetFiltersService.selectedBuckets).toEqual([]); expect(searchFacetFiltersService.selectedBuckets).toEqual([]);
@@ -70,8 +70,6 @@ export class SearchFacetFiltersService {
this.responseFacets = null; this.responseFacets = null;
}); });
this.queryBuilder.updated.pipe(takeUntilDestroyed()).subscribe((query) => this.queryBuilder.execute(true, query));
this.queryBuilder.executed.pipe(takeUntilDestroyed()).subscribe((resultSetPaging: ResultSetPaging) => { this.queryBuilder.executed.pipe(takeUntilDestroyed()).subscribe((resultSetPaging: ResultSetPaging) => {
this.onDataLoaded(resultSetPaging); this.onDataLoaded(resultSetPaging);
this.searchService.dataLoaded.next(resultSetPaging); this.searchService.dataLoaded.next(resultSetPaging);
@@ -397,7 +395,7 @@ export class SearchFacetFiltersService {
bucket.checked = false; bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(facetField?.field, bucket); this.queryBuilder.removeUserFacetBucket(facetField?.field, bucket);
this.updateSelectedBuckets(); this.updateSelectedBuckets();
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
@@ -432,7 +430,7 @@ export class SearchFacetFiltersService {
this.updateSelectedBuckets(); this.updateSelectedBuckets();
} }
}); });
this.queryBuilder.update(); this.queryBuilder.execute();
} }
resetQueryFragments() { resetQueryFragments() {
@@ -444,7 +442,7 @@ export class SearchFacetFiltersService {
this.responseFacets = []; this.responseFacets = [];
this.selectedBuckets = []; this.selectedBuckets = [];
this.tabbedFacet = null; this.tabbedFacet = null;
this.queryBuilder.resetToDefaults(true); this.queryBuilder.resetToDefaults(true, false);
this.queryBuilder.update(); this.queryBuilder.execute();
} }
} }
@@ -231,19 +231,17 @@ describe('SearchHeaderQueryBuilderService', () => {
spyOn(router, 'navigate'); spyOn(router, 'navigate');
spyOn(console, 'error'); spyOn(console, 'error');
const searchUrl = 'search'; const searchUrl = 'search';
builderService.filterRawParams = { const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))';
userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' builderService.searchMode = 'formula';
};
builderService.encodeQuery(); await builderService.navigateToSearch(nonLatinQuery, searchUrl);
await builderService.navigateToSearch('', searchUrl);
expect(console.error).not.toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([searchUrl], { const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args;
queryParams: { expect(navigateArgs[0]).toEqual([searchUrl]);
q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' expect(navigateArgs[1].queryParamsHandling).toBe('merge');
}, const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q))));
queryParamsHandling: 'merge' expect(decoded.userQuery).toBe(nonLatinQuery);
});
}); });
}); });
@@ -21,7 +21,6 @@ import { SearchConfiguration } from '../models/search-configuration.interface';
import { BaseQueryBuilderService } from './base-query-builder.service'; import { BaseQueryBuilderService } from './base-query-builder.service';
import { SearchCategory } from '../models/search-category.interface'; import { SearchCategory } from '../models/search-category.interface';
import { Node } from '@alfresco/js-api'; import { Node } from '@alfresco/js-api';
import { filter } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { SearchSortingDefinition } from '../models/search-sorting-definition.interface'; import { SearchSortingDefinition } from '../models/search-sorting-definition.interface';
import { FilterSearch } from '../models/filter-search.interface'; import { FilterSearch } from '../models/filter-search.interface';
@@ -43,10 +42,6 @@ export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService {
const alfrescoApiService = inject(AlfrescoApiService); const alfrescoApiService = inject(AlfrescoApiService);
super(appConfig, alfrescoApiService); super(appConfig, alfrescoApiService);
this.updated.pipe(filter((query) => !!query)).subscribe(() => {
this.execute();
});
} }
public isFilterServiceActive(): boolean { public isFilterServiceActive(): boolean {
@@ -126,16 +126,16 @@ describe('SearchQueryBuilder', () => {
expect(builder.userQuery).toBe(''); expect(builder.userQuery).toBe('');
}); });
it('should wrap user query with brackets', () => { it('should store the user query as the raw value', () => {
const builder = createQueryBuilder(); const builder = createQueryBuilder();
builder.userQuery = 'my query'; builder.userQuery = 'my query';
expect(builder.userQuery).toEqual('(my query)'); expect(builder.userQuery).toEqual('my query');
}); });
it('should trim user query value', () => { it('should expose the user query through filterRawParams', () => {
const builder = createQueryBuilder(); const builder = createQueryBuilder();
builder.userQuery = ' something '; builder.userQuery = 'something';
expect(builder.userQuery).toEqual('(something)'); expect(builder.filterRawParams['userQuery']).toEqual('something');
}); });
it('should use only enabled categories', () => { it('should use only enabled categories', () => {
@@ -579,7 +579,8 @@ describe('SearchQueryBuilder', () => {
categories: [{ id: 'cat1', enabled: true } as any] categories: [{ id: 'cat1', enabled: true } as any]
}; };
const builder = createQueryBuilder(config); const builder = createQueryBuilder(config);
builder.userQuery = 'my query'; builder.searchMode = 'formula';
builder.userQuery = '(my query)';
builder.queryFragments['cat1'] = 'cm:name:test'; builder.queryFragments['cat1'] = 'cm:name:test';
@@ -587,6 +588,21 @@ describe('SearchQueryBuilder', () => {
expect(compiled.query.query).toBe('(my query) AND (cm:name:test)'); expect(compiled.query.query).toBe('(my query) AND (cm:name:test)');
}); });
it('should build final request from the parsed query in regular mode', () => {
const config: SearchConfiguration = {
id: 'test-config',
categories: [{ id: 'cat1', enabled: true } as any]
};
const builder = createQueryBuilder(config);
builder.searchMode = 'regular';
builder.userQuery = 'my query';
builder.queryFragments['cat1'] = 'cm:name:test';
const compiled = builder.buildQuery();
expect(compiled.query.query).toBe(`${builder.parsedQuery} AND (cm:name:test)`);
});
it('should group facet buckets by field', () => { it('should group facet buckets by field', () => {
const field1: FacetField = { const field1: FacetField = {
field: 'f1', field: 'f1',
@@ -729,12 +745,13 @@ describe('SearchQueryBuilder', () => {
}); });
}); });
it('should add user query to filter raw params when query is built', () => { it('should add user query and parsed query to filter raw params when user query is set', () => {
const builder = createQueryBuilder(); const builder = createQueryBuilder();
builder.userQuery = 'nuka cola quantum'; builder.userQuery = 'nuka cola quantum';
builder.buildQuery(); builder.buildQuery();
expect(builder.filterRawParams).toEqual({ userQuery: '(nuka cola quantum)' }); expect(builder.filterRawParams['userQuery']).toBe('nuka cola quantum');
expect(builder.filterRawParams['parsedQuery']).toBe(builder.parsedQuery);
}); });
it('should encode query from filter raw params and update query params on executing query', (done) => { it('should encode query from filter raw params and update query params on executing query', (done) => {
@@ -754,13 +771,14 @@ describe('SearchQueryBuilder', () => {
it('should encode query from filter raw params and update query params on navigating to search', async () => { it('should encode query from filter raw params and update query params on navigating to search', async () => {
spyOn(router, 'navigate'); spyOn(router, 'navigate');
service.filterRawParams = { userQuery: '(test query)' }; service.searchMode = 'formula';
await service.navigateToSearch('test query', '/search'); await service.navigateToSearch('test query', '/search');
expect(router.navigate).toHaveBeenCalledWith(['/search'], { const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args;
queryParams: { q: 'eyJ1c2VyUXVlcnkiOiIodGVzdCBxdWVyeSkifQ==' }, expect(navigateArgs[0]).toEqual(['/search']);
queryParamsHandling: 'merge' expect(navigateArgs[1].queryParamsHandling).toBe('merge');
}); const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q))));
expect(decoded.userQuery).toBe('test query');
}); });
describe('Multiple search configuration', () => { describe('Multiple search configuration', () => {
@@ -859,17 +877,17 @@ describe('SearchQueryBuilder', () => {
spyOn(router, 'navigate'); spyOn(router, 'navigate');
spyOn(console, 'error'); spyOn(console, 'error');
const searchUrl = 'search'; const searchUrl = 'search';
service.filterRawParams = { userQuery: '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))' }; const nonLatinQuery = '((cm:name:"wąż*" OR cm:title:"wąż*" OR cm:description:"wąż*" OR TEXT:"wąż*" OR TAG:"wąż*"))';
service.encodeQuery(); service.searchMode = 'formula';
await service.navigateToSearch(nonLatinQuery, searchUrl);
await service.navigateToSearch('', searchUrl);
expect(console.error).not.toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([searchUrl], { const navigateArgs = (router.navigate as jasmine.Spy).calls.mostRecent().args;
queryParams: { expect(navigateArgs[0]).toEqual([searchUrl]);
q: 'eyJ1c2VyUXVlcnkiOiIoKGNtOm5hbWU6XCJ3xIXFvCpcIiBPUiBjbTp0aXRsZTpcInfEhcW8KlwiIE9SIGNtOmRlc2NyaXB0aW9uOlwid8SFxbwqXCIgT1IgVEVYVDpcInfEhcW8KlwiIE9SIFRBRzpcInfEhcW8KlwiKSkifQ==' expect(navigateArgs[1].queryParamsHandling).toBe('merge');
}, const decoded = JSON.parse(decodeURIComponent(escape(atob(navigateArgs[1].queryParams.q))));
queryParamsHandling: 'merge' expect(decoded.userQuery).toBe(nonLatinQuery);
});
}); });
}); });
@@ -80,6 +80,7 @@ describe('OidcAuthenticationService', () => {
}); });
service = TestBed.inject(OidcAuthenticationService); service = TestBed.inject(OidcAuthenticationService);
oauthService = TestBed.inject(OAuthService); oauthService = TestBed.inject(OAuthService);
mockOAuthService.logOut.calls.reset();
}); });
it('should be created', () => { it('should be created', () => {
@@ -165,6 +166,14 @@ describe('OidcAuthenticationService', () => {
}); });
}); });
describe('reset', () => {
it('should clear local OAuth state without redirecting to the IdP', () => {
service.reset();
expect(oauthService.logOut as jasmine.Spy).toHaveBeenCalledOnceWith(true);
});
});
describe('loggedIn', () => { describe('loggedIn', () => {
it('should return true if has valid tokens', () => { it('should return true if has valid tokens', () => {
mockOAuthService.hasValidAccessToken.and.returnValue(true); mockOAuthService.hasValidAccessToken.and.returnValue(true);
@@ -131,8 +131,7 @@ export class OidcAuthenticationService extends BaseAuthenticationService {
} }
reset(): void { reset(): void {
const config = this.authConfig.loadAppConfig(); this.oauthService.logOut(true);
this.auth.updateIDPConfiguration(config);
} }
isPublicUrl(): boolean { isPublicUrl(): boolean {
+1
View File
@@ -49,4 +49,5 @@ export * from './models/identity-role.model';
export * from './models/user-access.model'; export * from './models/user-access.model';
export * from './models/application-access.model'; export * from './models/application-access.model';
export * from './session-timeout/public-api';
export * from './oidc/public-api'; export * from './oidc/public-api';
@@ -0,0 +1,112 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { NgZone } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ACTIVITY_THROTTLE_MS, IdleActivityTracker } from './idle-activity-tracker';
describe('IdleActivityTracker', () => {
let tracker: IdleActivityTracker;
let doc: Document;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [IdleActivityTracker] });
tracker = TestBed.inject(IdleActivityTracker);
doc = TestBed.inject(DOCUMENT);
});
afterEach(() => tracker.stop());
it('emits on activity$ when a registered DOM event fires after start()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
doc.dispatchEvent(new Event('click'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('throttles a burst of activity events within the throttle window', fakeAsync(() => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
// Leading edge emits immediately, the rest of the burst is throttled.
for (let i = 0; i < 10; i++) {
doc.dispatchEvent(new Event('mousemove'));
}
expect(spy).toHaveBeenCalledTimes(1);
// After the throttle window, activity emits again.
tick(ACTIVITY_THROTTLE_MS);
doc.dispatchEvent(new Event('mousemove'));
expect(spy.calls.count()).toBeGreaterThan(1);
tick(ACTIVITY_THROTTLE_MS);
}));
it('does not emit after stop()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
tracker.stop();
doc.dispatchEvent(new Event('click'));
expect(spy).not.toHaveBeenCalled();
});
it('registers listeners only once across repeated start() calls', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
tracker.start();
doc.dispatchEvent(new Event('click'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('is a no-op when stop() is called without a prior start()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
expect(() => tracker.stop()).not.toThrow();
doc.dispatchEvent(new Event('click'));
expect(spy).not.toHaveBeenCalled();
});
it('emits the current visibility state on visibilitychange', () => {
const spy = jasmine.createSpy('visibility');
tracker.visibilityChange$.subscribe(spy);
tracker.start();
doc.dispatchEvent(new Event('visibilitychange'));
expect(spy).toHaveBeenCalledWith(doc.visibilityState);
});
it('registers listeners outside the Angular zone', () => {
const ngZone = TestBed.inject(NgZone);
const runOutside = spyOn(ngZone, 'runOutsideAngular').and.callThrough();
tracker.start();
expect(runOutside).toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { Injectable, NgZone, OnDestroy, inject } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
export const ACTIVITY_EVENTS = ['click', 'keydown', 'mousedown', 'mousemove', 'pointerdown', 'scroll', 'touchstart', 'wheel'] as const;
/** High-frequency activity events (e.g. mousemove, scroll) are throttled to avoid rescheduling the idle timer on every DOM event. */
export const ACTIVITY_THROTTLE_MS = 1000;
@Injectable()
export class IdleActivityTracker implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly ngZone = inject(NgZone);
private readonly activitySubject = new Subject<void>();
private readonly visibilitySubject = new Subject<DocumentVisibilityState>();
private isRegistered = false;
readonly activity$: Observable<void> = this.activitySubject
.asObservable()
.pipe(throttleTime(ACTIVITY_THROTTLE_MS, undefined, { leading: true, trailing: true }));
readonly visibilityChange$: Observable<DocumentVisibilityState> = this.visibilitySubject.asObservable();
start(): void {
if (this.isRegistered) {
return;
}
this.ngZone.runOutsideAngular(() => {
ACTIVITY_EVENTS.forEach((eventName) => this.document.addEventListener(eventName, this.handleActivity, { passive: true }));
this.document.addEventListener('visibilitychange', this.handleVisibilityChange);
});
this.isRegistered = true;
}
stop(): void {
if (!this.isRegistered) {
return;
}
ACTIVITY_EVENTS.forEach((eventName) => this.document.removeEventListener(eventName, this.handleActivity));
this.document.removeEventListener('visibilitychange', this.handleVisibilityChange);
this.isRegistered = false;
}
ngOnDestroy(): void {
this.stop();
}
private readonly handleActivity = (): void => {
this.activitySubject.next();
};
private readonly handleVisibilityChange = (): void => {
this.visibilitySubject.next(this.document.visibilityState);
};
}
@@ -0,0 +1,70 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApplicationInitStatus } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { provideSessionTimeout } from './provide-session-timeout';
import { SessionTimeoutService } from './session-timeout.service';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
describe('provideSessionTimeout', () => {
it('registers SessionTimeoutService and runs its start() during app init', async () => {
const startSpy = spyOn(SessionTimeoutService.prototype, 'start');
TestBed.configureTestingModule({
providers: [
provideSessionTimeout(),
{ provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } },
{
provide: AuthenticationService,
useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() }
}
]
});
await TestBed.inject(ApplicationInitStatus).donePromise;
expect(startSpy).toHaveBeenCalled();
});
it('waits for startWhen to emit true before starting SessionTimeoutService', async () => {
const startWhen$ = new Subject<boolean>();
const startSpy = spyOn(SessionTimeoutService.prototype, 'start');
TestBed.configureTestingModule({
providers: [
provideSessionTimeout({ startWhen: () => startWhen$ }),
{ provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } },
{
provide: AuthenticationService,
useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() }
}
]
});
await TestBed.inject(ApplicationInitStatus).donePromise;
expect(startSpy).not.toHaveBeenCalled();
startWhen$.next(false);
expect(startSpy).not.toHaveBeenCalled();
startWhen$.next(true);
expect(startSpy).toHaveBeenCalledTimes(1);
startWhen$.next(true);
expect(startSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,53 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { SESSION_TIMEOUT_OPTIONS, SessionTimeoutOptions } from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel';
import { SessionTimeoutService } from './session-timeout.service';
import { filter, take } from 'rxjs/operators';
/**
* Provides the session timeout feature: idle tracking, the countdown dialog and cross-tab sync.
*
* When the countdown dialog is not answered (or the user clicks "Log out"), the normal logout flow
* runs and the user is redirected to the configured IdP/login page.
*
* @param options - Optional overrides that take precedence over the `sessionTimeout` app config block
* @returns Environment providers that register the service and start it during app initialization
*/
export function provideSessionTimeout(options?: SessionTimeoutOptions): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: SESSION_TIMEOUT_OPTIONS, useValue: options ?? {} },
IdleActivityTracker,
SessionTimeoutSyncChannel,
SessionTimeoutService,
provideAppInitializer(() => {
const sessionTimeoutService = inject(SessionTimeoutService);
const sessionTimeoutOptions = inject(SESSION_TIMEOUT_OPTIONS);
const startWhen$ = sessionTimeoutOptions.startWhen?.();
if (!startWhen$) {
sessionTimeoutService.start();
return;
}
startWhen$.pipe(filter(Boolean), take(1)).subscribe(() => sessionTimeoutService.start());
})
]);
}
@@ -0,0 +1,23 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './session-timeout.config';
export * from './idle-activity-tracker';
export * from './session-timeout-sync-channel';
export * from './session-timeout-dialog.component';
export * from './session-timeout.service';
export * from './provide-session-timeout';
@@ -0,0 +1,14 @@
<h2 mat-dialog-title>{{ 'SESSION_TIMEOUT.TITLE' | translate }}</h2>
<mat-dialog-content aria-live="polite">
{{ 'SESSION_TIMEOUT.MESSAGE' | translate: { seconds: remainingSeconds } }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" data-automation-id="session-timeout-dialog__logout-button" (click)="logout()">
{{ 'SESSION_TIMEOUT.LOG_OUT' | translate }}
</button>
<button mat-flat-button color="primary" type="button" data-automation-id="session-timeout-dialog__continue-button" (click)="continueWorking()">
{{ 'SESSION_TIMEOUT.CONTINUE_WORKING' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,4 @@
.adf-session-timeout-backdrop {
background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(16px);
}
@@ -0,0 +1,64 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SessionTimeoutDialogComponent } from './session-timeout-dialog.component';
describe('SessionTimeoutDialogComponent', () => {
const dialogRef = { close: jasmine.createSpy('close') };
beforeEach(() => {
dialogRef.close.calls.reset();
TestBed.configureTestingModule({
imports: [SessionTimeoutDialogComponent, NoopAnimationsModule, TranslateModule.forRoot()],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: { dialogTimeoutMs: 3000 } }
]
});
});
it('initializes remainingSeconds from dialogTimeoutMs', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
expect(fixture.componentInstance.remainingSeconds).toBe(3);
});
it('counts down each second', fakeAsync(() => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.detectChanges();
tick(1000);
expect(fixture.componentInstance.remainingSeconds).toBe(2);
tick(1000);
expect(fixture.componentInstance.remainingSeconds).toBe(1);
fixture.destroy();
}));
it('closes with true on continueWorking()', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.componentInstance.continueWorking();
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('closes with false on logout()', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.componentInstance.logout();
expect(dialogRef.close).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,67 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
export const SESSION_TIMEOUT_BACKDROP_CLASS = 'adf-session-timeout-backdrop';
export interface SessionTimeoutDialogData {
dialogTimeoutMs: number;
}
@Component({
selector: 'adf-session-timeout-dialog',
standalone: true,
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
templateUrl: './session-timeout-dialog.component.html',
styleUrl: './session-timeout-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SessionTimeoutDialogComponent implements OnDestroy {
private readonly dialogRef = inject<MatDialogRef<SessionTimeoutDialogComponent, boolean>>(MatDialogRef);
private readonly data = inject<SessionTimeoutDialogData>(MAT_DIALOG_DATA);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly timeoutEndTime = Date.now() + this.data.dialogTimeoutMs;
private readonly countdownIntervalId = setInterval(() => this.updateRemainingSeconds(), 1000);
remainingSeconds = this.getRemainingSeconds();
ngOnDestroy(): void {
clearInterval(this.countdownIntervalId);
}
continueWorking(): void {
this.dialogRef.close(true);
}
logout(): void {
this.dialogRef.close(false);
}
private updateRemainingSeconds(): void {
this.remainingSeconds = this.getRemainingSeconds();
this.changeDetectorRef.markForCheck();
}
private getRemainingSeconds(): number {
return Math.max(Math.ceil((this.timeoutEndTime - Date.now()) / 1000), 0);
}
}
@@ -0,0 +1,119 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel';
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
postMessage = jasmine.createSpy('postMessage').and.callFake((message: unknown) => {
MockBroadcastChannel.instances
.filter((instance) => instance !== this && instance.name === this.name)
.forEach((instance) => instance.dispatch(message));
});
close = jasmine.createSpy('close');
private readonly listeners = new Set<(event: MessageEvent<unknown>) => void>();
constructor(public name: string) {
MockBroadcastChannel.instances.push(this);
}
addEventListener(type: string, listener: (event: MessageEvent<unknown>) => void): void {
if (type === 'message') {
this.listeners.add(listener);
}
}
removeEventListener(_type: string, listener: (event: MessageEvent<unknown>) => void): void {
this.listeners.delete(listener);
}
dispatch(data: unknown): void {
this.listeners.forEach((listener) => listener({ data } as MessageEvent<unknown>));
}
}
describe('SessionTimeoutSyncChannel', () => {
let doc: Document;
let original: typeof BroadcastChannel | undefined;
beforeEach(() => {
MockBroadcastChannel.instances = [];
TestBed.configureTestingModule({ providers: [SessionTimeoutSyncChannel] });
doc = TestBed.inject(DOCUMENT);
original = (doc.defaultView as any).BroadcastChannel;
(doc.defaultView as any).BroadcastChannel = MockBroadcastChannel;
});
afterEach(() => {
(doc.defaultView as any).BroadcastChannel = original;
});
it('posts a well-formed event with type, sourceTabId and createdAt', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
channel.open();
channel.post('activity');
const instance = MockBroadcastChannel.instances[0];
expect(instance.postMessage).toHaveBeenCalledTimes(1);
const payload = instance.postMessage.calls.mostRecent().args[0];
expect(payload.type).toBe('activity');
expect(typeof payload.sourceTabId).toBe('string');
expect(typeof payload.createdAt).toBe('number');
});
it('ignores messages originating from its own tab', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
channel.post('timeout');
instance.dispatch(instance.postMessage.calls.mostRecent().args[0]);
expect(spy).not.toHaveBeenCalled();
});
it('emits validated messages from other tabs', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
instance.dispatch({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 });
expect(spy).toHaveBeenCalledWith({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 });
});
it('drops malformed messages', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
instance.dispatch({ type: 'nope' });
instance.dispatch(null);
expect(spy).not.toHaveBeenCalled();
});
it('post() does not throw when channel is unavailable', () => {
(doc.defaultView as any).BroadcastChannel = undefined;
const channel = TestBed.inject(SessionTimeoutSyncChannel);
channel.open();
expect(() => channel.post('activity')).not.toThrow();
});
});
@@ -0,0 +1,120 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { Injectable, OnDestroy, inject } from '@angular/core';
import { Observable, Subject } from 'rxjs';
const SESSION_TIMEOUT_CHANNEL_NAME = 'adf-session-timeout';
export const SESSION_TIMEOUT_SYNC_EVENT_TYPES = ['activity', 'timeout', 'continue', 'logout'] as const;
export type SessionTimeoutSyncEventType = (typeof SESSION_TIMEOUT_SYNC_EVENT_TYPES)[number];
export interface SessionTimeoutSyncEvent {
type: SessionTimeoutSyncEventType;
sourceTabId: string;
createdAt: number;
}
@Injectable()
export class SessionTimeoutSyncChannel implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly window = this.document.defaultView;
private readonly tabId = this.createTabId();
private readonly messageSubject = new Subject<SessionTimeoutSyncEvent>();
private channel: BroadcastChannel | undefined;
readonly messages$: Observable<SessionTimeoutSyncEvent> = this.messageSubject.asObservable();
open(): void {
if (this.channel || !this.window?.BroadcastChannel) {
return;
}
this.channel = new this.window.BroadcastChannel(SESSION_TIMEOUT_CHANNEL_NAME);
this.channel.addEventListener('message', this.handleMessage);
}
close(): void {
if (!this.channel) {
return;
}
this.channel.removeEventListener('message', this.handleMessage);
this.channel.close();
this.channel = undefined;
}
post(type: SessionTimeoutSyncEventType): void {
try {
this.channel?.postMessage({
type,
sourceTabId: this.tabId,
createdAt: Date.now()
} satisfies SessionTimeoutSyncEvent);
} catch {
/* empty */
}
}
ngOnDestroy(): void {
this.close();
}
private readonly handleMessage = (event: MessageEvent<unknown>): void => {
const syncEvent = this.parse(event.data);
if (!syncEvent || syncEvent.sourceTabId === this.tabId) {
return;
}
this.messageSubject.next(syncEvent);
};
private parse(value: unknown): SessionTimeoutSyncEvent | undefined {
if (typeof value !== 'object' || value === null) {
return undefined;
}
const syncEvent = value as Partial<SessionTimeoutSyncEvent>;
if (
typeof syncEvent.type === 'string' &&
(SESSION_TIMEOUT_SYNC_EVENT_TYPES as readonly string[]).includes(syncEvent.type) &&
typeof syncEvent.sourceTabId === 'string' &&
typeof syncEvent.createdAt === 'number'
) {
return syncEvent as SessionTimeoutSyncEvent;
}
return undefined;
}
private createTabId(): string {
const crypto = this.window?.crypto;
if (crypto?.randomUUID) {
return crypto.randomUUID();
}
if (crypto?.getRandomValues) {
const buffer = new Uint32Array(4);
crypto.getRandomValues(buffer);
return Array.from(buffer, (value) => value.toString(16)).join('-');
}
return `${Date.now()}-${this.window?.performance?.now?.() ?? 0}`;
}
}
@@ -0,0 +1,47 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { normalizeSessionTimeoutOptions, DEFAULT_SESSION_TIMEOUT_OPTIONS } from './session-timeout.config';
describe('normalizeSessionTimeoutOptions', () => {
it('coerces string "true" to boolean enabled', () => {
expect(normalizeSessionTimeoutOptions({ enabled: 'true' }).enabled).toBe(true);
});
it('preserves boolean enabled values', () => {
expect(normalizeSessionTimeoutOptions({ enabled: true }).enabled).toBe(true);
expect(normalizeSessionTimeoutOptions({ enabled: false }).enabled).toBe(false);
});
it('treats non-"true" strings and missing as disabled', () => {
expect(normalizeSessionTimeoutOptions({ enabled: 'false' }).enabled).toBe(false);
expect(normalizeSessionTimeoutOptions({}).enabled).toBe(false);
});
it('parses numeric strings for timeouts', () => {
const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: '1000', dialogTimeoutMs: '2000' });
expect(result.idleTimeoutMs).toBe(1000);
expect(result.dialogTimeoutMs).toBe(2000);
});
it('falls back to defaults for non-positive or invalid numbers', () => {
const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: 0, dialogTimeoutMs: 'abc' });
expect(result.enabled).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled);
expect(result.idleTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs);
expect(result.dialogTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs);
});
});
@@ -0,0 +1,63 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
export const SESSION_TIMEOUT_CONFIG_KEY = 'sessionTimeout';
export interface SessionTimeoutOptions {
enabled?: boolean | string;
idleTimeoutMs?: number | string;
dialogTimeoutMs?: number | string;
startWhen?: () => Observable<boolean>;
}
export interface NormalizedSessionTimeoutOptions {
enabled: boolean;
idleTimeoutMs: number;
dialogTimeoutMs: number;
}
export const DEFAULT_SESSION_TIMEOUT_OPTIONS: NormalizedSessionTimeoutOptions = {
enabled: false,
idleTimeoutMs: 30 * 60 * 1000,
dialogTimeoutMs: 60 * 1000
};
export const SESSION_TIMEOUT_OPTIONS = new InjectionToken<SessionTimeoutOptions>('SESSION_TIMEOUT_OPTIONS');
const toBoolean = (value: boolean | string | undefined): boolean => value === true || value === 'true';
const toPositiveNumber = (value: number | string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* Coerces raw session timeout options (which may arrive as strings from app config) into validated values.
*
* @param merged - Raw options merged from defaults, app config and provider input
* @returns Normalized options with a boolean `enabled` flag and positive numeric timeouts
*/
export function normalizeSessionTimeoutOptions(merged: SessionTimeoutOptions): NormalizedSessionTimeoutOptions {
return {
enabled: merged.enabled === undefined ? DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled : toBoolean(merged.enabled),
idleTimeoutMs: toPositiveNumber(merged.idleTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs),
dialogTimeoutMs: toPositiveNumber(merged.dialogTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs)
};
}
@@ -0,0 +1,537 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { Observable, Subject, defer } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
import { SessionTimeoutService } from './session-timeout.service';
import { SessionTimeoutOptions, SESSION_TIMEOUT_OPTIONS } from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel';
import { SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component';
describe('SessionTimeoutService', () => {
let authService: {
isLoggedIn: jasmine.Spy<() => boolean>;
logout: jasmine.Spy<() => Observable<void>>;
reset: jasmine.Spy<() => void>;
onLogin: Subject<unknown>;
onLogout: Subject<unknown>;
};
let logoutSideEffect: jasmine.Spy;
let appConfigService: {
get: jasmine.Spy<(key: string, fallback: SessionTimeoutOptions) => SessionTimeoutOptions>;
isLoaded: boolean;
onLoad: Subject<unknown>;
};
let dialog: {
open: jasmine.Spy;
};
let tracker: {
start: jasmine.Spy;
stop: jasmine.Spy;
activity$: Subject<void>;
visibilityChange$: Subject<DocumentVisibilityState>;
};
let syncChannel: {
open: jasmine.Spy;
close: jasmine.Spy;
post: jasmine.Spy;
messages$: Subject<SessionTimeoutSyncEvent>;
};
let dialogClosed$: Subject<boolean | undefined>;
let isLoggedIn: boolean;
const configureTestingModule = (sessionTimeoutOptions?: SessionTimeoutOptions, providerOptions?: SessionTimeoutOptions) => {
isLoggedIn = true;
logoutSideEffect = jasmine.createSpy('logoutSideEffect');
authService = {
isLoggedIn: jasmine.createSpy('isLoggedIn').and.callFake(() => isLoggedIn),
logout: jasmine.createSpy('logout').and.callFake(() =>
defer(() => {
logoutSideEffect();
return new Observable<void>((subscriber) => subscriber.complete());
})
),
reset: jasmine.createSpy('reset'),
onLogin: new Subject<unknown>(),
onLogout: new Subject<unknown>()
};
appConfigService = {
get: jasmine.createSpy('get').and.callFake((_key: string, fallback: SessionTimeoutOptions) => sessionTimeoutOptions ?? fallback),
isLoaded: true,
onLoad: new Subject<unknown>()
};
dialogClosed$ = new Subject<boolean | undefined>();
dialog = {
open: jasmine.createSpy('open').and.returnValue({
afterClosed: () => dialogClosed$,
close: (value?: boolean) => {
dialogClosed$.next(value);
dialogClosed$.complete();
}
})
};
tracker = {
start: jasmine.createSpy('start'),
stop: jasmine.createSpy('stop'),
activity$: new Subject<void>(),
visibilityChange$: new Subject<DocumentVisibilityState>()
};
syncChannel = {
open: jasmine.createSpy('open'),
close: jasmine.createSpy('close'),
post: jasmine.createSpy('post'),
messages$: new Subject<SessionTimeoutSyncEvent>()
};
TestBed.configureTestingModule({
providers: [
SessionTimeoutService,
{ provide: SESSION_TIMEOUT_OPTIONS, useValue: providerOptions ?? {} },
{ provide: AuthenticationService, useValue: authService },
{ provide: AppConfigService, useValue: appConfigService },
{ provide: MatDialog, useValue: dialog },
{ provide: IdleActivityTracker, useValue: tracker },
{ provide: SessionTimeoutSyncChannel, useValue: syncChannel }
]
});
};
const startService = ({ emitLoginEvent = true } = {}) => {
const service = TestBed.inject(SessionTimeoutService);
service.start();
if (emitLoginEvent) {
authService.onLogin.next(undefined);
}
return service;
};
afterEach(() => {
TestBed.resetTestingModule();
});
it('should not activate when session timeout is disabled', fakeAsync(() => {
configureTestingModule({ enabled: false, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
expect(syncChannel.open).not.toHaveBeenCalled();
expect(dialog.open).not.toHaveBeenCalled();
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should not activate by default when enabled is not configured', fakeAsync(() => {
configureTestingModule({ idleTimeoutMs: 1000 });
startService();
expect(tracker.start).not.toHaveBeenCalled();
expect(syncChannel.open).not.toHaveBeenCalled();
}));
it('should wait for app config to load before starting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
appConfigService.isLoaded = false;
startService({ emitLoginEvent: false });
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
appConfigService.isLoaded = true;
appConfigService.onLoad.next({});
flushMicrotasks();
authService.onLogin.next(undefined);
tick(1000);
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should start tracker and open sync channel on login when logged in', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
}));
it('should clear session state when login event fires but user is not logged in', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
isLoggedIn = false;
startService();
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
expect(dialog.open).not.toHaveBeenCalled();
}));
it('should arm immediately on start when a session is already authenticated', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
// No onLogin event is emitted: the session predates this service.
startService({ emitLoginEvent: false });
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should not leak handler subscriptions across logout and login cycles', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
authService.onLogout.next({});
flushMicrotasks();
// Logout must tear down the session-scoped subscriptions.
expect(tracker.activity$.observers.length).toBe(0);
expect(syncChannel.messages$.observers.length).toBe(0);
authService.onLogin.next(undefined);
// Re-login arms exactly one fresh set, never two.
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
}));
it('should not re-arm or duplicate subscriptions when redundant login events fire', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
authService.onLogin.next(undefined);
authService.onLogin.next(undefined);
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
}));
it('should open dialog after idle timeout with no activity', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: { dialogTimeoutMs: 60000 },
backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS,
disableClose: true,
width: '420px'
})
);
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should reschedule timeout when activity occurs before idle timeout', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(750);
tracker.activity$.next();
tick(750);
expect(dialog.open).not.toHaveBeenCalled();
tick(250);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should ignore activity while dialog is open', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
tracker.activity$.next();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should continue working when dialog is confirmed', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(true);
flushMicrotasks();
expect(authService.logout).not.toHaveBeenCalled();
expect(syncChannel.post).toHaveBeenCalledWith('continue');
tick(999);
expect(dialog.open).toHaveBeenCalledTimes(1);
tick(1);
expect(dialog.open).toHaveBeenCalledTimes(2);
}));
it('should run the full logout flow when the dialog auto-closes on timeout', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000, dialogTimeoutMs: 500 });
startService();
tick(1000);
tick(500);
flushMicrotasks();
// An unanswered (timed-out) dialog logs the user out and redirects, same as the explicit "Log out" button.
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(logoutSideEffect).toHaveBeenCalledTimes(1);
expect(authService.reset).not.toHaveBeenCalled();
// Other tabs are told to log out too.
expect(syncChannel.post).toHaveBeenCalledWith('logout');
}));
it('should logout (not redirect) when the user explicitly chooses to log out', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(logoutSideEffect).toHaveBeenCalledTimes(1);
}));
it('should logout once when dialog is rejected (idempotent)', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(syncChannel.post).toHaveBeenCalledWith('logout');
// Try to logout again
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
}));
it('should throttle activity sync posts to one per 1000ms', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 5000 });
startService();
tracker.activity$.next();
tracker.activity$.next();
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledWith('activity');
expect(syncChannel.post).toHaveBeenCalledTimes(1);
tick(999);
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledTimes(1);
tick(1);
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledTimes(2);
}));
it('should continue session when inbound activity message received', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(750);
syncChannel.messages$.next({
type: 'activity',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
tick(750);
expect(dialog.open).not.toHaveBeenCalled();
tick(250);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should continue session when inbound continue message received', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
// When continue is received, the dialog closes without a value
// We need to complete the dialog observable to avoid the EmptyError
const dialogCloseSpy = jasmine.createSpy('dialogClose');
const mockDialogRef = dialog.open.calls.mostRecent().returnValue;
mockDialogRef.close = dialogCloseSpy;
syncChannel.messages$.next({
type: 'continue',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(dialogCloseSpy).toHaveBeenCalled();
// Session should continue, new timeout scheduled
tick(999);
expect(dialog.open).toHaveBeenCalledTimes(1);
tick(1);
expect(dialog.open).toHaveBeenCalledTimes(2);
}));
it('should open dialog when inbound timeout message received without re-broadcasting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
(service as any).lastActivityAt = Date.now() - 500;
syncChannel.messages$.next({
type: 'timeout',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(syncChannel.post).not.toHaveBeenCalledWith('timeout');
}));
it('should not open dialog when inbound timeout message is stale', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
(service as any).lastActivityAt = Date.now();
syncChannel.messages$.next({
type: 'timeout',
sourceTabId: 'other-tab',
createdAt: Date.now() - 1000
});
expect(dialog.open).not.toHaveBeenCalled();
}));
it('should logout when inbound logout message received without re-broadcasting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
syncChannel.messages$.next({
type: 'logout',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(syncChannel.post).not.toHaveBeenCalledWith('logout');
}));
it('should re-evaluate timeout when visibility changes to visible', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(500);
// Simulate elapsed time during hidden state
const service = TestBed.inject(SessionTimeoutService);
(service as any).lastActivityAt = Date.now() - 1001;
tracker.visibilityChange$.next('visible');
flushMicrotasks();
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should clear state and stop tracker on logout event', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
authService.onLogout.next({});
flushMicrotasks();
expect(tracker.stop).toHaveBeenCalledTimes(1);
expect(syncChannel.close).toHaveBeenCalledTimes(1);
}));
it('should tear down resources on ngOnDestroy', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
service.ngOnDestroy();
expect(tracker.stop).toHaveBeenCalledTimes(1);
expect(syncChannel.close).toHaveBeenCalledTimes(1);
}));
it('should not logout when dialog is closed without explicit choice', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(undefined);
flushMicrotasks();
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should use provider options over app config options', fakeAsync(() => {
configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }, { enabled: true, idleTimeoutMs: 500 });
startService();
tick(500);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should post timeout message when dialog opens', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(syncChannel.post).toHaveBeenCalledWith('timeout');
}));
});
@@ -0,0 +1,370 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, NgZone, OnDestroy, inject } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
import {
SESSION_TIMEOUT_CONFIG_KEY,
SESSION_TIMEOUT_OPTIONS,
SessionTimeoutOptions,
DEFAULT_SESSION_TIMEOUT_OPTIONS,
normalizeSessionTimeoutOptions
} from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel';
import { SessionTimeoutDialogComponent, SessionTimeoutDialogData, SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component';
const ACTIVITY_SYNC_THROTTLE_MS = 1000;
@Injectable()
export class SessionTimeoutService implements OnDestroy {
private readonly appConfigService = inject(AppConfigService);
private readonly authService = inject(AuthenticationService);
private readonly dialog = inject(MatDialog);
private readonly ngZone = inject(NgZone);
private readonly providerOptions = inject<SessionTimeoutOptions>(SESSION_TIMEOUT_OPTIONS);
private readonly tracker = inject(IdleActivityTracker);
private readonly syncChannel = inject(SessionTimeoutSyncChannel);
private readonly subscription = new Subscription();
private sessionSubscription: Subscription | undefined;
private timeoutId: ReturnType<typeof setTimeout> | undefined;
private dialogTimeoutId: ReturnType<typeof setTimeout> | undefined;
private dialogRef: MatDialogRef<SessionTimeoutDialogComponent, boolean> | undefined;
private idleTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs;
private dialogTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs;
private lastActivityAt = Date.now();
private isStarted = false;
private isSessionActive = false;
private isLoggingOut = false;
private lastActivitySyncAt = 0;
start(): void {
if (this.isStarted) {
return;
}
if (this.appConfigService.isLoaded === false) {
this.appConfigService.onLoad.pipe(take(1)).subscribe(() => {
this.startFromLoadedConfig();
});
return;
}
this.startFromLoadedConfig();
}
ngOnDestroy(): void {
this.clearTimeout();
this.clearDialogTimeout();
this.dialogRef?.close();
this.tracker.stop();
this.syncChannel.close();
this.sessionSubscription?.unsubscribe();
this.subscription.unsubscribe();
}
private startFromLoadedConfig(): void {
if (this.isStarted) {
return;
}
const appConfigOptions = this.appConfigService.get(SESSION_TIMEOUT_CONFIG_KEY, {}) as SessionTimeoutOptions;
const mergedOptions = {
...DEFAULT_SESSION_TIMEOUT_OPTIONS,
...appConfigOptions,
...this.providerOptions
};
const options = normalizeSessionTimeoutOptions(mergedOptions);
if (!options.enabled) {
return;
}
this.idleTimeoutMs = options.idleTimeoutMs;
this.dialogTimeoutMs = options.dialogTimeoutMs;
this.isStarted = true;
this.subscribeToAuthEvents();
if (this.authService.isLoggedIn()) {
this.isSessionActive = true;
this.isLoggingOut = false;
this.activateSessionTimeout();
}
}
private subscribeToAuthEvents(): void {
this.subscription.add(
this.authService.onLogin.subscribe(() => {
const isLoggedIn = this.authService.isLoggedIn();
if (!isLoggedIn) {
this.clearSessionState();
return;
}
this.isSessionActive = true;
this.isLoggingOut = false;
this.activateSessionTimeout();
})
);
this.subscription.add(
this.authService.onLogout.subscribe(() => {
this.clearSessionState();
})
);
}
private activateSessionTimeout(): void {
this.ngZone.runOutsideAngular(() => {
if (!this.canArmSessionTimeout()) {
return;
}
if (this.sessionSubscription) {
this.refreshSession();
return;
}
this.sessionSubscription = new Subscription();
this.tracker.start();
this.syncChannel.open();
this.subscribeToTrackerEvents();
this.subscribeToSyncEvents();
this.refreshSession();
});
}
private subscribeToTrackerEvents(): void {
this.sessionSubscription?.add(
this.tracker.activity$.subscribe(() => {
this.handleActivity();
})
);
this.sessionSubscription?.add(
this.tracker.visibilityChange$.subscribe((state) => {
this.handleVisibilityChange(state);
})
);
}
private subscribeToSyncEvents(): void {
this.sessionSubscription?.add(
this.syncChannel.messages$.subscribe((syncEvent) => {
this.handleSyncEvent(syncEvent);
})
);
}
private handleActivity(): void {
if (this.dialogRef) {
return;
}
this.refreshSession({ shouldNotifyActivity: true });
}
private handleVisibilityChange(state: DocumentVisibilityState): void {
if (state === 'visible') {
this.handleTimeout();
}
}
private handleSyncEvent(syncEvent: SessionTimeoutSyncEvent): void {
if (!this.canArmSessionTimeout() && syncEvent.type !== 'logout') {
return;
}
if (syncEvent.type === 'activity' || syncEvent.type === 'continue') {
this.continueSession();
return;
}
if (syncEvent.type === 'timeout') {
if (syncEvent.createdAt >= this.lastActivityAt) {
this.openContinueWorkingDialog(false);
}
return;
}
this.logout(false);
}
private refreshSession(options?: { shouldNotifyActivity?: boolean }): void {
if (!this.canArmSessionTimeout()) {
return;
}
this.lastActivityAt = Date.now();
if (options?.shouldNotifyActivity === true) {
this.notifyActivity();
}
this.scheduleTimeout();
}
private scheduleTimeout(): void {
this.clearTimeout();
if (!this.canArmSessionTimeout()) {
return;
}
const elapsedMs = Date.now() - this.lastActivityAt;
const remainingMs = Math.max(this.idleTimeoutMs - elapsedMs, 0);
this.timeoutId = setTimeout(() => this.handleTimeout(), remainingMs);
}
private handleTimeout(): void {
if (!this.canArmSessionTimeout()) {
this.clearTimeout();
return;
}
const elapsedMs = Date.now() - this.lastActivityAt;
if (elapsedMs < this.idleTimeoutMs) {
this.scheduleTimeout();
return;
}
this.openContinueWorkingDialog();
}
private openContinueWorkingDialog(shouldNotifyTabs = true): void {
if (this.dialogRef) {
return;
}
if (!this.canArmSessionTimeout()) {
return;
}
this.clearTimeout();
this.dialogRef = this.ngZone.run(() =>
this.dialog.open<SessionTimeoutDialogComponent, SessionTimeoutDialogData, boolean>(SessionTimeoutDialogComponent, {
data: {
dialogTimeoutMs: this.dialogTimeoutMs
},
backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS,
disableClose: true,
width: '420px'
})
);
this.dialogTimeoutId = setTimeout(() => {
this.dialogRef?.close(false);
}, this.dialogTimeoutMs);
if (shouldNotifyTabs) {
this.syncChannel.post('timeout');
}
this.dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((shouldContinueWorking) => {
this.clearDialogTimeout();
this.dialogRef = undefined;
if (shouldContinueWorking === true) {
this.continueSession();
this.syncChannel.post('continue');
return;
}
if (shouldContinueWorking === false) {
this.logout();
}
});
}
private continueSession(): void {
this.closeDialogWithoutAction();
this.refreshSession();
}
private logout(shouldNotifyTabs = true): void {
if (this.isLoggingOut) {
return;
}
this.isSessionActive = false;
this.isLoggingOut = true;
this.clearTimeout();
this.clearDialogTimeout();
if (shouldNotifyTabs) {
this.syncChannel.post('logout');
}
this.ngZone.run(() => this.authService.logout().pipe(take(1)).subscribe());
}
private clearSessionState(): void {
this.isSessionActive = false;
this.dialogRef?.close();
this.clearTimeout();
this.clearDialogTimeout();
this.sessionSubscription?.unsubscribe();
this.sessionSubscription = undefined;
this.tracker.stop();
this.syncChannel.close();
}
private closeDialogWithoutAction(): void {
const dialogRef = this.dialogRef;
if (!dialogRef) {
return;
}
this.dialogRef = undefined;
this.clearDialogTimeout();
dialogRef.close();
}
private canArmSessionTimeout(): boolean {
return this.isSessionActive && !this.isLoggingOut && this.authService.isLoggedIn();
}
private notifyActivity(): void {
const now = Date.now();
if (now - this.lastActivitySyncAt < ACTIVITY_SYNC_THROTTLE_MS) {
return;
}
this.lastActivitySyncAt = now;
this.syncChannel.post('activity');
}
private clearTimeout(): void {
if (this.timeoutId === undefined) {
return;
}
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
private clearDialogTimeout(): void {
if (this.dialogTimeoutId === undefined) {
return;
}
clearTimeout(this.dialogTimeoutId);
this.dialogTimeoutId = undefined;
}
}
+6
View File
@@ -667,5 +667,11 @@
"ACTION": "Do you want to proceed?", "ACTION": "Do you want to proceed?",
"YES_LABEL": "Yes", "YES_LABEL": "Yes",
"NO_LABEL": "No" "NO_LABEL": "No"
},
"SESSION_TIMEOUT": {
"TITLE": "Are you still working?",
"MESSAGE": "Your session will end in {{seconds}} seconds because there has been no activity.",
"LOG_OUT": "Log out",
"CONTINUE_WORKING": "Continue working"
} }
} }
@@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { FormFieldTypes, FormFieldModel, FormModel } from '@alfresco/adf-core'; import { FormFieldTypes, FormFieldModel, FormModel } from '@alfresco/adf-core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
@@ -250,6 +250,22 @@ describe('PeopleWidgetComponent', () => {
expect(widget.searchTerm.value).toBe(''); expect(widget.searchTerm.value).toBe('');
}); });
it('should call the users API only once when the user types several characters quickly', fakeAsync(() => {
const getWorkflowUsersSpy = spyOn(peopleProcessService, 'getWorkflowUsers').and.returnValue(of([]));
widget.searchTerm.setValue('T');
tick(100);
widget.searchTerm.setValue('Te');
tick(100);
widget.searchTerm.setValue('Tes');
tick(100);
widget.searchTerm.setValue('Test');
tick(300);
expect(getWorkflowUsersSpy).toHaveBeenCalledTimes(1);
expect(getWorkflowUsersSpy).toHaveBeenCalledWith(undefined, 'Test', widget.groupId);
}));
it('should remove user from selectedUsers if user exists', () => { it('should remove user from selectedUsers if user exists', () => {
const users: LightUserRepresentation[] = [ const users: LightUserRepresentation[] = [
{ id: 1, firstName: 'John', lastName: 'Doe' }, { id: 1, firstName: 'John', lastName: 'Doe' },
@@ -351,21 +367,21 @@ describe('PeopleWidgetComponent', () => {
expect(element.querySelector('#people-widget-content')).not.toBeNull(); expect(element.querySelector('#people-widget-content')).not.toBeNull();
}); });
it('should show an error message if the user is invalid', async () => { it('should show an error message if the user is invalid', fakeAsync(() => {
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input'); const peopleHTMLElement = element.querySelector<HTMLInputElement>('input');
peopleHTMLElement.focus(); peopleHTMLElement.focus();
peopleHTMLElement.value = 'K'; peopleHTMLElement.value = 'K';
peopleHTMLElement.dispatchEvent(new Event('keyup')); peopleHTMLElement.dispatchEvent(new Event('keyup'));
peopleHTMLElement.dispatchEvent(new Event('input')); peopleHTMLElement.dispatchEvent(new Event('input'));
tick(300);
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable();
expect(element.querySelector('.adf-error-text')).not.toBeNull(); expect(element.querySelector('.adf-error-text')).not.toBeNull();
expect(element.querySelector('.adf-error-text').textContent).toContain('FORM.FIELD.VALIDATOR.INVALID_VALUE'); expect(element.querySelector('.adf-error-text').textContent).toContain('FORM.FIELD.VALIDATOR.INVALID_VALUE');
}); }));
it('should show the people if the typed result match', async () => { it('should show the people if the typed result match', fakeAsync(() => {
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input'); const peopleHTMLElement = element.querySelector<HTMLInputElement>('input');
peopleHTMLElement.focus(); peopleHTMLElement.focus();
peopleHTMLElement.value = 'T'; peopleHTMLElement.value = 'T';
@@ -373,13 +389,14 @@ describe('PeopleWidgetComponent', () => {
peopleHTMLElement.dispatchEvent(new Event('input')); peopleHTMLElement.dispatchEvent(new Event('input'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); tick(300);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull();
}); }));
it('should hide result list if input is empty', async () => { it('should hide result list if input is empty', fakeAsync(() => {
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input'); const peopleHTMLElement = element.querySelector<HTMLInputElement>('input');
peopleHTMLElement.focus(); peopleHTMLElement.focus();
peopleHTMLElement.value = ''; peopleHTMLElement.value = '';
@@ -388,14 +405,14 @@ describe('PeopleWidgetComponent', () => {
peopleHTMLElement.dispatchEvent(new Event('input')); peopleHTMLElement.dispatchEvent(new Event('input'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); tick(300);
expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).toBeNull();
}); }));
it('should display two options if we tap one letter', async () => { it('should display two options if we tap one letter', fakeAsync(() => {
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); tick(300);
const peopleHTMLElement = element.querySelector<HTMLInputElement>('input'); const peopleHTMLElement = element.querySelector<HTMLInputElement>('input');
peopleHTMLElement.focus(); peopleHTMLElement.focus();
@@ -404,11 +421,12 @@ describe('PeopleWidgetComponent', () => {
peopleHTMLElement.dispatchEvent(new Event('input')); peopleHTMLElement.dispatchEvent(new Event('input'));
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); tick(300);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-0'))).not.toBeNull();
expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull(); expect(fixture.debugElement.query(By.css('#adf-people-widget-user-1'))).not.toBeNull();
}); }));
it('should emit peopleSelected if option is valid', async () => { it('should emit peopleSelected if option is valid', async () => {
const selectEmitSpy = spyOn(widget.peopleSelected, 'emit'); const selectEmitSpy = spyOn(widget.peopleSelected, 'emit');
@@ -23,7 +23,7 @@ import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { PeopleProcessService } from '../../../services/people-process.service'; import { PeopleProcessService } from '../../../services/people-process.service';
import { LightUserRepresentation } from '@alfresco/js-api'; import { LightUserRepresentation } from '@alfresco/js-api';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -77,6 +77,7 @@ export class PeopleWidgetComponent extends WidgetComponent implements OnInit {
searchTerms$ = this.searchTerm.valueChanges; searchTerms$ = this.searchTerm.valueChanges;
users$: Observable<LightUserRepresentation[]> = this.searchTerms$.pipe( users$: Observable<LightUserRepresentation[]> = this.searchTerms$.pipe(
debounceTime(300),
distinctUntilChanged(), distinctUntilChanged(),
switchMap((searchTerm) => { switchMap((searchTerm) => {
if (!searchTerm) { if (!searchTerm) {
+1 -1
View File
@@ -47,7 +47,7 @@
}, },
"namedInputs": { "namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"], "default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/angular.json", "{workspaceRoot}/e2e/protractor.excludes.json", "{workspaceRoot}/e2e/protractor.conf.js"], "sharedGlobals": ["{workspaceRoot}/package.json", "{workspaceRoot}/angular.json", "{workspaceRoot}/e2e/protractor.excludes.json", "{workspaceRoot}/e2e/protractor.conf.js"],
"production": [ "production": [
"default", "default",
"!{projectRoot}/.storybook/**/*", "!{projectRoot}/.storybook/**/*",
+1 -1
View File
@@ -156,7 +156,7 @@
"engines": { "engines": {
"node": ">=24.14.0" "node": ">=24.14.0"
}, },
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b",
"module": "./index.js", "module": "./index.js",
"typings": "./index.d.ts" "typings": "./index.d.ts"
} }