[ACS-12037] Remove Knowledge Discovery from ACA (#5278)

* [ACS-12037] Remove Knowledge Discovery from ACA

* [ACS-12037] CR comments

* [ACS-12037] Update package-lock

* [ACS-12037] Add new rule documentation

* [ACS-12037] Fix unit tests
This commit is contained in:
Grzegorz Jaśkowski
2026-07-11 00:57:12 +02:00
committed by GitHub
parent 318a6ea43c
commit 48842513a9
73 changed files with 216 additions and 5785 deletions
-10
View File
@@ -94,19 +94,9 @@
"styles": [ "styles": [
"node_modules/cropperjs/dist/cropper.min.css", "node_modules/cropperjs/dist/cropper.min.css",
"node_modules/pdfjs-dist/web/pdf_viewer.css", "node_modules/pdfjs-dist/web/pdf_viewer.css",
"node_modules/katex/dist/katex.min.css",
"node_modules/prismjs/themes/prism-okaidia.css",
"projects/aca-content/src/lib/ui/application.scss", "projects/aca-content/src/lib/ui/application.scss",
"app/src/styles.scss" "app/src/styles.scss"
], ],
"scripts": [
"node_modules/mermaid/dist/mermaid.min.js",
"node_modules/katex/dist/katex.min.js",
"node_modules/katex/dist/contrib/auto-render.min.js",
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js"
],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"buildOptimizer": false, "buildOptimizer": false,
+2 -2
View File
@@ -3,6 +3,7 @@
"ecmHost": "{protocol}//{hostname}{:port}", "ecmHost": "{protocol}//{hostname}{:port}",
"aosHost": "{protocol}//{hostname}{:port}/alfresco/aos", "aosHost": "{protocol}//{hostname}{:port}/alfresco/aos",
"baseShareUrl": "{protocol}//{hostname}{:port}/#/preview/s", "baseShareUrl": "{protocol}//{hostname}{:port}/#/preview/s",
"knowledgeDiscoveryUrl": "",
"providers": "ECM", "providers": "ECM",
"authType": "BASIC", "authType": "BASIC",
"loginRoute": "login", "loginRoute": "login",
@@ -14,8 +15,7 @@
"contentService": true, "contentService": true,
"folderRules": true, "folderRules": true,
"tagsEnabled": true, "tagsEnabled": true,
"categoriesEnabled": true, "categoriesEnabled": true
"knowledgeRetrievalEnabled": false
}, },
"oauth2": { "oauth2": {
"host": "{protocol}//{hostname}{:port}/auth/realms/alfresco", "host": "{protocol}//{hostname}{:port}/auth/realms/alfresco",
@@ -148,8 +148,3 @@ if [ -n "${APP_CONFIG_PLUGIN_CATEGORIES}" ]; then
echo "SET APP_CONFIG_PLUGIN_CATEGORIES" echo "SET APP_CONFIG_PLUGIN_CATEGORIES"
sed -e "s/\"categoriesEnabled\": [^,]*/\"categoriesEnabled\": ${APP_CONFIG_PLUGIN_CATEGORIES}/g" -i "$APP_CONFIG_FILE" sed -e "s/\"categoriesEnabled\": [^,]*/\"categoriesEnabled\": ${APP_CONFIG_PLUGIN_CATEGORIES}/g" -i "$APP_CONFIG_FILE"
fi fi
if [ -n "${APP_CONFIG_PLUGIN_KNOWLEDGE_RETRIEVAL}" ]; then
echo "SET APP_CONFIG_PLUGIN_KNOWLEDGE_RETRIEVAL"
sed -e "s/\"knowledgeRetrievalEnabled\": [^,]*/\"knowledgeRetrievalEnabled\": ${APP_CONFIG_PLUGIN_KNOWLEDGE_RETRIEVAL}/g" -i "$APP_CONFIG_FILE"
fi
+1
View File
@@ -59,6 +59,7 @@ Application related evaluators which can be used to check various different aspe
| 5.1.1 | isMultiSelection | Checks if the user has selected multiple files | | 5.1.1 | isMultiSelection | Checks if the user has selected multiple files |
| 6.1.0 | canPrintFile | Checks if current file can be printed or not (media files such as audio/video cannot be printed). | | 6.1.0 | canPrintFile | Checks if current file can be printed or not (media files such as audio/video cannot be printed). |
| 7.4.0 | app.selection.isCheckedOut | Checks if selected node contains `cm:checkedOut` aspect. | | 7.4.0 | app.selection.isCheckedOut | Checks if selected node contains `cm:checkedOut` aspect. |
| 8.0.0 | app.isKnowledgeDiscoveryUrlPresent | Checks if the Knowledge Discovery URL is specified in the app config. |
#### Navigation Rules/Evaluators #### Navigation Rules/Evaluators
+9
View File
@@ -27,3 +27,12 @@ This application simplifies the complexity of Content Management and provides co
- [Search forms](/features/search-forms) - [Search forms](/features/search-forms)
- [Application Hook](/extending/application-hook) - [Application Hook](/extending/application-hook)
- [Context Menu actions](context-menu-actions) - [Context Menu actions](context-menu-actions)
## Notes
### Knowledge Discovery removal (v8.0.0)
The Knowledge Discovery components, services, and APIs have been removed as of ACA version 8.0.0 and ADF version 9.0.0.
The integrated Knowledge Discovery features have been replaced with a section in the side navigation panel containing a link to the standalone Knowledge Discovery UI in ACA and ADW.
If you were using those features, please consider cleaning Local Storage in your browser as *aiReferences* entry may remain.
+4 -1156
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -67,12 +67,8 @@
"@ngrx/store-devtools": "~20.1.0", "@ngrx/store-devtools": "~20.1.0",
"@ngx-translate/core": "^17.0.0", "@ngx-translate/core": "^17.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"katex": "^0.16.21",
"material-icons": "^1.13.12", "material-icons": "^1.13.12",
"mermaid": "^11.15.0",
"ngx-markdown": "20.0.0",
"pdfjs-dist": "5.1.91", "pdfjs-dist": "5.1.91",
"prismjs": "^1.30.0",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"zone.js": "0.15.0" "zone.js": "0.15.0"
+12 -26
View File
@@ -62,16 +62,6 @@
} }
} }
], ],
"icons": [
{
"id": "adf:three_magic_stars_ai",
"value": "./assets/images/three-magic-stars-ai.svg"
},
{
"id": "adf:colored-stars-ai",
"value": "./assets/images/colored-stars-ai.svg"
}
],
"create": [ "create": [
{ {
"id": "app.create.folder", "id": "app.create.folder",
@@ -229,6 +219,18 @@
"component": "app.search.navbar" "component": "app.search.navbar"
} }
] ]
},
{
"id": "app.navbar.tertiary",
"items": [
{
"id": "app.knowledgeDiscovery.sidenav",
"component": "app.knowledgeDiscovery.sidenav",
"rules": {
"visible": "app.isKnowledgeDiscoveryUrlPresent"
}
}
]
} }
], ],
"toolbar": [ "toolbar": [
@@ -833,22 +835,6 @@
} }
} }
] ]
},
{
"id": "app.toolbar.ai.search",
"order": 0,
"title": "KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.TITLE",
"component": "app.toolbar.ai.agents-button",
"type": "custom",
"rules": {
"visible": "app.selection.displayedKnowledgeRetrievalButton"
},
"actions": {
"click": "app.action.toggle-ai-search-input.execute"
},
"data": {
"trigger": "TOGGLE_AI_SEARCH_INPUT"
}
} }
], ],
"contextMenu": [ "contextMenu": [
+3 -34
View File
@@ -739,40 +739,9 @@
} }
}, },
"KNOWLEDGE_RETRIEVAL": { "KNOWLEDGE_RETRIEVAL": {
"SEARCH": { "SIDENAV": {
"RESULTS_PAGE": { "TITLE": "AI Agents",
"QUERY_INPUT_PLACEHOLDER": "Would you like to ask anything else?", "DISCOVERY": "Discovery"
"REFERENCED_DOCUMENTS_HEADER": "Referenced documents",
"REGENERATION_BUTTON_LABEL": "Regenerate",
"COPY_BUTTON_LABEL": "Copy",
"COPY_MESSAGE": "Copied response to clipboard",
"RELOAD_REFERENCES_BUTTON_LABEL": "Reload"
},
"AGENTS_BUTTON": {
"LABEL": "Ask Discovery",
"TITLE": "Knowledge Retrieval"
},
"SEARCH_INPUT": {
"ASK_BUTTON_LABEL": "Ask",
"DEFAULT_PLACEHOLDER": "Please ask your question with as much detail as possible...",
"HIDE_INPUT": "Hide input",
"HIDE_ANSWER": "Hide answer"
},
"ERRORS": {
"AGENTS_FETCHING": "Error while fetching agents.",
"HX_INSIGHT_URL_FETCHING": "Error while fetching HX Insight URL.",
"LOADING_ERROR": "Hmm... something seems to have gone wrong.",
"PAGE_NOT_AVAILABLE_ERROR": "Page is not available for these conditions.",
"REFERENCES_LOADING_ERROR": "Something went wrong while loading the references."
},
"DISCARD_CHANGES": {
"WARNING": "Warning!",
"ASK_AI": "Ask AI",
"DO_NOT_SHOW_MESSAGE": "Don't show this message again",
"DISCARD_CONVERSATION": "Discard Conversation",
"CONVERSATION_DISCARDED": "This conversation will be discarded",
"LOSE_RESPONSE": "By asking another question you will lose your previous response"
}
} }
} }
} }
@@ -1,19 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_340_107020)">
<g clip-path="url(#clip1_340_107020)">
<path d="M23 13L21.75 10.25L19 8.99999L21.75 7.74999L23 4.99999L24.25 7.74999L27 8.99999L24.25 10.25L23 13ZM23 27L21.75 24.25L19 23L21.75 21.75L23 19L24.25 21.75L27 23L24.25 24.25L23 27ZM13 24L10.5 18.5L4.99998 16L10.5 13.5L13 7.99999L15.5 13.5L21 16L15.5 18.5L13 24ZM13 19.15L14 17L16.15 16L14 15L13 12.85L12 15L9.84998 16L12 17L13 19.15Z" fill="url(#paint0_linear_340_107020)" fill-opacity="0.7"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_340_107020" x1="4" y1="16" x2="28" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#2A7DE1"/>
<stop offset="1" stop-color="#902AE1"/>
</linearGradient>
<clipPath id="clip0_340_107020">
<rect width="32" height="32" rx="6" fill="white"/>
</clipPath>
<clipPath id="clip1_340_107020">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 987 B

@@ -1,19 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_340_107020)">
<g clip-path="url(#clip1_340_107020)">
<path d="M23 13L21.75 10.25L19 8.99999L21.75 7.74999L23 4.99999L24.25 7.74999L27 8.99999L24.25 10.25L23 13ZM23 27L21.75 24.25L19 23L21.75 21.75L23 19L24.25 21.75L27 23L24.25 24.25L23 27ZM13 24L10.5 18.5L4.99998 16L10.5 13.5L13 7.99999L15.5 13.5L21 16L15.5 18.5L13 24ZM13 19.15L14 17L16.15 16L14 15L13 12.85L12 15L9.84998 16L12 17L13 19.15Z" fill="white" />
</g>
</g>
<defs>
<linearGradient id="paint0_linear_340_107020" x1="4" y1="16" x2="28" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#2A7DE1"/>
<stop offset="1" stop-color="#902AE1"/>
</linearGradient>
<clipPath id="clip0_340_107020">
<rect width="32" height="32" rx="6" fill="white"/>
</clipPath>
<clipPath id="clip1_340_107020">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 944 B

+1 -7
View File
@@ -10,12 +10,6 @@ module.exports = function (config) {
coverageReporter: { coverageReporter: {
...baseConfig.coverageReporter, ...baseConfig.coverageReporter,
dir: join(__dirname, '../../coverage/aca-content'), dir: join(__dirname, '../../coverage/aca-content'),
}, }
files: [
'../../node_modules/katex/dist/katex.min.js',
'../../node_modules/katex/dist/contrib/auto-render.min.js',
'../../node_modules/katex/dist/katex.min.css',
'../../node_modules/mermaid/dist/mermaid.min.js'
]
}); });
}; };
-1
View File
@@ -5,7 +5,6 @@
"styleIncludePaths": ["projects/aca-content/src/lib/ui"], "styleIncludePaths": ["projects/aca-content/src/lib/ui"],
"entryFile": "src/public-api.ts" "entryFile": "src/public-api.ts"
}, },
"allowedNonPeerDependencies": ["ngx-markdown"],
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
+1 -2
View File
@@ -16,8 +16,7 @@
"zone.js": "^0.14.0 || ^0.15.0" "zone.js": "^0.14.0 || ^0.15.0"
}, },
"dependencies": { "dependencies": {
"tslib": ">=2.3.0", "tslib": ">=2.3.0"
"ngx-markdown": ">=20.0.0"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
@@ -67,7 +67,7 @@ import { UserMenuComponent } from './components/sidenav/user-menu/user-menu.comp
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog'; import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
import { SearchResultsRowComponent } from './components/search/search-results-row/search-results-row.component'; import { SearchResultsRowComponent } from './components/search/search-results-row/search-results-row.component';
import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component'; import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component';
import { AgentsButtonComponent } from './components/knowledge-retrieval/search-ai/agents-button/agents-button.component'; import { KnowledgeDiscoverySidenavComponent } from './components/knowledge-discovery/knowledge-discovery-sidenav.component';
import { SaveSearchSidenavComponent } from './components/search/search-save/sidenav/save-search-sidenav.component'; import { SaveSearchSidenavComponent } from './components/search/search-save/sidenav/save-search-sidenav.component';
import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported.pipe'; import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported.pipe';
@@ -104,7 +104,6 @@ import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported
'app.toolbar.toggleFavorite': ToggleFavoriteComponent, 'app.toolbar.toggleFavorite': ToggleFavoriteComponent,
'app.toolbar.toggleFavoriteLibrary': ToggleFavoriteLibraryComponent, 'app.toolbar.toggleFavoriteLibrary': ToggleFavoriteLibraryComponent,
'app.toolbar.toggleJoinLibrary': ToggleJoinLibraryButtonComponent, 'app.toolbar.toggleJoinLibrary': ToggleJoinLibraryButtonComponent,
'app.toolbar.ai.agents-button': AgentsButtonComponent,
'app.menu.toggleJoinLibrary': ToggleJoinLibraryMenuComponent, 'app.menu.toggleJoinLibrary': ToggleJoinLibraryMenuComponent,
'app.bulk-actions-dropdown': BulkActionsDropdownComponent, 'app.bulk-actions-dropdown': BulkActionsDropdownComponent,
'app.shared-link.toggleSharedLink': ToggleSharedComponent, 'app.shared-link.toggleSharedLink': ToggleSharedComponent,
@@ -123,7 +122,8 @@ import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported
'app.notification-center': NotificationHistoryComponent, 'app.notification-center': NotificationHistoryComponent,
'app.user.menu': UserMenuComponent, 'app.user.menu': UserMenuComponent,
'app.search.columns.name': SearchResultsRowComponent, 'app.search.columns.name': SearchResultsRowComponent,
'app.search.navbar': SaveSearchSidenavComponent 'app.search.navbar': SaveSearchSidenavComponent,
'app.knowledgeDiscovery.sidenav': KnowledgeDiscoverySidenavComponent
}, },
evaluators: { evaluators: {
canToggleJoinLibrary: rules.canToggleJoinLibrary, canToggleJoinLibrary: rules.canToggleJoinLibrary,
@@ -152,7 +152,6 @@ import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported
'app.selection.hasLibraryRole': rules.hasLibraryRole, 'app.selection.hasLibraryRole': rules.hasLibraryRole,
'app.selection.folder': rules.hasFolderSelected, 'app.selection.folder': rules.hasFolderSelected,
'app.selection.folder.canUpdate': rules.canUpdateSelectedFolder, 'app.selection.folder.canUpdate': rules.canUpdateSelectedFolder,
'app.selection.displayedKnowledgeRetrievalButton': rules.canDisplayKnowledgeRetrievalButton,
'app.selection.isCheckedOut': rules.isCheckedOut, 'app.selection.isCheckedOut': rules.isCheckedOut,
'app.selection.isNodeLink': rules.isNodeLink, 'app.selection.isNodeLink': rules.isNodeLink,
@@ -170,7 +169,8 @@ import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported
'app.isContentServiceEnabled': rules.isContentServiceEnabled, 'app.isContentServiceEnabled': rules.isContentServiceEnabled,
'app.areTagsEnabled': rules.areTagsEnabled, 'app.areTagsEnabled': rules.areTagsEnabled,
'app.areCategoriesEnabled': rules.areCategoriesEnabled, 'app.areCategoriesEnabled': rules.areCategoriesEnabled,
'app.isSSOEnabled': rules.isSSOEnabled 'app.isSSOEnabled': rules.isSSOEnabled,
'app.isKnowledgeDiscoveryUrlPresent': rules.isKnowledgeDiscoveryUrlPresent
} }
}), }),
IsFeatureSupportedInCurrentAcsPipe IsFeatureSupportedInCurrentAcsPipe
@@ -27,8 +27,8 @@ import { LibrariesComponent } from './components/libraries/libraries.component';
import { FavoriteLibrariesComponent } from './components/favorite-libraries/favorite-libraries.component'; import { FavoriteLibrariesComponent } from './components/favorite-libraries/favorite-libraries.component';
import { SearchResultsComponent } from './components/search/search-results/search-results.component'; import { SearchResultsComponent } from './components/search/search-results/search-results.component';
import { SearchLibrariesResultsComponent } from './components/search/search-libraries-results/search-libraries-results.component'; import { SearchLibrariesResultsComponent } from './components/search/search-libraries-results/search-libraries-results.component';
import { AppSharedRuleGuard, ExtensionRoute, ExtensionsDataLoaderGuard, GenericErrorComponent, PluginEnabledGuard } from '@alfresco/aca-shared'; import { AppSharedRuleGuard, ExtensionRoute, ExtensionsDataLoaderGuard, GenericErrorComponent } from '@alfresco/aca-shared';
import { AuthGuard, UnsavedChangesGuard } from '@alfresco/adf-core'; import { AuthGuard } from '@alfresco/adf-core';
import { FavoritesComponent } from './components/favorites/favorites.component'; import { FavoritesComponent } from './components/favorites/favorites.component';
import { RecentFilesComponent } from './components/recent-files/recent-files.component'; import { RecentFilesComponent } from './components/recent-files/recent-files.component';
import { SharedFilesComponent } from './components/shared-files/shared-files.component'; import { SharedFilesComponent } from './components/shared-files/shared-files.component';
@@ -40,7 +40,6 @@ import { Data, Route, Routes } from '@angular/router';
import { SharedLinkViewComponent } from './components/shared-link-view/shared-link-view.component'; import { SharedLinkViewComponent } from './components/shared-link-view/shared-link-view.component';
import { TrashcanComponent } from './components/trashcan/trashcan.component'; import { TrashcanComponent } from './components/trashcan/trashcan.component';
import { ShellLayoutComponent } from '@alfresco/adf-core/shell'; import { ShellLayoutComponent } from '@alfresco/adf-core/shell';
import { SearchAiResultsComponent } from './components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component';
import { SavedSearchesSmartListComponent } from './components/search/search-save/list/smart-list/saved-searches-smart-list.component'; import { SavedSearchesSmartListComponent } from './components/search/search-save/list/smart-list/saved-searches-smart-list.component';
import { LibraryListComponent } from './components/library-list/library-list.component'; import { LibraryListComponent } from './components/library-list/library-list.component';
import { RepositoryViewComponent } from './components/repository-view/repository-view.component'; import { RepositoryViewComponent } from './components/repository-view/repository-view.component';
@@ -410,21 +409,6 @@ export const CONTENT_LAYOUT_ROUTES: Route[] = [
} }
] ]
}, },
{
path: 'knowledge-retrieval',
canDeactivate: [UnsavedChangesGuard],
canActivate: [PluginEnabledGuard],
data: {
plugin: 'plugins.knowledgeRetrievalEnabled'
},
children: [
{
path: '',
component: SearchAiResultsComponent
},
...createViewRoutes('knowledge-retrieval')
]
},
{ {
path: 'saved-searches', path: 'saved-searches',
children: [ children: [
@@ -1,16 +1,11 @@
<aca-page-layout> <aca-page-layout>
<div class="aca-page-layout-header"> <div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId" />
<ng-template #header>
<div class="aca-header-container"> <div class="aca-header-container">
<h1 class="aca-page-title"> <h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.FAVORITES.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }} {{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.FAVORITES.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
</h1> </h1>
<aca-toolbar [items]="actions" /> <aca-toolbar [items]="actions" />
</div> </div>
</ng-template>
</div> </div>
<div class="aca-page-layout-content"> <div class="aca-page-layout-content">
@@ -46,7 +46,6 @@ import {
import { DocumentListDirective } from '../../directives/document-list.directive'; import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { DocumentListComponent } from '@alfresco/adf-content-services'; import { DocumentListComponent } from '@alfresco/adf-content-services';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({ @Component({
imports: [ imports: [
@@ -59,7 +58,6 @@ import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-a
PageLayoutComponent, PageLayoutComponent,
TranslatePipe, TranslatePipe,
ToolbarComponent, ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent, EmptyContentComponent,
DynamicColumnComponent, DynamicColumnComponent,
DocumentListComponent, DocumentListComponent,
@@ -1,9 +1,5 @@
<aca-page-layout [hasError]="!isValidPath"> <aca-page-layout [hasError]="!isValidPath">
<div class="aca-page-layout-header"> <div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId" />
<ng-template #header>
<div class="aca-header-container"> <div class="aca-header-container">
<adf-breadcrumb <adf-breadcrumb
[root]="title" [root]="title"
@@ -12,7 +8,6 @@
(navigate)="onBreadcrumbNavigate($event)" /> (navigate)="onBreadcrumbNavigate($event)" />
<aca-toolbar [items]="actions" /> <aca-toolbar [items]="actions" />
</div> </div>
</ng-template>
</div> </div>
<div class="aca-page-layout-error"> <div class="aca-page-layout-error">
@@ -59,7 +59,6 @@ import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { DocumentListDirective } from '../../directives/document-list.directive'; import { DocumentListDirective } from '../../directives/document-list.directive';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { extractFiltersFromEncodedQuery } from '../../utils/aca-search-utils'; import { extractFiltersFromEncodedQuery } from '../../utils/aca-search-utils';
@@ -77,7 +76,6 @@ import { extractFiltersFromEncodedQuery } from '../../utils/aca-search-utils';
PaginationDirective, PaginationDirective,
PageLayoutComponent, PageLayoutComponent,
ToolbarComponent, ToolbarComponent,
SearchAiInputContainerComponent,
DynamicColumnComponent, DynamicColumnComponent,
BreadcrumbComponent, BreadcrumbComponent,
UploadDragAreaComponent, UploadDragAreaComponent,
@@ -0,0 +1 @@
<app-expand-menu [item]="item" />
@@ -0,0 +1,66 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { KnowledgeDiscoverySidenavComponent } from './knowledge-discovery-sidenav.component';
import { AppTestingModule } from '../../testing/app-testing.module';
import { AppSettingsService } from '@alfresco/aca-shared';
describe('KnowledgeDiscoverySidenavComponent', () => {
let fixture: ComponentFixture<KnowledgeDiscoverySidenavComponent>;
let component: KnowledgeDiscoverySidenavComponent;
let appSettings: AppSettingsService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, KnowledgeDiscoverySidenavComponent]
});
appSettings = TestBed.inject(AppSettingsService);
fixture = TestBed.createComponent(KnowledgeDiscoverySidenavComponent);
component = fixture.componentInstance;
});
it('should set item with children using URL from config', () => {
spyOnProperty(appSettings, 'knowledgeDiscoveryUrl', 'get').and.returnValue('https://discovery.example.com');
fixture.detectChanges();
expect(component.item).toEqual({
id: 'app.knowledgeDiscovery.sidenav',
icon: '',
title: 'KNOWLEDGE_RETRIEVAL.SIDENAV.TITLE',
route: '/',
children: [
{
id: 'app.knowledgeDiscovery.sidenav.discovery',
icon: '',
title: 'KNOWLEDGE_RETRIEVAL.SIDENAV.DISCOVERY',
route: 'https://discovery.example.com',
url: 'https://discovery.example.com'
}
]
});
});
});
@@ -22,33 +22,38 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Injectable, inject } from '@angular/core'; import { Component, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { Params, Router } from '@angular/router'; import { NavBarLinkRef } from '@alfresco/adf-extensions';
import { SearchAiService } from '@alfresco/adf-content-services'; import { ExpandMenuComponent } from '../sidenav/components/expand-menu.component';
import { AppSettingsService } from '@alfresco/aca-shared';
@Injectable({ providedIn: 'root' }) @Component({
export class SearchAiNavigationService { selector: 'aca-knowledge-discovery-sidenav',
private readonly router = inject(Router); imports: [ExpandMenuComponent],
private readonly searchAiService = inject(SearchAiService); templateUrl: './knowledge-discovery-sidenav.component.html',
encapsulation: ViewEncapsulation.None
})
export class KnowledgeDiscoverySidenavComponent implements OnInit {
private readonly appSettings = inject(AppSettingsService);
private readonly knowledgeRetrievalRoute = '/knowledge-retrieval'; item: NavBarLinkRef;
private previousRoute = ''; ngOnInit(): void {
const url = this.appSettings.knowledgeDiscoveryUrl;
navigateToPreviousRouteOrCloseInput(): void { this.item = {
if (this.router.url.includes(this.knowledgeRetrievalRoute)) { id: 'app.knowledgeDiscovery.sidenav',
void this.router.navigateByUrl(this.previousRoute || '/personal-files'); icon: '',
} else { title: 'KNOWLEDGE_RETRIEVAL.SIDENAV.TITLE',
this.searchAiService.updateSearchAiInputState({ route: '/',
active: false children: [
}); {
id: 'app.knowledgeDiscovery.sidenav.discovery',
icon: '',
title: 'KNOWLEDGE_RETRIEVAL.SIDENAV.DISCOVERY',
route: url,
url
} }
} ]
};
navigateToSearchAi(queryParams: Params): void {
if (!this.router.url.includes(this.knowledgeRetrievalRoute)) {
this.previousRoute = this.router.url;
}
void this.router.navigate([this.knowledgeRetrievalRoute], { queryParams });
} }
} }
@@ -1,36 +0,0 @@
<ng-container *ngIf="agents.length && hxInsightUrl">
<button
[matMenuTriggerFor]="disabled ? null : agentsList"
class="aca-agents-menu-button aca-agents-button-menu-trigger"
(mouseup)="onClick()"
(keydown.enter)="onClick()"
data-automation-id="aca-agents-button">
<adf-icon
value="adf:colored-stars-ai"
class="aca-agents-button-icon" />
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.LABEL' | translate}}
</button>
<mat-menu
#agentsList="matMenu"
class="aca-agents-button-menu"
xPosition="before">
<mat-selection-list
(selectionChange)="onAgentSelection($event)"
[multiple]="false"
class="aca-agents-button-menu-list"
[hideSingleSelectionIndicator]="true">
<mat-list-option
*ngFor="let agent of agents"
class="aca-agents-button-menu-list-agent"
[attr.data-automation-id]="'aca-agents-button-agent-' + agent.id"
[value]="agent">
<div class="aca-agents-button-menu-list-agent-content">
<adf-avatar [src]="agent?.avatarUrl" [initials]="initialsByAgentId[agent.id]" />
<span class="aca-agents-button-menu-list-agent-content-name" [matTooltip]="agent.name" [matTooltipPosition]="'right'">
{{ agent.name }}
</span>
</div>
</mat-list-option>
</mat-selection-list>
</mat-menu>
</ng-container>
@@ -1,74 +0,0 @@
aca-agents-button.aca-agents-button {
height: 32px;
display: block;
button {
&.aca-agents-menu-button {
display: flex;
align-items: end;
&.aca-agents-button-menu-trigger {
height: auto;
cursor: pointer;
border: none;
background: transparent;
width: max-content;
padding: 0 4px 0 0;
}
.aca-agents-button-icon {
display: flex;
align-self: baseline;
svg {
height: 32px;
width: 32px;
position: absolute;
margin-left: -21px;
}
}
}
}
}
.aca-agents-button-menu {
padding-top: 2px;
padding-bottom: 1px;
.aca-agents-button-menu-list {
margin-left: -6px;
padding-top: 0;
padding-bottom: 0;
&-agent {
height: 40px;
&:not(:last-child) {
margin-bottom: 2px;
}
&-content {
display: flex;
align-items: center;
&-name {
width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-right: 12px;
margin-bottom: 2px;
padding-left: 1px;
padding-top: 1px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
}
@@ -1,470 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AgentsButtonComponent } from './agents-button.component';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { Subject } from 'rxjs';
import { By } from '@angular/platform-browser';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, SearchAiActionTypes } from '@alfresco/aca-shared/store';
import { AvatarComponent, NoopTranslateModule, NotificationService } from '@alfresco/adf-core';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatMenu, MatMenuPanel, MatMenuTrigger } from '@angular/material/menu';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectionListHarness } from '@angular/material/list/testing';
import { MatMenuHarness } from '@angular/material/menu/testing';
import { MatSelectionList } from '@angular/material/list';
import { MatSnackBarRef } from '@angular/material/snack-bar';
import { ChangeDetectorRef } from '@angular/core';
import { Agent, KnowledgeRetrievalConfigEntry } from '@alfresco/js-api';
import { MatIconTestingModule } from '@angular/material/icon/testing';
describe('AgentsButtonComponent', () => {
let component: AgentsButtonComponent;
let fixture: ComponentFixture<AgentsButtonComponent>;
let agents$: Subject<Agent[]>;
let agentsMock: Agent[];
let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
let selectionState: SelectionState;
let store: MockStore;
let config$: Subject<KnowledgeRetrievalConfigEntry>;
const knowledgeRetrievalUrl = 'some url';
const getMenu = (): MatMenu => fixture.debugElement.query(By.directive(MatMenu)).componentInstance;
const getAgentsButton = (): HTMLButtonElement => fixture.debugElement.query(By.css('.aca-agents-menu-button'))?.nativeElement;
const runButtonActions = (eventName: string): void => {
let event: Event;
let notificationService: NotificationService;
let message: string;
beforeEach(() => {
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
event =
eventName === 'mouseup'
? new MouseEvent(eventName)
: new KeyboardEvent(eventName, {
key: 'Enter'
});
agents$.next(agentsMock);
agents$.complete();
spyOn(window, 'open');
notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'showError');
message = 'Some message';
});
const getMenuTrigger = (): MatMenuPanel => fixture.debugElement.query(By.directive(MatMenuTrigger)).injector.get(MatMenuTrigger).menu;
const testButtonActions = (): void => {
it('should not display notification if checkSearchAvailability from SearchAiService returns empty message', () => {
message = '';
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).not.toHaveBeenCalled();
});
it('should disable menu triggering if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue('Some message');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
expect(getMenuTrigger()).toBeNull();
});
};
describe('with selected nodes', () => {
beforeEach(() => {
selectionState.isEmpty = false;
});
it('should display notification if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).toHaveBeenCalledWith(message);
});
testButtonActions();
it('should enable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
const menuTrigger = getMenuTrigger();
expect(menuTrigger).toBeTruthy();
expect(menuTrigger).toBe(getMenu());
});
it('should call checkSearchAvailability from SearchAiService with correct parameter', () => {
getAgentsButton().dispatchEvent(event);
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
});
it('should not open new tab for url loaded from config', () => {
getAgentsButton().dispatchEvent(event);
expect(window.open).not.toHaveBeenCalled();
});
});
describe('without selected nodes', () => {
it('should not display notification if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).not.toHaveBeenCalled();
});
testButtonActions();
it('should disable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
expect(getMenuTrigger()).toBeNull();
});
it('should not call checkSearchAvailability from SearchAiService', () => {
getAgentsButton().dispatchEvent(event);
expect(checkSearchAvailabilitySpy).not.toHaveBeenCalled();
});
it('should open new tab for url loaded from config', () => {
getAgentsButton().dispatchEvent(event);
expect(window.open).toHaveBeenCalledWith(knowledgeRetrievalUrl);
});
});
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule, MatIconTestingModule, AgentsButtonComponent],
providers: [provideMockStore({})]
});
fixture = TestBed.createComponent(AgentsButtonComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
agents$ = new Subject<Agent[]>();
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
agentsMock = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
const searchAiService = TestBed.inject(SearchAiService);
checkSearchAvailabilitySpy = spyOn(searchAiService, 'checkSearchAvailability');
config$ = new Subject<KnowledgeRetrievalConfigEntry>();
spyOn(searchAiService, 'getConfig').and.returnValue(config$);
selectionState = {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
};
store.overrideSelector(getAppSelection, selectionState);
fixture.detectChanges();
});
afterEach(() => {
store.resetSelectors();
});
describe('Button', () => {
let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef<any>>;
beforeEach(() => {
const notificationService = TestBed.inject(NotificationService);
notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
});
describe('loaded config', () => {
beforeEach(() => {
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
});
it('should be rendered if any agentsMock are loaded', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeTruthy();
});
it('should get agentsMock on component init', () => {
agents$.next(agentsMock);
agents$.complete();
component.ngOnInit();
expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
expect(component.agents).toEqual(agentsMock);
expect(notificationServiceSpy).not.toHaveBeenCalled();
});
it('should run detectChanges when getting the agentsMock', () => {
const changeDetectorRef2 = fixture.debugElement.injector.get(ChangeDetectorRef);
const detectChangesSpy = spyOn(changeDetectorRef2.constructor.prototype, 'detectChanges');
component.ngOnInit();
agents$.next(agentsMock);
expect(detectChangesSpy).toHaveBeenCalled();
});
it('should show notification error on getAgents error', () => {
agents$.error('error');
component.ngOnInit();
expect(component.agents).toEqual([]);
expect(component.initialsByAgentId).toEqual({});
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
});
it('should not be rendered if none agent is loaded', () => {
agentsMock = [];
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeFalsy();
});
it('should have correct label', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton().textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.LABEL');
});
it('should contain stars icon', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.aca-agents-menu-button adf-icon')).componentInstance.value).toBe('adf:colored-stars-ai');
});
});
describe('loaded config with error', () => {
beforeEach(() => {
config$.error('error');
config$.complete();
});
it('should not be rendered', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeFalsy();
});
it('should show notification error', () => {
agents$.next(agentsMock);
agents$.complete();
component.ngOnInit();
expect(component.hxInsightUrl).toBeUndefined();
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING');
});
});
});
const buttonKeyboardActions = (eventName: string): void => {
describe(`Button action - ${eventName} event`, () => {
runButtonActions(eventName);
});
};
['mouseup', 'keydown'].forEach((eventName) => {
buttonKeyboardActions(eventName);
});
describe('Agents menu', () => {
let loader: HarnessLoader;
const prepareData = (agents: Agent[]): void => {
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
loader = TestbedHarnessEnvironment.loader(fixture);
agents$.next(agents);
selectionState.isEmpty = false;
checkSearchAvailabilitySpy.and.returnValue('');
const button = getAgentsButton();
button.dispatchEvent(new MouseEvent('mouseup'));
fixture.detectChanges();
button.click();
fixture.detectChanges();
};
const getAvatar = (agentId: string): AvatarComponent =>
fixture.debugElement.query(By.css(`[data-automation-id=aca-agents-button-agent-${agentId}]`)).query(By.directive(AvatarComponent))
.componentInstance;
describe('Agents position', () => {
it('should have assigned before to xPosition', () => {
prepareData(agentsMock);
agents$.complete();
expect(getMenu().xPosition).toBe('before');
});
});
describe('Agents multi words name', () => {
beforeEach(() => {
prepareData(agentsMock);
agents$.complete();
});
const getAgentsListHarness = async (): Promise<MatSelectionListHarness> =>
(await loader.getHarness(MatMenuHarness)).getHarness(MatSelectionListHarness);
const selectAgent = async (): Promise<void> =>
(await getAgentsListHarness()).selectItems({
fullText: 'PA Policy Agent'
});
const getAgentsList = (): MatSelectionList => fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance;
it('should deselect selected agent after selecting other', async () => {
component.data = {
trigger: SearchAiActionTypes.ToggleAiSearchInput
};
const selectionList = getAgentsList();
spyOn(selectionList, 'deselectAll');
await selectAgent();
expect(selectionList.deselectAll).toHaveBeenCalled();
});
it('should dispatch on store selected agent', async () => {
component.data = {
trigger: SearchAiActionTypes.ToggleAiSearchInput
};
spyOn(store, 'dispatch');
await selectAgent();
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
type: SearchAiActionTypes.ToggleAiSearchInput,
agentId: '2'
})
);
});
it('should disallow selecting multiple agentsMock', () => {
expect(getAgentsList().multiple).toBeFalse();
});
it('should have hidden single selection indicator', () => {
expect(getAgentsList().hideSingleSelectionIndicator).toBeTrue();
});
it('should display option for each agent', async () => {
const agents = await (await getAgentsListHarness()).getItems();
expect(agents.length).toBe(2);
expect(await agents[0].getFullText()).toBe('HA HR Agent');
expect(await agents[1].getFullText()).toBe('PA Policy Agent');
});
it('should display avatar for each agent', () => {
expect(getAvatar('1')).toBeTruthy();
expect(getAvatar('2')).toBeTruthy();
});
it('should assign correct initials to each avatar for each agent with double section name', () => {
expect(getAvatar('1').initials).toBe('HA');
expect(getAvatar('2').initials).toBe('PA');
});
it('should assign correct src to each avatar', () => {
agentsMock[0].avatarUrl = 'some-url-1';
agentsMock[1].avatarUrl = 'some-url-2';
fixture.detectChanges();
expect(getAvatar('1').src).toBe('some-url-1');
expect(getAvatar('2').src).toBe('some-url-2');
});
});
describe('Agents single word name', () => {
it('should assign correct initials to each avatar for each agent with single section name', () => {
agentsMock = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
agentsMock[0].name = 'Adam';
agentsMock[1].name = 'Bob';
prepareData(agentsMock);
expect(getAvatar('1').initials).toBe('A');
expect(getAvatar('2').initials).toBe('B');
});
});
});
});
@@ -1,136 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SelectionState } from '@alfresco/adf-extensions';
import { Store } from '@ngrx/store';
import { AppStore, getAppSelection } from '@alfresco/aca-shared/store';
import { AvatarComponent, IconComponent, NotificationService } from '@alfresco/adf-core';
import { forkJoin, throwError } from 'rxjs';
import { catchError, take } from 'rxjs/operators';
import { MatMenuModule } from '@angular/material/menu';
import { MatListModule, MatSelectionListChange } from '@angular/material/list';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Agent } from '@alfresco/js-api';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { MatTooltipModule } from '@angular/material/tooltip';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
imports: [CommonModule, MatMenuModule, MatListModule, TranslatePipe, AvatarComponent, IconComponent, MatTooltipModule],
selector: 'aca-agents-button',
templateUrl: './agents-button.component.html',
styleUrls: ['./agents-button.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-agents-button' }
})
export class AgentsButtonComponent implements OnInit {
private readonly store = inject<Store<AppStore>>(Store);
private readonly notificationService = inject(NotificationService);
private readonly searchAiService = inject(SearchAiService);
private readonly agentService = inject(AgentService);
private readonly translateService = inject(TranslateService);
private readonly cd = inject(ChangeDetectorRef);
@Input()
data: { trigger: string };
private selectedNodesState: SelectionState;
private _agents: Agent[] = [];
private _disabled = true;
private _initialsByAgentId: { [key: string]: string } = {};
private _hxInsightUrl: string;
get agents(): Agent[] {
return this._agents;
}
get disabled(): boolean {
return this._disabled;
}
get initialsByAgentId(): { [key: string]: string } {
return this._initialsByAgentId;
}
get hxInsightUrl(): string {
return this._hxInsightUrl;
}
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.store
.select(getAppSelection)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((selection) => {
this.selectedNodesState = selection;
});
forkJoin({
agents: this.agentService.getAgents().pipe(
take(1),
catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
),
config: this.searchAiService.getConfig().pipe(catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING')))
}).subscribe(
(result) => {
this._hxInsightUrl = result.config.entry.knowledgeRetrievalUrl;
this._agents = result.agents;
this.cd.detectChanges();
if (this.agents.length) {
this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
return initials;
}, {});
}
},
(error: string) => this.notificationService.showError(this.translateService.instant(error))
);
}
onClick(): void {
if (!this.selectedNodesState.isEmpty) {
const message = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
if (message) {
this.notificationService.showError(message);
}
this._disabled = !!message;
return;
}
this._disabled = true;
open(this.hxInsightUrl);
}
onAgentSelection(change: MatSelectionListChange): void {
this.store.dispatch({
type: this.data.trigger,
agentId: change.options[0].value.id
});
change.source.deselectAll();
}
}
@@ -1,17 +0,0 @@
<aca-search-ai-input
[searchTerm]="(inputState$ | async).searchTerm"
[placeholder]="placeholder"
[agentId]="agentId"
[usedInAiResultsPage]="usedInAiResultsPage" />
<mat-divider
[vertical]="true"
aria-hidden="true"
class="aca-search-ai-input-container-divider" />
<button
mat-icon-button
(click)="leaveSearchInput()"
data-automation-id="aca-search-ai-input-container-leaving-search-button"
[title]="(isKnowledgeRetrievalPage ? 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_ANSWER' : 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT') | translate"
class="aca-search-ai-input-container-close">
<mat-icon>close</mat-icon>
</button>
@@ -1,18 +0,0 @@
aca-search-ai-input-container {
display: flex;
flex-direction: row;
flex: 1;
align-items: center;
width: 100%;
.aca-search-ai-input-container-divider {
height: 24px;
margin-left: 30px;
margin-right: 7px;
background: var(--adf-theme-foreground-text-color-025);
}
.aca-search-ai-input-container-close {
display: flex;
}
}
@@ -1,195 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchAiInputContainerComponent } from './search-ai-input-container.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { By } from '@angular/platform-browser';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { of, Subject } from 'rxjs';
import { MatDivider } from '@angular/material/divider';
import { DebugElement } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
import { NavigationEnd, provideRouter, Router, RouterEvent } from '@angular/router';
import { getAppSelection } from '@alfresco/aca-shared/store';
import { NoopTranslateModule } from '@alfresco/adf-core';
import { MatIconTestingModule } from '@angular/material/icon/testing';
describe('SearchAiInputContainerComponent', () => {
const routingEvents$: Subject<RouterEvent> = new Subject();
let component: SearchAiInputContainerComponent;
let fixture: ComponentFixture<SearchAiInputContainerComponent>;
let searchAiService: SearchAiService;
let store: MockStore;
let mockSearchAiService: jasmine.SpyObj<SearchAiService>;
let searchNavigationService: SearchAiNavigationService;
let mockRouter: any;
beforeEach(() => {
mockSearchAiService = jasmine.createSpyObj('SearchAiService', ['updateSearchAiInputState'], {
toggleSearchAiInput$: of(true)
});
mockRouter = {
url: '/some-url',
events: routingEvents$.asObservable(),
routerState: {
root: {}
},
snapshot: {}
};
TestBed.configureTestingModule({
imports: [NoopTranslateModule, MatIconTestingModule, SearchAiInputContainerComponent],
providers: [
provideRouter([]),
{ provide: Router, useValue: mockRouter },
provideMockStore(),
{ provide: SearchAiService, useValue: mockSearchAiService },
{
provide: AgentService,
useValue: {
getAgents: () =>
of([
{
id: '1',
name: 'HR Agent',
description: 'HR Agent',
avatar: 'avatar1'
}
])
}
}
]
});
fixture = TestBed.createComponent(SearchAiInputContainerComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
searchAiService = TestBed.inject(SearchAiService);
searchNavigationService = TestBed.inject(SearchAiNavigationService);
store.overrideSelector(getAppSelection, {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
});
component.agentId = '1';
fixture.detectChanges();
});
afterEach(() => {
store.resetSelectors();
});
describe('Search ai input', () => {
let inputComponent: SearchAiInputComponent;
beforeEach(() => {
inputComponent = fixture.debugElement.query(By.directive(SearchAiInputComponent)).componentInstance;
});
it('should have assigned correct default placeholder', () => {
expect(inputComponent.placeholder).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER');
});
it('should have assigned correct placeholder if placeholder has been changed', () => {
component.placeholder = 'Some placeholder';
fixture.detectChanges();
expect(inputComponent.placeholder).toBe(component.placeholder);
});
it('should have assigned correct agentId', () => {
expect(inputComponent.agentId).toBe(component.agentId);
});
it('should have assigned correct usedInAiResultsPage flag', () => {
component.usedInAiResultsPage = true;
fixture.detectChanges();
expect(inputComponent.usedInAiResultsPage).toBeTrue();
});
it('should set inputState$ to toggleSearchAiInput$ from the service on ngOnInit', () => {
component.ngOnInit();
expect(component.inputState$).toBe(mockSearchAiService.toggleSearchAiInput$);
});
});
describe('Divider', () => {
it('should have a vertical divider', () => {
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(MatDivider)).componentInstance.vertical).toBeTrue();
});
});
describe('Leaving button', () => {
let button: DebugElement;
beforeEach(() => {
button = fixture.debugElement.query(By.directive(MatIconButton));
});
it('should have correct title when page is not knowledge-retrieval', () => {
mockRouter.url = '/other-page';
component.ngOnInit();
expect(button.nativeElement.title).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT');
});
it('should have correct title when page is knowledge-retrieval', () => {
mockRouter.url = '/knowledge-retrieval/some-data';
component.ngOnInit();
fixture.detectChanges();
expect(button.nativeElement.title).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_ANSWER');
});
it('should contain close icon', () => {
expect(button.query(By.directive(MatIcon)).nativeElement.textContent).toBe('close');
});
it('should call navigateToPreviousRoute on SearchAiService when clicked', () => {
spyOn(searchNavigationService, 'navigateToPreviousRouteOrCloseInput');
button.nativeElement.click();
expect(searchNavigationService.navigateToPreviousRouteOrCloseInput).toHaveBeenCalled();
});
it('should not call updateSearchAiInputState on SearchAiService when there is different event than navigation starts', () => {
routingEvents$.next(new NavigationEnd(1, '', ''));
expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
});
});
});
@@ -1,67 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, Input, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MatDividerModule } from '@angular/material/divider';
import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
import { SearchAiInputState, SearchAiService } from '@alfresco/adf-content-services';
import { TranslatePipe } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { Router } from '@angular/router';
@Component({
imports: [SearchAiInputComponent, MatIconModule, MatDividerModule, MatButtonModule, TranslatePipe, AsyncPipe],
selector: 'aca-search-ai-input-container',
templateUrl: './search-ai-input-container.component.html',
styleUrls: ['./search-ai-input-container.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchAiInputContainerComponent implements OnInit {
private readonly searchAiService = inject(SearchAiService);
private readonly searchNavigationService = inject(SearchAiNavigationService);
private readonly router = inject(Router);
@Input()
placeholder = 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER';
@Input()
agentId: string;
@Input()
usedInAiResultsPage: boolean;
inputState$: Observable<SearchAiInputState>;
isKnowledgeRetrievalPage = false;
ngOnInit(): void {
this.isKnowledgeRetrievalPage = this.router.url.startsWith('/knowledge-retrieval');
this.inputState$ = this.searchAiService.toggleSearchAiInput$;
}
leaveSearchInput(): void {
this.searchNavigationService.navigateToPreviousRouteOrCloseInput();
}
}
@@ -1,60 +0,0 @@
<div class="aca-search-ai-input-agent-container">
<mat-select
[formControl]="agentControl"
class="aca-search-ai-input-agent-select"
panelClass="aca-search-ai-input-agent-select-options aca-search-ai-input-agent-select-agents"
data-automation-id="aca-search-ai-agents-select"
[hideSingleSelectionIndicator]="true">
<mat-select-trigger class="aca-search-ai-input-agent-select-displayed-value">
<adf-avatar
[src]="agentControl.value?.avatarUrl"
[initials]="initialsByAgentId[agentControl.value?.id]"
size="26px" />
<span class="aca-search-ai-input-agent-select-displayed-value-text">{{ agentControl.value?.name }}</span>
</mat-select-trigger>
<mat-option
*ngFor="let agent of agents"
[value]="agent"
class="aca-search-ai-input-agent-select-options-option"
[attr.data-automation-id]="'aca-search-ai-input-agent-' + agent.id"><div class="aca-search-ai-input-agent-select-options-option-content">
<adf-avatar [src]="agent?.avatarUrl" [initials]="initialsByAgentId[agent.id]" />
<span class="aca-search-ai-input-agent-select-options-option-content-text" [matTooltip]="agent.name" [matTooltipPosition]="'right'">{{ agent.name }}</span>
</div>
</mat-option>
</mat-select>
<div class="aca-search-ai-input-agent-popup-hover-card">
<mat-card class="aca-search-ai-input-agent-popup-hover-card-container">
<mat-card-title class="aca-search-ai-input-agent-popup-hover-card-container-title">
<adf-avatar
[initials]="initialsByAgentId[agentControl.value?.id]"
[src]="agentControl.value?.avatarUrl"
size="50px" />
<span class="aca-search-ai-input-agent-popup-hover-card-container-title-name"
[matTooltipPosition]="'right'"
[matTooltip]="agentControl.value?.name">
{{ agentControl.value?.name }}
</span>
</mat-card-title>
<mat-card-content class="aca-search-ai-input-agent-popup-hover-card-container-content">
{{ agentControl.value?.description }}
</mat-card-content>
</mat-card>
</div>
</div>
<input
class="aca-search-ai-input-text"
matInput
[formControl]="queryControl"
data-automation-id="aca-search-ai-input"
[placeholder]="placeholder | translate"
(keyup.enter)="onSearchSubmit()"/>
<button
mat-flat-button
class="aca-search-ai-asking-button"
(click)="onSearchSubmit()"
[disabled]="!queryControl.value"
data-automation-id="aca-search-ai-asking-button">
<adf-icon [value]="'adf:three_magic_stars_ai'" />
<span class="aca-search-ai-asking-button-label">{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL' | translate }}</span>
</button>
@@ -1,191 +0,0 @@
@use '../../../../ui/mat-selectors' as ms;
@use '@angular/material' as mat;
aca-search-ai-input {
width: 100%;
display: flex;
align-items: center;
.aca-search-ai-input-text {
margin-top: 4px;
flex: 1;
font-size: 20px;
margin-right: 167px;
border: none;
outline: none;
&:focus {
&::placeholder {
color: var(--theme-primary-color);
}
}
}
.aca-search-ai-asking-button {
display: flex;
align-items: center;
padding-left: 0;
padding-right: 12px;
height: 32px;
border-radius: 6px;
width: 92px;
font-weight: 600;
@include mat.button-overrides(
(
filled-container-color: var(--theme-primary-color),
filled-label-text-color: var(--theme-primary-color-default-contrast)
)
);
&-label {
vertical-align: super;
}
adf-icon {
margin-bottom: 3px;
margin-right: 7px;
svg {
width: 34px;
height: 34px;
margin-left: -6px;
margin-top: -4px;
}
}
}
.aca-search-ai-input-agent-select {
width: 149px;
height: 35px;
align-content: center;
border-radius: 16px;
padding-left: 3px;
padding-right: 10px;
background-color: var(--theme-grey-text-background-color);
color: var(--theme-text-light-color);
font-size: 15px;
margin-right: 26px;
#{ms.$mat-select-trigger} {
height: auto;
margin-top: 4px;
}
&:focus {
outline: -webkit-focus-ring-color auto 1px;
}
&-displayed-value {
display: flex;
align-items: center;
&-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-left: 2px;
margin-right: 6px;
padding-top: 1px;
padding-bottom: 3px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
.aca-search-ai-input-agent-select-options.aca-search-ai-input-agent-select-agents#{ms.$mat-select-panel} {
margin-top: 9px;
.aca-search-ai-input-agent-select-options-option {
padding-left: 11px;
padding-right: 11px;
&-content {
display: flex;
align-items: center;
padding-top: 1px;
padding-bottom: 1px;
&-text {
width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-right: 12px;
padding-left: 1px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
.aca-search-ai-input-agent-container {
position: relative;
.aca-search-ai-input-agent-popup-hover-card {
display: none;
position: absolute;
left: 0;
z-index: 1;
&-container {
width: 315px;
height: fit-content;
border-radius: 12px;
margin-top: 4px;
&-title {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 700;
padding: 16px 16px 8px;
gap: 4px;
&-name {
margin: 0 12px;
}
img {
height: 50px;
width: 50px;
min-width: 50px;
min-height: 50px;
}
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 14px;
}
}
&-content {
display: flex;
color: var(--theme-content-color);
text-align: justify;
text-justify: inter-word;
}
}
}
&:hover {
.aca-search-ai-input-agent-popup-hover-card {
display: block;
}
}
}
@@ -1,489 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchAiInputComponent } from './search-ai-input.component';
import { MatSelect, MatSelectModule } from '@angular/material/select';
import { By } from '@angular/platform-browser';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { getAppSelection, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { of, Subject } from 'rxjs';
import { Agent, NodeEntry } from '@alfresco/js-api';
import { FormControlDirective } from '@angular/forms';
import { DebugElement } from '@angular/core';
import {
AvatarComponent,
IconComponent,
NoopTranslateModule,
NotificationService,
UnsavedChangesDialogComponent,
UserPreferencesService
} from '@alfresco/adf-core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatInput } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { MatInputHarness } from '@angular/material/input/testing';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatSnackBarRef } from '@angular/material/snack-bar';
import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { MatIconTestingModule } from '@angular/material/icon/testing';
describe('SearchAiInputComponent', () => {
let component: SearchAiInputComponent;
let fixture: ComponentFixture<SearchAiInputComponent>;
let loader: HarnessLoader;
let selectionState: SelectionState;
let store: MockStore;
let agents$: Subject<Agent[]>;
let dialog: MatDialog;
let activatedRoute: ActivatedRoute;
let userPreferencesService: UserPreferencesService;
let agentList: Agent[];
const newSelectionState: SelectionState = {
...selectionState,
file: {
entry: {
id: 'some-id'
}
} as NodeEntry
};
const prepareBeforeTest = (): void => {
selectionState = {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
};
store.overrideSelector(getAppSelection, selectionState);
component.agentId = '2';
component.ngOnInit();
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule, MatIconTestingModule, SearchAiInputComponent, MatSelectModule],
providers: [
provideMockStore(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParams: { query: 'some query' }
}
}
}
]
});
activatedRoute = TestBed.inject(ActivatedRoute);
fixture = TestBed.createComponent(SearchAiInputComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
loader = TestbedHarnessEnvironment.loader(fixture);
agents$ = new Subject<Agent[]>();
dialog = TestBed.inject(MatDialog);
userPreferencesService = TestBed.inject(UserPreferencesService);
spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
spyOn(userPreferencesService, 'set');
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
agentList = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
prepareBeforeTest();
});
afterEach(() => {
store.resetSelectors();
});
describe('Agent select box', () => {
let selectElement: DebugElement;
let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef<any>>;
beforeEach(() => {
selectElement = fixture.debugElement.query(By.directive(MatSelect));
const notificationService = TestBed.inject(NotificationService);
notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
});
it('should have assigned formControl', () => {
expect(selectElement.injector.get(FormControlDirective).form).toBe(component.agentControl);
});
it('should have hidden single selection indicator', () => {
expect(selectElement.componentInstance.hideSingleSelectionIndicator).toBeTrue();
});
it('should set queryControl value to searchTerm if searchTerm is defined', () => {
const query = 'some new query';
component.searchTerm = query;
component.ngOnInit();
expect(component.queryControl.value).toBe(query);
});
it('should set queryControl value to "some new query" if usedInAiResultsPage is equal to false', () => {
const query = 'some new query';
component.usedInAiResultsPage = false;
component.searchTerm = query;
component.ngOnInit();
expect(component.queryControl.value).toBe('some new query');
});
it('should set queryControl value to empty string if usedInAiResultsPage is equal to true', () => {
const query = 'some new query';
component.usedInAiResultsPage = true;
component.searchTerm = query;
component.ngOnInit();
expect(component.queryControl.value).toBe('');
});
it('should get agents on init', () => {
agents$.next(agentList);
component.ngOnInit();
expect(component.agents).toEqual(agentList);
expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
expect(notificationServiceSpy).not.toHaveBeenCalled();
});
it('should show notification on getAgents error', () => {
agents$.error('error');
component.ngOnInit();
expect(component.agents).toEqual([]);
expect(component.initialsByAgentId).toEqual({});
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
});
it('should have selected correct agent', async () => {
agentList[0].avatarUrl = 'some-url-1';
agentList[1].avatarUrl = 'some-url-2';
agents$.next(agentList);
expect(await (await loader.getHarness(MatSelectHarness)).getValueText()).toBe('Policy Agent');
const avatar = selectElement.query(By.directive(AvatarComponent))?.componentInstance;
expect(avatar.initials).toBe('PA');
expect(avatar.size).toBe('26px');
expect(avatar.src).toBe('some-url-2');
});
describe('Agents options', () => {
let options: MatOptionHarness[];
const getAvatarForAgent = (agentId: string): AvatarComponent =>
fixture.debugElement.query(By.css(`[data-automation-id=aca-search-ai-input-agent-${agentId}]`)).query(By.directive(AvatarComponent))
.componentInstance;
beforeEach(async () => {
agents$.next(agentList);
const selectHarness = await loader.getHarness(MatSelectHarness);
await selectHarness.open();
options = await selectHarness.getOptions();
});
it('should have correct number of agents', () => {
expect(options.length).toBe(2);
});
it('should have correct agent names', async () => {
expect(await options[0].getText()).toBe('HAHR Agent');
expect(await options[1].getText()).toBe('PAPolicy Agent');
});
it('should display avatar for each agent', () => {
expect(getAvatarForAgent('1')).toBeTruthy();
expect(getAvatarForAgent('2')).toBeTruthy();
});
it('should have correct initials for avatars for each of agent', () => {
expect(getAvatarForAgent('1').initials).toBe('HA');
expect(getAvatarForAgent('2').initials).toBe('PA');
});
it('should have correct initials for avatars for each of agent', () => {
agentList[0].avatarUrl = 'some-url-1';
agentList[1].avatarUrl = 'some-url-2';
fixture.detectChanges();
expect(getAvatarForAgent('1').src).toBe('some-url-1');
expect(getAvatarForAgent('2').src).toBe('some-url-2');
});
it('should assign correct initials to each avatar for each agent with single section name', () => {
const newAgentList = [
{ ...agentList[0], name: 'Adam' },
{ ...agentList[1], name: 'Bob' }
];
agents$.next(newAgentList);
fixture.detectChanges();
expect(getAvatarForAgent('1').initials).toBe('A');
expect(getAvatarForAgent('2').initials).toBe('B');
});
});
});
describe('Agents popup', () => {
it('should have selected correct agent', () => {
agentList[0].avatarUrl = 'some-url-1';
agentList[1].avatarUrl = 'some-url-2';
agents$.next(agentList);
fixture.detectChanges();
fixture.debugElement.query(By.css('.aca-search-ai-input-agent-container')).nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
expect(fixture.debugElement.query(By.css('.aca-search-ai-input-agent-popup-hover-card-container-title adf-avatar')).componentInstance.src).toBe(
'some-url-2'
);
});
});
describe('Query input', () => {
let queryInput: DebugElement;
beforeEach(() => {
queryInput = fixture.debugElement.query(By.directive(MatInput));
agents$.next(agentList);
});
it('should have assigned formControl', () => {
fixture.detectChanges();
expect(queryInput.injector.get(FormControlDirective).form).toBe(component.queryControl);
});
it('should have assigned correct placeholder', () => {
component.placeholder = 'Please ask your question with as much detail as possible...';
expect(queryInput.componentInstance.placeholder).toBe(component.placeholder);
});
testSubmitting(false);
});
describe('Submit button', () => {
let submitButton: DebugElement;
let queryInput: MatInputHarness;
beforeEach(async () => {
submitButton = fixture.debugElement.query(By.directive(MatButton));
queryInput = await loader.getHarness(MatInputHarness);
agents$.next(agentList);
});
it('should be disabled by default', () => {
activatedRoute.snapshot.queryParams = { query: '' };
component.ngOnInit();
fixture.detectChanges();
expect(submitButton.nativeElement.disabled).toBeTrue();
});
it('should be enabled if query input is filled', async () => {
await queryInput.setValue('Some question');
expect(submitButton.nativeElement.disabled).toBeFalse();
});
it('should be disabled if query input was filled but after that it was emptied', async () => {
await queryInput.setValue('Some question');
await queryInput.setValue('');
expect(submitButton.nativeElement.disabled).toBeTrue();
});
it('should contain stars icon', () => {
expect(submitButton.query(By.directive(IconComponent)).componentInstance.value).toBe('adf:three_magic_stars_ai');
});
it('should have correct label', () => {
expect(submitButton.nativeElement.textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL');
});
testSubmitting();
});
function testSubmitting(useButton = true) {
describe('Submitting', () => {
let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
let notificationService: NotificationService;
let submitButton: DebugElement;
let queryInput: MatInputHarness;
let submittingTrigger: () => void;
const query = 'some query';
let dialogOpenSpy: jasmine.Spy<<T, R>(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef<T, R>>;
let modalAiService: ModalAiService;
beforeEach(async () => {
prepareBeforeTest();
modalAiService = TestBed.inject(ModalAiService);
checkSearchAvailabilitySpy = spyOn(TestBed.inject(SearchAiService), 'checkSearchAvailability');
notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'showError');
queryInput = await loader.getHarness(MatInputHarness);
submitButton = fixture.debugElement.query(By.directive(MatButton));
await queryInput.setValue(query);
const inputElement = fixture.debugElement.query(By.directive(MatInput)).nativeElement;
dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
afterClosed: () => of(true)
} as MatDialogRef<MatDialog>);
submittingTrigger = useButton
? () => submitButton.nativeElement.click()
: () =>
inputElement.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'Enter'
})
);
});
it('should call showError on NotificationService if checkSearchAvailability from SearchAiService returns message', () => {
const message = 'Some message';
checkSearchAvailabilitySpy.and.returnValue(message);
submittingTrigger();
expect(notificationService.showError).toHaveBeenCalledWith(message);
});
it('should not call showError on NotificationService if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
submittingTrigger();
expect(notificationService.showError).not.toHaveBeenCalled();
});
it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by store', () => {
submittingTrigger();
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
});
it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by UserPreferencesService', () => {
component.usedInAiResultsPage = true;
component.ngOnInit();
submittingTrigger();
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(newSelectionState);
expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
});
it('should call set on UserPreferencesService with parameter based on value returned by store', () => {
submittingTrigger();
expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(selectionState));
});
it('should call set on UserPreferencesService with parameter based on value returned by UserPreferencesService', () => {
component.usedInAiResultsPage = true;
component.ngOnInit();
submittingTrigger();
expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(newSelectionState));
});
it('should call dispatch on store with correct parameter', () => {
spyOn(store, 'dispatch');
submittingTrigger();
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
...new SearchByTermAiAction({
searchTerm: query,
agentId: component.agentId
})
})
);
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ToggleAISearchInput('2', 'some query') }));
});
it('should call dispatch on store with correct parameter if selected agent was changed', async () => {
spyOn(store, 'dispatch');
await (
await loader.getHarness(MatSelectHarness)
).clickOptions({
text: 'HAHR Agent'
});
submittingTrigger();
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
...new SearchByTermAiAction({
searchTerm: query,
agentId: '1'
})
})
);
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ToggleAISearchInput('1', 'some query') }));
});
it('should call open modal if there was a previous search phrase in url', () => {
submittingTrigger();
expect(dialogOpenSpy).toHaveBeenCalled();
});
it('should open Unsaved Changes Modal and run callback successfully', () => {
const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
fixture.detectChanges();
submittingTrigger();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
});
it('should call reset on queryControl', () => {
spyOn(component.queryControl, 'reset');
submittingTrigger();
expect(component.queryControl.reset).toHaveBeenCalled();
});
});
}
});
@@ -1,178 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, DestroyRef, inject, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { A11yModule } from '@angular/cdk/a11y';
import { AvatarComponent, IconComponent, NotificationService, UserPreferencesService } from '@alfresco/adf-core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AiSearchByTermPayload, AppStore, getAppSelection, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatSelectModule } from '@angular/material/select';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { MatCardModule } from '@angular/material/card';
import {
MAT_TOOLTIP_DEFAULT_OPTIONS,
MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY,
MatTooltipDefaultOptions,
MatTooltipModule
} from '@angular/material/tooltip';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { Agent } from '@alfresco/js-api';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
const MatTooltipOptions: MatTooltipDefaultOptions = {
...MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(),
disableTooltipInteractivity: true
};
@Component({
imports: [
CommonModule,
TranslatePipe,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
A11yModule,
FormsModule,
ReactiveFormsModule,
MatSelectModule,
IconComponent,
AvatarComponent,
MatCardModule,
MatTooltipModule
],
selector: 'aca-search-ai-input',
templateUrl: './search-ai-input.component.html',
styleUrls: ['./search-ai-input.component.scss'],
encapsulation: ViewEncapsulation.None,
providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: MatTooltipOptions }]
})
export class SearchAiInputComponent implements OnInit {
private readonly store = inject<Store<AppStore>>(Store);
private readonly searchAiService = inject(SearchAiService);
private readonly notificationService = inject(NotificationService);
private readonly agentService = inject(AgentService);
private readonly userPreferencesService = inject(UserPreferencesService);
private readonly translateService = inject(TranslateService);
private readonly modalAiService = inject(ModalAiService);
@Input()
placeholder: string;
@Input()
agentId: string;
@Input()
usedInAiResultsPage: boolean;
@Input()
searchTerm: string;
private readonly storedNodesKey = 'knowledgeRetrievalNodes';
private readonly _agentControl = new FormControl<Agent>(null);
private _agents: Agent[] = [];
private selectedNodesState: SelectionState;
private readonly _queryControl = new FormControl('');
private _initialsByAgentId: { [key: string]: string } = {};
get agentControl(): FormControl<Agent> {
return this._agentControl;
}
get agents(): Agent[] {
return this._agents;
}
get queryControl(): FormControl<string> {
return this._queryControl;
}
get initialsByAgentId(): { [key: string]: string } {
return this._initialsByAgentId;
}
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
const queryValue = this.usedInAiResultsPage ? '' : this.searchTerm || '';
this.queryControl.setValue(queryValue);
if (!this.usedInAiResultsPage) {
this.store
.select(getAppSelection)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((selection) => {
this.selectedNodesState = selection;
});
} else {
this.selectedNodesState = JSON.parse(this.userPreferencesService.get(this.storedNodesKey));
}
this.agentService
.getAgents()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
(agents) => {
this._agents = agents;
this.agentControl.setValue(this._agents.find((agent) => agent.id === this.agentId));
this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
return initials;
}, {});
},
() => this.notificationService.showError(this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
);
}
onSearchSubmit() {
this.modalAiService.openUnsavedChangesModal(() => this.search());
}
private search(): void {
const error = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
if (error) {
this.notificationService.showError(error);
} else {
const payload: AiSearchByTermPayload = {
searchTerm: this.queryControl.value,
agentId: this.agentControl.value.id
};
this.userPreferencesService.set(this.storedNodesKey, JSON.stringify(this.selectedNodesState));
this.store.dispatch(new SearchByTermAiAction(payload));
this.store.dispatch(new ToggleAISearchInput(this.agentControl.value.id, this.queryControl.value));
this.queryControl.reset();
}
}
}
@@ -1,77 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { searchAiMarkedOptions } from './search-ai-marked-options';
describe('SearchAiMarkedOptions', () => {
let link = '';
beforeEach(() => {
link = searchAiMarkedOptions.renderer.link({
raw: '',
text: 'Example Link',
type: 'link',
href: 'https://example.com',
title: 'Example',
tokens: []
});
});
it('should return a element', () => {
expect(link).toContain('<a');
});
it('should returned link contain correct href', () => {
expect(link).toContain('href="https://example.com"');
});
it('should returned link contain correct target', () => {
expect(link).toContain('target="_blank"');
});
it('should returned link contain correct rel', () => {
expect(link).toContain('rel="noopener noreferrer"');
});
it('should returned link contain correct title', () => {
expect(link).toContain('title="Example"');
});
it('should returned link contain correct text', () => {
expect(link).toContain('>Example Link</a>');
});
it('should returned link contain correct title if title is null', () => {
expect(
searchAiMarkedOptions.renderer.link({
raw: '',
text: 'Example Link',
type: 'link',
href: 'https://example.com',
title: '',
tokens: []
})
).toContain('title=""');
});
});
@@ -1,33 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { MarkedOptions, MarkedRenderer } from 'ngx-markdown';
import { Tokens } from 'marked/lib/marked';
const renderer = new MarkedRenderer();
renderer.link = ({ href, title, text }: Tokens.Link): any =>
`<a href="${href}" target="_blank" rel="noopener noreferrer" title="${title || ''}">${text}</a>`;
export const searchAiMarkedOptions: MarkedOptions = {
renderer
};
@@ -1,126 +0,0 @@
<aca-page-layout>
@if (!hasError && agentId) {
<aca-search-ai-input-container
class="aca-page-layout-header"
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
[agentId]="agentId"
[usedInAiResultsPage]="true" />
}
@if (!hasError) {
<div class="aca-page-layout-content">
<div class="aca-search-ai-results-container">
<div
class="aca-search-ai-results-container-query"
data-automation-id="aca-search-ai-results-query">
{{ searchQuery }}
</div>
<div
class="aca-search-ai-response-container"
[class.aca-search-ai-response-container-error]="hasAnsweringError">
@if (!loading) {
@if (!hasAnsweringError) {
<div class="aca-search-ai-response-container-body">
<markdown
class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response"
[data]="displayedAnswer"
(ready)="addSourceCodeTooltips()"
mermaid
katex />
<button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration"
mat-icon-button
(click)="checkUnsavedChangesAndSearch()"
data-automation-id="aca-search-ai-results-regeneration-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate">
<mat-icon>cached</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action"
mat-icon-button
(click)="copyResponseToClipboard()"
data-automation-id="aca-search-ai-results-copying-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate">
<mat-icon>copy</mat-icon>
</button>
@if (references$ | async; as refs) {
@if (refs?.length || hasReferencesLoadingError) {
<mat-divider
class="aca-search-ai-response-container-body-divider"
[class.aca-search-ai-response-container-body-divider-error]="hasReferencesLoadingError"
aria-hidden="true"
/>
<div class="aca-search-ai-response-container-body-references-container">
<p class="aca-search-ai-response-container-body-references-container-header">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
</p>
@if (hasReferencesLoadingError) {
<div class="aca-search-ai-response-container-body-references-container-loading-error" role="alert">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.REFERENCES_LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="loadReferences()"
[attr.aria-label]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.RELOAD_REFERENCES_BUTTON_LABEL' | translate"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-response-container-body-references-container-retry-references-loading-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.RELOAD_REFERENCES_BUTTON_LABEL' | translate }}
</button>
</div>
} @else {
<div class="aca-search-ai-response-container-body-references-container-documents">
@for (node of refs; track node.id) {
<a
class="aca-search-ai-response-container-body-references-container-documents-document"
[attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'"
role="link"
(click)="openFile(node.id)"
(keyup.enter)="openFile(node.id)"
tabindex="0">
<mat-icon
mat-list-icon
class="aca-search-ai-response-container-body-references-container-documents-document-icon">
<img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]" />
</mat-icon>
<div class="aca-search-ai-response-container-body-references-container-documents-document-name">
{{ node.name }}
</div>
</a>
}
</div>
}
</div>
}
}
</div>
} @else {
<div class="aca-search-ai-response-container-error-message" role="alert">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="performAiSearch()"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-results-error-regeneration-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }}
</button>
</div>
}
} @else {
<div class="adf-skeleton"></div>
<div class="adf-skeleton"></div>
<div class="adf-skeleton adf-skeleton-half"></div>
}
</div>
</div>
</div>
} @else {
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR" />
}
</aca-page-layout>
@@ -1,223 +0,0 @@
@use '../../../../ui/mat-selectors' as ms;
.aca-search-ai-results {
aca-page-layout {
.aca-page-layout-content {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: var(--theme-white-background);
border-top: 1px solid var(--theme-grey-background-color);
padding-top: 28px;
.aca-search-ai-results-container {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-right: 24%;
padding-left: 24%;
min-width: 51%;
&-query {
border-radius: 12px;
padding: 20px 15px 19px;
background: var(--theme-card-background-grey-color);
}
}
.aca-search-ai-response-container {
padding: 18px 20px;
display: flex;
flex-direction: column;
border: 1px solid var(--adf-theme-foreground-divider-color);
border-radius: 12px;
margin: 16px 0 75px;
&-references-container-header {
padding-left: 8px;
}
.adf-skeleton {
position: relative;
background-image: linear-gradient(
to left,
var(--theme-light-grey-1-color) 0%,
var(--theme-light-grey-2-color) 20%,
var(--theme-light-grey-3-color) 40%,
var(--theme-light-grey-1-color) 100%
);
background-size: 200%;
display: inline-block;
height: 1em;
overflow: hidden;
width: 100%;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
&-half {
width: 50%;
margin-bottom: 8px;
}
&::after {
position: absolute;
inset: 0;
transform: translateX(-100%);
background-image: linear-gradient(90deg, rgba(white, 0) 0, rgba(white, 0.2) 20%, rgba(white, 0.5) 60%, rgba(white, 0));
animation: shimmer 2s infinite;
content: '';
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
}
&-error {
border-color: var(--adf-error-color);
padding: 32px 18px;
&-message {
display: flex;
justify-content: space-between;
align-items: center;
&-regeneration-button {
background-color: var(--adf-secondary-button-background);
&-icon {
font-size: 24px;
height: 24px;
width: 23px;
}
}
}
}
&-body {
&-response {
margin-bottom: 17px;
overflow-wrap: break-word;
white-space: pre-wrap;
&-action {
width: max-content;
padding: 0;
mat-icon {
font-size: 17.25px;
}
&-regeneration {
margin-left: 2px;
margin-right: 2px;
}
#{ms.$mat-button-touch-target} {
width: 24px;
}
}
table {
width: 100%;
border-collapse: collapse;
box-shadow: 0 2px 4px var(--theme-grey-divider-color);
border-radius: 4px;
overflow: hidden;
& th {
background-color: var(--adf-theme-mat-grey-color-a200);
text-align: left;
}
& th,
& td {
padding: 16px;
border-bottom: 1px solid var(--adf-theme-foreground-divider-color);
}
& tr {
&:hover {
background-color: var(--adf-theme-background-hover-color);
}
&:nth-child(even) {
background-color: var(--theme-card-background-grey-color);
}
}
}
}
&-divider {
margin-top: 9px;
&-error {
border-color: var(--adf-error-color);
}
}
&-references-container {
padding-right: 8px;
padding-left: 8px;
&-header {
margin-top: 16px;
color: var(--theme-text-light-color);
font-weight: 400;
margin-bottom: 3px;
}
&-loading-error {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
padding-bottom: 3px;
}
&-documents {
padding-right: 5px;
padding-top: 5px;
margin-left: -2px;
display: flex;
flex-wrap: wrap;
gap: 21px;
&-document {
display: flex;
flex-direction: row;
padding-top: 7px;
padding-bottom: 7px;
&-icon {
height: auto;
align-content: center;
padding-right: 11px;
}
&-name {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
}
&:hover {
text-decoration: underline;
text-decoration-color: var(--theme-primary-color);
color: var(--theme-primary-color);
cursor: pointer;
}
}
}
}
}
}
}
}
}
@@ -1,755 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Observable, of, Subject, throwError } from 'rxjs';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { ClipboardService, EmptyContentComponent, UnitTestingUtils, UnsavedChangesGuard, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialogModule } from '@angular/material/dialog';
import { AppTestingModule } from '../../../../testing/app-testing.module';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { delay } from 'rxjs/operators';
import { AiAnswerEntry, Node, QuestionModel, ResultSetPaging } from '@alfresco/js-api/typings';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store';
import { ContentApiService } from '@alfresco/aca-shared';
import { ViewerService } from '@alfresco/aca-content/viewer';
import { DebugElement } from '@angular/core';
import { MarkdownComponent, MarkdownModule, MARKED_OPTIONS } from 'ngx-markdown';
import { searchAiMarkedOptions } from './search-ai-marked-options';
const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } };
const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => {
return {
entry: {
answer: noAnswer ? '' : 'Some answer',
question: 'some question',
objectReferences: [],
complete: true
}
};
};
describe('SearchAiResultsComponent', () => {
const knowledgeRetrievalNodes = '{"isEmpty":"false","nodes":[{"entry":{"id": "someId","isFolder":"true"}}]}';
let fixture: ComponentFixture<SearchAiResultsComponent>;
let component: SearchAiResultsComponent;
let userPreferencesService: UserPreferencesService;
let mockQueryParams = new Subject<Params>();
let modalAiService: ModalAiService;
let searchAiService: SearchAiService;
let store: MockStore;
let viewerService: ViewerService;
let unsavedChangesGuard: UnsavedChangesGuard;
let unitTestingUtils: UnitTestingUtils;
let clipboardService: ClipboardService;
let contentApiService: ContentApiService;
afterEach(() => {
store.resetSelectors();
mockQueryParams = new Subject<Params>();
fixture.destroy();
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule, MarkdownModule.forRoot()],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
snapshot: {
queryParams: { query: 'testQuery' }
}
}
},
{
provide: UnsavedChangesGuard,
useValue: {
unsaved: false
}
},
provideMockStore()
]
});
fixture = TestBed.createComponent(SearchAiResultsComponent);
modalAiService = TestBed.inject(ModalAiService);
searchAiService = TestBed.inject(SearchAiService);
userPreferencesService = TestBed.inject(UserPreferencesService);
viewerService = TestBed.inject(ViewerService);
unsavedChangesGuard = TestBed.inject(UnsavedChangesGuard);
clipboardService = TestBed.inject(ClipboardService);
contentApiService = TestBed.inject(ContentApiService);
store = TestBed.inject(MockStore);
store.overrideSelector(getAppSelection, {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
});
store.overrideSelector(getCurrentFolder, null);
spyOn(searchAiService, 'ask').and.returnValue(of(questionMock));
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(of([]));
component = fixture.componentInstance;
unitTestingUtils = new UnitTestingUtils(fixture.debugElement);
component.ngOnInit();
});
describe('query params change', () => {
const getEmptyContentElement = (): DebugElement => unitTestingUtils.getByDirective(EmptyContentComponent);
it('should perform ai search and sets agents on query params change', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeFalse();
});
it('should throw an error if searchQuery not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ agentId: 'agentId1' });
expect(component.searchQuery).toBe('');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeTrue();
});
it('should not throw an error if selectedNodesState nodes not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue('{}');
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeFalse();
});
it('should throw an error if agentId not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ query: 'test' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe(undefined);
expect(component.hasError).toBeTrue();
});
it('should not get query answer and display an error when getAnswer throws error', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(throwError(() => 'error').pipe(delay(100)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should get query answer and not display an error when getAnswer throws one error and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(
throwError(() => 'error'),
of(getAiAnswerEntry())
);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3000);
expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse();
}));
it('should display and answer and not display an error when getAnswer throws nine errors and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(throwError(() => 'error')), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(50000);
expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse();
}));
it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(14).fill(throwError(() => 'error')), of(getAiAnswerEntry(true)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should not display answer and display an error if received AiAnswerPaging without answer ten times', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(10).fill(of(getAiAnswerEntry(true))));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.displayedAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should not display error and display and answer if received AiAnswerPaging without answer nine times and with answer one time', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(of(getAiAnswerEntry(true))), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasAnsweringError).toBeFalse();
}));
it('should render empty content when there are not selected nodes', () => {
mockQueryParams.next({
query: 'test',
agentId: 'agentId1'
});
fixture.detectChanges();
expect(getEmptyContentElement()).not.toBeNull();
});
it('should not render empty content when there are selected nodes', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({
query: 'test',
agentId: 'agentId1'
});
fixture.detectChanges();
expect(getEmptyContentElement()).toBeNull();
});
describe('when queryAnswer already exists', () => {
it('should not re-run search when returning from viewer', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(searchAiService.getAnswer).toHaveBeenCalledTimes(1);
mockQueryParams.next({ query: 'test', agentId: 'agentId1', location: 'viewer' });
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(searchAiService.getAnswer).toHaveBeenCalledTimes(1);
}));
});
describe('when query params contains location', () => {
let params: Params;
beforeEach(() => {
params = {
query: 'test',
agentId: 'agentId1',
location: 'some-location'
};
});
it('should not render search ai input container', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(unitTestingUtils.getByDirective(SearchAiInputComponent)).toBeNull();
});
it('should not render empty content', () => {
mockQueryParams.next({
location: 'some-location'
});
fixture.detectChanges();
expect(getEmptyContentElement()).toBeNull();
});
it('should not display search query', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(unitTestingUtils.getByDataAutomationId('aca-search-ai-results-query').nativeElement.textContent.trim()).toBe('');
});
it('should not call searchAiService.ask', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(searchAiService.ask).not.toHaveBeenCalled();
});
});
});
describe('skeleton loader', () => {
const getSkeletonElementsLength = (): number => {
return fixture.nativeElement.querySelectorAll('.adf-skeleton').length;
};
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
});
it('should display skeleton when loading is true', () => {
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
component.performAiSearch();
fixture.detectChanges();
expect(component.loading).toBeTrue();
expect(getSkeletonElementsLength()).toBe(3);
});
it('should not display skeleton when loading is false', fakeAsync(() => {
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
component.performAiSearch();
tick(30000);
expect(component.loading).toBeFalse();
expect(getSkeletonElementsLength()).toBe(0);
}));
});
describe('Unsaved Changes Modal', () => {
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue('true');
});
it('should open Unsaved Changes Modal and run callback successfully', () => {
const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
fixture.detectChanges();
unitTestingUtils.getByDataAutomationId('aca-search-ai-results-regeneration-button').nativeElement.click();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
expect(component.displayedAnswer).toEqual('Some answer');
});
});
describe('Markdown', () => {
const getMarkdown = (): MarkdownComponent => unitTestingUtils.getByDirective(MarkdownComponent)?.componentInstance;
const removeTabs = (answer: string): string =>
answer
.split('\n')
.map((line) => line.trim())
.join('\n');
let queryParams: Params;
let getAnswerSpyAnd: jasmine.SpyAnd<(questionId: string) => Observable<AiAnswerEntry>>;
let answerEntry: AiAnswerEntry;
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
queryParams = {
query: 'test',
agentId: 'agentId1'
};
getAnswerSpyAnd = spyOn(searchAiService, 'getAnswer').and;
answerEntry = getAiAnswerEntry();
});
it('should have correct marked options', () => {
expect(fixture.debugElement.injector.get(MARKED_OPTIONS)).toBe(searchAiMarkedOptions);
});
it('should be rendered when answer is loaded successfully', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown()).toBeTruthy();
}));
it('should not be rendered when answer loading is failed', fakeAsync(() => {
getAnswerSpyAnd.returnValue(throwError(() => 'error').pipe(delay(100)));
mockQueryParams.next(queryParams);
tick(30000);
fixture.detectChanges();
expect(getMarkdown()).toBeUndefined();
}));
it('should have assigned mermaid to true', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().mermaid).toBeTrue();
}));
it('should have assigned katex to true', fakeAsync(() => {
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().katex).toBeTrue();
}));
it('should have assigned correct data', fakeAsync(() => {
const answer = '#### Some title\n\nSome description';
answerEntry.entry.answer = answer;
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().data).toEqual(answer);
}));
it('should have assigned correct data when answer contains mermaids', fakeAsync(() => {
answerEntry.entry.answer =
'First example:\\n\\n```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```\\n\\n' +
'Second example:\\n\\n```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```\\n\\n';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(removeTabs(getMarkdown().data)).toEqual(
removeTabs(`First example:\\n\\n\`\`\`mermaid
\\ngraph LR\\n animal --> dog\\n animal --> cat\\n
\`\`\`\\n\\nSecond example:\\n\\n\`\`\`mermaid
\\ngraph LR\\n animal[Animal] --> dog[Dog]\\n animal[Animal] --> cat[Cat]\\n
\`\`\`\\n\\n`)
);
}));
it('should have assigned correct data when answer contains latex', fakeAsync(() => {
answerEntry.entry.answer = '\n\n### Mathematical Formula\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
expect(getMarkdown().data).toEqual('\n\n### Mathematical Formula\n\n$$f(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx$$');
}));
it('should set source code tooltip for mermaid when answer contains mermaid', fakeAsync(() => {
answerEntry.entry.answer =
'First example:\\n\\n```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```\\n\\n' +
'Second example:\\n\\n```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```\\n\\n';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
const elements = [document.createElement('div'), document.createElement('div')];
spyOn(fixture.nativeElement, 'querySelectorAll').withArgs('.mermaid').and.returnValue(elements).and.returnValue([]);
getMarkdown().ready.emit();
expect(elements[0].title).toBe('```mermaid\\ngraph LR\\n animal --> dog\\n animal --> cat\\n```');
expect(elements[1].title).toBe(
'```mermaid\\ngraph LR\\n animal[label="Animal"] --> dog[label="Dog"]\\n animal[label="Animal"] --> cat[label="Cat"]\\n```'
);
}));
it('should set source code tooltip for mermaid when answer contains latex', fakeAsync(() => {
answerEntry.entry.answer =
'\n\n### Mathematical Formula 1\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```' +
'\n\n### Mathematical Formula 2\n\n```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{5 (pi i Ixi x} dx\n```';
getAnswerSpyAnd.returnValues(
throwError(() => 'error'),
of(answerEntry)
);
mockQueryParams.next(queryParams);
tick(3000);
fixture.detectChanges();
const elements = [document.createElement('div'), document.createElement('div')];
spyOn(fixture.nativeElement, 'querySelectorAll').withArgs('.katex').and.returnValue(elements).and.returnValue([]);
getMarkdown().ready.emit();
expect(elements[0].title).toBe('```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{2 (pi i Ixi x} dx\n```');
expect(elements[1].title).toBe('```latex\nf(x) = Vint_{-\\infty}^{\\infty} \\hat{f}(lxi) e^{5 (pi i Ixi x} dx\n```');
}));
});
describe('References', () => {
let documentElement: DebugElement;
const nodeId = 'someId';
const secondNodeId = 'someId1';
const url = 'some-url';
const node1 = { id: nodeId, isFolder: true } as Node;
const node2 = { id: secondNodeId, isFolder: false } as Node;
const nodeError = throwError(() => 'error');
const toSearchResult = (nodes: Node[]): Observable<ResultSetPaging> =>
of({
list: {
entries: nodes.map((node) => ({ entry: node }))
}
} as ResultSetPaging).pipe(delay(50));
const setupReferencesTest = (objectIds: string[] = [], searchResult: Observable<ResultSetPaging>[] = [], hasNodeId = false) => {
spyOn(contentApiService, 'search').and.returnValues(...searchResult);
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
const answer = getAiAnswerEntry();
answer.entry.objectReferences = objectIds.map((id) => ({
objectId: `sourceId__${id}`,
nodeId: hasNodeId ? `plain-${id}` : undefined,
references: []
}));
spyOn(searchAiService, 'getAnswer').and.returnValues(
throwError(() => 'error'),
of(answer)
);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3051);
fixture.detectChanges();
tick(51);
fixture.detectChanges();
documentElement = unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`);
spyOnProperty(TestBed.inject(Router), 'url').and.returnValue(url);
};
it('should dispatch ViewNodeAction on store when clicked', fakeAsync(() => {
spyOn(store, 'dispatch');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
documentElement.nativeElement.click();
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
...new ViewNodeAction(nodeId, {
location: url
})
})
);
}));
it('should dispatch ViewNodeAction on store when pressed enter', fakeAsync(() => {
spyOn(store, 'dispatch');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
documentElement.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'Enter'
})
);
expect(store.dispatch).toHaveBeenCalledWith(
jasmine.objectContaining({
...new ViewNodeAction(nodeId, {
location: url
})
})
);
}));
it('should assign nodes ids to customNodesOrder for ViewerService', fakeAsync(() => {
let nodesOrder: string[];
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder = passedNodesOrder));
setupReferencesTest([nodeId], [toSearchResult([node1])]);
expect(nodesOrder).toEqual([nodeId]);
}));
it('should call set on userPreferencesService with correct parameters', fakeAsync(() => {
spyOn(userPreferencesService, 'set');
setupReferencesTest([nodeId], [toSearchResult([node1])]);
expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId]));
}));
it('should display answer and reference nodes when all are fetched', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1, node2])]);
expect(component.displayedAnswer).toEqual('Some answer');
expect(component.hasReferencesLoadingError).toBeFalse();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeTruthy();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${secondNodeId}-document`)).toBeTruthy();
}));
it('should use nodeId when available when fetching references', fakeAsync(() => {
setupReferencesTest([nodeId], [toSearchResult([node1])], true);
expect(contentApiService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: {
query: `ID:"plain-${nodeId}"`,
language: 'afts'
}
})
);
}));
describe('Reload References', () => {
const getReloadButton = (): DebugElement =>
unitTestingUtils.getByDataAutomationId('aca-search-ai-response-container-body-references-container-retry-references-loading-button');
it('should set hasReferencesLoadingError and display reload button when not all references are fetched', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1])]);
const reloadButton = getReloadButton();
expect(component.hasReferencesLoadingError).toBeTrue();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeFalsy();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${secondNodeId}-document`)).toBeFalsy();
expect(reloadButton).toBeTruthy();
}));
it('should set hasReferencesLoadingError and display reload button when search request fails', fakeAsync(() => {
setupReferencesTest([nodeId], [nodeError]);
const reloadButton = getReloadButton();
expect(component.hasReferencesLoadingError).toBeTrue();
expect(unitTestingUtils.getByDataAutomationId(`aca-search-ai-results-${nodeId}-document`)).toBeFalsy();
expect(reloadButton).toBeTruthy();
}));
it('should call search api when reload references button is clicked', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([node1]), toSearchResult([node1, node2])]);
getReloadButton().nativeElement.click();
expect(contentApiService.search).toHaveBeenCalledWith(
jasmine.objectContaining({
query: {
query: `ID:"${nodeId}" OR ID:"${secondNodeId}"`,
language: 'afts'
}
})
);
}));
it('should not display reload button after references are successfully reloaded', fakeAsync(() => {
setupReferencesTest([nodeId, secondNodeId], [toSearchResult([]), toSearchResult([node1, node2])]);
const reloadButton = getReloadButton();
reloadButton.nativeElement.click();
tick(51);
fixture.detectChanges();
expect(getReloadButton()).toBeNull();
}));
it('should not display reload button when there are no references', fakeAsync(() => {
setupReferencesTest();
expect(component.hasReferencesLoadingError).toBeFalse();
expect(getReloadButton()).toBeNull();
}));
});
});
describe('ngOnInit', () => {
it('should set customNodesOrder on ViewerService', () => {
spyOn(userPreferencesService, 'get').and.returnValue('["node1", "node2"]');
let nodesOrder: string[];
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder = passedNodesOrder));
component.ngOnInit();
expect(nodesOrder).toEqual(['node1', 'node2']);
});
it('should set unsaved on UnsavedChangesGuard to false when there are no selected nodes', () => {
mockQueryParams.next({
query: 'test',
agentId: 'agentId1'
});
component.ngOnInit();
expect(unsavedChangesGuard.unsaved).toBeFalse();
});
it('should set unsaved on UnsavedChangesGuard to true when there are selected nodes', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({
query: 'test',
agentId: 'agentId1'
});
component.ngOnInit();
expect(unsavedChangesGuard.unsaved).toBeTrue();
});
it('should set correct data on unsavedChangesGuard', () => {
component.ngOnInit();
expect(unsavedChangesGuard.data).toEqual({
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.CONVERSATION_DISCARDED',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DISCARD_CONVERSATION',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING',
maxWidth: 'none'
});
});
});
it('should copy answer to clipboard and show notification on copy button click', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
spyOn(clipboardService, 'copyContentToClipboard');
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
fixture.detectChanges();
const copyButton = unitTestingUtils.getByDataAutomationId('aca-search-ai-results-copying-button').nativeElement;
copyButton.click();
expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith(
component.displayedAnswer,
'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_MESSAGE'
);
});
});
@@ -1,332 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, ElementRef, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent, ContentApiService } from '@alfresco/aca-shared';
import { catchError, delay, filter, finalize, map, retry, shareReplay, switchMap, tap } from 'rxjs/operators';
import { ClipboardService, EmptyContentComponent, ThumbnailService, UnsavedChangesGuard } from '@alfresco/adf-core';
import { AiAnswer, Node } from '@alfresco/js-api';
import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { from, Observable, of, throwError } from 'rxjs';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { ViewNodeAction } from '@alfresco/aca-shared/store';
import { ViewerService } from '@alfresco/aca-content/viewer';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MarkdownModule, MARKED_OPTIONS, provideMarkdown } from 'ngx-markdown';
import { searchAiMarkedOptions } from './search-ai-marked-options';
@Component({
imports: [
CommonModule,
PageLayoutComponent,
SearchAiInputContainerComponent,
TranslatePipe,
MatIconModule,
MatButtonModule,
MatListModule,
EmptyContentComponent,
MatCardModule,
MatTooltipModule,
MarkdownModule
],
providers: [
provideMarkdown({
markedOptions: {
provide: MARKED_OPTIONS,
useValue: searchAiMarkedOptions
}
})
],
selector: 'aca-search-ai-results',
templateUrl: './search-ai-results.component.html',
styleUrls: ['./search-ai-results.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-search-ai-results' }
})
export class SearchAiResultsComponent extends PageComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly clipboardService = inject(ClipboardService);
private readonly thumbnailService = inject(ThumbnailService);
private readonly translateService = inject(TranslateService);
private readonly unsavedChangesGuard = inject(UnsavedChangesGuard);
private readonly modalAiService = inject(ModalAiService);
private readonly viewerService = inject(ViewerService);
private readonly elementRef = inject(ElementRef);
private readonly contentApi = inject(ContentApiService);
private static readonly MERMAID_BLOCK_REGEX = /```mermaid([\s\S]*?)```/g;
private static readonly LATEX_BLOCK_REGEX = /```latex([\s\S]*?)```/g;
references$: Observable<Node[]> = of([]);
private _agentId: string;
private _hasAnsweringError = false;
private _hasError = false;
private _loading = false;
private _mimeTypeIconsByNodeId: { [key: string]: string } = {};
private openedViewer = false;
private _selectedNodesState: SelectionState;
private _searchQuery = '';
private queryAnswer: AiAnswer;
private _displayedAnswer: string;
private _hasReferencesLoadingError = false;
private _referencesLoading = false;
get agentId(): string {
return this._agentId;
}
get hasAnsweringError(): boolean {
return this._hasAnsweringError;
}
get hasError(): boolean {
return this._hasError;
}
get loading(): boolean {
return this._loading;
}
get mimeTypeIconsByNodeId(): { [key: string]: string } {
return this._mimeTypeIconsByNodeId;
}
get searchQuery(): string {
return this._searchQuery;
}
get displayedAnswer(): string {
return this._displayedAnswer;
}
get hasReferencesLoadingError(): boolean {
return this._hasReferencesLoadingError;
}
ngOnInit(): void {
this.viewerService.customNodesOrder = JSON.parse(this.userPreferencesService.get('aiReferences', '[]'));
this.route.queryParams
.pipe(
filter((params) => {
const openedViewerPreviously = this.openedViewer;
this.openedViewer = !!params.location;
return !this.openedViewer && (!openedViewerPreviously || !this.queryAnswer);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((params) => {
this._agentId = params.agentId;
this._searchQuery = params.query ? decodeURIComponent(params.query) : '';
const selectedNodesState = this.userPreferencesService.get('knowledgeRetrievalNodes');
if (!this.searchQuery || !this.agentId || !selectedNodesState) {
this._hasError = true;
return;
}
this._selectedNodesState = JSON.parse(selectedNodesState);
this.performAiSearch();
});
super.ngOnInit();
this.unsavedChangesGuard.unsaved = this.route.snapshot?.queryParams?.query?.length > 0 && !this.hasError;
this.unsavedChangesGuard.data = {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.CONVERSATION_DISCARDED',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DISCARD_CONVERSATION',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING',
maxWidth: 'none'
};
}
copyResponseToClipboard(): void {
this.clipboardService.copyContentToClipboard(
this.queryAnswer.answer,
this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_MESSAGE')
);
}
checkUnsavedChangesAndSearch(): void {
this.modalAiService.openUnsavedChangesModal(() => this.performAiSearch());
}
performAiSearch(): void {
this._loading = true;
this._hasAnsweringError = false;
this.searchAiService
.ask({
question: this.searchQuery,
nodeIds: this._selectedNodesState?.nodes?.length ? this._selectedNodesState.nodes.map((node) => node.entry.id) : [],
agentId: this._agentId
})
.pipe(
switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
tap((response) => {
if (!response.entry?.answer) {
throw new Error();
}
this.queryAnswer = response.entry;
this._displayedAnswer = this.preprocessMarkdownFormat(response.entry.answer);
this.loadReferences();
}),
retry({
delay: (error: Error, retryCount) => this.aiSearchRetryDelay(error, retryCount)
}),
finalize(() => {
this._loading = false;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => (this._hasAnsweringError = true)
});
}
openFile(id: string): void {
this.store.dispatch(
new ViewNodeAction(id, {
location: this.router.url
})
);
}
addSourceCodeTooltips(): void {
this.setTooltip(SearchAiResultsComponent.MERMAID_BLOCK_REGEX, '.mermaid');
this.setTooltip(SearchAiResultsComponent.LATEX_BLOCK_REGEX, '.katex');
}
loadReferences(): void {
if (this._referencesLoading) {
return;
}
this._referencesLoading = true;
this.references$ = this.fetchReferences(this.queryAnswer).pipe(
tap((nodes) => this.updateNodes(nodes)),
finalize(() => {
this._referencesLoading = false;
}),
shareReplay({ bufferSize: 1, refCount: true })
);
}
private setTooltip(codeBlockRegexp: RegExp, targetElementsSelector: string): void {
const codeBlocks = [...this.queryAnswer.answer.matchAll(codeBlockRegexp)].map((match) => match[0].trim());
const elements: HTMLElement[] = this.elementRef.nativeElement.querySelectorAll(targetElementsSelector);
for (let i = 0; i < elements.length; i++) {
elements[i].title = codeBlocks[i];
}
}
private aiSearchRetryDelay(error: Error, retryCount: number): Observable<void> {
this._hasAnsweringError = false;
const delayBetweenRetries = 3000;
const maxRetries = 9;
if (retryCount > maxRetries) {
this._hasAnsweringError = true;
return throwError(() => error);
}
return of(undefined).pipe(delay(delayBetweenRetries));
}
private preprocessMarkdownFormat(answer: string): string {
return this.transformLatex(this.transformMermaid(answer));
}
private transformMermaid(answer: string): string {
return answer.replace(SearchAiResultsComponent.MERMAID_BLOCK_REGEX, (_mermaidBlockRegex, blockContent: string) => {
const transformedLines = blockContent.split('\n').map((line) => {
const label = 'label="';
while (line.includes(label)) {
const labelIndex = line.indexOf(label);
const start = labelIndex + label.length;
const end = line.indexOf('"', start);
line = line.slice(0, labelIndex) + line.slice(start, end) + line.slice(end + 1);
}
return line;
});
return `\`\`\`mermaid\n${transformedLines.join('\n')}\n\`\`\``;
});
}
private transformLatex(answer: string): string {
return answer.replace(SearchAiResultsComponent.LATEX_BLOCK_REGEX, (_, latexContent: string) => `$$${latexContent.trim()}$$`);
}
private fetchReferences(answer?: AiAnswer): Observable<Node[]> {
this._hasReferencesLoadingError = false;
const objectIds = answer?.objectReferences?.map((reference) => reference.nodeId ?? reference.objectId.split('__')[1]);
if (!objectIds?.length) {
return of([]);
}
const query = objectIds.map((id) => `ID:"${id}"`).join(' OR ');
return from(
this.contentApi.search({
query: {
query,
language: 'afts'
}
})
).pipe(
map((result) => {
const nodes = result.list.entries.map((entry) => entry.entry as Node);
if (nodes.length !== objectIds.length) {
this._hasReferencesLoadingError = true;
return [];
}
return nodes;
}),
catchError(() => {
this._hasReferencesLoadingError = true;
return of([]);
})
);
}
private updateNodes(nodes: Node[]): void {
const nodesIds: string[] = [];
nodes.forEach((node) => {
nodesIds.push(node.id);
this._mimeTypeIconsByNodeId[node.id] = this.thumbnailService.getMimeTypeIcon(node.content?.mimeType);
});
this.viewerService.customNodesOrder = nodesIds;
this.userPreferencesService.set('aiReferences', JSON.stringify(nodesIds));
}
}
@@ -1,16 +1,11 @@
<aca-page-layout> <aca-page-layout>
<div class="aca-page-layout-header"> <div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId" />
<ng-template #header>
<div class="aca-header-container"> <div class="aca-header-container">
<h1 class="aca-page-title"> <h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.RECENT.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }} {{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.RECENT.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
</h1> </h1>
<aca-toolbar [items]="actions" /> <aca-toolbar [items]="actions" />
</div> </div>
</ng-template>
</div> </div>
<div class="aca-page-layout-content"> <div class="aca-page-layout-content">
@@ -44,7 +44,6 @@ import {
} from '@alfresco/adf-core'; } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive'; import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import { DocumentListComponent } from '@alfresco/adf-content-services'; import { DocumentListComponent } from '@alfresco/adf-content-services';
@Component({ @Component({
@@ -58,7 +57,6 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
PageLayoutComponent, PageLayoutComponent,
TranslatePipe, TranslatePipe,
ToolbarComponent, ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent, EmptyContentComponent,
DynamicColumnComponent, DynamicColumnComponent,
DocumentListComponent, DocumentListComponent,
@@ -1,6 +1,5 @@
<aca-page-layout [class.aca-search-results-active-search-ai-input]="searchAiInputState.active"> <aca-page-layout>
<div class="aca-page-layout-header"> <div class="aca-page-layout-header">
<aca-search-ai-input-container *ngIf="searchAiInputState.active" [agentId]="searchAiInputState.selectedAgentId" />
<div class="aca-header-container"> <div class="aca-header-container">
<aca-search-input /> <aca-search-input />
<aca-bulk-actions-dropdown *ngIf="bulkActions && ('isBulkActionsAvailable' | isFeatureSupportedInCurrentAcs | async)" [items]="bulkActions" /> <aca-bulk-actions-dropdown *ngIf="bulkActions && ('isBulkActionsAvailable' | isFeatureSupportedInCurrentAcs | async)" [items]="bulkActions" />
@@ -38,13 +38,6 @@ aca-search-results {
) )
); );
.aca-search-results-active-search-ai-input {
.aca-header-container,
.adf-search-results__content-header.aca-content {
display: none;
}
}
.aca-search-results__toolbar-actions { .aca-search-results__toolbar-actions {
margin-left: 8px; margin-left: 8px;
} }
@@ -35,7 +35,7 @@ import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppService } from '@alfresco/aca-shared'; import { AppService } from '@alfresco/aca-shared';
import { MatSnackBarModule, MatSnackBarRef } from '@angular/material/snack-bar'; import { MatSnackBarModule, MatSnackBarRef } from '@angular/material/snack-bar';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { testHeader } from '../../../testing/document-base-page-utils';
import { HarnessLoader } from '@angular/cdk/testing'; import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -523,6 +523,4 @@ describe('SearchComponent', () => {
flush(); flush();
})); }));
}); });
testHeader(SearchResultsComponent, false);
}); });
@@ -75,7 +75,6 @@ import { SearchActionMenuComponent } from '../search-action-menu/search-action-m
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions'; import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component'; import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import { extractFiltersFromEncodedQuery, extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils'; import { extractFiltersFromEncodedQuery, extractUserQueryFromEncodedQuery } from '../../../utils/aca-search-utils';
import { SaveSearchDirective } from '../search-save/directive/save-search.directive'; import { SaveSearchDirective } from '../search-save/directive/save-search.directive';
import { combineLatest, Observable, of } from 'rxjs'; import { combineLatest, Observable, of } from 'rxjs';
@@ -115,7 +114,6 @@ import { SavedSearchesContextService } from '../../../services/saved-searches-co
CustomEmptyContentTemplateDirective, CustomEmptyContentTemplateDirective,
ViewerToolbarComponent, ViewerToolbarComponent,
BulkActionsDropdownComponent, BulkActionsDropdownComponent,
SearchAiInputContainerComponent,
SaveSearchDirective, SaveSearchDirective,
IsFeatureSupportedInCurrentAcsPipe, IsFeatureSupportedInCurrentAcsPipe,
AsyncPipe AsyncPipe
@@ -1,9 +1,5 @@
<aca-page-layout> <aca-page-layout>
<div class="aca-page-layout-header"> <div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId" />
<ng-template #header>
<div class="aca-header-container"> <div class="aca-header-container">
<h1 class="aca-page-title"> <h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.SHARED.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }} {{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.SHARED.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
@@ -11,7 +7,6 @@
<aca-toolbar [items]="actions" /> <aca-toolbar [items]="actions" />
</div> </div>
</ng-template>
</div> </div>
<div class="aca-page-layout-content"> <div class="aca-page-layout-content">
@@ -45,7 +45,6 @@ import {
} from '@alfresco/adf-core'; } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive'; import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import { DocumentListComponent } from '@alfresco/adf-content-services'; import { DocumentListComponent } from '@alfresco/adf-content-services';
@Component({ @Component({
@@ -59,7 +58,6 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
PageLayoutComponent, PageLayoutComponent,
TranslatePipe, TranslatePipe,
ToolbarComponent, ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent, EmptyContentComponent,
DynamicColumnComponent, DynamicColumnComponent,
DocumentListComponent, DocumentListComponent,
@@ -65,4 +65,20 @@ describe('ActionDirective', () => {
directive.onClick(); directive.onClick();
expect(store.dispatch).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalled();
}); });
it('should open external URL in new tab and not call router.navigate', () => {
spyOn(window, 'open');
spyOn(router, 'navigate');
directive.action = { url: 'https://discovery.example.com/agents' };
directive.onClick();
expect(window.open).toHaveBeenCalledWith('https://discovery.example.com/agents', '_blank', 'noopener,noreferrer');
expect(router.navigate).not.toHaveBeenCalled();
});
it('should open http URL in new tab', () => {
spyOn(window, 'open');
directive.action = { url: 'http://internal.example.com/path' };
directive.onClick();
expect(window.open).toHaveBeenCalledWith('http://internal.example.com/path', '_blank', 'noopener,noreferrer');
});
}); });
@@ -45,7 +45,11 @@ export class ActionDirective {
@HostListener('click') @HostListener('click')
onClick() { onClick() {
if (this.action.url) { if (this.action.url) {
if (this.isExternalUrl(this.action.url)) {
window.open(this.action.url, '_blank', 'noopener,noreferrer');
} else {
this.router.navigate(this.getNavigationCommands(this.action.url), { queryParams: this.getNavigationQueryParams(this.action.url) }); this.router.navigate(this.getNavigationCommands(this.action.url), { queryParams: this.getNavigationQueryParams(this.action.url) });
}
} else if (this.action.click) { } else if (this.action.click) {
this.store.dispatch({ this.store.dispatch({
type: this.action.click.action, type: this.action.click.action,
@@ -73,4 +77,8 @@ export class ActionDirective {
private getNavigationQueryParams(url: string): Params { private getNavigationQueryParams(url: string): Params {
return this.router.parseUrl(url).queryParams; return this.router.parseUrl(url).queryParams;
} }
private isExternalUrl(url: string): boolean {
return /^https?:\/\//i.test(url);
}
} }
@@ -1,113 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ModalAiService } from './modal-ai.service';
import { TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogConfig, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Params } from '@angular/router';
import { of, Subject } from 'rxjs';
import { NoopTranslateModule, StorageService, UnsavedChangesDialogComponent } from '@alfresco/adf-core';
describe('ModalAiService', () => {
const mockQueryParams = new Subject<Params>();
let service: ModalAiService;
let dialogOpenSpy: jasmine.Spy<<T, R>(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef<T, R>>;
let dialog: MatDialog;
const setupBeforeEach = (query: string, storageGetItem: string) => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule, MatDialogModule],
providers: [
{
provide: StorageService,
useValue: {
getItem: () => storageGetItem,
setItem: () => storageGetItem
}
},
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
snapshot: {
queryParams: { query }
}
}
}
]
});
dialog = TestBed.inject(MatDialog);
dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
afterClosed: () => of(true)
} as MatDialogRef<any>);
service = TestBed.inject(ModalAiService);
};
describe('when there is no previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in the storage', () => {
it('should not open unsaved changes modal when there is not previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
setupBeforeEach('', '');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).not.toHaveBeenCalled();
});
});
describe('when there is no previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
it('should open unsaved changes modal when there is previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
setupBeforeEach('test', '');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).toHaveBeenCalledWith(UnsavedChangesDialogComponent, {
width: '345px',
data: {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
}
});
});
it('should call callback after modal has been closed and change test value to true', () => {
let test = false;
const mockFunc = () => {
test = true;
};
setupBeforeEach('test', '');
service.openUnsavedChangesModal(mockFunc);
expect(test).toBeTrue();
});
});
describe('when there is previous search in query and UNSAVED_CHANGES_MODAL_HIDDEN is the storage', () => {
it('should not open unsaved changes modal when has previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
setupBeforeEach('test', 'true');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).not.toHaveBeenCalled();
});
});
});
@@ -1,62 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { inject, Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AppConfigValues, UnsavedChangesDialogComponent, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialog } from '@angular/material/dialog';
@Injectable({ providedIn: 'root' })
export class ModalAiService {
private readonly route = inject(ActivatedRoute);
private readonly dialog = inject(MatDialog);
private readonly userPreferencesService = inject(UserPreferencesService);
openUnsavedChangesModal(callback: () => void): void {
const hasPreviousSearch = this.route.snapshot?.queryParams?.query?.length > 0;
const modalHidden = this.userPreferencesService.get(AppConfigValues.UNSAVED_CHANGES_MODAL_HIDDEN) === 'true';
if (!hasPreviousSearch || modalHidden) {
callback();
return;
}
this.dialog
.open<UnsavedChangesDialogComponent>(UnsavedChangesDialogComponent, {
width: '345px',
data: {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
}
})
.afterClosed()
.subscribe((openModal: boolean) => {
if (openModal) {
callback();
}
});
}
}
@@ -1,139 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchAiNavigationService } from './search-ai-navigation.service';
import { Params, Router } from '@angular/router';
import { TestBed } from '@angular/core/testing';
import { SearchAiService } from '@alfresco/adf-content-services';
import { NoopTranslateModule } from '@alfresco/adf-core';
describe('SearchAiNavigationService', () => {
let service: SearchAiNavigationService;
let router: Router;
let searchAiService: SearchAiService;
const knowledgeRetrievalUrl = '/knowledge-retrieval';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopTranslateModule]
});
service = TestBed.inject(SearchAiNavigationService);
router = TestBed.inject(Router);
searchAiService = TestBed.inject(SearchAiService);
});
describe('navigateToPreviousRoute', () => {
let urlSpy: jasmine.Spy<() => string>;
let navigateByUrlSpy: jasmine.Spy<(url: string) => Promise<boolean>>;
const sourceUrl = '/some-url';
const personalFilesUrl = '/personal-files';
beforeEach(() => {
navigateByUrlSpy = spyOn(router, 'navigateByUrl');
urlSpy = spyOnProperty(router, 'url');
});
it('should navigate to personal files if there is not previous route and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
});
it('should not navigate if there is not previous route and actual route is not knowledge retrieval but should updateSearchAiInputState', () => {
spyOn(searchAiService, 'updateSearchAiInputState');
urlSpy.and.returnValue('/some-url');
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({ active: false });
});
it('should navigate to previous route if there is some previous route and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(sourceUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue(knowledgeRetrievalUrl);
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).toHaveBeenCalledWith(sourceUrl);
});
it('should not navigate to previous route if there is some previous route but actual route is not knowledge retrieval', () => {
urlSpy.and.returnValue(sourceUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue('/some-different-url');
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
});
it('should navigate to personal files if previous route is knowledge retrieval and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
});
it('should not navigate if previous route is knowledge retrieval and actual route is different than knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue(sourceUrl);
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRouteOrCloseInput();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
});
});
describe('navigateToSearchAi', () => {
beforeEach(() => {
spyOn(router, 'navigate');
});
it('should navigate to search ai results page', () => {
const queryParams: Params = {
agentId: 'some agent id'
};
service.navigateToSearchAi(queryParams);
expect(router.navigate).toHaveBeenCalledWith([knowledgeRetrievalUrl], {
queryParams
});
});
});
});
@@ -40,7 +40,6 @@ import {
UploadEffects, UploadEffects,
ViewerEffects ViewerEffects
} from './effects'; } from './effects';
import { SearchAiEffects } from './effects/search-ai.effects';
@NgModule({ @NgModule({
providers: [ providers: [
@@ -65,7 +64,6 @@ import { SearchAiEffects } from './effects/search-ai.effects';
FavoriteEffects, FavoriteEffects,
TemplateEffects, TemplateEffects,
ContextMenuEffects, ContextMenuEffects,
SearchAiEffects,
RouterEffects RouterEffects
]) ])
] ]
@@ -33,4 +33,3 @@ export * from './effects/upload.effects';
export * from './effects/upload.effects'; export * from './effects/upload.effects';
export * from './effects/template.effects'; export * from './effects/template.effects';
export * from './effects/contextmenu.effects'; export * from './effects/contextmenu.effects';
export * from './effects/search-ai.effects';
@@ -1,78 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { TestBed } from '@angular/core/testing';
import { provideEffects } from '@ngrx/effects';
import { SearchAiEffects } from './search-ai.effects';
import { Store } from '@ngrx/store';
import { AppTestingModule } from '../../testing/app-testing.module';
import { SearchAiNavigationService } from '../../services/search-ai-navigation.service';
import { AppStore, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { SearchAiService } from '@alfresco/adf-content-services';
describe('SearchAiEffects', () => {
let store: Store<AppStore>;
const agentId = '1';
const searchTerm = 'test';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
providers: [provideEffects([SearchAiEffects])]
});
store = TestBed.inject(Store);
});
describe('searchByTerm$', () => {
it('should call navigateToSearchAi on SearchAiNavigationService', () => {
const searchAiNavigationService = TestBed.inject(SearchAiNavigationService);
spyOn(searchAiNavigationService, 'navigateToSearchAi');
store.dispatch(
new SearchByTermAiAction({
searchTerm,
agentId
})
);
expect(searchAiNavigationService.navigateToSearchAi).toHaveBeenCalledWith({
query: searchTerm,
agentId
});
});
});
describe('toggleAISearchInput$', () => {
it('should call updateSearchAiInputState on SearchAiService', () => {
const searchAiService = TestBed.inject(SearchAiService);
spyOn(searchAiService, 'updateSearchAiInputState');
store.dispatch(new ToggleAISearchInput(agentId, searchTerm));
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
active: true,
selectedAgentId: agentId,
searchTerm
});
});
});
});
@@ -1,67 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { SearchAiActionTypes, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { map } from 'rxjs/operators';
import { SearchAiNavigationService } from '../../services/search-ai-navigation.service';
import { SearchAiService } from '@alfresco/adf-content-services';
import { Params } from '@angular/router';
@Injectable()
export class SearchAiEffects {
private readonly actions$ = inject(Actions);
private readonly searchNavigationService = inject(SearchAiNavigationService);
private readonly searchAiService = inject(SearchAiService);
searchByTerm$ = createEffect(
() =>
this.actions$.pipe(
ofType<SearchByTermAiAction>(SearchAiActionTypes.SearchByTermAi),
map((action) => {
const queryParams: Params = {
query: encodeURIComponent(action.payload.searchTerm),
agentId: action.payload.agentId
};
this.searchNavigationService.navigateToSearchAi(queryParams);
})
),
{ dispatch: false }
);
toggleAISearchInput$ = createEffect(
() =>
this.actions$.pipe(
ofType<ToggleAISearchInput>(SearchAiActionTypes.ToggleAiSearchInput),
map((action) =>
this.searchAiService.updateSearchAiInputState({
active: true,
selectedAgentId: action.agentId,
searchTerm: action.searchTerm
})
)
),
{ dispatch: false }
);
}
@@ -22,90 +22,25 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { BehaviorSubject, Subject } from 'rxjs'; import { FileUploadCompleteEvent, FileUploadDeleteEvent, UploadService } from '@alfresco/adf-content-services';
import { import { Type } from '@angular/core';
AgentService,
FileUploadCompleteEvent,
FileUploadDeleteEvent,
SearchAiInputState,
SearchAiService,
UploadService
} from '@alfresco/adf-content-services';
import { DebugElement, Type } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { SearchAiInputContainerComponent } from '../components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { PageComponent } from '@alfresco/aca-shared'; import { PageComponent } from '@alfresco/aca-shared';
import { Agent } from '@alfresco/js-api/typings';
export const testHeader = <T extends PageComponent>(component: Type<T>, checkHeaderVisibility = true) => { export const testHeader = <T extends PageComponent>(component: Type<T>) => {
describe('Header', () => { describe('Header', () => {
let fixture: ComponentFixture<T>; let fixture: ComponentFixture<T>;
let toggleSearchAiInput$: BehaviorSubject<SearchAiInputState>; const getHeaderElement = () => fixture.debugElement.query(By.css('.aca-header-container'));
const getSearchAiInputElement = (): DebugElement => fixture.debugElement.query(By.directive(SearchAiInputContainerComponent));
const getHeaderElement = (): DebugElement => fixture.debugElement.query(By.css('.aca-header-container'));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(component); fixture = TestBed.createComponent(component);
toggleSearchAiInput$ = new BehaviorSubject<SearchAiInputState>({
searchTerm: ''
} as SearchAiInputState);
TestBed.inject(SearchAiService).toggleSearchAiInput$ = toggleSearchAiInput$;
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(new Subject<Agent[]>());
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should display ai input if input is active', () => {
toggleSearchAiInput$.next({
active: true,
searchTerm: ''
});
fixture.detectChanges();
expect(getSearchAiInputElement()).not.toBeNull();
});
it('should not display ai input if input is not active', () => {
toggleSearchAiInput$.next({
active: false,
searchTerm: ''
});
fixture.detectChanges();
expect(getSearchAiInputElement()).toBeNull();
});
it('should not display ai input by default', () => {
expect(getSearchAiInputElement()).toBeNull();
});
if (checkHeaderVisibility) {
it('should not display header if input is active', () => {
toggleSearchAiInput$.next({
active: true,
searchTerm: ''
});
fixture.detectChanges();
expect(getHeaderElement()).toBeNull();
});
it('should display header if input is not active', () => {
toggleSearchAiInput$.next({
active: false,
searchTerm: ''
});
fixture.detectChanges();
expect(getHeaderElement()).not.toBeNull();
});
it('should display header by default', () => { it('should display header by default', () => {
expect(getHeaderElement()).not.toBeNull(); expect(getHeaderElement()).not.toBeNull();
}); });
}
}); });
}; };
@@ -18,12 +18,10 @@ $mat-form-field-wrapper: '.mat-mdc-text-field-wrapper';
$mat-line-ripple: '.mdc-line-ripple'; $mat-line-ripple: '.mdc-line-ripple';
$mat-dialog-actions: '.mat-mdc-dialog-actions'; $mat-dialog-actions: '.mat-mdc-dialog-actions';
$mat-dialog-content: '.mat-mdc-dialog-content'; $mat-dialog-content: '.mat-mdc-dialog-content';
$mat-button-touch-target: '.mat-mdc-button-touch-target';
$mat-card: '.mat-mdc-card'; $mat-card: '.mat-mdc-card';
$mat-list-item-primary-text: '.mdc-list-item__primary-text'; $mat-list-item-primary-text: '.mdc-list-item__primary-text';
$mat-select: '.mat-mdc-select'; $mat-select: '.mat-mdc-select';
$mat-select-value: '.mat-select-value'; $mat-select-value: '.mat-select-value';
$mat-select-panel: '.mat-mdc-select-panel';
$mat-expansion-panel: '.mat-expansion-panel'; $mat-expansion-panel: '.mat-expansion-panel';
$mat-expansion-panel-header: '.mat-expansion-panel-header'; $mat-expansion-panel-header: '.mat-expansion-panel-header';
$mat-calendar: '.mat-calendar'; $mat-calendar: '.mat-calendar';
@@ -53,7 +51,6 @@ $mat-notched-outline-notch: '.mdc-notched-outline__notch';
$mat-mdc-button: '.mdc-button'; $mat-mdc-button: '.mdc-button';
$mat-outlined-button: '.mat-mdc-outlined-button'; $mat-outlined-button: '.mat-mdc-outlined-button';
$mat-button-base: '.mat-mdc-button-base'; $mat-button-base: '.mat-mdc-button-base';
$mat-button-touch-target: '.mat-mdc-button-touch-target';
$mat-autocomplete-panel: '.mat-mdc-autocomplete-panel'; $mat-autocomplete-panel: '.mat-mdc-autocomplete-panel';
$mat-menu-surface: '.mdc-menu-surface'; $mat-menu-surface: '.mdc-menu-surface';
$mat-text-field-filled: '.mdc-text-field--filled'; $mat-text-field-filled: '.mdc-text-field--filled';
@@ -85,7 +85,6 @@
--adf-metadata-property-panel-text-color: rgba(33, 35, 40, 0.7), --adf-metadata-property-panel-text-color: rgba(33, 35, 40, 0.7),
--adf-metadata-property-panel-label-color: rgba(33, 33, 33, 0.24), --adf-metadata-property-panel-label-color: rgba(33, 33, 33, 0.24),
--adf-error-color: #ba1b1b, --adf-error-color: #ba1b1b,
--adf-secondary-button-background: #2121210d,
--adf-secondary-modal-text-color: #212121, --adf-secondary-modal-text-color: #212121,
--adf-disabled-button-background: rgba(0, 0, 0, 0.12), --adf-disabled-button-background: rgba(0, 0, 0, 0.12),
--adf-chip-border-color: #757575, --adf-chip-border-color: #757575,
@@ -26,7 +26,6 @@ $blue-save-button-background: #1f74db;
$blue-checkbox-background: rgb(10, 96, 206); $blue-checkbox-background: rgb(10, 96, 206);
$blue-active-table-row: rgb(10, 96, 206, 0.24); $blue-active-table-row: rgb(10, 96, 206, 0.24);
$black-heading: #4e4c4c; $black-heading: #4e4c4c;
$light-grey-content: #4b5563;
$theme-dropdown-background: color.adjust($background-color, $lightness: -5%); $theme-dropdown-background: color.adjust($background-color, $lightness: -5%);
$theme-dropdown-background-hover: color.adjust($background-color, $lightness: -10%); $theme-dropdown-background-hover: color.adjust($background-color, $lightness: -10%);
$grey-divider: rgba(0, 0, 0, 0.22); $grey-divider: rgba(0, 0, 0, 0.22);
@@ -46,10 +45,6 @@ $search-highlight-background-color: #ffd180;
$info-snackbar-background: #1f74db; $info-snackbar-background: #1f74db;
$warning-snackbar-background: #8c7012; $warning-snackbar-background: #8c7012;
$text-light-color: rgba(33, 35, 40, 0.7); $text-light-color: rgba(33, 35, 40, 0.7);
$card-background-grey-color: rgb(248, 248, 248);
$light-grey-1: #d5d5d5;
$light-grey-2: #d9d9d9;
$light-grey-3: #dedede;
$user-profile-background: #595959; $user-profile-background: #595959;
$app-blue-folder: #2a7de1; $app-blue-folder: #2a7de1;
$box-shadow-first-color: rgba(0, 0, 0, 0.2); $box-shadow-first-color: rgba(0, 0, 0, 0.2);
@@ -84,7 +79,6 @@ $defaults: (
--theme-warning-snackbar-background: $warning-snackbar-background, --theme-warning-snackbar-background: $warning-snackbar-background,
--theme-blue-checkbox-color: $blue-checkbox-background, --theme-blue-checkbox-color: $blue-checkbox-background,
--theme-blue-active-table-row-color: $blue-active-table-row, --theme-blue-active-table-row-color: $blue-active-table-row,
--theme-content-color: $light-grey-content,
--theme-dropdown-color: $theme-dropdown-background, --theme-dropdown-color: $theme-dropdown-background,
--theme-dropdown-background-hover: $theme-dropdown-background-hover, --theme-dropdown-background-hover: $theme-dropdown-background-hover,
--theme-grey-divider-color: $grey-divider, --theme-grey-divider-color: $grey-divider,
@@ -108,10 +102,6 @@ $defaults: (
--theme-secondary-text: $secondary-text, --theme-secondary-text: $secondary-text,
--theme-search-highlight-background-color: $search-highlight-background-color, --theme-search-highlight-background-color: $search-highlight-background-color,
--theme-text-light-color: $text-light-color, --theme-text-light-color: $text-light-color,
--theme-card-background-grey-color: $card-background-grey-color,
--theme-light-grey-1-color: $light-grey-1,
--theme-light-grey-2-color: $light-grey-2,
--theme-light-grey-3-color: $light-grey-3,
--theme-user-profile-background-color: $user-profile-background, --theme-user-profile-background-color: $user-profile-background,
--theme-app-blue-folder: $app-blue-folder, --theme-app-blue-folder: $app-blue-folder,
--theme-login-button-bg-color: $login-button-bg-color, --theme-login-button-bg-color: $login-button-bg-color,
@@ -215,8 +215,7 @@ describe('ViewerService', () => {
expect(ids).toEqual([]); expect(ids).toEqual([]);
}); });
it('should return custom nodes order if did not find nodes', async () => { it('should return empty array when there are no nodes', async () => {
viewerService.customNodesOrder = ['someNode1', 'someNode2']; expect(await viewerService.getFileIds('', null)).toEqual([]);
expect(await viewerService.getFileIds('', null)).toEqual(['someNode1', 'someNode2']);
}); });
}); });
@@ -39,12 +39,6 @@ export class ViewerService {
private readonly preferences = inject(UserPreferencesService); private readonly preferences = inject(UserPreferencesService);
private readonly contentApi = inject(ContentApiService); private readonly contentApi = inject(ContentApiService);
private _customNodesOrder: string[] = [];
set customNodesOrder(customNodesOrder: string[]) {
this._customNodesOrder = customNodesOrder;
}
recentFileFilters = [ recentFileFilters = [
'TYPE:"content"', 'TYPE:"content"',
'-PATH:"//cm:wiki/*"', '-PATH:"//cm:wiki/*"',
@@ -170,7 +164,7 @@ export class ViewerService {
}; };
nodes = await this.contentApi.search(query).toPromise(); nodes = await this.contentApi.search(query).toPromise();
} }
return this.getCustomNodesOrderIfNoNodes(nodes, isClient, previousSortKey, previousSortDir, sortKey, sortDirection); return this.getNodeIds(nodes, isClient, previousSortKey, previousSortDir, sortKey, sortDirection);
} }
/** /**
@@ -223,7 +217,7 @@ export class ViewerService {
} }
} }
private getCustomNodesOrderIfNoNodes( private getNodeIds(
nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging, nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging,
isClient: boolean, isClient: boolean,
previousSortKey: string, previousSortKey: string,
@@ -240,8 +234,7 @@ export class ViewerService {
this.sort(entries, sortKey, sortDirection); this.sort(entries, sortKey, sortDirection);
} }
return entries.map((entry) => entry.id ?? entry.nodeId); return entries.map((entry) => entry.id ?? entry.nodeId);
} else {
return this._customNodesOrder;
} }
return [];
} }
} }
@@ -27,7 +27,6 @@ import { createVersionRule, getFileExtension, isPreferencesApiAvailable, isNodeI
import { TestRuleContext } from './test-rule-context'; import { TestRuleContext } from './test-rule-context';
import { NodeEntry, RepositoryInfo, StatusInfo } from '@alfresco/js-api'; import { NodeEntry, RepositoryInfo, StatusInfo } from '@alfresco/js-api';
import { ProfileState, RuleContext } from '@alfresco/adf-extensions'; import { ProfileState, RuleContext } from '@alfresco/adf-extensions';
import { AppConfigService } from '@alfresco/adf-core';
describe('app.evaluators', () => { describe('app.evaluators', () => {
let context: TestRuleContext; let context: TestRuleContext;
@@ -544,124 +543,6 @@ describe('app.evaluators', () => {
}); });
}); });
describe('canDisplayKnowledgeRetrievalButton', () => {
const testCanDisplayKnowledgeRetrievalButton = (testTitle: string, url: string, knowledgeRetrievalEnabled: boolean, expected: boolean) => {
it(testTitle, () => {
context.appConfig = jasmine.createSpyObj<AppConfigService>({
get: knowledgeRetrievalEnabled
});
context.navigation.url = url;
expect(app.canDisplayKnowledgeRetrievalButton(context)).toBe(expected);
});
};
[
{
pageName: 'personal files',
pageUrl: '/personal-files'
},
{
pageName: 'shared files',
pageUrl: '/shared'
},
{
pageName: 'recent files',
pageUrl: '/recent-files'
},
{
pageName: 'favorites',
pageUrl: '/favorites'
},
{
pageName: 'library content',
pageUrl: '/libraries/some-id'
},
{
pageName: 'repository view',
pageUrl: '/repository'
}
].forEach((testCase) => {
testCanDisplayKnowledgeRetrievalButton(
`should return false if get from appConfig returns false and navigation is ${testCase.pageName}`,
testCase.pageUrl,
false,
false
);
testCanDisplayKnowledgeRetrievalButton(
`should return true if get from appConfig returns true and navigation is ${testCase.pageName}`,
testCase.pageUrl,
true,
true
);
});
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns false and navigation is search results but not for libraries',
'/search',
false,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return true if get from appConfig returns true and navigation is search results but not for libraries',
'/search',
true,
true
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns false and navigation is search results for libraries',
'/search/libraries',
false,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns true and navigation is search results for libraries',
'/search/libraries',
true,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns false and navigation is libraries',
'/libraries',
false,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns true and navigation is libraries',
'/libraries',
true,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns false and navigation is incorrect',
'/my-special-files',
false,
false
);
testCanDisplayKnowledgeRetrievalButton(
'should return false if get from appConfig returns true but navigation is incorrect',
'/my-special-files',
true,
false
);
it('should call get on context.appConfig with correct parameters', () => {
context.appConfig = jasmine.createSpyObj<AppConfigService>({
get: false
});
app.canDisplayKnowledgeRetrievalButton(context);
expect(context.appConfig.get).toHaveBeenCalledWith('plugins.knowledgeRetrievalEnabled', false);
});
});
describe('isContentServiceEnabled', () => { describe('isContentServiceEnabled', () => {
it('should call context.appConfig.get with correct parameters', () => { it('should call context.appConfig.get with correct parameters', () => {
context.appConfig = { get: jasmine.createSpy() } as any; context.appConfig = { get: jasmine.createSpy() } as any;
+8 -9
View File
@@ -570,15 +570,6 @@ export const areTagsEnabled = (context: AcaRuleContext): boolean => context.appC
export const areCategoriesEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.categoriesEnabled', true); export const areCategoriesEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.categoriesEnabled', true);
export const canDisplayKnowledgeRetrievalButton = (context: AcaRuleContext): boolean =>
context.appConfig.get('plugins.knowledgeRetrievalEnabled', false) &&
(navigation.isPersonalFiles(context) ||
navigation.isRepositoryView(context) ||
navigation.isSharedFiles(context) ||
navigation.isRecentFiles(context) ||
navigation.isFavorites(context) ||
((navigation.isSearchResults(context) || navigation.isLibraryContent(context)) && !navigation.isLibraries(context)));
export const isSSOEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('authType') === 'OAUTH'; export const isSSOEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('authType') === 'OAUTH';
/** /**
@@ -604,3 +595,11 @@ export const isCheckedOut = (context: RuleContext): boolean => {
export const isNodeLink = (context: RuleContext): boolean => export const isNodeLink = (context: RuleContext): boolean =>
!context.selection?.isEmpty && !context.selection?.isEmpty &&
context.selection.nodes.some((node) => node.entry?.nodeType === 'app:filelink' || node.entry?.nodeType === 'app:folderlink'); context.selection.nodes.some((node) => node.entry?.nodeType === 'app:filelink' || node.entry?.nodeType === 'app:folderlink');
/**
* Checks if the Knowledge Discovery URL is configured.
* JSON ref: `app.isKnowledgeDiscoveryUrlPresent`
*
* @param context Rule execution context
*/
export const isKnowledgeDiscoveryUrlPresent = (context: AcaRuleContext): boolean => !!context.appConfig.get<string>('knowledgeDiscoveryUrl', '');
@@ -22,15 +22,8 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>. * from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { import { DocumentListComponent, DocumentListService, ShareDataRow, UploadService } from '@alfresco/adf-content-services';
DocumentListComponent, import { ShowHeaderMode } from '@alfresco/adf-core';
DocumentListService,
SearchAiInputState,
SearchAiService,
ShareDataRow,
UploadService
} from '@alfresco/adf-content-services';
import { ShowHeaderMode, UserPreferencesService } from '@alfresco/adf-core';
import { ContentActionRef, DocumentListPresetRef, SelectionState } from '@alfresco/adf-extensions'; import { ContentActionRef, DocumentListPresetRef, SelectionState } from '@alfresco/adf-extensions';
import { DestroyRef, Directive, HostListener, inject, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { DestroyRef, Directive, HostListener, inject, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -52,7 +45,6 @@ import { AutoDownloadService } from '../../services/auto-download.service';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AppSettingsService } from '../../services/app-settings.service'; import { AppSettingsService } from '../../services/app-settings.service';
import { NavigationHistoryService } from '../../services/navigation-history.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
/* eslint-disable @angular-eslint/directive-class-suffix */ /* eslint-disable @angular-eslint/directive-class-suffix */
@@ -86,24 +78,13 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
protected breakpointObserver = inject(BreakpointObserver); protected breakpointObserver = inject(BreakpointObserver);
protected uploadService = inject(UploadService); protected uploadService = inject(UploadService);
protected router = inject(Router); protected router = inject(Router);
protected userPreferencesService = inject(UserPreferencesService);
protected searchAiService = inject(SearchAiService);
protected readonly destroyRef = inject(DestroyRef); protected readonly destroyRef = inject(DestroyRef);
private readonly autoDownloadService = inject(AutoDownloadService, { optional: true }); private readonly autoDownloadService = inject(AutoDownloadService, { optional: true });
private readonly navigationHistoryService = inject(NavigationHistoryService);
protected subscriptions: Subscription[] = []; protected subscriptions: Subscription[] = [];
private _searchAiInputState: SearchAiInputState = {
active: false
};
get searchAiInputState(): SearchAiInputState {
return this._searchAiInputState;
}
ngOnInit() { ngOnInit() {
this.extensions this.extensions
.getCreateActions() .getCreateActions()
@@ -156,12 +137,6 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
.subscribe((result) => { .subscribe((result) => {
this.isSmallScreen = result.matches; this.isSmallScreen = result.matches;
}); });
this.searchAiService.toggleSearchAiInput$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((searchAiInputState) => (this._searchAiInputState = searchAiInputState));
this.setKnowledgeRetrievalState();
} }
ngOnChanges(changes: SimpleChanges) { ngOnChanges(changes: SimpleChanges) {
@@ -245,19 +220,6 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
return obj.id; return obj.id;
} }
private setKnowledgeRetrievalState() {
const nodes = this.userPreferencesService.get('knowledgeRetrievalNodes');
if (nodes && this.navigationHistoryService.shouldReturnLastSelection('/knowledge-retrieval')) {
this.selectedNodesState = JSON.parse(nodes);
}
if (!this.selectedNodesState && !this.router.url.startsWith('/knowledge-retrieval')) {
this.searchAiService.updateSearchAiInputState({
active: false
});
}
}
private isOutletPreviewUrl(): boolean { private isOutletPreviewUrl(): boolean {
return location.href.includes('viewer:view'); return location.href.includes('viewer:view');
} }
@@ -30,13 +30,11 @@ import { NodeEntry } from '@alfresco/js-api';
import { DocumentBasePageService } from './document-base-page.service'; import { DocumentBasePageService } from './document-base-page.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { DiscoveryApiService, DocumentListService, SearchAiInputState, SearchAiService } from '@alfresco/adf-content-services'; import { DiscoveryApiService, DocumentListService } from '@alfresco/adf-content-services';
import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { provideCoreAuth, UserPreferencesService } from '@alfresco/adf-core'; import { provideCoreAuth } from '@alfresco/adf-core';
import { of, Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { NavigationHistoryService } from '../../services/navigation-history.service';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'aca-test', selector: 'aca-test',
@@ -57,49 +55,23 @@ class TestComponent extends PageComponent {
} }
describe('PageComponent', () => { describe('PageComponent', () => {
const mockNodes = JSON.stringify({ node: 'mockNode' });
let component: TestComponent; let component: TestComponent;
let store: Store<AppState>; let store: Store<AppState>;
let fixture: ComponentFixture<TestComponent>; let fixture: ComponentFixture<TestComponent>;
let documentListService: DocumentListService; let documentListService: DocumentListService;
let userPreferencesService: jasmine.SpyObj<UserPreferencesService>;
let navigationHistoryService: { shouldReturnLastSelection: jasmine.Spy };
let searchAiService: SearchAiService;
let router: { url: string };
beforeEach(() => { beforeEach(() => {
userPreferencesService = jasmine.createSpyObj('UserPreferencesService', ['get', 'set']);
navigationHistoryService = jasmine.createSpyObj('NavigationHistoryService', ['shouldReturnLastSelection']);
router = { url: '/some-url' };
searchAiService = jasmine.createSpyObj('SearchAiService', ['updateSearchAiInputState', 'toggleSearchAiInput$']);
searchAiService.toggleSearchAiInput$ = of({ active: false });
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [LibTestingModule, MatDialogModule], imports: [LibTestingModule, MatDialogModule],
declarations: [TestComponent], declarations: [TestComponent],
providers: [ providers: [
provideCoreAuth(), provideCoreAuth(),
{ provide: DocumentBasePageService, useClass: DocumentBasePageServiceMock }, { provide: DocumentBasePageService, useClass: DocumentBasePageServiceMock },
{ provide: DiscoveryApiService, useValue: discoveryApiServiceMockValue }, { provide: DiscoveryApiService, useValue: discoveryApiServiceMockValue }
{
provide: UserPreferencesService,
useValue: userPreferencesService
},
{
provide: NavigationHistoryService,
useValue: navigationHistoryService
},
{
provide: Router,
useValue: router
},
{ provide: SearchAiService, useValue: searchAiService }
] ]
}); });
store = TestBed.inject(Store); store = TestBed.inject(Store);
searchAiService = TestBed.inject(SearchAiService);
documentListService = TestBed.inject(DocumentListService); documentListService = TestBed.inject(DocumentListService);
fixture = TestBed.createComponent(TestComponent); fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
@@ -203,81 +175,6 @@ describe('PageComponent', () => {
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ViewNodeAction(id) })); expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ViewNodeAction(id) }));
}); });
}); });
describe('setKnowledgeRetrievalState()', () => {
it('should set selectedNodesState when nodes exist and last selection is valid', () => {
userPreferencesService.get.and.returnValue(mockNodes);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(true);
component.ngOnInit();
expect(component.selectedNodesState).toEqual(JSON.parse(mockNodes));
});
it('should not set selectedNodesState when nodes do not exist', () => {
userPreferencesService.get.and.returnValue(null);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(true);
component.ngOnInit();
expect(component.selectedNodesState).toBeUndefined();
});
it('should not set selectedNodesState when shouldReturnLastSelection returns false', () => {
userPreferencesService.get.and.returnValue(mockNodes);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(false);
component.ngOnInit();
expect(component.selectedNodesState).toBeUndefined();
});
it('should update searchAiInputState when selectedNodesState is undefined and url does not start with /knowledge-retrieval', () => {
userPreferencesService.get.and.returnValue(mockNodes);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(false);
router.url = '/some-other-url';
component.ngOnInit();
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({ active: false });
});
it('should not update searchAiInputState when url starts with /knowledge-retrieval', () => {
userPreferencesService.get.and.returnValue(undefined);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(true);
router.url = '/knowledge-retrieval';
component.ngOnInit();
expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
});
it('should not update searchAiInputState when selectedNodesState in not null', () => {
userPreferencesService.get.and.returnValue(mockNodes);
navigationHistoryService.shouldReturnLastSelection.and.returnValue(true);
router.url = '/other';
component.ngOnInit();
expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
});
});
describe('SearchAiService toggleSearchAiInput$ event handler', () => {
it('should set searchAiInputState', () => {
const initialSearchAiInputState = component.searchAiInputState;
const searchAiInputState: SearchAiInputState = {
active: true
};
searchAiService.toggleSearchAiInput$ = of(searchAiInputState);
component.ngOnInit();
expect(component.searchAiInputState).toBe(searchAiInputState);
expect(initialSearchAiInputState).toEqual({
active: false
});
});
});
}); });
describe('Info Drawer state', () => { describe('Info Drawer state', () => {
@@ -167,4 +167,11 @@ export class AppSettingsService {
get authDownloadThreshold(): number { get authDownloadThreshold(): number {
return this.appConfig.get<number>('viewer.fileAutoDownloadSizeThresholdInMB', 15); return this.appConfig.get<number>('viewer.fileAutoDownloadSizeThresholdInMB', 15);
} }
/**
* Get the Knowledge Discovery URL from the app settings.
*/
get knowledgeDiscoveryUrl(): string {
return this.appConfig.get<string>('knowledgeDiscoveryUrl', '');
}
} }
@@ -1,45 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Action } from '@ngrx/store';
import { AiSearchByTermPayload } from '../models/ai-search-by-term-payload';
export enum SearchAiActionTypes {
SearchByTermAi = 'SEARCH_BY_TERM_AI',
ToggleAiSearchInput = 'TOGGLE_AI_SEARCH_INPUT'
}
export class SearchByTermAiAction implements Action {
readonly type = SearchAiActionTypes.SearchByTermAi;
constructor(public payload: AiSearchByTermPayload) {}
}
export class ToggleAISearchInput implements Action {
readonly type = SearchAiActionTypes.ToggleAiSearchInput;
constructor(
public agentId: string,
public searchTerm?: string
) {}
}
@@ -1,28 +0,0 @@
/*!
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
export interface AiSearchByTermPayload {
searchTerm: string;
agentId: string;
}
@@ -36,11 +36,9 @@ export * from './actions/viewer.actions';
export * from './actions/metadata-aspect.actions'; export * from './actions/metadata-aspect.actions';
export * from './actions/template.actions'; export * from './actions/template.actions';
export * from './actions/contextmenu.actions'; export * from './actions/contextmenu.actions';
export * from './actions/search-ai.actions';
export * from './effects/router.effects'; export * from './effects/router.effects';
export * from './models/ai-search-by-term-payload';
export * from './models/delete-status.model'; export * from './models/delete-status.model';
export * from './models/deleted-node-info.model'; export * from './models/deleted-node-info.model';
export * from './models/node-info.model'; export * from './models/node-info.model';
@@ -25,7 +25,6 @@
import { AppStore } from '../states/app.state'; import { AppStore } from '../states/app.state';
import { createSelector } from '@ngrx/store'; import { createSelector } from '@ngrx/store';
const HXI_CONNECTOR = 'alfresco-hxinsight-connector-prediction-applier-extension';
export const selectApp = (state: AppStore) => state.app; export const selectApp = (state: AppStore) => state.app;
/** @deprecated use `UserProfileService` instead */ /** @deprecated use `UserProfileService` instead */
@@ -39,7 +38,6 @@ export const infoDrawerPreview = createSelector(selectApp, (state) => state.info
export const getRepositoryStatus = createSelector(selectApp, (state) => state.repository); export const getRepositoryStatus = createSelector(selectApp, (state) => state.repository);
export const getCurrentACSVersion = createSelector(getRepositoryStatus, (repository) => repository.version?.display?.split(' ')[0]); export const getCurrentACSVersion = createSelector(getRepositoryStatus, (repository) => repository.version?.display?.split(' ')[0]);
export const isQuickShareEnabled = createSelector(getRepositoryStatus, (info) => info.status.isQuickShareEnabled); export const isQuickShareEnabled = createSelector(getRepositoryStatus, (info) => info.status.isQuickShareEnabled);
export const isHXIConnectorEnabled = createSelector(getRepositoryStatus, (info) => !!info?.modules?.find((module) => module.id === HXI_CONNECTOR));
export const isAdmin = createSelector(selectApp, (state) => state.user.isAdmin); export const isAdmin = createSelector(selectApp, (state) => state.user.isAdmin);
export const getFileUploadingDialog = createSelector(selectApp, (state) => state.fileUploadingDialog); export const getFileUploadingDialog = createSelector(selectApp, (state) => state.fileUploadingDialog);
export const showLoaderSelector = createSelector(selectApp, (state) => state.showLoader); export const showLoaderSelector = createSelector(selectApp, (state) => state.showLoader);