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

* [ACS-12037] Remove Knowledge Discovery from ACA

* [ACS-12037] CR comments

* [ACS-12037] Update package-lock

* [ACS-12037] Add new rule documentation

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