[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:
jacekpluta
2024-09-20 14:47:41 +02:00
committed by GitHub
parent cde561806d
commit edcc6f8047
65 changed files with 3778 additions and 255 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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">

View File

@@ -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[] = [];

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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');
});
});
});
});

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
});
});
});

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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();
});
});
}
});

View File

@@ -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();
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}
}
}
}
}

View File

@@ -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']);
});
});
});

View File

@@ -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);
})
);
}
}

View File

@@ -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;
};

View File

@@ -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">

View File

@@ -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[] = [];

View File

@@ -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">

View File

@@ -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%;
}

View File

@@ -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',

View File

@@ -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">

View File

@@ -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[] = [];

View File

@@ -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();
});
});
});

View File

@@ -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));

View 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();
});
});
});

View 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();
}
});
}
}

View File

@@ -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
});
});
});
});

View File

@@ -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 });
}
}

View File

@@ -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
])

View File

@@ -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';

View File

@@ -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 }
);
}

View File

@@ -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%;
}

View File

@@ -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);
}
}

View File

@@ -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