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