[ACS-8201] Knowledge Retrieval - getting AI response for one or more selected files (#4127)

* [ACS-8202] basic flow getting ai response for one or more selected files (#3936)

* ACS-8202 Added animated icon

* ACS-8202 Added search ai input

* ACS-8202 Added AI search results page

* ACS-8202 Allow to run knowledge retrieval on files inside library, shared, favourites and recent files

* ACS-8202 Hide icon when selected more than 100 files or non text files

* ACS-8202 Display notification when too many files are selected

* ACS-8202 Added agents dropdown

* ACS-8202 Styles for AI response

* ACS-8202 Applied design changes

* ACS-8202 Added query card to Knowledge retrieval page results

* ACS-8202 Fixed search collapsing when opened results page

* ACS-8202 Changed placeholder in input for results page, wrapping text and scrolling for results page

* ACS-8202 Display snackbar with messages when conditions are not met

* ACS-8202 Disallow run knowledge retrieval for libraries, leave input when click on x button

* ACS-8202 Renaming files

* ACS-8202 Trigger ai input by selecting agent instead of clicking on button

* ACS-8202 Reverted triggering showing input by selecting option from select

* ACS-8202 Display dropdown with agents by clicking on button

* ACS-8202 Structural changes - services and agents button component

* ACS-8202 Removed part for examples from search page

* ACS-8202 Simplified html for search page

* ACS-8202 Refactored html and styles for search page, translations for search page

* ACS-8202 More html and styles refactoring

* ACS-8202 Formatting html

* ACS-8202 Removed references to angular material classes

* ACS-8202 Added data automation id attributes

* ACS-8202 Load agents from backend, formatting html for agents button component and adding data automation ids to that component

* ACS-8202 Correction after rebase

* ACS-8202 Set agent for input based on selected agent from dropdown for agents button

* ACS-8202 Hide agent button for libraries pages and use translations for warnings when clicked on agents button

* ACS-8202 Pass agent id to search results page

* ACS-8202 Used form control instead of ngmodel for search query

* ACS-8202 Moved search ai service and search ai input state to ADF

* ACS-8202 Results page ts clean up

* ACS-8202 Used ask and getAnswer functions from search ai service

* ACS-8202 Cleaning of search ai navigation service

* ACS-8202 Small clean ups

* ACS-8202 Renamed sources to references

* ACS-8202 Fixed asking next question from results page

* ACS-8202 Added possibility to use knowledge retrieval from search results page

* ACS-8202 Fixed issue with selecting the same agent after previously closing input on search results page

* ACS-8202 Disallowed using knowledge retrieval on trash page

* ACS-8202 Hide toggling knowledge retrieval for tasks and processes, fixed displaying ask button for favorites page

* ACS-8202 Removed redundant image and function

* ACS-8202 Renamed breadcrumbTemplate to header

* ACS-8202 Removed redundant code, added some comments, made some fields as private

* ACS-8202 Display error message on search page

* ACS-8202 Accessibility changes

* ACS-8202 Small correction

* ACS-8202 Addressed comments

* ACS-8202 Displayed correct initials

* ACS-8202 Removed redundant imports

* ACS-8202 Change css value

* ACS-8202 Removed icon animation

* ACS-8202 Removed icon animation

* ACS-8201 Small correction after rebasing with Angular 15

* [ACS-8398] unit tests (#3973)

* ACS-8398 Unit tests for agents button and part for agents menu

* ACS-8398 Unit tests for search ai input component

* [ACS-8210] Agent basic details popup (#3942)

* [ACS-8210] Agent basic details popup

* [ACS-8210] Agent basic details popup - review fixes

---------

Co-authored-by: Aleksander Sklorz <aleksander.sklorz@hyland.com>

* [ACS-8382] Blurring the AI answer section before getting response from backend (#3948)

* [ACS-8398] Unit tests part 2 (#3989)

* ACS-8398 Unit tests for search ai input container

* ACS-8398 Unit tests for search ai navigation service and rest tests for search ai input container component

* ACS-8398 Added missing type

* [ACS-8484] Add feature flag to knowledge retrieval (#4003)

* [ACS-8562] "Ask Agent" button name is changed to "Ask Discovery"

* [ACS-8573] Allow user to ask question without file selection

* [ACS-8312] Display warning about losing response (#4012)

* ACS-8201 Fixed issues after rebase

* [ACS-8588] Navigation is triggered twice when leaving Knowledge Retrieval page (#4030)

[ACS-8588] Navigation is triggered twice when leaving Knowledge Retrieval page

* [ACS-8399] Integrate all changes with backend (#4076)

* [ACS-8399] Integrate all changes with backend

* [ACS-8399] Integrate all changes with backend - review fixes

* Answers endpoint fix

* Answers endpoint fix (#4107)

* [ACS-8664] generic question redirection to hx insight page (#4102)

* ACS-8664 Open page in new tab

* ACS-8664 Loading HX insight url

* ACS-8664 Unit tests

* ACS-8664 Fix after rebasing

* ACS-8664 Fixed unit tests

* ACS-8664 Added type

* ACS-8664 Removed duplicated lines

* ACS-8664 Removed duplicated lines

* ACS-8664 Addressed comments

* [ACS-8695] Getting Agent avatar (#4110)

* [ACS-8695] Getting Agent avatar

* [ACS-8695] Getting Agent avatar - fixes

* [ACS-8695] Getting Agent avatar - fixes 2

* Adding mocked agent avatars (#4117)

* [ACS-8201] review fixes

* [ACS-8201] review fixes

* [E2E] excluded failing tests to fix later pt.1

* [ACS-8767] allow to open referenced files (#4129)

* ACS-8767 Opening referenced files

* ACS-8767 Reverted one line

* ACS-8767 Removed unwanted code

* ACS-8767

* ACS-8767 Unit tests for allowing clicking on references

* ACS-8767 Unit tests

* ACS-8767 Moved duplicated code to function

* ACS-8767 Resolved sonar issue

* ACS-8767 Resolved sonar issue

* [ACS-8201] knowledge retrieval feature flag - false

* [E2E] excluded failing tests to fix later pt.2

* ACS-8201 Fixed tests

---------

Co-authored-by: AleksanderSklorz <115619721+AleksanderSklorz@users.noreply.github.com>
Co-authored-by: Aleksander Sklorz <Aleksander.Sklorz@hyland.com>
Co-authored-by: datguychen <adam.swiderski@hyland.com>
This commit is contained in:
jacekpluta 2024-09-20 14:47:41 +02:00 committed by GitHub
parent cde561806d
commit edcc6f8047
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 3778 additions and 255 deletions

View File

@ -14,7 +14,8 @@
"contentService": true,
"folderRules": true,
"tagsEnabled": true,
"categoriesEnabled": true
"categoriesEnabled": true,
"knowledgeRetrievalEnabled": false
},
"oauth2": {
"host": "{protocol}//{hostname}{:port}/auth/realms/alfresco",

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -1 +1,13 @@
{}
{
"C217135": "https://hyland.atlassian.net/browse/ACS-8812",
"C291888": "https://hyland.atlassian.net/browse/ACS-8812",
"C291889": "https://hyland.atlassian.net/browse/ACS-8812",
"C217137": "https://hyland.atlassian.net/browse/ACS-8812",
"C217138": "https://hyland.atlassian.net/browse/ACS-8812",
"C217139": "https://hyland.atlassian.net/browse/ACS-8812",
"C217140": "https://hyland.atlassian.net/browse/ACS-8812",
"C217171": "https://hyland.atlassian.net/browse/ACS-8812",
"C217172": "https://hyland.atlassian.net/browse/ACS-8812",
"C217173": "https://hyland.atlassian.net/browse/ACS-8812",
"C217174": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -1,3 +1,18 @@
{
"C280502": "https://hyland.atlassian.net/browse/ACS-8200"
"C217125": "https://hyland.atlassian.net/browse/ACS-8812",
"C217129": "https://hyland.atlassian.net/browse/ACS-8812",
"C217091": "https://hyland.atlassian.net/browse/ACS-8812",
"C280416": "https://hyland.atlassian.net/browse/ACS-8812",
"C290103": "https://hyland.atlassian.net/browse/ACS-8812",
"C280417": "https://hyland.atlassian.net/browse/ACS-8812",
"C217177": "https://hyland.atlassian.net/browse/ACS-8812",
"C280438": "https://hyland.atlassian.net/browse/ACS-8812",
"C290104": "https://hyland.atlassian.net/browse/ACS-8812",
"C217182": "https://hyland.atlassian.net/browse/ACS-8812",
"C217178": "https://hyland.atlassian.net/browse/ACS-8812",
"C217179": "https://hyland.atlassian.net/browse/ACS-8812",
"C217183": "https://hyland.atlassian.net/browse/ACS-8812",
"C280502": "https://hyland.atlassian.net/browse/ACS-8812",
"C217130": "https://hyland.atlassian.net/browse/ACS-8812",
"C217184": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -1 +1,3 @@
{}
{
"XAT-5095": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -1 +1,3 @@
{}
{
"C280034": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -2,5 +2,6 @@
"C290019": "https://hyland.atlassian.net/browse/ACS-6928",
"C290018": "https://hyland.atlassian.net/browse/ACS-6928",
"C699046-3": "https://hyland.atlassian.net/browse/ACS-7464",
"C699498": "https://hyland.atlassian.net/browse/ACS-7682"
"C699498": "https://hyland.atlassian.net/browse/ACS-7682",
"C280034": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -1 +1,3 @@
{}
{
"C297548": "https://hyland.atlassian.net/browse/ACS-8812"
}

View File

@ -128,6 +128,16 @@
}
}
],
"icons": [
{
"id": "adf:three_magic_stars_ai",
"value": "./assets/images/three-magic-stars-ai.svg"
},
{
"id": "adf:colored-stars-ai",
"value": "./assets/images/colored-stars-ai.svg"
}
],
"create": [
{
"id": "app.create.folder",
@ -194,6 +204,9 @@
"order": 100,
"title": "APP.BROWSE.FILE.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.FILE.SIDENAV_LINK.TOOLTIP",
"data": {
"canBeInactive": true
},
"children": [
{
"id": "app.navbar.personalFiles",
@ -676,6 +689,22 @@
}
}
]
},
{
"id": "app.toolbar.ai.search",
"order": 0,
"title": "KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.TITLE",
"component": "app.toolbar.ai.agents-button",
"type": "custom",
"rules": {
"visible": "app.selection.displayedKnowledgeRetrievalButton"
},
"actions": {
"click": "app.action.toggle-ai-search-input.execute"
},
"data": {
"trigger": "TOGGLE_AI_SEARCH_INPUT"
}
}
],
"contextMenu": [

View File

@ -609,5 +609,41 @@
"BOOKS-24PX": "file library",
"BASELINE-LOCK-24PX": "locked file"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"RESULTS_PAGE": {
"QUERY_INPUT_PLACEHOLDER": "Would you like to ask anything else?",
"REFERENCED_DOCUMENTS_HEADER": "Referenced documents",
"REGENERATION_BUTTON_LABEL": "Regenerate",
"COPY_BUTTON_LABEL": "Copy",
"LIKE_BUTTON_LABEL": "Like",
"DISLIKE_BUTTON_LABEL": "Dislike",
"COPY_MESSAGE": "Copied response to clipboard"
},
"AGENTS_BUTTON": {
"LABEL": "Ask Discovery",
"TITLE": "Knowledge Retrieval"
},
"SEARCH_INPUT": {
"ASK_BUTTON_LABEL": "Ask",
"DEFAULT_PLACEHOLDER": "Please ask your question with as much detail as possible...",
"HIDE_INPUT": "Hide input"
},
"ERRORS": {
"AGENTS_FETCHING": "Error while fetching agents.",
"HX_INSIGHT_URL_FETCHING": "Error while fetching HX Insight URL.",
"LOADING_ERROR": "Hmm... something seems to have gone wrong.",
"PAGE_NOT_AVAILABLE_ERROR": "Page is not available for these conditions."
},
"DISCARD_CHANGES": {
"WARNING": "Warning!",
"ASK_AI": "Ask AI",
"DO_NOT_SHOW_MESSAGE": "Don't show this message again",
"OKAY": "Okay",
"CONVERSATION_DISCARDED": "This conversation will be discarded",
"LOSE_RESPONSE": "By asking another question you will lose your previous response"
}
}
}
}

View File

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

After

Width:  |  Height:  |  Size: 987 B

View File

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

After

Width:  |  Height:  |  Size: 944 B

View File

@ -77,6 +77,7 @@ import { ContextMenuComponent } from './components/context-menu/context-menu.com
import { MAT_DIALOG_DEFAULT_OPTIONS } from '@angular/material/dialog';
import { SearchResultsRowComponent } from './components/search/search-results-row/search-results-row.component';
import { BulkActionsDropdownComponent } from './components/bulk-actions-dropdown/bulk-actions-dropdown.component';
import { AgentsButtonComponent } from './components/knowledge-retrieval/search-ai/agents-button/agents-button.component';
@NgModule({
imports: [
@ -138,6 +139,7 @@ export class ContentServiceExtensionModule {
'app.toolbar.toggleFavorite': ToggleFavoriteComponent,
'app.toolbar.toggleFavoriteLibrary': ToggleFavoriteLibraryComponent,
'app.toolbar.toggleJoinLibrary': ToggleJoinLibraryButtonComponent,
'app.toolbar.ai.agents-button': AgentsButtonComponent,
'app.menu.toggleJoinLibrary': ToggleJoinLibraryMenuComponent,
'app.bulk-actions-dropdown': BulkActionsDropdownComponent,
'app.shared-link.toggleSharedLink': ToggleSharedComponent,
@ -197,6 +199,7 @@ export class ContentServiceExtensionModule {
'app.selection.hasNoLibraryRole': rules.hasNoLibraryRole,
'app.selection.folder': rules.hasFolderSelected,
'app.selection.folder.canUpdate': rules.canUpdateSelectedFolder,
'app.selection.displayedKnowledgeRetrievalButton': rules.canDisplayKnowledgeRetrievalButton,
'app.navigation.folder.canCreate': rules.canCreateFolder,
'app.navigation.folder.canUpload': rules.canUpload,

View File

@ -27,8 +27,8 @@ import { LibrariesComponent } from './components/libraries/libraries.component';
import { FavoriteLibrariesComponent } from './components/favorite-libraries/favorite-libraries.component';
import { SearchResultsComponent } from './components/search/search-results/search-results.component';
import { SearchLibrariesResultsComponent } from './components/search/search-libraries-results/search-libraries-results.component';
import { AppSharedRuleGuard, GenericErrorComponent, ExtensionRoute, ExtensionsDataLoaderGuard } from '@alfresco/aca-shared';
import { AuthGuard } from '@alfresco/adf-core';
import { AppSharedRuleGuard, GenericErrorComponent, ExtensionRoute, ExtensionsDataLoaderGuard, PluginEnabledGuard } from '@alfresco/aca-shared';
import { AuthGuard, UnsavedChangesGuard } from '@alfresco/adf-core';
import { FavoritesComponent } from './components/favorites/favorites.component';
import { RecentFilesComponent } from './components/recent-files/recent-files.component';
import { SharedFilesComponent } from './components/shared-files/shared-files.component';
@ -36,10 +36,11 @@ import { DetailsComponent } from './components/details/details.component';
import { HomeComponent } from './components/home/home.component';
import { ViewProfileComponent } from './components/view-profile/view-profile.component';
import { ViewProfileRuleGuard } from './components/view-profile/view-profile.guard';
import { Route } from '@angular/router';
import { Data, Route, Routes } from '@angular/router';
import { SharedLinkViewComponent } from './components/shared-link-view/shared-link-view.component';
import { TrashcanComponent } from './components/trashcan/trashcan.component';
import { ShellLayoutComponent } from '@alfresco/adf-core/shell';
import { SearchAiResultsComponent } from './components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component';
export const CONTENT_ROUTES: ExtensionRoute[] = [
{
@ -72,6 +73,36 @@ export const CONTENT_ROUTES: ExtensionRoute[] = [
}
];
const createViewRoutes = (navigateSource: string, additionalData: Data = {}): Routes => [
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
],
...additionalData
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
];
export const CONTENT_LAYOUT_ROUTES: Route = {
path: '',
canActivate: [ExtensionsDataLoaderGuard],
@ -117,32 +148,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
}
]
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'personal-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'personal-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('personal-files')
]
},
{
@ -156,32 +162,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
sortingPreferenceKey: 'personal-files'
}
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'personal-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'personal-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('personal-files')
]
},
{
@ -208,35 +189,11 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
sortingPreferenceKey: 'libraries-files'
}
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'libraries'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
],
...createViewRoutes('libraries', {
data: {
navigateSource: 'libraries'
}
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'libraries'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
})
]
},
{
@ -268,32 +225,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
sortingPreferenceKey: 'libraries-files'
}
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'libraries'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'libraries'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('libraries')
]
},
{
@ -310,32 +242,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
sortingPreferenceKey: 'favorites'
}
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'favorites'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'favorites'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('favorites')
]
},
{
@ -351,32 +258,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
title: 'APP.BROWSE.RECENT.TITLE'
}
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'recent-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'recent-files'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('recent-files')
]
},
{
@ -390,32 +272,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
},
component: SharedFilesComponent
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'shared'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'shared'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('shared')
],
canActivateChild: [AppSharedRuleGuard],
canActivate: [AppSharedRuleGuard]
@ -444,32 +301,7 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
sortingPreferenceKey: 'search'
}
},
{
path: 'view/:nodeId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'search'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
},
{
path: 'view/:nodeId/:versionId',
outlet: 'viewer',
children: [
{
path: '',
data: {
navigateSource: 'search'
},
loadChildren: () => import('@alfresco/aca-content/viewer').then((m) => m.AcaViewerModule)
}
]
}
...createViewRoutes('search')
]
},
{
@ -507,6 +339,21 @@ export const CONTENT_LAYOUT_ROUTES: Route = {
}
]
},
{
path: 'knowledge-retrieval',
canDeactivate: [UnsavedChangesGuard],
canActivate: [PluginEnabledGuard],
data: {
plugin: 'plugins.knowledgeRetrievalEnabled'
},
children: [
{
path: '',
component: SearchAiResultsComponent
},
...createViewRoutes('knowledge-retrieval')
]
},
{
path: '**',
component: GenericErrorComponent

View File

@ -1,10 +1,18 @@
<aca-page-layout>
<div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId">
</aca-search-ai-input-container>
<ng-template #header>
<div class="aca-header-container">
<h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.FAVORITES.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
</h1>
<aca-toolbar [items]="actions"></aca-toolbar>
</div>
</ng-template>
</div>
<div class="aca-page-layout-content">
<div class="aca-main-content">

View File

@ -46,6 +46,7 @@ import {
import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslateModule } from '@ngx-translate/core';
import { DocumentListComponent } from '@alfresco/adf-content-services';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@ -59,6 +60,7 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
PageLayoutComponent,
TranslateModule,
ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent,
DynamicColumnComponent,
DocumentListComponent,
@ -67,7 +69,8 @@ import { DocumentListComponent } from '@alfresco/adf-content-services';
CustomEmptyContentTemplateDirective
],
templateUrl: './favorites.component.html',
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
selector: 'aca-favorites'
})
export class FavoritesComponent extends PageComponent implements OnInit {
columns: DocumentListPresetRef[] = [];

View File

@ -1,13 +1,21 @@
<aca-page-layout [hasError]="!isValidPath">
<div class="aca-page-layout-header">
<adf-breadcrumb [root]="title"
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId">
</aca-search-ai-input-container>
<ng-template #header>
<div class="aca-header-container">
<adf-breadcrumb
[root]="title"
[folderNode]="node"
[selectedRowItemsCount]="selectedRowItemsCount"
[maxItems]="isSmallScreen ? 1 : 0"
[selectedRowItemsCount]="selectedRowItemsCount" [maxItems]="isSmallScreen ? 1 : 0"
(navigate)="onBreadcrumbNavigate($event)">
</adf-breadcrumb>
<aca-toolbar [items]="actions"></aca-toolbar>
</div>
</ng-template>
</div>
<div class="aca-page-layout-error">
<aca-generic-error></aca-generic-error>

View File

@ -58,6 +58,7 @@ import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@ -73,6 +74,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
PaginationDirective,
PageLayoutComponent,
ToolbarComponent,
SearchAiInputContainerComponent,
DynamicColumnComponent,
BreadcrumbComponent,
UploadDragAreaComponent,
@ -82,7 +84,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
CustomEmptyContentTemplateDirective
],
templateUrl: './files.component.html',
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
selector: 'aca-files'
})
export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
isValidPath = true;

View File

@ -0,0 +1,37 @@
<ng-container *ngIf="agents.length && hxInsightUrl">
<button
[matMenuTriggerFor]="disabled ? null : agentsList"
class="aca-agents-menu-button aca-agents-button-menu-trigger"
(mouseup)="onClick()"
(keydown.enter)="onClick()"
data-automation-id="aca-agents-button">
<adf-icon
value="adf:colored-stars-ai"
class="aca-agents-button-icon">
</adf-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.LABEL' | translate}}
</button>
<mat-menu
#agentsList="matMenu"
class="aca-agents-button-menu"
xPosition="before">
<mat-selection-list
(selectionChange)="onAgentSelection($event)"
[multiple]="false"
class="aca-agents-button-menu-list"
[hideSingleSelectionIndicator]="true">
<mat-list-option
*ngFor="let agent of agents"
class="aca-agents-button-menu-list-agent"
[attr.data-automation-id]="'aca-agents-button-agent-' + agent.id"
[value]="agent">
<div class="aca-agents-button-menu-list-agent-content">
<adf-avatar [src]="agent?.avatarUrl" [initials]="initialsByAgentId[agent.id]"></adf-avatar>
<span class="aca-agents-button-menu-list-agent-content-name" [matTooltip]="agent.name" [matTooltipPosition]="'right'">
{{ agent.name }}
</span>
</div>
</mat-list-option>
</mat-selection-list>
</mat-menu>
</ng-container>

View File

@ -0,0 +1,74 @@
aca-agents-button.aca-agents-button {
height: 32px;
display: block;
button {
&.aca-agents-menu-button {
display: flex;
align-items: end;
&.aca-agents-button-menu-trigger {
height: auto;
cursor: pointer;
border: none;
background: transparent;
width: max-content;
padding: 0 4px 0 0;
}
.aca-agents-button-icon {
display: flex;
align-self: baseline;
svg {
height: 32px;
width: 32px;
position: absolute;
margin-left: -21px;
}
}
}
}
}
.aca-agents-button-menu {
padding-top: 2px;
padding-bottom: 1px;
.aca-agents-button-menu-list {
margin-left: -6px;
padding-top: 0;
padding-bottom: 0;
&-agent {
height: 40px;
&:not(:last-child) {
margin-bottom: 2px;
}
&-content {
display: flex;
align-items: center;
&-name {
width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-right: 12px;
margin-bottom: 2px;
padding-left: 1px;
padding-top: 1px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
}

View File

@ -0,0 +1,461 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AgentsButtonComponent } from './agents-button.component';
import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
import { Subject } from 'rxjs';
import { By } from '@angular/platform-browser';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, SearchAiActionTypes, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { AvatarComponent, NotificationService } from '@alfresco/adf-core';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatMenu, MatMenuPanel, MatMenuTrigger } from '@angular/material/menu';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectionListHarness } from '@angular/material/list/testing';
import { MatMenuHarness } from '@angular/material/menu/testing';
import { MatSelectionList } from '@angular/material/list';
import { MatSnackBarRef } from '@angular/material/snack-bar';
import { ChangeDetectorRef } from '@angular/core';
import { Agent, KnowledgeRetrievalConfigEntry } from '@alfresco/js-api';
describe('AgentsButtonComponent', () => {
let component: AgentsButtonComponent;
let fixture: ComponentFixture<AgentsButtonComponent>;
let agents$: Subject<Agent[]>;
let agentsMock: Agent[];
let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
let selectionState: SelectionState;
let store: MockStore;
let config$: Subject<KnowledgeRetrievalConfigEntry>;
const knowledgeRetrievalUrl = 'some url';
const getMenu = (): MatMenu => fixture.debugElement.query(By.directive(MatMenu)).componentInstance;
const getAgentsButton = (): HTMLButtonElement => fixture.debugElement.query(By.css('.aca-agents-menu-button'))?.nativeElement;
const runButtonActions = (eventName: string): void => {
let event: Event;
let notificationService: NotificationService;
let message: string;
beforeEach(() => {
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
event =
eventName === 'mouseup'
? new MouseEvent(eventName)
: new KeyboardEvent(eventName, {
key: 'Enter'
});
agents$.next(agentsMock);
agents$.complete();
spyOn(window, 'open');
notificationService = TestBed.inject(NotificationService);
spyOn(notificationService, 'showError');
message = 'Some message';
component.avatarsMocked = false;
});
const getMenuTrigger = (): MatMenuPanel => fixture.debugElement.query(By.directive(MatMenuTrigger)).injector.get(MatMenuTrigger).menu;
const testButtonActions = (): void => {
it('should not display notification if checkSearchAvailability from SearchAiService returns empty message', () => {
message = '';
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).not.toHaveBeenCalled();
});
it('should disable menu triggering if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue('Some message');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
expect(getMenuTrigger()).toBeNull();
});
};
describe('with selected nodes', () => {
beforeEach(() => {
selectionState.isEmpty = false;
});
it('should display notification if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).toHaveBeenCalledWith(message);
});
testButtonActions();
it('should enable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
const menuTrigger = getMenuTrigger();
expect(menuTrigger).toBeTruthy();
expect(menuTrigger).toBe(getMenu());
});
it('should call checkSearchAvailability from SearchAiService with correct parameter', () => {
getAgentsButton().dispatchEvent(event);
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
});
it('should not open new tab for url loaded from config', () => {
getAgentsButton().dispatchEvent(event);
expect(window.open).not.toHaveBeenCalled();
});
});
describe('without selected nodes', () => {
it('should not display notification if checkSearchAvailability from SearchAiService returns message', () => {
checkSearchAvailabilitySpy.and.returnValue(message);
getAgentsButton().dispatchEvent(event);
expect(notificationService.showError).not.toHaveBeenCalled();
});
testButtonActions();
it('should disable menu triggering if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
getAgentsButton().dispatchEvent(event);
fixture.detectChanges();
expect(getMenuTrigger()).toBeNull();
});
it('should not call checkSearchAvailability from SearchAiService', () => {
getAgentsButton().dispatchEvent(event);
expect(checkSearchAvailabilitySpy).not.toHaveBeenCalled();
});
it('should open new tab for url loaded from config', () => {
getAgentsButton().dispatchEvent(event);
expect(window.open).toHaveBeenCalledWith(knowledgeRetrievalUrl);
});
});
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AgentsButtonComponent, ContentTestingModule],
providers: [provideMockStore({})]
});
fixture = TestBed.createComponent(AgentsButtonComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
agents$ = new Subject<Agent[]>();
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
agentsMock = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
const searchAiService = TestBed.inject(SearchAiService);
checkSearchAvailabilitySpy = spyOn(searchAiService, 'checkSearchAvailability');
config$ = new Subject<KnowledgeRetrievalConfigEntry>();
spyOn(searchAiService, 'getConfig').and.returnValue(config$);
selectionState = {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
};
store.overrideSelector(getAppSelection, selectionState);
fixture.detectChanges();
});
afterEach(() => {
store.resetSelectors();
});
describe('Button', () => {
let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef<any>>;
beforeEach(() => {
const notificationService = TestBed.inject(NotificationService);
notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
});
describe('loaded config', () => {
beforeEach(() => {
component.avatarsMocked = false;
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
});
it('should be rendered if any agentsMock are loaded', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeTruthy();
});
it('should get agentsMock on component init', () => {
agents$.next(agentsMock);
agents$.complete();
component.ngOnInit();
expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
expect(component.agents).toEqual(agentsMock);
expect(notificationServiceSpy).not.toHaveBeenCalled();
});
it('should run detectChanges when getting the agentsMock', () => {
const changeDetectorRef2 = fixture.debugElement.injector.get(ChangeDetectorRef);
const detectChangesSpy = spyOn(changeDetectorRef2.constructor.prototype, 'detectChanges');
component.ngOnInit();
agents$.next(agentsMock);
expect(detectChangesSpy).toHaveBeenCalled();
});
it('should show notification error on getAgents error', () => {
agents$.error('error');
component.ngOnInit();
expect(component.agents).toEqual([]);
expect(component.initialsByAgentId).toEqual({});
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
});
it('should not be rendered if none agent is loaded', () => {
agentsMock = [];
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeFalsy();
});
it('should have correct label', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton().textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.AGENTS_BUTTON.LABEL');
});
it('should contain stars icon', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.aca-agents-menu-button adf-icon')).componentInstance.value).toBe('adf:colored-stars-ai');
});
});
describe('loaded config with error', () => {
beforeEach(() => {
config$.error('error');
config$.complete();
});
it('should not be rendered', () => {
agents$.next(agentsMock);
agents$.complete();
fixture.detectChanges();
expect(getAgentsButton()).toBeFalsy();
});
it('should show notification error', () => {
agents$.next(agentsMock);
agents$.complete();
component.ngOnInit();
expect(component.hxInsightUrl).toBeUndefined();
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING');
});
});
});
const buttonKeyboardActions = (eventName: string): void => {
describe(`Button action - ${eventName} event`, () => {
runButtonActions(eventName);
});
};
['mouseup', 'keydown'].forEach((eventName) => {
buttonKeyboardActions(eventName);
});
describe('Agents menu', () => {
let loader: HarnessLoader;
const prepareData = (agents: Agent[]): void => {
component.avatarsMocked = false;
config$.next({
entry: {
knowledgeRetrievalUrl
}
});
config$.complete();
loader = TestbedHarnessEnvironment.loader(fixture);
agents$.next(agents);
selectionState.isEmpty = false;
checkSearchAvailabilitySpy.and.returnValue('');
const button = getAgentsButton();
button.dispatchEvent(new MouseEvent('mouseup'));
fixture.detectChanges();
button.click();
fixture.detectChanges();
};
const getAvatar = (agentId: string): AvatarComponent =>
fixture.debugElement.query(By.css(`[data-automation-id=aca-agents-button-agent-${agentId}]`)).query(By.directive(AvatarComponent))
.componentInstance;
describe('Agents position', () => {
it('should have assigned before to xPosition', () => {
prepareData(agentsMock);
agents$.complete();
expect(getMenu().xPosition).toBe('before');
});
});
describe('Agents multi words name', () => {
beforeEach(() => {
prepareData(agentsMock);
agents$.complete();
});
const getAgentsListHarness = async (): Promise<MatSelectionListHarness> =>
(await loader.getHarness(MatMenuHarness)).getHarness(MatSelectionListHarness);
const selectAgent = async (): Promise<void> =>
(await getAgentsListHarness()).selectItems({
fullText: 'PA Policy Agent'
});
const getAgentsList = (): MatSelectionList => fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance;
it('should deselect selected agent after selecting other', async () => {
component.data = {
trigger: SearchAiActionTypes.ToggleAiSearchInput
};
const selectionList = getAgentsList();
spyOn(selectionList, 'deselectAll');
await selectAgent();
expect(selectionList.deselectAll).toHaveBeenCalled();
});
it('should dispatch on store selected agent', async () => {
component.data = {
trigger: SearchAiActionTypes.ToggleAiSearchInput
};
spyOn(store, 'dispatch');
await selectAgent();
expect(store.dispatch<ToggleAISearchInput>).toHaveBeenCalledWith({
type: SearchAiActionTypes.ToggleAiSearchInput,
agentId: '2'
});
});
it('should disallow selecting multiple agentsMock', () => {
expect(getAgentsList().multiple).toBeFalse();
});
it('should have hidden single selection indicator', () => {
expect(getAgentsList().hideSingleSelectionIndicator).toBeTrue();
});
it('should display option for each agent', async () => {
const agents = await (await getAgentsListHarness()).getItems();
expect(agents.length).toBe(2);
expect(await agents[0].getFullText()).toBe('HA HR Agent');
expect(await agents[1].getFullText()).toBe('PA Policy Agent');
});
it('should display avatar for each agent', () => {
expect(getAvatar('1')).toBeTruthy();
expect(getAvatar('2')).toBeTruthy();
});
it('should assign correct initials to each avatar for each agent with double section name', () => {
expect(getAvatar('1').initials).toBe('HA');
expect(getAvatar('2').initials).toBe('PA');
});
});
describe('Agents multi words name', () => {
it('should assign correct initials to each avatar for each agent with single section name', () => {
agentsMock = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
agentsMock[0].name = 'Adam';
agentsMock[1].name = 'Bob';
prepareData(agentsMock);
expect(getAvatar('1').initials).toBe('A');
expect(getAvatar('2').initials).toBe('B');
});
});
});
});

View File

@ -0,0 +1,148 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SelectionState } from '@alfresco/adf-extensions';
import { Store } from '@ngrx/store';
import { AppStore, getAppSelection } from '@alfresco/aca-shared/store';
import { AvatarComponent, IconComponent, NotificationService } from '@alfresco/adf-core';
import { forkJoin, Subject, throwError } from 'rxjs';
import { catchError, take, takeUntil } from 'rxjs/operators';
import { MatMenuModule } from '@angular/material/menu';
import { MatListModule, MatSelectionListChange } from '@angular/material/list';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { Agent } from '@alfresco/js-api';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { MatTooltipModule } from '@angular/material/tooltip';
import { getAgentsWithMockedAvatars } from '../search-ai-utils';
@Component({
standalone: true,
imports: [CommonModule, MatMenuModule, MatListModule, TranslateModule, AvatarComponent, IconComponent, MatTooltipModule],
selector: 'aca-agents-button',
templateUrl: './agents-button.component.html',
styleUrls: ['./agents-button.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-agents-button' }
})
export class AgentsButtonComponent implements OnInit, OnDestroy {
@Input()
data: { trigger: string };
private selectedNodesState: SelectionState;
private _agents: Agent[] = [];
private onDestroy$ = new Subject<void>();
private _disabled = true;
private _initialsByAgentId: { [key: string]: string } = {};
private _hxInsightUrl: string;
avatarsMocked = true;
get agents(): Agent[] {
return this._agents;
}
get disabled(): boolean {
return this._disabled;
}
get initialsByAgentId(): { [key: string]: string } {
return this._initialsByAgentId;
}
get hxInsightUrl(): string {
return this._hxInsightUrl;
}
constructor(
private store: Store<AppStore>,
private notificationService: NotificationService,
private searchAiService: SearchAiService,
private agentService: AgentService,
private translateService: TranslateService,
private cd: ChangeDetectorRef
) {}
ngOnInit(): void {
this.store
.select(getAppSelection)
.pipe(takeUntil(this.onDestroy$))
.subscribe((selection) => {
this.selectedNodesState = selection;
});
forkJoin({
agents: this.agentService.getAgents().pipe(
take(1),
catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
),
config: this.searchAiService.getConfig().pipe(catchError(() => throwError('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.HX_INSIGHT_URL_FETCHING')))
}).subscribe(
(result) => {
this._hxInsightUrl = result.config.entry.knowledgeRetrievalUrl;
this._agents = result.agents;
// TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
this._agents = getAgentsWithMockedAvatars(result.agents, this.avatarsMocked);
this.cd.detectChanges();
if (this.agents.length) {
this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
return initials;
}, {});
}
},
(error: string) => this.notificationService.showError(this.translateService.instant(error))
);
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
onClick(): void {
if (!this.selectedNodesState.isEmpty) {
const message = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
if (message) {
this.notificationService.showError(message);
}
this._disabled = !!message;
return;
}
this._disabled = true;
open(this.hxInsightUrl);
}
onAgentSelection(change: MatSelectionListChange): void {
this.store.dispatch({
type: this.data.trigger,
agentId: change.options[0].value.id
});
change.source.deselectAll();
}
}

View File

@ -0,0 +1,18 @@
<aca-search-ai-input
(searchSubmitted)="hideSearchInput()"
[placeholder]="placeholder"
[agentId]="agentId"
[useStoredNodes]="useStoredNodes">
</aca-search-ai-input>
<mat-divider
[vertical]="true"
class="aca-search-ai-input-container-divider">
</mat-divider>
<button
mat-icon-button
(click)="leaveSearchInput()"
data-automation-id="aca-search-ai-input-container-leaving-search-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT' | translate"
class="aca-search-ai-input-container-close">
<mat-icon>close</mat-icon>
</button>

View File

@ -0,0 +1,18 @@
aca-search-ai-input-container {
display: flex;
flex-direction: row;
flex: 1;
align-items: center;
width: 100%;
.aca-search-ai-input-container-divider {
height: 24px;
margin-left: 30px;
margin-right: 7px;
background: var(--adf-theme-foreground-text-color-025);
}
.aca-search-ai-input-container-close {
display: flex;
}
}

View File

@ -0,0 +1,186 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchAiInputContainerComponent } from './search-ai-input-container.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { By } from '@angular/platform-browser';
import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { of, Subject } from 'rxjs';
import { MatDivider } from '@angular/material/divider';
import { DebugElement } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
import { NavigationEnd, NavigationStart, Router, RouterEvent } from '@angular/router';
import { getAppSelection } from '@alfresco/aca-shared/store';
describe('SearchAiInputContainerComponent', () => {
let component: SearchAiInputContainerComponent;
let fixture: ComponentFixture<SearchAiInputContainerComponent>;
let routingEvents$: Subject<RouterEvent>;
let searchAiService: SearchAiService;
let store: MockStore;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SearchAiInputContainerComponent, ContentTestingModule],
providers: [
provideMockStore(),
{
provide: AgentService,
useValue: {
getAgents: () =>
of([
{
id: '1',
name: 'HR Agent',
description: 'HR Agent',
avatar: 'avatar1'
}
])
}
}
]
});
fixture = TestBed.createComponent(SearchAiInputContainerComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
searchAiService = TestBed.inject(SearchAiService);
store.overrideSelector(getAppSelection, {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
});
component.agentId = '1';
routingEvents$ = new Subject<RouterEvent>();
spyOnProperty(TestBed.inject(Router), 'events').and.returnValue(routingEvents$);
fixture.detectChanges();
});
afterEach(() => {
store.resetSelectors();
});
describe('Search ai input', () => {
let inputComponent: SearchAiInputComponent;
beforeEach(() => {
inputComponent = fixture.debugElement.query(By.directive(SearchAiInputComponent)).componentInstance;
});
it('should have assigned correct default placeholder', () => {
expect(inputComponent.placeholder).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER');
});
it('should have assigned correct placeholder if placeholder has been changed', () => {
component.placeholder = 'Some placeholder';
fixture.detectChanges();
expect(inputComponent.placeholder).toBe(component.placeholder);
});
it('should have assigned correct agentId', () => {
expect(inputComponent.agentId).toBe(component.agentId);
});
it('should have assigned correct useStoredNodes flag', () => {
component.useStoredNodes = true;
fixture.detectChanges();
expect(inputComponent.useStoredNodes).toBeTrue();
});
it('should call updateSearchAiInputState on SearchAiService when triggered searchSubmitted event', () => {
spyOn(searchAiService, 'updateSearchAiInputState');
inputComponent.searchSubmitted.emit();
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
active: false
});
});
});
describe('Divider', () => {
it('should have a vertical divider', () => {
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(MatDivider)).componentInstance.vertical).toBeTrue();
});
});
describe('Leaving button', () => {
let button: DebugElement;
beforeEach(() => {
button = fixture.debugElement.query(By.directive(MatIconButton));
});
it('should have correct title', () => {
expect(button.nativeElement.title).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.HIDE_INPUT');
});
it('should contain close icon', () => {
expect(button.query(By.directive(MatIcon)).nativeElement.textContent).toBe('close');
});
it('should call updateSearchAiInputState on SearchAiService when clicked', () => {
spyOn(searchAiService, 'updateSearchAiInputState');
button.nativeElement.click();
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
active: false
});
});
it('should call navigateToPreviousRoute on SearchAiNavigationService when clicked', () => {
const searchNavigationService = TestBed.inject(SearchAiNavigationService);
spyOn(searchNavigationService, 'navigateToPreviousRoute');
button.nativeElement.click();
expect(searchNavigationService.navigateToPreviousRoute).toHaveBeenCalled();
});
});
describe('Navigation', () => {
it('should call updateSearchAiInputState on SearchAiService when navigation starts', () => {
spyOn(searchAiService, 'updateSearchAiInputState');
routingEvents$.next(new NavigationStart(1, ''));
expect(searchAiService.updateSearchAiInputState).toHaveBeenCalledWith({
active: false
});
});
it('should not call updateSearchAiInputState on SearchAiService when there is different event than navigation starts', () => {
spyOn(searchAiService, 'updateSearchAiInputState');
routingEvents$.next(new NavigationEnd(1, '', ''));
expect(searchAiService.updateSearchAiInputState).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,81 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MatDividerModule } from '@angular/material/divider';
import { SearchAiNavigationService } from '../../../../services/search-ai-navigation.service';
import { NavigationStart, Router } from '@angular/router';
import { filter, takeUntil } from 'rxjs/operators';
import { SearchAiService } from '@alfresco/adf-content-services';
import { TranslateModule } from '@ngx-translate/core';
import { Subject } from 'rxjs';
@Component({
standalone: true,
imports: [SearchAiInputComponent, MatIconModule, MatDividerModule, MatButtonModule, TranslateModule],
selector: 'aca-search-ai-input-container',
templateUrl: './search-ai-input-container.component.html',
styleUrls: ['./search-ai-input-container.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchAiInputContainerComponent implements OnInit, OnDestroy {
@Input()
placeholder = 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.DEFAULT_PLACEHOLDER';
@Input()
agentId: string;
@Input()
useStoredNodes: boolean;
private onDestroy$ = new Subject<void>();
constructor(private searchAiService: SearchAiService, private searchNavigationService: SearchAiNavigationService, private router: Router) {}
ngOnInit(): void {
this.router.events
.pipe(
filter((event) => event instanceof NavigationStart),
takeUntil(this.onDestroy$)
)
.subscribe(() => this.hideSearchInput());
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
hideSearchInput(): void {
this.searchAiService.updateSearchAiInputState({
active: false
});
}
leaveSearchInput(): void {
this.searchNavigationService.navigateToPreviousRoute();
this.hideSearchInput();
}
}

View File

@ -0,0 +1,63 @@
<div class="aca-search-ai-input-agent-container">
<mat-select
[formControl]="agentControl"
class="aca-search-ai-input-agent-select"
panelClass="aca-search-ai-input-agent-select-options aca-search-ai-input-agent-select-agents"
data-automation-id="aca-search-ai-agents-select"
[hideSingleSelectionIndicator]="true">
<mat-select-trigger class="aca-search-ai-input-agent-select-displayed-value">
<adf-avatar
[src]="agentControl.value?.avatarUrl"
[initials]="initialsByAgentId[agentControl.value?.id]"
size="26px">
</adf-avatar>
<span class="aca-search-ai-input-agent-select-displayed-value-text">{{ agentControl.value?.name }}</span>
</mat-select-trigger>
<mat-option
*ngFor="let agent of agents"
[value]="agent"
class="aca-search-ai-input-agent-select-options-option"
[attr.data-automation-id]="'aca-search-ai-input-agent-' + agent.id"><div class="aca-search-ai-input-agent-select-options-option-content">
<adf-avatar [src]="agent?.avatarUrl" [initials]="initialsByAgentId[agent.id]"></adf-avatar>
<span class="aca-search-ai-input-agent-select-options-option-content-text" [matTooltip]="agent.name" [matTooltipPosition]="'right'">{{ agent.name }}</span>
</div>
</mat-option>
</mat-select>
<div class="aca-search-ai-input-agent-popup-hover-card">
<mat-card class="aca-search-ai-input-agent-popup-hover-card-container">
<mat-card-title class="aca-search-ai-input-agent-popup-hover-card-container-title">
<adf-avatar
[initials]="initialsByAgentId[agentControl.value?.id]"
[src]="agentControl.value?.avatarUrl"
size="50px">
</adf-avatar>
<span class="aca-search-ai-input-agent-popup-hover-card-container-title-name"
[matTooltipPosition]="'right'"
[matTooltip]="agentControl.value?.name">
{{ agentControl.value?.name }}
</span>
</mat-card-title>
<mat-card-content class="aca-search-ai-input-agent-popup-hover-card-container-content">
{{ agentControl.value?.description }}
</mat-card-content>
</mat-card>
</div>
</div>
<input
class="aca-search-ai-input-text"
matInput
[formControl]="queryControl"
data-automation-id="aca-search-ai-input"
[placeholder]="placeholder | translate"
(keyup.enter)="onSearchSubmit()"/>
<button
mat-flat-button
color="primary"
class="aca-search-ai-asking-button"
(click)="onSearchSubmit()"
[disabled]="!queryControl.value"
data-automation-id="aca-search-ai-asking-button">
<adf-icon [value]="'adf:three_magic_stars_ai'"></adf-icon>
<span class="aca-search-ai-asking-button-label">{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL' | translate }}</span>
</button>

View File

@ -0,0 +1,183 @@
@import '@alfresco/adf-core/lib/styles/mat-selectors';
aca-search-ai-input {
width: 100%;
display: flex;
align-items: center;
.aca-search-ai-input-text {
margin-top: 4px;
flex: 1;
font-size: 20px;
margin-right: 167px;
border: none;
outline: none;
&:focus {
&::placeholder {
color: var(--theme-primary-color);
}
}
}
.aca-search-ai-asking-button {
display: flex;
align-items: center;
padding-left: 0;
padding-right: 12px;
height: 32px;
border-radius: 6px;
width: 92px;
font-weight: 600;
&-label {
vertical-align: super;
}
adf-icon {
margin-bottom: 3px;
margin-right: 7px;
svg {
width: 34px;
height: 34px;
margin-left: -6px;
margin-top: -4px;
}
}
}
.aca-search-ai-input-agent-select {
width: 149px;
height: 35px;
align-content: center;
border-radius: 16px;
padding-left: 3px;
padding-right: 10px;
background-color: var(--theme-grey-text-background-color);
color: var(--theme-text-light-color);
font-size: 15px;
margin-right: 26px;
#{$mat-select-trigger} {
height: auto;
margin-top: 4px;
}
&:focus {
outline: -webkit-focus-ring-color auto 1px;
}
&-displayed-value {
display: flex;
align-items: center;
&-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-left: 2px;
margin-right: 6px;
padding-top: 1px;
padding-bottom: 3px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
.aca-search-ai-input-agent-select-options.aca-search-ai-input-agent-select-agents#{$mat-select-panel} {
margin-top: 9px;
.aca-search-ai-input-agent-select-options-option {
padding-left: 11px;
padding-right: 11px;
&-content {
display: flex;
align-items: center;
padding-top: 1px;
padding-bottom: 1px;
&-text {
width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
adf-avatar {
margin-right: 12px;
padding-left: 1px;
.adf-avatar__image {
cursor: pointer;
}
}
}
}
.aca-search-ai-input-agent-container {
position: relative;
.aca-search-ai-input-agent-popup-hover-card {
display: none;
position: absolute;
left: 0;
z-index: 1;
&-container {
width: 315px;
height: fit-content;
border-radius: 12px;
margin-top: 4px;
&-title {
display: flex;
align-items: center;
font-size: 20px;
font-weight: 700;
padding: 16px 16px 8px;
gap: 4px;
&-name {
margin: 0 12px;
}
img {
height: 50px;
width: 50px;
min-width: 50px;
min-height: 50px;
}
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 14px;
}
}
&-content {
display: flex;
color: var(--theme-content-color);
text-align: justify;
text-justify: inter-word;
}
}
}
&:hover {
.aca-search-ai-input-agent-popup-hover-card {
display: block;
}
}
}

View File

@ -0,0 +1,431 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchAiInputComponent } from './search-ai-input.component';
import { MatSelect, MatSelectModule } from '@angular/material/select';
import { By } from '@angular/platform-browser';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { AgentService, ContentTestingModule, SearchAiService } from '@alfresco/adf-content-services';
import { getAppSelection, SearchByTermAiAction } from '@alfresco/aca-shared/store';
import { of, Subject } from 'rxjs';
import { Agent, NodeEntry } from '@alfresco/js-api';
import { FormControlDirective } from '@angular/forms';
import { DebugElement } from '@angular/core';
import { AvatarComponent, IconComponent, NotificationService, UnsavedChangesDialogComponent, UserPreferencesService } from '@alfresco/adf-core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatInput } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { MatInputHarness } from '@angular/material/input/testing';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatSnackBarRef } from '@angular/material/snack-bar';
import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router';
import { ModalAiService } from '../../../../services/modal-ai.service';
const agentList: Agent[] = [
{
id: '1',
name: 'HR Agent',
description: 'Test 1',
avatarUrl: undefined
},
{
id: '2',
name: 'Policy Agent',
description: 'Test 2',
avatarUrl: undefined
}
];
describe('SearchAiInputComponent', () => {
let component: SearchAiInputComponent;
let fixture: ComponentFixture<SearchAiInputComponent>;
let loader: HarnessLoader;
let selectionState: SelectionState;
let store: MockStore;
let agents$: Subject<Agent[]>;
let dialog: MatDialog;
const prepareBeforeTest = (): void => {
selectionState = {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
};
store.overrideSelector(getAppSelection, selectionState);
component.agentId = '2';
component.avatarsMocked = false;
component.ngOnInit();
fixture.detectChanges();
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SearchAiInputComponent, ContentTestingModule, MatSelectModule],
providers: [
provideMockStore(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParams: { query: 'some query' }
}
}
}
]
});
fixture = TestBed.createComponent(SearchAiInputComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
loader = TestbedHarnessEnvironment.loader(fixture);
agents$ = new Subject<Agent[]>();
dialog = TestBed.inject(MatDialog);
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(agents$);
prepareBeforeTest();
});
afterEach(() => {
store.resetSelectors();
});
describe('Agent select box', () => {
let selectElement: DebugElement;
let notificationServiceSpy: jasmine.Spy<(message: string) => MatSnackBarRef<any>>;
beforeEach(() => {
selectElement = fixture.debugElement.query(By.directive(MatSelect));
const notificationService = TestBed.inject(NotificationService);
notificationServiceSpy = spyOn(notificationService, 'showError').and.callThrough();
});
it('should have assigned formControl', () => {
expect(selectElement.injector.get(FormControlDirective).form).toBe(component.agentControl);
});
it('should have hidden single selection indicator', () => {
expect(selectElement.componentInstance.hideSingleSelectionIndicator).toBeTrue();
});
it('should get agents on init', () => {
agents$.next(agentList);
component.ngOnInit();
expect(component.agents).toEqual(agentList);
expect(component.initialsByAgentId).toEqual({ 1: 'HA', 2: 'PA' });
expect(notificationServiceSpy).not.toHaveBeenCalled();
});
it('should show notification on getAgents error', () => {
agents$.error('error');
component.ngOnInit();
expect(component.agents).toEqual([]);
expect(component.initialsByAgentId).toEqual({});
expect(notificationServiceSpy).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING');
});
it('should have selected correct agent', async () => {
agents$.next(agentList);
expect(await (await loader.getHarness(MatSelectHarness)).getValueText()).toBe('PAPolicy Agent');
const avatar = selectElement.query(By.directive(AvatarComponent))?.componentInstance;
expect(avatar.initials).toBe('PA');
expect(avatar.size).toBe('26px');
});
describe('Agents options', () => {
let options: MatOptionHarness[];
const getAvatarForAgent = (agentId: string): AvatarComponent =>
fixture.debugElement.query(By.css(`[data-automation-id=aca-search-ai-input-agent-${agentId}]`)).query(By.directive(AvatarComponent))
.componentInstance;
beforeEach(async () => {
agents$.next(agentList);
const selectHarness = await loader.getHarness(MatSelectHarness);
await selectHarness.open();
options = await selectHarness.getOptions();
});
it('should have correct number of agents', () => {
expect(options.length).toBe(2);
});
it('should have correct agent names', async () => {
expect(await options[0].getText()).toBe('HAHR Agent');
expect(await options[1].getText()).toBe('PAPolicy Agent');
});
it('should display avatar for each agent', () => {
expect(getAvatarForAgent('1')).toBeTruthy();
expect(getAvatarForAgent('2')).toBeTruthy();
});
it('should have correct initials for avatars for each of agent', () => {
expect(getAvatarForAgent('1').initials).toBe('HA');
expect(getAvatarForAgent('2').initials).toBe('PA');
});
it('should assign correct initials to each avatar for each agent with single section name', () => {
const newAgentList = [
{ ...agentList[0], name: 'Adam' },
{ ...agentList[1], name: 'Bob' }
];
agents$.next(newAgentList);
fixture.detectChanges();
expect(getAvatarForAgent('1').initials).toBe('A');
expect(getAvatarForAgent('2').initials).toBe('B');
});
});
});
describe('Query input', () => {
let queryInput: DebugElement;
beforeEach(() => {
queryInput = fixture.debugElement.query(By.directive(MatInput));
agents$.next(agentList);
});
it('should have assigned formControl', () => {
fixture.detectChanges();
expect(queryInput.injector.get(FormControlDirective).form).toBe(component.queryControl);
});
it('should have assigned correct placeholder', () => {
component.placeholder = 'Please ask your question with as much detail as possible...';
expect(queryInput.componentInstance.placeholder).toBe(component.placeholder);
});
testSubmitting(false);
});
describe('Submit button', () => {
let submitButton: DebugElement;
let queryInput: MatInputHarness;
beforeEach(async () => {
submitButton = fixture.debugElement.query(By.directive(MatButton));
queryInput = await loader.getHarness(MatInputHarness);
agents$.next(agentList);
});
it('should be disabled by default', () => {
expect(submitButton.nativeElement.disabled).toBeTrue();
});
it('should be enabled if query input is filled', async () => {
await queryInput.setValue('Some question');
expect(submitButton.nativeElement.disabled).toBeFalse();
});
it('should be disabled if query input was filled but after that it was emptied', async () => {
await queryInput.setValue('Some question');
await queryInput.setValue('');
expect(submitButton.nativeElement.disabled).toBeTrue();
});
it('should contain stars icon', () => {
expect(submitButton.query(By.directive(IconComponent)).componentInstance.value).toBe('adf:three_magic_stars_ai');
});
it('should have correct label', () => {
expect(submitButton.nativeElement.textContent.trim()).toBe('KNOWLEDGE_RETRIEVAL.SEARCH.SEARCH_INPUT.ASK_BUTTON_LABEL');
});
testSubmitting();
});
function testSubmitting(useButton = true) {
describe('Submitting', () => {
let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>;
let notificationService: NotificationService;
let userPreferencesService: UserPreferencesService;
let submitButton: DebugElement;
let queryInput: MatInputHarness;
let submittingTrigger: () => void;
const query = 'some query';
let dialogOpenSpy: jasmine.Spy<<T, R>(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef<T, R>>;
let modalAiService: ModalAiService;
beforeEach(async () => {
prepareBeforeTest();
modalAiService = TestBed.inject(ModalAiService);
checkSearchAvailabilitySpy = spyOn(TestBed.inject(SearchAiService), 'checkSearchAvailability');
notificationService = TestBed.inject(NotificationService);
userPreferencesService = TestBed.inject(UserPreferencesService);
spyOn(userPreferencesService, 'set');
spyOn(notificationService, 'showError');
queryInput = await loader.getHarness(MatInputHarness);
submitButton = fixture.debugElement.query(By.directive(MatButton));
await queryInput.setValue(query);
const inputElement = fixture.debugElement.query(By.directive(MatInput)).nativeElement;
dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
afterClosed: () => of(true)
} as MatDialogRef<MatDialog>);
submittingTrigger = useButton
? () => submitButton.nativeElement.click()
: () =>
inputElement.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'Enter'
})
);
});
it('should call showError on NotificationService if checkSearchAvailability from SearchAiService returns message', () => {
const message = 'Some message';
checkSearchAvailabilitySpy.and.returnValue(message);
submittingTrigger();
expect(notificationService.showError).toHaveBeenCalledWith(message);
});
it('should not call showError on NotificationService if checkSearchAvailability from SearchAiService returns empty message', () => {
checkSearchAvailabilitySpy.and.returnValue('');
submittingTrigger();
expect(notificationService.showError).not.toHaveBeenCalled();
});
it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by store', () => {
submittingTrigger();
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(selectionState);
});
it('should call checkSearchAvailability on SearchAiService with parameter based on value returned by UserPreferencesService', () => {
component.useStoredNodes = true;
const newSelectionState: SelectionState = {
...selectionState,
file: {
entry: {
id: 'some-id'
}
} as NodeEntry
};
spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
component.ngOnInit();
submittingTrigger();
expect(checkSearchAvailabilitySpy).toHaveBeenCalledWith(newSelectionState);
expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
});
it('should call set on UserPreferencesService with parameter based on value returned by store', () => {
submittingTrigger();
expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(selectionState));
});
it('should call set on UserPreferencesService with parameter based on value returned by UserPreferencesService', () => {
component.useStoredNodes = true;
const newSelectionState: SelectionState = {
...selectionState,
file: {
entry: {
id: 'some-id'
}
} as NodeEntry
};
spyOn(userPreferencesService, 'get').and.returnValue(JSON.stringify(newSelectionState));
component.ngOnInit();
submittingTrigger();
expect(userPreferencesService.get).toHaveBeenCalledWith('knowledgeRetrievalNodes');
expect(userPreferencesService.set).toHaveBeenCalledWith('knowledgeRetrievalNodes', JSON.stringify(newSelectionState));
});
it('should call dispatch on store with correct parameter', () => {
spyOn(store, 'dispatch');
submittingTrigger();
expect(store.dispatch).toHaveBeenCalledOnceWith(
new SearchByTermAiAction({
searchTerm: query,
agentId: component.agentId
})
);
});
it('should call dispatch on store with correct parameter if selected agent was changed', async () => {
spyOn(store, 'dispatch');
await (
await loader.getHarness(MatSelectHarness)
).clickOptions({
text: 'HAHR Agent'
});
submittingTrigger();
expect(store.dispatch).toHaveBeenCalledOnceWith(
new SearchByTermAiAction({
searchTerm: query,
agentId: '1'
})
);
});
it('should reset query input', () => {
spyOn(component.queryControl, 'reset');
submittingTrigger();
expect(component.queryControl.reset).toHaveBeenCalled();
});
it('should emit searchSubmitted event', () => {
spyOn(component.searchSubmitted, 'emit');
submittingTrigger();
expect(component.searchSubmitted.emit).toHaveBeenCalled();
});
it('should call open modal if there was a previous search phrase in url', () => {
submittingTrigger();
expect(dialogOpenSpy).toHaveBeenCalled();
});
it('should open Unsaved Changes Modal and run callback successfully', () => {
const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
spyOn(component.searchSubmitted, 'emit');
fixture.detectChanges();
submittingTrigger();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
expect(component.searchSubmitted.emit).toHaveBeenCalled();
});
});
}
});

View File

@ -0,0 +1,187 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { A11yModule } from '@angular/cdk/a11y';
import { AvatarComponent, IconComponent, NotificationService, UserPreferencesService } from '@alfresco/adf-core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AiSearchByTermPayload, AppStore, getAppSelection, SearchByTermAiAction } from '@alfresco/aca-shared/store';
import { takeUntil } from 'rxjs/operators';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatSelectModule } from '@angular/material/select';
import { AgentService, SearchAiService } from '@alfresco/adf-content-services';
import { MatCardModule } from '@angular/material/card';
import {
MAT_TOOLTIP_DEFAULT_OPTIONS,
MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY,
MatTooltipDefaultOptions,
MatTooltipModule
} from '@angular/material/tooltip';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { Agent } from '@alfresco/js-api';
import { getAgentsWithMockedAvatars } from '../search-ai-utils';
const MatTooltipOptions: MatTooltipDefaultOptions = {
...MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(),
disableTooltipInteractivity: true
};
@Component({
standalone: true,
imports: [
CommonModule,
TranslateModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
A11yModule,
FormsModule,
ReactiveFormsModule,
MatSelectModule,
IconComponent,
AvatarComponent,
MatCardModule,
MatTooltipModule
],
selector: 'aca-search-ai-input',
templateUrl: './search-ai-input.component.html',
styleUrls: ['./search-ai-input.component.scss'],
encapsulation: ViewEncapsulation.None,
providers: [{ provide: MAT_TOOLTIP_DEFAULT_OPTIONS, useValue: MatTooltipOptions }]
})
export class SearchAiInputComponent implements OnInit, OnDestroy {
@Input()
placeholder: string;
@Input()
agentId: string;
@Input()
useStoredNodes: boolean;
@Output()
searchSubmitted = new EventEmitter<void>();
private readonly storedNodesKey = 'knowledgeRetrievalNodes';
private _agentControl = new FormControl<Agent>(null);
private _agents: Agent[] = [];
private onDestroy$ = new Subject<void>();
private selectedNodesState: SelectionState;
private _queryControl = new FormControl('');
private _initialsByAgentId: { [key: string]: string } = {};
avatarsMocked = true;
get agentControl(): FormControl<Agent> {
return this._agentControl;
}
get agents(): Agent[] {
return this._agents;
}
get queryControl(): FormControl<string> {
return this._queryControl;
}
get initialsByAgentId(): { [key: string]: string } {
return this._initialsByAgentId;
}
constructor(
private store: Store<AppStore>,
private searchAiService: SearchAiService,
private notificationService: NotificationService,
private agentService: AgentService,
private userPreferencesService: UserPreferencesService,
private translateService: TranslateService,
private modalAiService: ModalAiService
) {}
ngOnInit(): void {
if (!this.useStoredNodes) {
this.store
.select(getAppSelection)
.pipe(takeUntil(this.onDestroy$))
.subscribe((selection) => {
this.selectedNodesState = selection;
});
} else {
this.selectedNodesState = JSON.parse(this.userPreferencesService.get(this.storedNodesKey));
}
this.agentService
.getAgents()
.pipe(takeUntil(this.onDestroy$))
.subscribe(
(agents) => {
// TODO remove mocked avatar images after backend is done (https://hyland.atlassian.net/browse/ACS-8769)
this._agents = getAgentsWithMockedAvatars(agents, this.avatarsMocked);
this.agentControl.setValue(this._agents.find((agent) => agent.id === this.agentId));
this._initialsByAgentId = this.agents.reduce((initials, agent) => {
const words = agent.name.split(' ').filter((word) => !word.match(/[^a-zA-Z]+/g));
initials[agent.id] = `${words[0][0]}${words[1]?.[0] || ''}`;
return initials;
}, {});
},
() => this.notificationService.showError(this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.AGENTS_FETCHING'))
);
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
onSearchSubmit() {
this.modalAiService.openUnsavedChangesModal(() => this.search());
}
search() {
const error = this.searchAiService.checkSearchAvailability(this.selectedNodesState);
if (error) {
this.notificationService.showError(error);
} else {
const payload: AiSearchByTermPayload = {
searchTerm: this.queryControl.value,
agentId: this.agentControl.value.id
};
this.userPreferencesService.set(this.storedNodesKey, JSON.stringify(this.selectedNodesState));
this.store.dispatch(new SearchByTermAiAction(payload));
this.queryControl.reset();
this.searchSubmitted.emit();
}
}
}

View File

@ -0,0 +1,116 @@
<aca-page-layout>
<aca-search-ai-input-container
class="aca-page-layout-header"
placeholder="KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.QUERY_INPUT_PLACEHOLDER"
[agentId]="agentId"
[useStoredNodes]="true"
*ngIf="!hasError && agentId">
</aca-search-ai-input-container>
<div
class="aca-page-layout-content"
*ngIf="!hasError">
<div class="aca-search-ai-results-container">
<div
class="aca-search-ai-results-container-query"
data-automation-id="aca-search-ai-results-query">
{{ searchQuery }}
</div>
<div
class="aca-search-ai-response-container"
[class.aca-search-ai-response-container-error]="hasAnsweringError">
<ng-container *ngIf="!loading else skeleton">
<div
class="aca-search-ai-response-container-body"
*ngIf="!hasAnsweringError">
<div
class="aca-search-ai-response-container-body-response"
data-automation-id="aca-search-ai-results-response">
{{ queryAnswer?.answer }}
</div>
<button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-regeneration"
mat-icon-button
(click)="checkUnsavedChangesAndSearch()"
data-automation-id="aca-search-ai-results-regeneration-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate">
<mat-icon>cached</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action"
mat-icon-button
(click)="copyResponseToClipboard()"
data-automation-id="aca-search-ai-results-copying-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_BUTTON_LABEL' | translate">
<mat-icon>copy</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action"
mat-icon-button
data-automation-id="aca-search-ai-results-thumb-up-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.LIKE_BUTTON_LABEL' | translate">
<mat-icon>thumb_up</mat-icon>
</button>
<button
class="aca-search-ai-response-container-body-response-action aca-search-ai-response-container-body-response-action-thumb-down"
mat-icon-button
data-automation-id="aca-search-ai-results-thumb-down-button"
[title]="'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.DISLIKE_BUTTON_LABEL' | translate">
<mat-icon>thumb_down</mat-icon>
</button>
<ng-container *ngIf="nodes?.length">
<mat-divider class="aca-search-ai-response-container-body-divider"></mat-divider>
<div class="aca-search-ai-response-container-body-references-container">
<p class="aca-search-ai-response-container-body-references-container-header">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }}
</p>
<div class="aca-search-ai-response-container-body-references-container-documents">
<a
class="aca-search-ai-response-container-body-references-container-documents-document"
*ngFor="let node of nodes"
[attr.data-automation-id]="'aca-search-ai-results-' + node.id + '-document'"
(click)="openFile(node.id)"
(keyup.enter)="openFile(node.id)"
tabindex="0">
<mat-icon
mat-list-icon
class="aca-search-ai-response-container-body-references-container-documents-document-icon">
<img [alt]="node.content?.mimeType" [src]="mimeTypeIconsByNodeId[node.id]"/>
</mat-icon>
<div class="aca-search-ai-response-container-body-references-container-documents-document-name">
{{ node.name }}
</div>
</a>
</div>
</div>
</ng-container>
</div>
<div
*ngIf="hasAnsweringError"
class="aca-search-ai-response-container-error-message">
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }}
<button
mat-flat-button
(click)="performAiSearch()"
class="aca-search-ai-response-container-error-message-regeneration-button"
data-automation-id="aca-search-ai-results-error-regeneration-button">
<mat-icon class="aca-search-ai-response-container-error-message-regeneration-button-icon">cached</mat-icon>
{{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REGENERATION_BUTTON_LABEL' | translate }}
</button>
</div>
</ng-container>
</div>
</div>
</div>
<adf-empty-content
class="aca-page-layout-content"
icon="star"
title="KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.PAGE_NOT_AVAILABLE_ERROR"
*ngIf="hasError">
</adf-empty-content>
</aca-page-layout>
<ng-template #skeleton>
<div class="adf-skeleton"></div>
<div class="adf-skeleton"></div>
<div class="adf-skeleton adf-skeleton-half"></div>
</ng-template>

View File

@ -0,0 +1,182 @@
@import '@alfresco/adf-core/lib/styles/mat-selectors';
.aca-search-ai-results {
aca-page-layout {
.aca-page-layout-content {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
background-color: white;
border-top: 1px solid var(--theme-grey-background-color);
padding-top: 28px;
.aca-search-ai-results-container {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding-right: 24%;
padding-left: 24%;
min-width: 51%;
&-query {
border-radius: 12px;
padding: 20px 15px 19px;
background: var(--theme-card-background-grey-color);
}
}
.aca-search-ai-response-container {
padding: 18px 20px;
display: flex;
flex-direction: column;
border: 1px solid var(--adf-card-view-border-color);
border-radius: 12px;
margin: 16px 0 75px;
&-references-container-header {
padding-left: 8px;
}
.adf-skeleton {
position: relative;
background-image: linear-gradient(
to left,
var(--theme-light-grey-1-color) 0%,
var(--theme-light-grey-2-color) 20%,
var(--theme-light-grey-3-color) 40%,
var(--theme-light-grey-1-color) 100%
);
background-size: 200%;
display: inline-block;
height: 1em;
overflow: hidden;
width: 100%;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
&-half {
width: 50%;
margin-bottom: 8px;
}
&::after {
position: absolute;
inset: 0;
transform: translateX(-100%);
background-image: linear-gradient(90deg, rgba(white, 0) 0, rgba(white, 0.2) 20%, rgba(white, 0.5) 60%, rgba(white, 0));
animation: shimmer 2s infinite;
content: '';
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
}
&-error {
border-color: var(--adf-error-color);
padding: 32px 18px;
&-message {
display: flex;
justify-content: space-between;
align-items: center;
&-regeneration-button {
background-color: var(--adf-secondary-button-background);
&-icon {
font-size: 24px;
height: 24px;
width: 23px;
}
}
}
}
&-body {
&-response {
margin-bottom: 17px;
overflow-wrap: break-word;
&-action {
width: max-content;
padding: 0;
mat-icon {
font-size: 17.25px;
}
&-regeneration {
margin-left: 2px;
margin-right: 2px;
}
&-thumb-down {
margin-left: 4px;
}
#{$mat-button-touch-target} {
width: 24px;
}
}
}
&-divider {
margin-top: 9px;
}
&-references-container {
padding-right: 8px;
padding-left: 8px;
&-header {
margin-top: 16px;
color: var(--theme-text-light-color);
font-weight: 400;
margin-bottom: 3px;
}
&-documents {
padding-right: 5px;
padding-top: 5px;
margin-left: -2px;
display: flex;
gap: 21px;
&-document {
display: flex;
flex-direction: row;
padding-top: 7px;
padding-bottom: 7px;
&-icon {
padding-right: 11px;
}
&-name {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
}
&:hover {
text-decoration: underline;
text-decoration-color: var(--theme-primary-color);
color: var(--theme-primary-color);
cursor: pointer;
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,372 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { TestBed, ComponentFixture, tick, fakeAsync } from '@angular/core/testing';
import { SearchAiResultsComponent } from './search-ai-results.component';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { of, Subject, throwError } from 'rxjs';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { EmptyContentComponent, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialogModule } from '@angular/material/dialog';
import { AppTestingModule } from '../../../../testing/app-testing.module';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AgentService, NodesApiService, SearchAiService } from '@alfresco/adf-content-services';
import { By } from '@angular/platform-browser';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { delay } from 'rxjs/operators';
import { AiAnswer, AiAnswerEntry, QuestionModel } from '@alfresco/js-api/typings';
import { SearchAiInputComponent } from '../search-ai-input/search-ai-input.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { getAppSelection, getCurrentFolder, ViewNodeAction } from '@alfresco/aca-shared/store';
import { ViewerService } from '@alfresco/aca-content/viewer';
const questionMock: QuestionModel = { question: 'test', questionId: 'testId', restrictionQuery: { nodesIds: [] } };
const aiAnswerMock: AiAnswer = { answer: 'Some answer', questionId: 'some id', references: [] };
const getAiAnswerEntry = (noAnswer?: boolean): AiAnswerEntry => {
return { entry: { answer: noAnswer ? '' : 'Some answer', questionId: 'some id', references: [] } };
};
describe('SearchAiResultsComponent', () => {
const knowledgeRetrievalNodes = '{"isEmpty":"false","nodes":[{"entry":{"id": "someId","isFolder":"true"}}]}';
let fixture: ComponentFixture<SearchAiResultsComponent>;
let component: SearchAiResultsComponent;
let userPreferencesService: UserPreferencesService;
let mockQueryParams = new Subject<Params>();
let modalAiService: ModalAiService;
let searchAiService: SearchAiService;
let store: MockStore;
let viewerService: ViewerService;
afterEach(() => {
store.resetSelectors();
mockQueryParams = new Subject<Params>();
fixture.destroy();
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, SearchAiResultsComponent, MatSnackBarModule, MatDialogModule, MatIconTestingModule],
providers: [
{
provide: NodesApiService,
useValue: {
getNode: () => of({ id: 'someId', isFolder: true }).pipe(delay(50))
}
},
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
snapshot: {
queryParams: { query: 'testQuery' }
}
}
},
provideMockStore()
]
});
fixture = TestBed.createComponent(SearchAiResultsComponent);
modalAiService = TestBed.inject(ModalAiService);
searchAiService = TestBed.inject(SearchAiService);
userPreferencesService = TestBed.inject(UserPreferencesService);
viewerService = TestBed.inject(ViewerService);
store = TestBed.inject(MockStore);
store.overrideSelector(getAppSelection, {
nodes: [],
isEmpty: true,
count: 0,
libraries: []
});
store.overrideSelector(getCurrentFolder, null);
spyOn(searchAiService, 'ask').and.returnValue(of(questionMock));
spyOn(TestBed.inject(AgentService), 'getAgents').and.returnValue(of([]));
component = fixture.componentInstance;
component.ngOnInit();
});
describe('query params change', () => {
it('should perform ai search and sets agents on query params change', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeFalse();
});
it('should throw an error if searchQuery not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ agentId: 'agentId1' });
expect(component.searchQuery).toBe('');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeTrue();
});
it('should not throw an error if selectedNodesState nodes not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue('{}');
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe('agentId1');
expect(component.hasError).toBeFalse();
});
it('should throw an error if agentId not available', () => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
mockQueryParams.next({ query: 'test' });
expect(component.searchQuery).toBe('test');
expect(component.agentId).toBe(undefined);
expect(component.hasError).toBeTrue();
});
it('should not get query answer and display an error when getAnswer throws error', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValue(throwError('error').pipe(delay(100)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.queryAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should get query answer and not display an error when getAnswer throws one error and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
expect(component.hasAnsweringError).toBeFalse();
}));
it('should display and answer and not display an error when getAnswer throws nine errors and one successful response', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(throwError('error')), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(50000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
expect(component.hasAnsweringError).toBeFalse();
}));
it('should not display an answer and display an error when getAnswer throws ten errors', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(14).fill(throwError('error')), of(getAiAnswerEntry(true)));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.queryAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should not display answer and display an error if received AiAnswerPaging without answer ten times', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(10).fill(of(getAiAnswerEntry(true))));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.queryAnswer).toBeUndefined();
expect(component.hasAnsweringError).toBeTrue();
expect(component.loading).toBeFalse();
}));
it('should not display error and display and answer if received AiAnswerPaging without answer nine times and with answer one time', fakeAsync(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
spyOn(searchAiService, 'getAnswer').and.returnValues(...Array(9).fill(of(getAiAnswerEntry(true))), of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(30000);
expect(component.queryAnswer).toEqual({ answer: 'Some answer', questionId: 'some id', references: [] });
expect(component.hasAnsweringError).toBeFalse();
}));
describe('when query params contains location', () => {
let params: Params;
beforeEach(() => {
params = {
query: 'test',
agentId: 'agentId1',
location: 'some-location'
};
});
it('should not render search ai input container', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(SearchAiInputComponent))).toBeNull();
});
it('should not render empty content', () => {
mockQueryParams.next({
location: 'some-location'
});
fixture.detectChanges();
expect(fixture.debugElement.query(By.directive(EmptyContentComponent))).toBeNull();
});
it('should not display search query', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-query"]`)).nativeElement.textContent.trim()).toBe('');
});
it('should not call searchAiService.ask', () => {
mockQueryParams.next(params);
fixture.detectChanges();
expect(searchAiService.ask).not.toHaveBeenCalled();
});
});
});
describe('skeleton loader', () => {
const getSkeletonElementsLength = (): number => {
return fixture.nativeElement.querySelectorAll('.adf-skeleton').length;
};
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
});
it('should display skeleton when loading is true', () => {
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
component.performAiSearch();
fixture.detectChanges();
expect(component.loading).toBeTrue();
expect(getSkeletonElementsLength()).toBe(3);
});
it('should not display skeleton when loading is false', fakeAsync(() => {
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
component.performAiSearch();
tick(30000);
expect(component.loading).toBeFalse();
expect(getSkeletonElementsLength()).toBe(0);
}));
});
describe('Unsaved Changes Modal', () => {
beforeEach(() => {
spyOn(userPreferencesService, 'get').and.returnValue('true');
});
it('should open Unsaved Changes Modal and run callback successfully', () => {
const modalAiSpy = spyOn(modalAiService, 'openUnsavedChangesModal').and.callThrough();
spyOn(searchAiService, 'getAnswer').and.returnValue(of(getAiAnswerEntry()));
fixture.detectChanges();
fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-regeneration-button"]`)).nativeElement.click();
expect(modalAiSpy).toHaveBeenCalledWith(jasmine.any(Function));
expect(component.queryAnswer).toEqual(aiAnswerMock);
});
});
describe('References', () => {
let documentElement: HTMLDivElement;
let nodesOrder: string[];
const nodeId = 'someId';
const url = 'some-url';
beforeEach(fakeAsync(() => {
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder ??= passedNodesOrder));
spyOn(userPreferencesService, 'set');
spyOn(userPreferencesService, 'get').and.returnValue(knowledgeRetrievalNodes);
const answer = getAiAnswerEntry();
answer.entry.references = [{ referenceId: nodeId, referenceText: 'some text' }];
spyOn(searchAiService, 'getAnswer').and.returnValues(throwError('error'), of(answer));
mockQueryParams.next({ query: 'test', agentId: 'agentId1' });
tick(3051);
fixture.detectChanges();
documentElement = fixture.debugElement.query(By.css(`[data-automation-id="aca-search-ai-results-someId-document"]`)).nativeElement;
spyOn(store, 'dispatch');
spyOnProperty(TestBed.inject(Router), 'url').and.returnValue(url);
}));
it('should dispatch ViewNodeAction on store when clicked', () => {
documentElement.click();
expect(store.dispatch).toHaveBeenCalledWith(
new ViewNodeAction(nodeId, {
location: url
})
);
});
it('should dispatch ViewNodeAction on store when pressed enter', () => {
documentElement.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'Enter'
})
);
expect(store.dispatch).toHaveBeenCalledWith(
new ViewNodeAction(nodeId, {
location: url
})
);
});
it('should assign nodes ids to customNodesOrder for ViewerService', () => {
expect(nodesOrder).toEqual([nodeId]);
});
it('should call set on userPreferencesService with correct parameters', () => {
expect(userPreferencesService.set).toHaveBeenCalledWith('aiReferences', JSON.stringify([nodeId]));
});
});
describe('ngOnInit', () => {
it('should set customNodesOrder on ViewerService', () => {
spyOn(userPreferencesService, 'get').and.returnValue('["node1", "node2"]');
let nodesOrder: string[];
spyOnProperty(viewerService, 'customNodesOrder', 'set').and.callFake((passedNodesOrder) => (nodesOrder = passedNodesOrder));
component.ngOnInit();
expect(nodesOrder).toEqual(['node1', 'node2']);
});
});
});

View File

@ -0,0 +1,245 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageComponent, PageLayoutComponent, ToolbarActionComponent, ToolbarComponent } from '@alfresco/aca-shared';
import { concatMap, delay, filter, finalize, retryWhen, skipWhile, switchMap, takeUntil } from 'rxjs/operators';
import {
AvatarComponent,
ClipboardService,
EmptyContentComponent,
ThumbnailService,
ToolbarModule,
UnsavedChangesGuard,
UserPreferencesService
} from '@alfresco/adf-core';
import { AiAnswer, Node } from '@alfresco/js-api';
import { CommonModule } from '@angular/common';
import { SearchAiInputContainerComponent } from '../search-ai-input-container/search-ai-input-container.component';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { NodesApiService } from '@alfresco/adf-content-services';
import { forkJoin, Observable, of, throwError } from 'rxjs';
import { SelectionState } from '@alfresco/adf-extensions';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ModalAiService } from '../../../../services/modal-ai.service';
import { ViewNodeAction } from '@alfresco/aca-shared/store';
import { ViewerService } from '@alfresco/aca-content/viewer';
@Component({
standalone: true,
imports: [
CommonModule,
PageLayoutComponent,
ToolbarActionComponent,
ToolbarModule,
ToolbarComponent,
SearchAiInputContainerComponent,
TranslateModule,
MatIconModule,
MatButtonModule,
MatListModule,
EmptyContentComponent,
MatCardModule,
AvatarComponent,
MatTooltipModule
],
selector: 'aca-search-ai-results',
templateUrl: './search-ai-results.component.html',
styleUrls: ['./search-ai-results.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-search-ai-results' }
})
export class SearchAiResultsComponent extends PageComponent implements OnInit, OnDestroy {
private _agentId: string;
private _hasAnsweringError = false;
private _hasError = false;
private _loading = false;
private _mimeTypeIconsByNodeId: { [key: string]: string } = {};
private _nodes: Node[] = [];
private openedViewer = false;
private _selectedNodesState: SelectionState;
private _searchQuery = '';
private _queryAnswer: AiAnswer;
get agentId(): string {
return this._agentId;
}
get hasAnsweringError(): boolean {
return this._hasAnsweringError;
}
get hasError(): boolean {
return this._hasError;
}
get loading(): boolean {
return this._loading;
}
get mimeTypeIconsByNodeId(): { [key: string]: string } {
return this._mimeTypeIconsByNodeId;
}
get nodes(): Node[] {
return this._nodes;
}
get queryAnswer(): AiAnswer {
return this._queryAnswer;
}
get searchQuery(): string {
return this._searchQuery;
}
constructor(
private route: ActivatedRoute,
private clipboardService: ClipboardService,
private thumbnailService: ThumbnailService,
private nodesApiService: NodesApiService,
private userPreferencesService: UserPreferencesService,
private translateService: TranslateService,
private unsavedChangesGuard: UnsavedChangesGuard,
private modalAiService: ModalAiService,
private viewerService: ViewerService
) {
super();
}
ngOnInit(): void {
this.viewerService.customNodesOrder = JSON.parse(this.userPreferencesService.get('aiReferences', '[]'));
this.route.queryParams
.pipe(
filter((params) => {
const openedViewerPreviously = this.openedViewer;
this.openedViewer = !!params.location;
return !this.openedViewer && (!openedViewerPreviously || !this.queryAnswer);
}),
takeUntil(this.onDestroy$)
)
.subscribe((params) => {
this._agentId = params.agentId;
this._searchQuery = params.query ? decodeURIComponent(params.query) : '';
if (!this.searchQuery || !this.agentId) {
this._hasError = true;
return;
}
this._selectedNodesState = JSON.parse(this.userPreferencesService.get('knowledgeRetrievalNodes'));
this.performAiSearch();
});
super.ngOnInit();
this.unsavedChangesGuard.unsaved = this.route.snapshot?.queryParams?.query?.length > 0;
this.unsavedChangesGuard.data = {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.CONVERSATION_DISCARDED',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.OKAY',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
};
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
copyResponseToClipboard(): void {
this.clipboardService.copyContentToClipboard(
this.queryAnswer.answer,
this.translateService.instant('KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.COPY_MESSAGE')
);
}
checkUnsavedChangesAndSearch(): void {
this.modalAiService.openUnsavedChangesModal(() => this.performAiSearch());
}
performAiSearch(): void {
this._loading = true;
this.searchAiService
.ask({
question: this.searchQuery,
nodeIds: this._selectedNodesState?.nodes?.length ? this._selectedNodesState.nodes.map((node) => node.entry.id) : [],
agentId: this._agentId
})
.pipe(
switchMap((response) => this.searchAiService.getAnswer(response.questionId)),
switchMap((response) => {
if (!response.entry?.answer) {
return throwError((e) => e);
}
this._queryAnswer = response.entry;
return forkJoin(this.queryAnswer.references.map((reference) => this.nodesApiService.getNode(reference.referenceId)));
}),
retryWhen((errors: Observable<Error>) => this.aiSearchRetryWhen(errors)),
finalize(() => (this._loading = false)),
takeUntil(this.onDestroy$)
)
.subscribe(
(nodes) => {
nodes.forEach((node) => {
this._mimeTypeIconsByNodeId[node.id] = this.thumbnailService.getMimeTypeIcon(node.content?.mimeType);
});
this._nodes = nodes;
const nodesIds = nodes.map((node) => node.id);
this.viewerService.customNodesOrder = nodesIds;
this.userPreferencesService.set('aiReferences', JSON.stringify(nodesIds));
},
() => (this._hasAnsweringError = true)
);
}
openFile(id: string): void {
this.store.dispatch(
new ViewNodeAction(id, {
location: this.router.url
})
);
}
private aiSearchRetryWhen(errors: Observable<Error>): Observable<Error> {
this._hasAnsweringError = false;
const delayBetweenRetries = 3000;
const maxRetries = 9;
return errors.pipe(
skipWhile(() => this.hasAnsweringError),
delay(delayBetweenRetries),
concatMap((e, index) => {
if (index === maxRetries) {
this._hasAnsweringError = true;
this._loading = false;
return throwError(e);
}
return of(null);
})
);
}
}

View File

@ -0,0 +1,35 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Agent } from '@alfresco/js-api/typings';
export const getAgentsWithMockedAvatars = (agents: Agent[], mocked: boolean) => {
if (mocked) {
const images = ['assets/images/avatars/Blue.png', 'assets/images/avatars/Gold.png', 'assets/images/avatars/Pink.png'];
return agents.map((agent, index) => {
return { ...agent, avatarUrl: images[index > 2 ? 2 : index] };
});
}
return agents;
};

View File

@ -1,10 +1,18 @@
<aca-page-layout>
<div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId">
</aca-search-ai-input-container>
<ng-template #header>
<div class="aca-header-container">
<h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.RECENT.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
</h1>
<aca-toolbar [items]="actions"></aca-toolbar>
</div>
</ng-template>
</div>
<div class="aca-page-layout-content">
<div class="aca-main-content">

View File

@ -39,6 +39,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslateModule } from '@ngx-translate/core';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@ -54,11 +55,13 @@ import { TranslateModule } from '@ngx-translate/core';
PageLayoutComponent,
TranslateModule,
ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent,
DynamicColumnComponent
],
templateUrl: './recent-files.component.html',
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
selector: 'aca-recent-files'
})
export class RecentFilesComponent extends PageComponent implements OnInit {
columns: DocumentListPresetRef[] = [];

View File

@ -1,10 +1,16 @@
<aca-page-layout>
<aca-page-layout [class.aca-search-results-active-search-ai-input]="searchAiInputState.active">
<div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active"
[agentId]="searchAiInputState.selectedAgentId">
</aca-search-ai-input-container>
<div class="aca-header-container">
<aca-search-input></aca-search-input>
<aca-bulk-actions-dropdown *ngIf="bulkActions" [items]="bulkActions"></aca-bulk-actions-dropdown>
<div class="aca-search-toolbar-spacer"></div>
<aca-toolbar [items]="actions"></aca-toolbar>
</div>
</div>
<div class="aca-page-layout-content">
<div class="aca-main-content">

View File

@ -1,6 +1,13 @@
@import '../../../ui/mixins';
aca-search-results {
.aca-search-results-active-search-ai-input {
.aca-header-container,
.adf-search-results__content-header.aca-content {
display: none;
}
}
.aca-search-toolbar-spacer {
width: 100%;
}

View File

@ -77,6 +77,7 @@ import { MatIconModule } from '@angular/material/icon';
import { SearchResultsRowComponent } from '../search-results-row/search-results-row.component';
import { DocumentListPresetRef, DynamicColumnComponent } from '@alfresco/adf-extensions';
import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-actions-dropdown.component';
import { SearchAiInputContainerComponent } from '../../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@ -110,7 +111,8 @@ import { BulkActionsDropdownComponent } from '../../bulk-actions-dropdown/bulk-a
DateColumnHeaderComponent,
CustomEmptyContentTemplateDirective,
ViewerToolbarComponent,
BulkActionsDropdownComponent
BulkActionsDropdownComponent,
SearchAiInputContainerComponent
],
selector: 'aca-search-results',
templateUrl: './search-results.component.html',

View File

@ -1,11 +1,19 @@
<aca-page-layout>
<div class="aca-page-layout-header">
<aca-search-ai-input-container
*ngIf="searchAiInputState.active; else header"
[agentId]="searchAiInputState.selectedAgentId">
</aca-search-ai-input-container>
<ng-template #header>
<div class="aca-header-container">
<h1 class="aca-page-title">
{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.SHARED.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}
</h1>
<aca-toolbar [items]="actions"></aca-toolbar>
</div>
</ng-template>
</div>
<div class="aca-page-layout-content">
<div class="aca-main-content">

View File

@ -40,6 +40,7 @@ import { DocumentListModule } from '@alfresco/adf-content-services';
import { DataTableModule, EmptyContentComponent, PaginationComponent } from '@alfresco/adf-core';
import { DocumentListDirective } from '../../directives/document-list.directive';
import { TranslateModule } from '@ngx-translate/core';
import { SearchAiInputContainerComponent } from '../knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component';
@Component({
standalone: true,
@ -55,11 +56,13 @@ import { TranslateModule } from '@ngx-translate/core';
PageLayoutComponent,
TranslateModule,
ToolbarComponent,
SearchAiInputContainerComponent,
EmptyContentComponent,
DynamicColumnComponent
],
templateUrl: './shared-files.component.html',
encapsulation: ViewEncapsulation.None
encapsulation: ViewEncapsulation.None,
selector: 'aca-shared-files'
})
export class SharedFilesComponent extends PageComponent implements OnInit {
columns: DocumentListPresetRef[] = [];

View File

@ -118,5 +118,25 @@ describe('AcaExpansionPanel', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('should not navigate to first child if none is active route and acaExpansionPanel has canBeInactive property', () => {
const router: any = new RouterStub('dummy-route-2');
spyOn(router, 'navigate').and.callThrough();
const item = {
children: [{ url: 'dummy-route-1' }, { url: 'dummy-route-2' }],
data: {
canBeInactive: true
}
};
const directive = new ExpansionPanelDirective(mockStore, router, mockMatExpansionPanel);
directive.acaExpansionPanel = item;
mockMatExpansionPanel.expanded = true;
directive.onClick();
expect(router.navigate).not.toHaveBeenCalled();
});
});
});

View File

@ -42,7 +42,7 @@ export class ExpansionPanelDirective implements OnInit, OnDestroy {
@HostListener('click')
onClick() {
if (this.expansionPanel.expanded && !this.hasActiveLinks()) {
if (this.expansionPanel.expanded && !this.hasActiveLinks() && !this.acaExpansionPanel.data?.canBeInactive) {
const firstChild = this.acaExpansionPanel.children[0];
if (firstChild.url) {
this.router.navigate(this.getNavigationCommands(firstChild.url));

View File

@ -0,0 +1,114 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ModalAiService } from './modal-ai.service';
import { TestBed } from '@angular/core/testing';
import { ContentTestingModule } from '@alfresco/adf-content-services';
import { MatDialog, MatDialogConfig, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Params } from '@angular/router';
import { of, Subject } from 'rxjs';
import { StorageService, UnsavedChangesDialogComponent } from '@alfresco/adf-core';
describe('ModalAiService', () => {
const mockQueryParams = new Subject<Params>();
let service: ModalAiService;
let dialogOpenSpy: jasmine.Spy<<T, R>(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef<T, R>>;
let dialog: MatDialog;
const setupBeforeEach = (query: string, storageGetItem: string) => {
TestBed.configureTestingModule({
imports: [ContentTestingModule, MatDialogModule],
providers: [
{
provide: StorageService,
useValue: {
getItem: () => storageGetItem,
setItem: () => storageGetItem
}
},
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
snapshot: {
queryParams: { query }
}
}
}
]
});
dialog = TestBed.inject(MatDialog);
dialogOpenSpy = spyOn(dialog, 'open').and.returnValue({
afterClosed: () => of(true)
} as MatDialogRef<any>);
service = TestBed.inject(ModalAiService);
};
describe('when there is no previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in the storage', () => {
it('should not open unsaved changes modal when there is not previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
setupBeforeEach('', '');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).not.toHaveBeenCalled();
});
});
describe('when there is no previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in storage', () => {
it('should open unsaved changes modal when there is previous search and no UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
setupBeforeEach('test', '');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).toHaveBeenCalledWith(UnsavedChangesDialogComponent, {
width: '345px',
data: {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
}
});
});
it('should call callback after modal has been closed and change test value to true', () => {
let test = false;
const mockFunc = () => {
test = true;
};
setupBeforeEach('test', '');
service.openUnsavedChangesModal(mockFunc);
expect(test).toBeTrue();
});
});
describe('when there is previous search in query and UNSAVED_CHANGES_MODAL_HIDDEN is the storage', () => {
it('should not open unsaved changes modal when has previous search and there is UNSAVED_CHANGES_MODAL_HIDDEN in local storage', () => {
setupBeforeEach('test', 'true');
service.openUnsavedChangesModal(() => {});
expect(dialogOpenSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,62 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { inject, Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AppConfigValues, UnsavedChangesDialogComponent, UserPreferencesService } from '@alfresco/adf-core';
import { MatDialog } from '@angular/material/dialog';
@Injectable({ providedIn: 'root' })
export class ModalAiService {
private route = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private userPreferencesService = inject(UserPreferencesService);
openUnsavedChangesModal(callback: () => void): void {
const hasPreviousSearch = this.route.snapshot?.queryParams?.query?.length > 0;
const modalHidden = this.userPreferencesService.get(AppConfigValues.UNSAVED_CHANGES_MODAL_HIDDEN) === 'true';
if (!hasPreviousSearch || modalHidden) {
callback();
return;
}
this.dialog
.open<UnsavedChangesDialogComponent>(UnsavedChangesDialogComponent, {
width: '345px',
data: {
descriptionText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.LOSE_RESPONSE',
confirmButtonText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.ASK_AI',
checkboxText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.DO_NOT_SHOW_MESSAGE',
headerText: 'KNOWLEDGE_RETRIEVAL.SEARCH.DISCARD_CHANGES.WARNING'
}
})
.afterClosed()
.subscribe((openModal: boolean) => {
if (openModal) {
callback();
}
});
}
}

View File

@ -0,0 +1,134 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchAiNavigationService } from './search-ai-navigation.service';
import { Params, Router } from '@angular/router';
import { TestBed } from '@angular/core/testing';
import { ContentTestingModule } from '@alfresco/adf-content-services';
describe('SearchAiNavigationService', () => {
let service: SearchAiNavigationService;
let router: Router;
const knowledgeRetrievalUrl = '/knowledge-retrieval';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContentTestingModule]
});
service = TestBed.inject(SearchAiNavigationService);
router = TestBed.inject(Router);
});
describe('navigateToPreviousRoute', () => {
let urlSpy: jasmine.Spy<() => string>;
let navigateByUrlSpy: jasmine.Spy<(url: string) => Promise<boolean>>;
const sourceUrl = '/some-url';
const personalFilesUrl = '/personal-files';
beforeEach(() => {
navigateByUrlSpy = spyOn(router, 'navigateByUrl');
urlSpy = spyOnProperty(router, 'url');
});
it('should navigate to personal files if there is not previous route and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
});
it('should not navigate if there is not previous route and actual route is not knowledge retrieval', () => {
urlSpy.and.returnValue('/some-url');
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
});
it('should navigate to previous route if there is some previous route and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(sourceUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue(knowledgeRetrievalUrl);
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).toHaveBeenCalledWith(sourceUrl);
});
it('should not navigate to previous route if there is some previous route but actual route is not knowledge retrieval', () => {
urlSpy.and.returnValue(sourceUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue('/some-different-url');
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
});
it('should navigate to personal files if previous route is knowledge retrieval and actual route is knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).toHaveBeenCalledWith(personalFilesUrl);
});
it('should not navigate if previous route is knowledge retrieval and actual route is different than knowledge retrieval', () => {
urlSpy.and.returnValue(knowledgeRetrievalUrl);
service.navigateToSearchAi({
agentId: 'some agent id'
});
urlSpy.and.returnValue(sourceUrl);
navigateByUrlSpy.calls.reset();
service.navigateToPreviousRoute();
expect(navigateByUrlSpy).not.toHaveBeenCalled();
});
});
describe('navigateToSearchAi', () => {
beforeEach(() => {
spyOn(router, 'navigate');
});
it('should navigate to search ai results page', () => {
const queryParams: Params = {
agentId: 'some agent id'
};
service.navigateToSearchAi(queryParams);
expect(router.navigate).toHaveBeenCalledWith([knowledgeRetrievalUrl], {
queryParams
});
});
});
});

View File

@ -0,0 +1,48 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { Params, Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class SearchAiNavigationService {
private readonly knowledgeRetrievalRoute = '/knowledge-retrieval';
private previousRoute = '';
constructor(private router: Router) {}
navigateToPreviousRoute(): void {
if (this.router.url.includes(this.knowledgeRetrievalRoute)) {
void this.router.navigateByUrl(this.previousRoute || '/personal-files');
}
}
navigateToSearchAi(queryParams: Params): void {
if (!this.router.url.includes(this.knowledgeRetrievalRoute)) {
this.previousRoute = this.router.url;
}
void this.router.navigate([this.knowledgeRetrievalRoute], { queryParams });
}
}

View File

@ -41,6 +41,7 @@ import {
ContextMenuEffects
} from './effects';
import { INITIAL_STATE } from './initial-state';
import { SearchAiEffects } from './effects/search-ai.effects';
@NgModule({
imports: [
@ -69,6 +70,8 @@ import { INITIAL_STATE } from './initial-state';
FavoriteEffects,
TemplateEffects,
ContextMenuEffects,
SearchAiEffects,
ContextMenuEffects,
SnackbarEffects,
RouterEffects
])

View File

@ -33,3 +33,4 @@ export * from './effects/upload.effects';
export * from './effects/upload.effects';
export * from './effects/template.effects';
export * from './effects/contextmenu.effects';
export * from './effects/search-ai.effects';

View File

@ -0,0 +1,65 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { SearchAiActionTypes, SearchByTermAiAction, ToggleAISearchInput } from '@alfresco/aca-shared/store';
import { map } from 'rxjs/operators';
import { SearchAiNavigationService } from '../../services/search-ai-navigation.service';
import { SearchAiService } from '@alfresco/adf-content-services';
import { Params } from '@angular/router';
@Injectable()
export class SearchAiEffects {
constructor(private actions$: Actions, private searchNavigationService: SearchAiNavigationService, private searchAiService: SearchAiService) {}
searchByTerm$ = createEffect(
() =>
this.actions$.pipe(
ofType<SearchByTermAiAction>(SearchAiActionTypes.SearchByTermAi),
map((action) => {
const queryParams: Params = {
query: encodeURIComponent(action.payload.searchTerm),
agentId: action.payload.agentId
};
this.searchNavigationService.navigateToSearchAi(queryParams);
})
),
{ dispatch: false }
);
toggleAISearchInput$ = createEffect(
() =>
this.actions$.pipe(
ofType<ToggleAISearchInput>(SearchAiActionTypes.ToggleAiSearchInput),
map((action) =>
this.searchAiService.updateSearchAiInputState({
active: true,
selectedAgentId: action.agentId
})
)
),
{ dispatch: false }
);
}

View File

@ -66,3 +66,11 @@ ng-component {
color: var(--adf-theme-foreground-text-color-087);
width: 100%;
}
.aca-header-container {
display: flex;
flex-direction: row;
flex: 1;
align-items: center;
width: 100%;
}

View File

@ -338,3 +338,9 @@ adf-dynamic-component {
min-width: 160px;
}
}
.adf-unsaved-changes-dialog {
.adf-unsaved-changes-dialog-actions-discard-changes-button:is(button) {
background-color: var(--theme-blue-button-color);
}
}

View File

@ -28,6 +28,7 @@ $blue-save-button-background: #1f74db;
$blue-checkbox-background: rgb(10, 96, 206);
$blue-active-table-row: rgb(10, 96, 206, 0.24);
$black-heading: #4e4c4c;
$light-grey-content: #4b5563;
$theme-dropdown-background: darken($background-color, 5%);
$theme-dropdown-background-hover: darken($background-color, 10%);
$grey-divider: rgba(0, 0, 0, 0.22);
@ -46,6 +47,11 @@ $disabled-chip-background-color: #f5f5f5;
$contrast-gray: mat.get-color-from-palette($foreground, 'secondary-tex');
$search-highlight-background-color: #ffd180;
$info-snackbar-background: #1f74db;
$text-light-color: rgba(33, 35, 40, 0.7);
$card-background-grey-color: rgb(248, 248, 248);
$light-grey-1: #d5d5d5;
$light-grey-2: #d9d9d9;
$light-grey-3: #dedede;
// CSS Variables
$defaults: (
@ -75,6 +81,7 @@ $defaults: (
--theme-blue-checkbox-color: $blue-checkbox-background,
--theme-blue-active-table-row-color: $blue-active-table-row,
--theme-heading-color: $black-heading,
--theme-content-color: $light-grey-content,
--theme-dropdown-color: $theme-dropdown-background,
--theme-dropdown-background-hover: $theme-dropdown-background-hover,
--theme-grey-divider-color: $grey-divider,
@ -96,7 +103,12 @@ $defaults: (
--theme-search-chip-icon-color: $search-chip-icon-color,
--theme-disabled-chip-background-color: $disabled-chip-background-color,
--theme-secondary-text: $secondary-text,
--theme-search-highlight-background-color: $search-highlight-background-color
--theme-search-highlight-background-color: $search-highlight-background-color,
--theme-text-light-color: $text-light-color,
--theme-card-background-grey-color: $card-background-grey-color,
--theme-light-grey-1-color: $light-grey-1,
--theme-light-grey-2-color: $light-grey-2,
--theme-light-grey-3-color: $light-grey-3
);
// propagates SCSS variables into the CSS variables scope

View File

@ -215,4 +215,9 @@ describe('ViewerService', () => {
const ids = await viewerService.getFileIds('libraries', null);
expect(ids).toEqual([]);
});
it('should return custom nodes order if did not find nodes', async () => {
viewerService.customNodesOrder = ['someNode1', 'someNode2'];
expect(await viewerService.getFileIds('', null)).toEqual(['someNode1', 'someNode2']);
});
});

View File

@ -36,6 +36,12 @@ interface AdjacentFiles {
providedIn: 'root'
})
export class ViewerService {
private _customNodesOrder: string[] = [];
set customNodesOrder(customNodesOrder: string[]) {
this._customNodesOrder = customNodesOrder;
}
constructor(private preferences: UserPreferencesService, private contentApi: ContentApiService) {}
recentFileFilters = [
@ -103,7 +109,6 @@ export class ViewerService {
}
const isClient = this.preferences.get(`${source}.sorting.mode`) === 'client';
const [sortKey, sortDirection, previousSortKey, previousSortDir] = this.getSortKeyDir(source);
let entries: Node[] | SharedLink[] = [];
let nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging;
if (source === 'personal-files' || source === 'libraries-files') {
@ -164,15 +169,7 @@ export class ViewerService {
};
nodes = await this.contentApi.search(query).toPromise();
}
entries = nodes.list.entries.map((obj) => obj.entry.target?.file ?? obj.entry);
if (isClient) {
if (previousSortKey) {
this.sort(entries, previousSortKey, previousSortDir);
}
this.sort(entries, sortKey, sortDirection);
}
return entries.map((entry) => entry.id ?? entry.nodeId);
return this.getCustomNodesOrderIfNoNodes(nodes, isClient, previousSortKey, previousSortDir, sortKey, sortDirection);
}
/**
@ -224,4 +221,26 @@ export class ViewerService {
return ['modifiedAt', 'desc'];
}
}
private getCustomNodesOrderIfNoNodes(
nodes: NodePaging | FavoritePaging | SharedLinkPaging | ResultSetPaging,
isClient: boolean,
previousSortKey: string,
previousSortDir: string,
sortKey: string,
sortDirection: string
): string[] {
if (nodes) {
const entries = nodes.list.entries.map((obj) => obj.entry.target?.file ?? obj.entry);
if (isClient) {
if (previousSortKey) {
this.sort(entries, previousSortKey, previousSortDir);
}
this.sort(entries, sortKey, sortDirection);
}
return entries.map((entry) => entry.id ?? entry.nodeId);
} else {
return this._customNodesOrder;
}
}
}

View File

@ -29,3 +29,4 @@
export * from './lib/components/viewer/viewer.component';
export * from './lib/components/preview/preview.component';
export * from './lib/viewer.module';
export * from './lib/services/viewer.service';

View File

@ -23,9 +23,9 @@
*/
import * as app from './app.rules';
import { getFileExtension } from './app.rules';
import { TestRuleContext } from './test-rule-context';
import { NodeEntry, RepositoryInfo, StatusInfo } from '@alfresco/js-api';
import { getFileExtension } from './app.rules';
describe('app.evaluators', () => {
let context: TestRuleContext;
@ -540,6 +540,47 @@ describe('app.evaluators', () => {
});
});
describe('isKnowledgeRetrievalEnabled', () => {
it('should call context.appConfig.get with correct parameters', () => {
context.appConfig = { get: jasmine.createSpy() } as any;
app.canDisplayKnowledgeRetrievalButton(context);
expect(context.appConfig.get).toHaveBeenCalledWith('plugins.knowledgeRetrievalEnabled', true);
});
it('should return false if get from appConfig returns false', () => {
expect(
app.canDisplayKnowledgeRetrievalButton({
appConfig: {
get: () => false
}
} as any)
).toBeFalse();
});
it('should return true if get from appConfig returns true and navigation is correct', () => {
expect(
app.canDisplayKnowledgeRetrievalButton({
navigation: { url: '/personal-files' },
appConfig: {
get: () => true
}
} as any)
).toBeTrue();
});
it('should return false if get from appConfig returns true, but navigation is not correct', () => {
expect(
app.canDisplayKnowledgeRetrievalButton({
navigation: { url: '/my-special-files' },
appConfig: {
get: () => true
}
} as any)
).toBeFalse();
});
});
describe('isContentServiceEnabled', () => {
it('should call context.appConfig.get with correct parameters', () => {
context.appConfig = { get: jasmine.createSpy() } as any;

View File

@ -629,3 +629,11 @@ export function isSmartFolder(context: RuleContext): boolean {
export const areTagsEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.tagsEnabled', true);
export const areCategoriesEnabled = (context: AcaRuleContext): boolean => context.appConfig.get('plugins.categoriesEnabled', true);
export const canDisplayKnowledgeRetrievalButton = (context: AcaRuleContext): boolean =>
context.appConfig.get('plugins.knowledgeRetrievalEnabled', false) &&
(navigation.isPersonalFiles(context) ||
navigation.isSharedFiles(context) ||
navigation.isRecentFiles(context) ||
navigation.isFavorites(context) ||
((navigation.isSearchResults(context) || navigation.isLibraryContent(context)) && navigation.isNotLibraries(context)));

View File

@ -22,7 +22,14 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { DocumentListComponent, DocumentListService, ShareDataRow, UploadService } from '@alfresco/adf-content-services';
import {
DocumentListComponent,
DocumentListService,
SearchAiInputState,
SearchAiService,
ShareDataRow,
UploadService
} from '@alfresco/adf-content-services';
import { ShowHeaderMode } from '@alfresco/adf-core';
import { ContentActionRef, DocumentListPresetRef, SelectionState } from '@alfresco/adf-extensions';
import { OnDestroy, OnInit, OnChanges, ViewChild, SimpleChanges, Directive, inject, HostListener } from '@angular/core';
@ -81,8 +88,17 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
protected router = inject(Router);
private autoDownloadService = inject(AutoDownloadService, { optional: true });
protected searchAiService: SearchAiService = inject(SearchAiService);
protected subscriptions: Subscription[] = [];
private _searchAiInputState: SearchAiInputState = {
active: false
};
get searchAiInputState(): SearchAiInputState {
return this._searchAiInputState;
}
ngOnInit() {
this.extensions
.getCreateActions()
@ -135,6 +151,10 @@ export abstract class PageComponent implements OnInit, OnDestroy, OnChanges {
.subscribe((result) => {
this.isSmallScreen = result.matches;
});
this.searchAiService.toggleSearchAiInput$
.pipe(takeUntil(this.onDestroy$))
.subscribe((searchAiInputState) => (this._searchAiInputState = searchAiInputState));
}
ngOnChanges(changes: SimpleChanges) {

View File

@ -0,0 +1,42 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Action } from '@ngrx/store';
import { AiSearchByTermPayload } from '../models/ai-search-by-term-payload';
export enum SearchAiActionTypes {
SearchByTermAi = 'SEARCH_BY_TERM_AI',
ToggleAiSearchInput = 'TOGGLE_AI_SEARCH_INPUT'
}
export class SearchByTermAiAction implements Action {
readonly type = SearchAiActionTypes.SearchByTermAi;
constructor(public payload: AiSearchByTermPayload) {}
}
export class ToggleAISearchInput implements Action {
readonly type = SearchAiActionTypes.ToggleAiSearchInput;
constructor(public agentId: string) {}
}

View File

@ -0,0 +1,28 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
export interface AiSearchByTermPayload {
searchTerm: string;
agentId: string;
}

View File

@ -37,10 +37,12 @@ export * from './actions/viewer.actions';
export * from './actions/metadata-aspect.actions';
export * from './actions/template.actions';
export * from './actions/contextmenu.actions';
export * from './actions/search-ai.actions';
export * from './effects/router.effects';
export * from './effects/snackbar.effects';
export * from './models/ai-search-by-term-payload';
export * from './models/delete-status.model';
export * from './models/deleted-node-info.model';
export * from './models/node-info.model';