mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
Merge branch 'develop' into copilot/explain-time-url-authentication
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:
|
||||||
|
|||||||
+372
-183
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+92
-70
@@ -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', () => {
|
||||||
|
|||||||
-3
@@ -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 });
|
||||||
|
|||||||
+20
-29
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-3
@@ -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);
|
||||||
|
|||||||
+10
-3
@@ -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);
|
||||||
|
|||||||
+3
-3
@@ -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', () => {
|
||||||
|
|||||||
+3
-3
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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' }];
|
||||||
|
|||||||
+4
-2
@@ -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 ?? [];
|
||||||
|
|||||||
+4
-4
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -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) {
|
||||||
|
|||||||
+4
-4
@@ -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];
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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' }]);
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -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 {
|
||||||
|
|||||||
+3
-3
@@ -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 () => {
|
||||||
|
|||||||
+6
-6
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -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 () => {
|
||||||
|
|||||||
+8
-8
@@ -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', () => {
|
||||||
|
|||||||
+7
-7
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -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();
|
||||||
|
|||||||
+2
-3
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -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 () => {
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-8
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-11
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user