mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2025-07-24 17:31:52 +00:00
[ACS-8201] Knowledge Retrieval - getting AI response for one or more selected files (#4127)
* [ACS-8202] basic flow getting ai response for one or more selected files (#3936) * ACS-8202 Added animated icon * ACS-8202 Added search ai input * ACS-8202 Added AI search results page * ACS-8202 Allow to run knowledge retrieval on files inside library, shared, favourites and recent files * ACS-8202 Hide icon when selected more than 100 files or non text files * ACS-8202 Display notification when too many files are selected * ACS-8202 Added agents dropdown * ACS-8202 Styles for AI response * ACS-8202 Applied design changes * ACS-8202 Added query card to Knowledge retrieval page results * ACS-8202 Fixed search collapsing when opened results page * ACS-8202 Changed placeholder in input for results page, wrapping text and scrolling for results page * ACS-8202 Display snackbar with messages when conditions are not met * ACS-8202 Disallow run knowledge retrieval for libraries, leave input when click on x button * ACS-8202 Renaming files * ACS-8202 Trigger ai input by selecting agent instead of clicking on button * ACS-8202 Reverted triggering showing input by selecting option from select * ACS-8202 Display dropdown with agents by clicking on button * ACS-8202 Structural changes - services and agents button component * ACS-8202 Removed part for examples from search page * ACS-8202 Simplified html for search page * ACS-8202 Refactored html and styles for search page, translations for search page * ACS-8202 More html and styles refactoring * ACS-8202 Formatting html * ACS-8202 Removed references to angular material classes * ACS-8202 Added data automation id attributes * ACS-8202 Load agents from backend, formatting html for agents button component and adding data automation ids to that component * ACS-8202 Correction after rebase * ACS-8202 Set agent for input based on selected agent from dropdown for agents button * ACS-8202 Hide agent button for libraries pages and use translations for warnings when clicked on agents button * ACS-8202 Pass agent id to search results page * ACS-8202 Used form control instead of ngmodel for search query * ACS-8202 Moved search ai service and search ai input state to ADF * ACS-8202 Results page ts clean up * ACS-8202 Used ask and getAnswer functions from search ai service * ACS-8202 Cleaning of search ai navigation service * ACS-8202 Small clean ups * ACS-8202 Renamed sources to references * ACS-8202 Fixed asking next question from results page * ACS-8202 Added possibility to use knowledge retrieval from search results page * ACS-8202 Fixed issue with selecting the same agent after previously closing input on search results page * ACS-8202 Disallowed using knowledge retrieval on trash page * ACS-8202 Hide toggling knowledge retrieval for tasks and processes, fixed displaying ask button for favorites page * ACS-8202 Removed redundant image and function * ACS-8202 Renamed breadcrumbTemplate to header * ACS-8202 Removed redundant code, added some comments, made some fields as private * ACS-8202 Display error message on search page * ACS-8202 Accessibility changes * ACS-8202 Small correction * ACS-8202 Addressed comments * ACS-8202 Displayed correct initials * ACS-8202 Removed redundant imports * ACS-8202 Change css value * ACS-8202 Removed icon animation * ACS-8202 Removed icon animation * ACS-8201 Small correction after rebasing with Angular 15 * [ACS-8398] unit tests (#3973) * ACS-8398 Unit tests for agents button and part for agents menu * ACS-8398 Unit tests for search ai input component * [ACS-8210] Agent basic details popup (#3942) * [ACS-8210] Agent basic details popup * [ACS-8210] Agent basic details popup - review fixes --------- Co-authored-by: Aleksander Sklorz <aleksander.sklorz@hyland.com> * [ACS-8382] Blurring the AI answer section before getting response from backend (#3948) * [ACS-8398] Unit tests part 2 (#3989) * ACS-8398 Unit tests for search ai input container * ACS-8398 Unit tests for search ai navigation service and rest tests for search ai input container component * ACS-8398 Added missing type * [ACS-8484] Add feature flag to knowledge retrieval (#4003) * [ACS-8562] "Ask Agent" button name is changed to "Ask Discovery" * [ACS-8573] Allow user to ask question without file selection * [ACS-8312] Display warning about losing response (#4012) * ACS-8201 Fixed issues after rebase * [ACS-8588] Navigation is triggered twice when leaving Knowledge Retrieval page (#4030) [ACS-8588] Navigation is triggered twice when leaving Knowledge Retrieval page * [ACS-8399] Integrate all changes with backend (#4076) * [ACS-8399] Integrate all changes with backend * [ACS-8399] Integrate all changes with backend - review fixes * Answers endpoint fix * Answers endpoint fix (#4107) * [ACS-8664] generic question redirection to hx insight page (#4102) * ACS-8664 Open page in new tab * ACS-8664 Loading HX insight url * ACS-8664 Unit tests * ACS-8664 Fix after rebasing * ACS-8664 Fixed unit tests * ACS-8664 Added type * ACS-8664 Removed duplicated lines * ACS-8664 Removed duplicated lines * ACS-8664 Addressed comments * [ACS-8695] Getting Agent avatar (#4110) * [ACS-8695] Getting Agent avatar * [ACS-8695] Getting Agent avatar - fixes * [ACS-8695] Getting Agent avatar - fixes 2 * Adding mocked agent avatars (#4117) * [ACS-8201] review fixes * [ACS-8201] review fixes * [E2E] excluded failing tests to fix later pt.1 * [ACS-8767] allow to open referenced files (#4129) * ACS-8767 Opening referenced files * ACS-8767 Reverted one line * ACS-8767 Removed unwanted code * ACS-8767 * ACS-8767 Unit tests for allowing clicking on references * ACS-8767 Unit tests * ACS-8767 Moved duplicated code to function * ACS-8767 Resolved sonar issue * ACS-8767 Resolved sonar issue * [ACS-8201] knowledge retrieval feature flag - false * [E2E] excluded failing tests to fix later pt.2 * ACS-8201 Fixed tests --------- Co-authored-by: AleksanderSklorz <115619721+AleksanderSklorz@users.noreply.github.com> Co-authored-by: Aleksander Sklorz <Aleksander.Sklorz@hyland.com> Co-authored-by: datguychen <adam.swiderski@hyland.com>
This commit is contained in:
@@ -77,6 +77,7 @@ import { ContextMenuComponent } from './components/context-menu/context-menu.com
|
||||
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
|
||||
import { SearchResultsRowComponent } from './components/search/search-results-row/search-results-row.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';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -138,6 +139,7 @@ export class ContentServiceExtensionModule {
|
||||
'app.toolbar.toggleFavorite': ToggleFavoriteComponent,
|
||||
'app.toolbar.toggleFavoriteLibrary': ToggleFavoriteLibraryComponent,
|
||||
'app.toolbar.toggleJoinLibrary': ToggleJoinLibraryButtonComponent,
|
||||
'app.toolbar.ai.agents-button': AgentsButtonComponent,
|
||||
'app.menu.toggleJoinLibrary': ToggleJoinLibraryMenuComponent,
|
||||
'app.bulk-actions-dropdown': BulkActionsDropdownComponent,
|
||||
'app.shared-link.toggleSharedLink': ToggleSharedComponent,
|
||||
@@ -197,6 +199,7 @@ export class ContentServiceExtensionModule {
|
||||
'app.selection.hasNoLibraryRole': rules.hasNoLibraryRole,
|
||||
'app.selection.folder': rules.hasFolderSelected,
|
||||
'app.selection.folder.canUpdate': rules.canUpdateSelectedFolder,
|
||||
'app.selection.displayedKnowledgeRetrievalButton': rules.canDisplayKnowledgeRetrievalButton,
|
||||
|
||||
'app.navigation.folder.canCreate': rules.canCreateFolder,
|
||||
'app.navigation.folder.canUpload': rules.canUpload,
|
||||
|
@@ -27,8 +27,8 @@ import { LibrariesComponent } from './components/libraries/libraries.component';
|
||||
import { FavoriteLibrariesComponent } from './components/favorite-libraries/favorite-libraries.component';
|
||||
import { SearchResultsComponent } from './components/search/search-results/search-results.component';
|
||||
import { SearchLibrariesResultsComponent } from './components/search/search-libraries-results/search-libraries-results.component';
|
||||
import { AppSharedRuleGuard, GenericErrorComponent, ExtensionRoute, ExtensionsDataLoaderGuard } from '@alfresco/aca-shared';
|
||||
import { AuthGuard } from '@alfresco/adf-core';
|
||||
import { AppSharedRuleGuard, GenericErrorComponent, ExtensionRoute, ExtensionsDataLoaderGuard, PluginEnabledGuard } from '@alfresco/aca-shared';
|
||||
import { AuthGuard, UnsavedChangesGuard } from '@alfresco/adf-core';
|
||||
import { FavoritesComponent } from './components/favorites/favorites.component';
|
||||
import { RecentFilesComponent } from './components/recent-files/recent-files.component';
|
||||
import { SharedFilesComponent } from './components/shared-files/shared-files.component';
|
||||
@@ -36,10 +36,11 @@ import { DetailsComponent } from './components/details/details.component';
|
||||
import { HomeComponent } from './components/home/home.component';
|
||||
import { ViewProfileComponent } from './components/view-profile/view-profile.component';
|
||||
import { ViewProfileRuleGuard } from './components/view-profile/view-profile.guard';
|
||||
import { Route } from '@angular/router';
|
||||
import { Data, Route, Routes } from '@angular/router';
|
||||
import { SharedLinkViewComponent } from './components/shared-link-view/shared-link-view.component';
|
||||
import { TrashcanComponent } from './components/trashcan/trashcan.component';
|
||||
import { ShellLayoutComponent } from '@alfresco/adf-core/shell';
|
||||
import { SearchAiResultsComponent } from './components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component';
|
||||
|
||||
export const CONTENT_ROUTES: ExtensionRoute[] = [
|
||||
{
|
||||
@@ -72,6 +73,36 @@ export const CONTENT_ROUTES: ExtensionRoute[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const createViewRoutes = (navigateSource: string, additionalData: Data = {}): Routes => [
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
],
|
||||
...additionalData
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
path: '',
|
||||
canActivate: [ExtensionsDataLoaderGuard],
|
||||
@@ -117,32 +148,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'personal-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'personal-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('personal-files')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -156,32 +162,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
sortingPreferenceKey: 'personal-files'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'personal-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'personal-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('personal-files')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -208,35 +189,11 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
sortingPreferenceKey: 'libraries-files'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'libraries'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
],
|
||||
...createViewRoutes('libraries', {
|
||||
data: {
|
||||
navigateSource: 'libraries'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'libraries'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -268,32 +225,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
sortingPreferenceKey: 'libraries-files'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'libraries'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'libraries'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('libraries')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -310,32 +242,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
sortingPreferenceKey: 'favorites'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'favorites'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'favorites'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('favorites')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -351,32 +258,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
title: 'APP.BROWSE.RECENT.TITLE'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'recent-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'recent-files'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('recent-files')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -390,32 +272,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
},
|
||||
component: SharedFilesComponent
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'shared'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'shared'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('shared')
|
||||
],
|
||||
canActivateChild: [AppSharedRuleGuard],
|
||||
canActivate: [AppSharedRuleGuard]
|
||||
@@ -444,32 +301,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
|
||||
sortingPreferenceKey: 'search'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'search'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'view/:nodeId/:versionId',
|
||||
outlet: 'viewer',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
data: {
|
||||
navigateSource: 'search'
|
||||
},
|
||||
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
|
||||
}
|
||||
]
|
||||
}
|
||||
...createViewRoutes('search')
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -507,6 +339,21 @@ 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: '**',
|
||||
component: GenericErrorComponent
|
||||
|
@@ -1,9 +1,17 @@
|
||||
<aca-page-layout>
|
||||
<div class="aca-page-layout-header">
|
||||
<h1 class="aca-page-title">
|
||||
<aca-search-ai-input-container
|
||||
*ngIf="searchAiInputState.active; else header"
|
||||
[agentId]="searchAiInputState.selectedAgentId">
|
||||
</aca-search-ai-input-container>
|
||||
<ng-template #header>
|
||||
<div class="aca-header-container">
|
||||
<h1 class="aca-page-title">
|
||||
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.FAVORITES.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
|
||||
</h1>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div class="aca-page-layout-content">
|
||||
|
@@ -46,6 +46,7 @@ import {
|
||||
import { DocumentListDirective } from '../../directives/document-list.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DocumentListComponent } from '@alfresco/adf-content-services';
|
||||
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -59,6 +60,7 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
|
||||
PageLayoutComponent,
|
||||
TranslateModule,
|
||||
ToolbarComponent,
|
||||
SearchAiInputContainerComponent,
|
||||
EmptyContentComponent,
|
||||
DynamicColumnComponent,
|
||||
DocumentListComponent,
|
||||
@@ -67,7 +69,8 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
|
||||
CustomEmptyContentTemplateDirective
|
||||
],
|
||||
templateUrl: './favorites.component.html',
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
selector: 'aca-favorites'
|
||||
})
|
||||
export class FavoritesComponent extends PageComponent implements OnInit {
|
||||
columns: DocumentListPresetRef[] = [];
|
||||
|
@@ -1,12 +1,20 @@
|
||||
<aca-page-layout [hasError]="!isValidPath">
|
||||
<div class="aca-page-layout-header">
|
||||
<adf-breadcrumb [root]="title"
|
||||
[folderNode]="node"
|
||||
[selectedRowItemsCount]="selectedRowItemsCount"
|
||||
[maxItems]="isSmallScreen ? 1 : 0"
|
||||
(navigate)="onBreadcrumbNavigate($event)">
|
||||
</adf-breadcrumb>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
<aca-search-ai-input-container
|
||||
*ngIf="searchAiInputState.active; else header"
|
||||
[agentId]="searchAiInputState.selectedAgentId">
|
||||
</aca-search-ai-input-container>
|
||||
<ng-template #header>
|
||||
<div class="aca-header-container">
|
||||
<adf-breadcrumb
|
||||
[root]="title"
|
||||
[folderNode]="node"
|
||||
[selectedRowItemsCount]="selectedRowItemsCount" [maxItems]="isSmallScreen ? 1 : 0"
|
||||
(navigate)="onBreadcrumbNavigate($event)">
|
||||
</adf-breadcrumb>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div class="aca-page-layout-error">
|
||||
|
@@ -58,6 +58,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DocumentListDirective } from '../../directives/document-list.directive';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -73,6 +74,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
PaginationDirective,
|
||||
PageLayoutComponent,
|
||||
ToolbarComponent,
|
||||
SearchAiInputContainerComponent,
|
||||
DynamicColumnComponent,
|
||||
BreadcrumbComponent,
|
||||
UploadDragAreaComponent,
|
||||
@@ -82,7 +84,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
CustomEmptyContentTemplateDirective
|
||||
],
|
||||
templateUrl: './files.component.html',
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
selector: 'aca-files'
|
||||
})
|
||||
export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
|
||||
isValidPath = true;
|
||||
|
@@ -0,0 +1,37 @@
|
||||
<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">
|
||||
</adf-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]"></adf-avatar>
|
||||
<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>
|
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,461 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, ContentTestingModule, 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, ToggleAISearchInput } from '@alfresco/aca-shared/store';
|
||||
import { AvatarComponent, 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';
|
||||
|
||||
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';
|
||||
component.avatarsMocked = false;
|
||||
});
|
||||
|
||||
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: [AgentsButtonComponent, ContentTestingModule],
|
||||
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(() => {
|
||||
component.avatarsMocked = false;
|
||||
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 => {
|
||||
component.avatarsMocked = false;
|
||||
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<ToggleAISearchInput>).toHaveBeenCalledWith({
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agents multi words 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,148 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, Input, OnDestroy, 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, Subject, throwError } from 'rxjs';
|
||||
import { catchError, take, takeUntil } from 'rxjs/operators';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatListModule, MatSelectionListChange } from '@angular/material/list';
|
||||
import { TranslateModule, 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 { getAgentsWithMockedAvatars } from '../search-ai-utils';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatMenuModule, MatListModule, TranslateModule, 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, OnDestroy {
|
||||
@Input()
|
||||
data: { trigger: string };
|
||||
|
||||
private selectedNodesState: SelectionState;
|
||||
private _agents: Agent[] = [];
|
||||
private onDestroy$ = new Subject<void>();
|
||||
private _disabled = true;
|
||||
private _initialsByAgentId: { [key: string]: string } = {};
|
||||
private _hxInsightUrl: string;
|
||||
|
||||
avatarsMocked = true;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private store: Store<AppStore>,
|
||||
private notificationService: NotificationService,
|
||||
private searchAiService: SearchAiService,
|
||||
private agentService: AgentService,
|
||||
private translateService: TranslateService,
|
||||
private cd: ChangeDetectorRef
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store
|
||||
.select(getAppSelection)
|
||||
.pipe(takeUntil(this.onDestroy$))
|
||||
.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;
|
||||
|
||||
// TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
|
||||
this._agents = getAgentsWithMockedAvatars(result.agents, this.avatarsMocked);
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy$.next();
|
||||
this.onDestroy$.complete();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
<aca-search-ai-input
|
||||
(searchSubmitted)="hideSearchInput()"
|
||||
[placeholder]="placeholder"
|
||||
[agentId]="agentId"
|
||||
[useStoredNodes]="useStoredNodes">
|
||||
</aca-search-ai-input>
|
||||
<mat-divider
|
||||
[vertical]="true"
|
||||
class="aca-search-ai-input-container-divider">
|
||||
</mat-divider>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="leaveSearchInput()"
|
||||
data-automation-id="aca-search-ai-input-container-leaving-search-button"
|
||||
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT' | translate"
|
||||
class="aca-search-ai-input-container-close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
}
|
||||
}
|
@@ -0,0 +1,186 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, ContentTestingModule, 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, NavigationStart, Router, RouterEvent } from '@angular/router';
|
||||
import { getAppSelection } from '@alfresco/aca-shared/store';
|
||||
|
||||
describe('SearchAiInputContainerComponent', () => {
|
||||
let component: SearchAiInputContainerComponent;
|
||||
let fixture: ComponentFixture<SearchAiInputContainerComponent>;
|
||||
let routingEvents$: Subject<RouterEvent>;
|
||||
let searchAiService: SearchAiService;
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SearchAiInputContainerComponent, ContentTestingModule],
|
||||
providers: [
|
||||
provideMockStore(),
|
||||
{
|
||||
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);
|
||||
store.overrideSelector(getAppSelection, {
|
||||
nodes: [],
|
||||
isEmpty: true,
|
||||
count: 0,
|
||||
libraries: []
|
||||
});
|
||||
component.agentId = '1';
|
||||
routingEvents$ = new Subject<RouterEvent>();
|
||||
spyOnProperty(TestBed.inject(Router), 'events').and.returnValue(routingEvents$);
|
||||
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 useStoredNodes flag', () => {
|
||||
component.useStoredNodes = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(inputComponent.useStoredNodes).toBeTrue();
|
||||
});
|
||||
|
||||
it('should call updateSearchAiInputState on SearchAiService when triggered searchSubmitted event', () => {
|
||||
spyOn(searchAiService, 'updateSearchAiInputState');
|
||||
inputComponent.searchSubmitted.emit();
|
||||
|
||||
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
|
||||
active: false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(button.nativeElement.title).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT');
|
||||
});
|
||||
|
||||
it('should contain close icon', () => {
|
||||
expect(button.query(By.directive(MatIcon)).nativeElement.textContent).toBe('close');
|
||||
});
|
||||
|
||||
it('should call updateSearchAiInputState on SearchAiService when clicked', () => {
|
||||
spyOn(searchAiService, 'updateSearchAiInputState');
|
||||
button.nativeElement.click();
|
||||
|
||||
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
|
||||
active: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should call navigateToPreviousRoute on SearchAiNavigationService when clicked', () => {
|
||||
const searchNavigationService = TestBed.inject(SearchAiNavigationService);
|
||||
spyOn(searchNavigationService, 'navigateToPreviousRoute');
|
||||
button.nativeElement.click();
|
||||
|
||||
expect(searchNavigationService.navigateToPreviousRoute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should call updateSearchAiInputState on SearchAiService when navigation starts', () => {
|
||||
spyOn(searchAiService, 'updateSearchAiInputState');
|
||||
routingEvents$.next(new NavigationStart(1, ''));
|
||||
|
||||
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
|
||||
active: false
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call updateSearchAiInputState on SearchAiService when there is different event than navigation starts', () => {
|
||||
spyOn(searchAiService, 'updateSearchAiInputState');
|
||||
routingEvents$.next(new NavigationEnd(1, '', ''));
|
||||
|
||||
expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,81 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, OnDestroy, OnInit, ViewEncapsulation } 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 { NavigationStart, Router } from '@angular/router';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
import { SearchAiService } from '@alfresco/adf-content-services';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [SearchAiInputComponent, MatIconModule, MatDividerModule, MatButtonModule, TranslateModule],
|
||||
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, OnDestroy {
|
||||
@Input()
|
||||
placeholder = 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER';
|
||||
@Input()
|
||||
agentId: string;
|
||||
@Input()
|
||||
useStoredNodes: boolean;
|
||||
|
||||
private onDestroy$ = new Subject<void>();
|
||||
|
||||
constructor(private searchAiService: SearchAiService, private searchNavigationService: SearchAiNavigationService, private router: Router) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationStart),
|
||||
takeUntil(this.onDestroy$)
|
||||
)
|
||||
.subscribe(() => this.hideSearchInput());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy$.next();
|
||||
this.onDestroy$.complete();
|
||||
}
|
||||
|
||||
hideSearchInput(): void {
|
||||
this.searchAiService.updateSearchAiInputState({
|
||||
active: false
|
||||
});
|
||||
}
|
||||
|
||||
leaveSearchInput(): void {
|
||||
this.searchNavigationService.navigateToPreviousRoute();
|
||||
this.hideSearchInput();
|
||||
}
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
<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">
|
||||
</adf-avatar>
|
||||
<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]"></adf-avatar>
|
||||
<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">
|
||||
</adf-avatar>
|
||||
<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
|
||||
color="primary"
|
||||
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'"></adf-icon>
|
||||
<span class="aca-search-ai-asking-button-label">{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL' | translate }}</span>
|
||||
</button>
|
@@ -0,0 +1,183 @@
|
||||
@import '@alfresco/adf-core/lib/styles/mat-selectors';
|
||||
|
||||
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;
|
||||
|
||||
&-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;
|
||||
|
||||
#{$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#{$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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,431 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
|
||||
import { getAppSelection, SearchByTermAiAction } 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, 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';
|
||||
|
||||
const agentList: Agent[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HR Agent',
|
||||
description: 'Test 1',
|
||||
avatarUrl: undefined
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Policy Agent',
|
||||
description: 'Test 2',
|
||||
avatarUrl: undefined
|
||||
}
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
const prepareBeforeTest = (): void => {
|
||||
selectionState = {
|
||||
nodes: [],
|
||||
isEmpty: true,
|
||||
count: 0,
|
||||
libraries: []
|
||||
};
|
||||
store.overrideSelector(getAppSelection, selectionState);
|
||||
component.agentId = '2';
|
||||
component.avatarsMocked = false;
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SearchAiInputComponent, ContentTestingModule, MatSelectModule],
|
||||
providers: [
|
||||
provideMockStore(),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParams: { query: 'some query' }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(SearchAiInputComponent);
|
||||
component = fixture.componentInstance;
|
||||
store = TestBed.inject(MockStore);
|
||||
loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
agents$ = new Subject<Agent[]>();
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
|
||||
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 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 () => {
|
||||
agents$.next(agentList);
|
||||
expect(await (await loader.getHarness(MatSelectHarness)).getValueText()).toBe('PAPolicy Agent');
|
||||
const avatar = selectElement.query(By.directive(AvatarComponent))?.componentInstance;
|
||||
expect(avatar.initials).toBe('PA');
|
||||
expect(avatar.size).toBe('26px');
|
||||
});
|
||||
|
||||
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 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('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', () => {
|
||||
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 userPreferencesService: UserPreferencesService;
|
||||
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);
|
||||
userPreferencesService = TestBed.inject(UserPreferencesService);
|
||||
spyOn(userPreferencesService, 'set');
|
||||
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.useStoredNodes = true;
|
||||
const newSelectionState: SelectionState = {
|
||||
...selectionState,
|
||||
file: {
|
||||
entry: {
|
||||
id: 'some-id'
|
||||
}
|
||||
} as NodeEntry
|
||||
};
|
||||
spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
|
||||
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.useStoredNodes = true;
|
||||
const newSelectionState: SelectionState = {
|
||||
...selectionState,
|
||||
file: {
|
||||
entry: {
|
||||
id: 'some-id'
|
||||
}
|
||||
} as NodeEntry
|
||||
};
|
||||
spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
|
||||
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).toHaveBeenCalledOnceWith(
|
||||
new SearchByTermAiAction({
|
||||
searchTerm: query,
|
||||
agentId: component.agentId
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledOnceWith(
|
||||
new SearchByTermAiAction({
|
||||
searchTerm: query,
|
||||
agentId: '1'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset query input', () => {
|
||||
spyOn(component.queryControl, 'reset');
|
||||
submittingTrigger();
|
||||
|
||||
expect(component.queryControl.reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit searchSubmitted event', () => {
|
||||
spyOn(component.searchSubmitted, 'emit');
|
||||
submittingTrigger();
|
||||
|
||||
expect(component.searchSubmitted.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
spyOn(component.searchSubmitted, 'emit');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
submittingTrigger();
|
||||
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
expect(component.searchSubmitted.emit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
@@ -0,0 +1,187 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateModule, 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 { Subject } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { AiSearchByTermPayload, AppStore, getAppSelection, SearchByTermAiAction } from '@alfresco/aca-shared/store';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
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 { getAgentsWithMockedAvatars } from '../search-ai-utils';
|
||||
|
||||
const MatTooltipOptions: MatTooltipDefaultOptions = {
|
||||
...MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(),
|
||||
disableTooltipInteractivity: true
|
||||
};
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule,
|
||||
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, OnDestroy {
|
||||
@Input()
|
||||
placeholder: string;
|
||||
|
||||
@Input()
|
||||
agentId: string;
|
||||
|
||||
@Input()
|
||||
useStoredNodes: boolean;
|
||||
|
||||
@Output()
|
||||
searchSubmitted = new EventEmitter<void>();
|
||||
|
||||
private readonly storedNodesKey = 'knowledgeRetrievalNodes';
|
||||
|
||||
private _agentControl = new FormControl<Agent>(null);
|
||||
private _agents: Agent[] = [];
|
||||
private onDestroy$ = new Subject<void>();
|
||||
private selectedNodesState: SelectionState;
|
||||
private _queryControl = new FormControl('');
|
||||
private _initialsByAgentId: { [key: string]: string } = {};
|
||||
|
||||
avatarsMocked = true;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private store: Store<AppStore>,
|
||||
private searchAiService: SearchAiService,
|
||||
private notificationService: NotificationService,
|
||||
private agentService: AgentService,
|
||||
private userPreferencesService: UserPreferencesService,
|
||||
private translateService: TranslateService,
|
||||
private modalAiService: ModalAiService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.useStoredNodes) {
|
||||
this.store
|
||||
.select(getAppSelection)
|
||||
.pipe(takeUntil(this.onDestroy$))
|
||||
.subscribe((selection) => {
|
||||
this.selectedNodesState = selection;
|
||||
});
|
||||
} else {
|
||||
this.selectedNodesState = JSON.parse(this.userPreferencesService.get(this.storedNodesKey));
|
||||
}
|
||||
|
||||
this.agentService
|
||||
.getAgents()
|
||||
.pipe(takeUntil(this.onDestroy$))
|
||||
.subscribe(
|
||||
(agents) => {
|
||||
// TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
|
||||
this._agents = getAgentsWithMockedAvatars(agents, this.avatarsMocked);
|
||||
|
||||
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'))
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy$.next();
|
||||
this.onDestroy$.complete();
|
||||
}
|
||||
|
||||
onSearchSubmit() {
|
||||
this.modalAiService.openUnsavedChangesModal(() => this.search());
|
||||
}
|
||||
|
||||
search() {
|
||||
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.queryControl.reset();
|
||||
this.searchSubmitted.emit();
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,116 @@
|
||||
<aca-page-layout>
|
||||
<aca-search-ai-input-container
|
||||
class="aca-page-layout-header"
|
||||
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
|
||||
[agentId]="agentId"
|
||||
[useStoredNodes]="true"
|
||||
*ngIf="!hasError && agentId">
|
||||
</aca-search-ai-input-container>
|
||||
<div
|
||||
class="aca-page-layout-content"
|
||||
*ngIf="!hasError">
|
||||
<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">
|
||||
<ng-container *ngIf="!loading else skeleton">
|
||||
<div
|
||||
class="aca-search-ai-response-container-body"
|
||||
*ngIf="!hasAnsweringError">
|
||||
<div
|
||||
class="aca-search-ai-response-container-body-response"
|
||||
data-automation-id="aca-search-ai-results-response">
|
||||
{{ queryAnswer?.answer }}
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
class="aca-search-ai-response-container-body-response-action"
|
||||
mat-icon-button
|
||||
data-automation-id="aca-search-ai-results-thumb-up-button"
|
||||
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.LIKE_BUTTON_LABEL' | translate">
|
||||
<mat-icon>thumb_up</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-thumb-down"
|
||||
mat-icon-button
|
||||
data-automation-id="aca-search-ai-results-thumb-down-button"
|
||||
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.DISLIKE_BUTTON_LABEL' | translate">
|
||||
<mat-icon>thumb_down</mat-icon>
|
||||
</button>
|
||||
<ng-container *ngIf="nodes?.length">
|
||||
<mat-divider class="aca-search-ai-response-container-body-divider"></mat-divider>
|
||||
<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>
|
||||
<div class="aca-search-ai-response-container-body-references-container-documents">
|
||||
<a
|
||||
class="aca-search-ai-response-container-body-references-container-documents-document"
|
||||
*ngFor="let node of nodes"
|
||||
[attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'"
|
||||
(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>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="hasAnsweringError"
|
||||
class="aca-search-ai-response-container-error-message">
|
||||
{{ '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>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<adf-empty-content
|
||||
class="aca-page-layout-content"
|
||||
icon="star"
|
||||
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR"
|
||||
*ngIf="hasError">
|
||||
</adf-empty-content>
|
||||
</aca-page-layout>
|
||||
|
||||
<ng-template #skeleton>
|
||||
<div class="adf-skeleton"></div>
|
||||
<div class="adf-skeleton"></div>
|
||||
<div class="adf-skeleton adf-skeleton-half"></div>
|
||||
</ng-template>
|
@@ -0,0 +1,182 @@
|
||||
@import '@alfresco/adf-core/lib/styles/mat-selectors';
|
||||
|
||||
.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: white;
|
||||
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-card-view-border-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;
|
||||
|
||||
&-action {
|
||||
width: max-content;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17.25px;
|
||||
}
|
||||
|
||||
&-regeneration {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
&-thumb-down {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
#{$mat-button-touch-target} {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-divider {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
&-references-container {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
|
||||
&-header {
|
||||
margin-top: 16px;
|
||||
color: var(--theme-text-light-color);
|
||||
font-weight: 400;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
&-documents {
|
||||
padding-right: 5px;
|
||||
padding-top: 5px;
|
||||
margin-left: -2px;
|
||||
display: flex;
|
||||
gap: 21px;
|
||||
|
||||
&-document {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
|
||||
&-icon {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,372 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { of, Subject, throwError } from 'rxjs';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { EmptyContentComponent, 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, NodesApiService, SearchAiService } from '@alfresco/adf-content-services';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ModalAiService } from '../../../../services/modal-ai.service';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AiAnswer, AiAnswerEntry, QuestionModel } 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 { ViewerService } from '@alfresco/aca-content/viewer';
|
||||
|
||||
const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } };
|
||||
const aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] };
|
||||
const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => {
|
||||
return { entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } };
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
afterEach(() => {
|
||||
store.resetSelectors();
|
||||
mockQueryParams = new Subject<Params>();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: NodesApiService,
|
||||
useValue: {
|
||||
getNode: () => of({ id: 'someId', isFolder: true }).pipe(delay(50))
|
||||
}
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParams: mockQueryParams.asObservable(),
|
||||
snapshot: {
|
||||
queryParams: { query: 'testQuery' }
|
||||
}
|
||||
}
|
||||
},
|
||||
provideMockStore()
|
||||
]
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(SearchAiResultsComponent);
|
||||
modalAiService = TestBed.inject(ModalAiService);
|
||||
searchAiService = TestBed.inject(SearchAiService);
|
||||
userPreferencesService = TestBed.inject(UserPreferencesService);
|
||||
viewerService = TestBed.inject(ViewerService);
|
||||
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;
|
||||
component.ngOnInit();
|
||||
});
|
||||
|
||||
describe('query params change', () => {
|
||||
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.queryAnswer).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.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
|
||||
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.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
|
||||
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.queryAnswer).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.queryAnswer).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.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
|
||||
expect(component.hasAnsweringError).toBeFalse();
|
||||
}));
|
||||
|
||||
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(fixture.debugElement.query(By.directive(SearchAiInputComponent))).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render empty content', () => {
|
||||
mockQueryParams.next({
|
||||
location: 'some-location'
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.directive(EmptyContentComponent))).toBeNull();
|
||||
});
|
||||
|
||||
it('should not display search query', () => {
|
||||
mockQueryParams.next(params);
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(fixture.debugElement.query(By.css(`[data-automation-id="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();
|
||||
|
||||
fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click();
|
||||
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
|
||||
expect(component.queryAnswer).toEqual(aiAnswerMock);
|
||||
});
|
||||
});
|
||||
|
||||
describe('References', () => {
|
||||
let documentElement: HTMLDivElement;
|
||||
let nodesOrder: string[];
|
||||
|
||||
const nodeId = 'someId';
|
||||
const url = 'some-url';
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder ??= passedNodesOrder));
|
||||
spyOn(userPreferencesService, 'set');
|
||||
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
|
||||
const answer = getAiAnswerEntry();
|
||||
answer.entry.references = [{ referenceId: nodeId, referenceText: 'some text' }];
|
||||
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(answer));
|
||||
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
|
||||
|
||||
tick(3051);
|
||||
fixture.detectChanges();
|
||||
documentElement = fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-someId-document"]`)).nativeElement;
|
||||
spyOn(store, 'dispatch');
|
||||
spyOnProperty(TestBed.inject(Router), 'url').and.returnValue(url);
|
||||
}));
|
||||
|
||||
it('should dispatch ViewNodeAction on store when clicked', () => {
|
||||
documentElement.click();
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
new ViewNodeAction(nodeId, {
|
||||
location: url
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch ViewNodeAction on store when pressed enter', () => {
|
||||
documentElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', {
|
||||
key: 'Enter'
|
||||
})
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
new ViewNodeAction(nodeId, {
|
||||
location: url
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should assign nodes ids to customNodesOrder for ViewerService', () => {
|
||||
expect(nodesOrder).toEqual([nodeId]);
|
||||
});
|
||||
|
||||
it('should call set on userPreferencesService with correct parameters', () => {
|
||||
expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId]));
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,245 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared';
|
||||
import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import {
|
||||
AvatarComponent,
|
||||
ClipboardService,
|
||||
EmptyContentComponent,
|
||||
ThumbnailService,
|
||||
ToolbarModule,
|
||||
UnsavedChangesGuard,
|
||||
UserPreferencesService
|
||||
} 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 { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { NodesApiService } from '@alfresco/adf-content-services';
|
||||
import { forkJoin, 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';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
PageLayoutComponent,
|
||||
ToolbarActionComponent,
|
||||
ToolbarModule,
|
||||
ToolbarComponent,
|
||||
SearchAiInputContainerComponent,
|
||||
TranslateModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatListModule,
|
||||
EmptyContentComponent,
|
||||
MatCardModule,
|
||||
AvatarComponent,
|
||||
MatTooltipModule
|
||||
],
|
||||
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, OnDestroy {
|
||||
private _agentId: string;
|
||||
private _hasAnsweringError = false;
|
||||
private _hasError = false;
|
||||
private _loading = false;
|
||||
private _mimeTypeIconsByNodeId: { [key: string]: string } = {};
|
||||
private _nodes: Node[] = [];
|
||||
private openedViewer = false;
|
||||
private _selectedNodesState: SelectionState;
|
||||
private _searchQuery = '';
|
||||
private _queryAnswer: AiAnswer;
|
||||
|
||||
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 nodes(): Node[] {
|
||||
return this._nodes;
|
||||
}
|
||||
|
||||
get queryAnswer(): AiAnswer {
|
||||
return this._queryAnswer;
|
||||
}
|
||||
|
||||
get searchQuery(): string {
|
||||
return this._searchQuery;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private clipboardService: ClipboardService,
|
||||
private thumbnailService: ThumbnailService,
|
||||
private nodesApiService: NodesApiService,
|
||||
private userPreferencesService: UserPreferencesService,
|
||||
private translateService: TranslateService,
|
||||
private unsavedChangesGuard: UnsavedChangesGuard,
|
||||
private modalAiService: ModalAiService,
|
||||
private viewerService: ViewerService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
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);
|
||||
}),
|
||||
takeUntil(this.onDestroy$)
|
||||
)
|
||||
.subscribe((params) => {
|
||||
this._agentId = params.agentId;
|
||||
this._searchQuery = params.query ? decodeURIComponent(params.query) : '';
|
||||
if (!this.searchQuery || !this.agentId) {
|
||||
this._hasError = true;
|
||||
return;
|
||||
}
|
||||
this._selectedNodesState = JSON.parse(this.userPreferencesService.get('knowledgeRetrievalNodes'));
|
||||
this.performAiSearch();
|
||||
});
|
||||
super.ngOnInit();
|
||||
|
||||
this.unsavedChangesGuard.unsaved = this.route.snapshot?.queryParams?.query?.length > 0;
|
||||
this.unsavedChangesGuard.data = {
|
||||
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.CONVERSATION_DISCARDED',
|
||||
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.OKAY',
|
||||
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy$.next();
|
||||
this.onDestroy$.complete();
|
||||
}
|
||||
|
||||
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.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)),
|
||||
switchMap((response) => {
|
||||
if (!response.entry?.answer) {
|
||||
return throwError((e) => e);
|
||||
}
|
||||
this._queryAnswer = response.entry;
|
||||
return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId)));
|
||||
}),
|
||||
retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)),
|
||||
finalize(() => (this._loading = false)),
|
||||
takeUntil(this.onDestroy$)
|
||||
)
|
||||
.subscribe(
|
||||
(nodes) => {
|
||||
nodes.forEach((node) => {
|
||||
this._mimeTypeIconsByNodeId[node.id] = this.thumbnailService.getMimeTypeIcon(node.content?.mimeType);
|
||||
});
|
||||
this._nodes = nodes;
|
||||
const nodesIds = nodes.map((node) => node.id);
|
||||
this.viewerService.customNodesOrder = nodesIds;
|
||||
this.userPreferencesService.set('aiReferences', JSON.stringify(nodesIds));
|
||||
},
|
||||
() => (this._hasAnsweringError = true)
|
||||
);
|
||||
}
|
||||
|
||||
openFile(id: string): void {
|
||||
this.store.dispatch(
|
||||
new ViewNodeAction(id, {
|
||||
location: this.router.url
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> {
|
||||
this._hasAnsweringError = false;
|
||||
const delayBetweenRetries = 3000;
|
||||
const maxRetries = 9;
|
||||
|
||||
return errors.pipe(
|
||||
skipWhile(() => this.hasAnsweringError),
|
||||
delay(delayBetweenRetries),
|
||||
concatMap((e, index) => {
|
||||
if (index === maxRetries) {
|
||||
this._hasAnsweringError = true;
|
||||
this._loading = false;
|
||||
return throwError(e);
|
||||
}
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,35 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { Agent } from '@alfresco/js-api/typings';
|
||||
|
||||
export const getAgentsWithMockedAvatars = (agents: Agent[], mocked: boolean) => {
|
||||
if (mocked) {
|
||||
const images = ['assets/images/avatars/Blue.png', 'assets/images/avatars/Gold.png', 'assets/images/avatars/Pink.png'];
|
||||
return agents.map((agent, index) => {
|
||||
return { ...agent, avatarUrl: images[index > 2 ? 2 : index] };
|
||||
});
|
||||
}
|
||||
return agents;
|
||||
};
|
@@ -1,9 +1,17 @@
|
||||
<aca-page-layout>
|
||||
<div class="aca-page-layout-header">
|
||||
<h1 class="aca-page-title">
|
||||
<aca-search-ai-input-container
|
||||
*ngIf="searchAiInputState.active; else header"
|
||||
[agentId]="searchAiInputState.selectedAgentId">
|
||||
</aca-search-ai-input-container>
|
||||
<ng-template #header>
|
||||
<div class="aca-header-container">
|
||||
<h1 class="aca-page-title">
|
||||
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.RECENT.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
|
||||
</h1>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div class="aca-page-layout-content">
|
||||
|
@@ -39,6 +39,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
|
||||
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
|
||||
import { DocumentListDirective } from '../../directives/document-list.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -54,11 +55,13 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
PageLayoutComponent,
|
||||
TranslateModule,
|
||||
ToolbarComponent,
|
||||
SearchAiInputContainerComponent,
|
||||
EmptyContentComponent,
|
||||
DynamicColumnComponent
|
||||
],
|
||||
templateUrl: './recent-files.component.html',
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
selector: 'aca-recent-files'
|
||||
})
|
||||
export class RecentFilesComponent extends PageComponent implements OnInit {
|
||||
columns: DocumentListPresetRef[] = [];
|
||||
|
@@ -1,9 +1,15 @@
|
||||
<aca-page-layout>
|
||||
<aca-page-layout [class.aca-search-results-active-search-ai-input]="searchAiInputState.active">
|
||||
<div class="aca-page-layout-header">
|
||||
<aca-search-input></aca-search-input>
|
||||
<aca-bulk-actions-dropdown *ngIf="bulkActions" [items]="bulkActions"></aca-bulk-actions-dropdown>
|
||||
<div class="aca-search-toolbar-spacer"></div>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
<aca-search-ai-input-container
|
||||
*ngIf="searchAiInputState.active"
|
||||
[agentId]="searchAiInputState.selectedAgentId">
|
||||
</aca-search-ai-input-container>
|
||||
<div class="aca-header-container">
|
||||
<aca-search-input></aca-search-input>
|
||||
<aca-bulk-actions-dropdown *ngIf="bulkActions" [items]="bulkActions"></aca-bulk-actions-dropdown>
|
||||
<div class="aca-search-toolbar-spacer"></div>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aca-page-layout-content">
|
||||
|
@@ -1,6 +1,13 @@
|
||||
@import '../../../ui/mixins';
|
||||
|
||||
aca-search-results {
|
||||
.aca-search-results-active-search-ai-input {
|
||||
.aca-header-container,
|
||||
.adf-search-results__content-header.aca-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.aca-search-toolbar-spacer {
|
||||
width: 100%;
|
||||
}
|
||||
|
@@ -77,6 +77,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { SearchResultsRowComponent } from '../search-results-row/search-results-row.component';
|
||||
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -110,7 +111,8 @@ import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-a
|
||||
DateColumnHeaderComponent,
|
||||
CustomEmptyContentTemplateDirective,
|
||||
ViewerToolbarComponent,
|
||||
BulkActionsDropdownComponent
|
||||
BulkActionsDropdownComponent,
|
||||
SearchAiInputContainerComponent
|
||||
],
|
||||
selector: 'aca-search-results',
|
||||
templateUrl: './search-results.component.html',
|
||||
|
@@ -1,10 +1,18 @@
|
||||
<aca-page-layout>
|
||||
<div class="aca-page-layout-header">
|
||||
<h1 class="aca-page-title">
|
||||
<aca-search-ai-input-container
|
||||
*ngIf="searchAiInputState.active; else header"
|
||||
[agentId]="searchAiInputState.selectedAgentId">
|
||||
</aca-search-ai-input-container>
|
||||
<ng-template #header>
|
||||
<div class="aca-header-container">
|
||||
<h1 class="aca-page-title">
|
||||
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.SHARED.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
|
||||
</h1>
|
||||
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
<aca-toolbar [items]="actions"></aca-toolbar>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div class="aca-page-layout-content">
|
||||
|
@@ -40,6 +40,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
|
||||
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
|
||||
import { DocumentListDirective } from '../../directives/document-list.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -55,11 +56,13 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
PageLayoutComponent,
|
||||
TranslateModule,
|
||||
ToolbarComponent,
|
||||
SearchAiInputContainerComponent,
|
||||
EmptyContentComponent,
|
||||
DynamicColumnComponent
|
||||
],
|
||||
templateUrl: './shared-files.component.html',
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
selector: 'aca-shared-files'
|
||||
})
|
||||
export class SharedFilesComponent extends PageComponent implements OnInit {
|
||||
columns: DocumentListPresetRef[] = [];
|
||||
|
@@ -118,5 +118,25 @@ describe('AcaExpansionPanel', () => {
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not navigate to first child if none is active route and acaExpansionPanel has canBeInactive property', () => {
|
||||
const router: any = new RouterStub('dummy-route-2');
|
||||
spyOn(router, 'navigate').and.callThrough();
|
||||
const item = {
|
||||
children: [{ url: 'dummy-route-1' }, { url: 'dummy-route-2' }],
|
||||
data: {
|
||||
canBeInactive: true
|
||||
}
|
||||
};
|
||||
|
||||
const directive = new ExpansionPanelDirective(mockStore, router, mockMatExpansionPanel);
|
||||
|
||||
directive.acaExpansionPanel = item;
|
||||
mockMatExpansionPanel.expanded = true;
|
||||
|
||||
directive.onClick();
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -42,7 +42,7 @@ export class ExpansionPanelDirective implements OnInit, OnDestroy {
|
||||
|
||||
@HostListener('click')
|
||||
onClick() {
|
||||
if (this.expansionPanel.expanded && !this.hasActiveLinks()) {
|
||||
if (this.expansionPanel.expanded && !this.hasActiveLinks() && !this.acaExpansionPanel.data?.canBeInactive) {
|
||||
const firstChild = this.acaExpansionPanel.children[0];
|
||||
if (firstChild.url) {
|
||||
this.router.navigate(this.getNavigationCommands(firstChild.url));
|
||||
|
114
projects/aca-content/src/lib/services/modal-ai.service.spec.ts
Normal file
114
projects/aca-content/src/lib/services/modal-ai.service.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { ContentTestingModule } from '@alfresco/adf-content-services';
|
||||
import { MatDialog, MatDialogConfig, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { 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: [ContentTestingModule, 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();
|
||||
});
|
||||
});
|
||||
});
|
62
projects/aca-content/src/lib/services/modal-ai.service.ts
Normal file
62
projects/aca-content/src/lib/services/modal-ai.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 route = inject(ActivatedRoute);
|
||||
private dialog = inject(MatDialog);
|
||||
private 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,134 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { ContentTestingModule } from '@alfresco/adf-content-services';
|
||||
|
||||
describe('SearchAiNavigationService', () => {
|
||||
let service: SearchAiNavigationService;
|
||||
let router: Router;
|
||||
|
||||
const knowledgeRetrievalUrl = '/knowledge-retrieval';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ContentTestingModule]
|
||||
});
|
||||
service = TestBed.inject(SearchAiNavigationService);
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
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.navigateToPreviousRoute();
|
||||
|
||||
expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
|
||||
});
|
||||
|
||||
it('should not navigate if there is not previous route and actual route is not knowledge retrieval', () => {
|
||||
urlSpy.and.returnValue('/some-url');
|
||||
service.navigateToPreviousRoute();
|
||||
|
||||
expect(navigateByUrlSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.navigateToPreviousRoute();
|
||||
|
||||
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.navigateToPreviousRoute();
|
||||
|
||||
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.navigateToPreviousRoute();
|
||||
|
||||
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.navigateToPreviousRoute();
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,48 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { Injectable } from '@angular/core';
|
||||
import { Params, Router } from '@angular/router';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchAiNavigationService {
|
||||
private readonly knowledgeRetrievalRoute = '/knowledge-retrieval';
|
||||
|
||||
private previousRoute = '';
|
||||
|
||||
constructor(private router: Router) {}
|
||||
|
||||
navigateToPreviousRoute(): void {
|
||||
if (this.router.url.includes(this.knowledgeRetrievalRoute)) {
|
||||
void this.router.navigateByUrl(this.previousRoute || '/personal-files');
|
||||
}
|
||||
}
|
||||
|
||||
navigateToSearchAi(queryParams: Params): void {
|
||||
if (!this.router.url.includes(this.knowledgeRetrievalRoute)) {
|
||||
this.previousRoute = this.router.url;
|
||||
}
|
||||
void this.router.navigate([this.knowledgeRetrievalRoute], { queryParams });
|
||||
}
|
||||
}
|
@@ -41,6 +41,7 @@ import {
|
||||
ContextMenuEffects
|
||||
} from './effects';
|
||||
import { INITIAL_STATE } from './initial-state';
|
||||
import { SearchAiEffects } from './effects/search-ai.effects';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -69,6 +70,8 @@ import { INITIAL_STATE } from './initial-state';
|
||||
FavoriteEffects,
|
||||
TemplateEffects,
|
||||
ContextMenuEffects,
|
||||
SearchAiEffects,
|
||||
ContextMenuEffects,
|
||||
SnackbarEffects,
|
||||
RouterEffects
|
||||
])
|
||||
|
@@ -33,3 +33,4 @@ export * from './effects/upload.effects';
|
||||
export * from './effects/upload.effects';
|
||||
export * from './effects/template.effects';
|
||||
export * from './effects/contextmenu.effects';
|
||||
export * from './effects/search-ai.effects';
|
||||
|
@@ -0,0 +1,65 @@
|
||||
/*!
|
||||
* Copyright © 2005-2024 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 { 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 {
|
||||
constructor(private actions$: Actions, private searchNavigationService: SearchAiNavigationService, private searchAiService: 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
|
||||
})
|
||||
)
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
@@ -66,3 +66,11 @@ ng-component {
|
||||
color: var(--adf-theme-foreground-text-color-087);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aca-header-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
@@ -338,3 +338,9 @@ adf-dynamic-component {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.adf-unsaved-changes-dialog {
|
||||
.adf-unsaved-changes-dialog-actions-discard-changes-button:is(button) {
|
||||
background-color: var(--theme-blue-button-color);
|
||||
}
|
||||
}
|
||||
|
@@ -28,6 +28,7 @@ $blue-save-button-background: #1f74db;
|
||||
$blue-checkbox-background: rgb(10, 96, 206);
|
||||
$blue-active-table-row: rgb(10, 96, 206, 0.24);
|
||||
$black-heading: #4e4c4c;
|
||||
$light-grey-content: #4b5563;
|
||||
$theme-dropdown-background: darken($background-color, 5%);
|
||||
$theme-dropdown-background-hover: darken($background-color, 10%);
|
||||
$grey-divider: rgba(0, 0, 0, 0.22);
|
||||
@@ -46,6 +47,11 @@ $disabled-chip-background-color: #f5f5f5;
|
||||
$contrast-gray: mat.get-color-from-palette($foreground, 'secondary-tex');
|
||||
$search-highlight-background-color: #ffd180;
|
||||
$info-snackbar-background: #1f74db;
|
||||
$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;
|
||||
|
||||
// CSS Variables
|
||||
$defaults: (
|
||||
@@ -75,6 +81,7 @@ $defaults: (
|
||||
--theme-blue-checkbox-color: $blue-checkbox-background,
|
||||
--theme-blue-active-table-row-color: $blue-active-table-row,
|
||||
--theme-heading-color: $black-heading,
|
||||
--theme-content-color: $light-grey-content,
|
||||
--theme-dropdown-color: $theme-dropdown-background,
|
||||
--theme-dropdown-background-hover: $theme-dropdown-background-hover,
|
||||
--theme-grey-divider-color: $grey-divider,
|
||||
@@ -96,7 +103,12 @@ $defaults: (
|
||||
--theme-search-chip-icon-color: $search-chip-icon-color,
|
||||
--theme-disabled-chip-background-color: $disabled-chip-background-color,
|
||||
--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-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
|
||||
);
|
||||
|
||||
// propagates SCSS variables into the CSS variables scope
|
||||
|
Reference in New Issue
Block a user