diff --git a/app/src/app.config.json b/app/src/app.config.json index d8e9f13ef..9e3a71950 100644 --- a/app/src/app.config.json +++ b/app/src/app.config.json @@ -14,7 +14,8 @@ "contentService": true, "folderRules": true, "tagsEnabled": true, - "categoriesEnabled": true + "categoriesEnabled": true, + "knowledgeRetrievalEnabled": false }, "oauth2": { "host": "{protocol}//{hostname}{:port}/auth/realms/alfresco", diff --git a/app/src/assets/images/avatars/Blue.png b/app/src/assets/images/avatars/Blue.png new file mode 100644 index 000000000..cfb695ab3 Binary files /dev/null and b/app/src/assets/images/avatars/Blue.png differ diff --git a/app/src/assets/images/avatars/Gold.png b/app/src/assets/images/avatars/Gold.png new file mode 100644 index 000000000..0298736e0 Binary files /dev/null and b/app/src/assets/images/avatars/Gold.png differ diff --git a/app/src/assets/images/avatars/Pink.png b/app/src/assets/images/avatars/Pink.png new file mode 100644 index 000000000..907062053 Binary files /dev/null and b/app/src/assets/images/avatars/Pink.png differ diff --git a/e2e/playwright/copy-move-actions/exclude.tests.json b/e2e/playwright/copy-move-actions/exclude.tests.json index 0967ef424..db5930c4b 100644 --- a/e2e/playwright/copy-move-actions/exclude.tests.json +++ b/e2e/playwright/copy-move-actions/exclude.tests.json @@ -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" +} diff --git a/e2e/playwright/delete-actions/exclude.tests.json b/e2e/playwright/delete-actions/exclude.tests.json index 893d21a54..00ab00aba 100644 --- a/e2e/playwright/delete-actions/exclude.tests.json +++ b/e2e/playwright/delete-actions/exclude.tests.json @@ -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" } diff --git a/e2e/playwright/edit-actions/exclude.tests.json b/e2e/playwright/edit-actions/exclude.tests.json index 0967ef424..aa475482b 100644 --- a/e2e/playwright/edit-actions/exclude.tests.json +++ b/e2e/playwright/edit-actions/exclude.tests.json @@ -1 +1,3 @@ -{} +{ + "XAT-5095": "https://hyland.atlassian.net/browse/ACS-8812" +} diff --git a/e2e/playwright/navigation/exclude.tests.json b/e2e/playwright/navigation/exclude.tests.json index 0967ef424..0ebca7ffa 100644 --- a/e2e/playwright/navigation/exclude.tests.json +++ b/e2e/playwright/navigation/exclude.tests.json @@ -1 +1,3 @@ -{} +{ + "C280034": "https://hyland.atlassian.net/browse/ACS-8812" +} diff --git a/e2e/playwright/search/exclude.tests.json b/e2e/playwright/search/exclude.tests.json index 031cfa0f6..1f79b2c29 100644 --- a/e2e/playwright/search/exclude.tests.json +++ b/e2e/playwright/search/exclude.tests.json @@ -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" } \ No newline at end of file diff --git a/e2e/playwright/upload-download-actions/exclude.tests.json b/e2e/playwright/upload-download-actions/exclude.tests.json index 9e26dfeeb..1a984fdf0 100644 --- a/e2e/playwright/upload-download-actions/exclude.tests.json +++ b/e2e/playwright/upload-download-actions/exclude.tests.json @@ -1 +1,3 @@ -{} \ No newline at end of file +{ + "C297548": "https://hyland.atlassian.net/browse/ACS-8812" +} diff --git a/projects/aca-content/assets/app.extensions.json b/projects/aca-content/assets/app.extensions.json index ca545b754..258fe3a9a 100644 --- a/projects/aca-content/assets/app.extensions.json +++ b/projects/aca-content/assets/app.extensions.json @@ -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": [ diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index 32d109265..2aad6b5a8 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -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" + } + } } } diff --git a/projects/aca-content/assets/images/colored-stars-ai.svg b/projects/aca-content/assets/images/colored-stars-ai.svg new file mode 100644 index 000000000..bf397f933 --- /dev/null +++ b/projects/aca-content/assets/images/colored-stars-ai.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/projects/aca-content/assets/images/three-magic-stars-ai.svg b/projects/aca-content/assets/images/three-magic-stars-ai.svg new file mode 100644 index 000000000..992394ec0 --- /dev/null +++ b/projects/aca-content/assets/images/three-magic-stars-ai.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/projects/aca-content/src/lib/aca-content.module.ts b/projects/aca-content/src/lib/aca-content.module.ts index 4182327ba..aa3a121ef 100644 --- a/projects/aca-content/src/lib/aca-content.module.ts +++ b/projects/aca-content/src/lib/aca-content.module.ts @@ -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, diff --git a/projects/aca-content/src/lib/aca-content.routes.ts b/projects/aca-content/src/lib/aca-content.routes.ts index 85da0484e..99c37c7a4 100644 --- a/projects/aca-content/src/lib/aca-content.routes.ts +++ b/projects/aca-content/src/lib/aca-content.routes.ts @@ -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 diff --git a/projects/aca-content/src/lib/components/favorites/favorites.component.html b/projects/aca-content/src/lib/components/favorites/favorites.component.html index 9b90dec77..75190109e 100644 --- a/projects/aca-content/src/lib/components/favorites/favorites.component.html +++ b/projects/aca-content/src/lib/components/favorites/favorites.component.html @@ -1,9 +1,17 @@
-

+ + + +
+

{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.FAVORITES.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}

- + +
+

diff --git a/projects/aca-content/src/lib/components/favorites/favorites.component.ts b/projects/aca-content/src/lib/components/favorites/favorites.component.ts index 16d5495ee..c17a0a771 100644 --- a/projects/aca-content/src/lib/components/favorites/favorites.component.ts +++ b/projects/aca-content/src/lib/components/favorites/favorites.component.ts @@ -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[] = []; diff --git a/projects/aca-content/src/lib/components/files/files.component.html b/projects/aca-content/src/lib/components/files/files.component.html index 6a63c5d96..35a5d0494 100644 --- a/projects/aca-content/src/lib/components/files/files.component.html +++ b/projects/aca-content/src/lib/components/files/files.component.html @@ -1,12 +1,20 @@
- - - + + + +
+ + + +
+
diff --git a/projects/aca-content/src/lib/components/files/files.component.ts b/projects/aca-content/src/lib/components/files/files.component.ts index 549f9e471..62746a938 100644 --- a/projects/aca-content/src/lib/components/files/files.component.ts +++ b/projects/aca-content/src/lib/components/files/files.component.ts @@ -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; diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html new file mode 100644 index 000000000..6f3c1a25d --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.html @@ -0,0 +1,37 @@ + + + + + +
+ + + {{ agent.name }} + +
+
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss new file mode 100644 index 000000000..f3aa6cc7a --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.scss @@ -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; + } + } + } + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts new file mode 100644 index 000000000..49b670140 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.spec.ts @@ -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 . + */ + +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; + let agents$: Subject; + let agentsMock: Agent[]; + let checkSearchAvailabilitySpy: jasmine.Spy<(selectedNodesState: SelectionState, maxSelectedNodes?: number) => string>; + let selectionState: SelectionState; + let store: MockStore; + let config$: Subject; + + 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(); + 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(); + 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>; + + 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 => + (await loader.getHarness(MatMenuHarness)).getHarness(MatSelectionListHarness); + + const selectAgent = async (): Promise => + (await getAgentsListHarness()).selectItems({ + fullText: 'PA Policy Agent' + }); + + const getAgentsList = (): MatSelectionList => fixture.debugElement.query(By.directive(MatSelectionList)).componentInstance; + + it('should deselect selected agent after selecting other', async () => { + component.data = { + trigger: SearchAiActionTypes.ToggleAiSearchInput + }; + const selectionList = getAgentsList(); + spyOn(selectionList, 'deselectAll'); + await selectAgent(); + + expect(selectionList.deselectAll).toHaveBeenCalled(); + }); + + it('should dispatch on store selected agent', async () => { + component.data = { + trigger: SearchAiActionTypes.ToggleAiSearchInput + }; + spyOn(store, 'dispatch'); + await selectAgent(); + + expect(store.dispatch).toHaveBeenCalledWith({ + 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'); + }); + }); + }); +}); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts new file mode 100644 index 000000000..4d36d8a04 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/agents-button/agents-button.component.ts @@ -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 . + */ + +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(); + 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, + 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(); + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html new file mode 100644 index 000000000..526f6ab3c --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.html @@ -0,0 +1,18 @@ + + + + + diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss new file mode 100644 index 000000000..6fd639575 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.scss @@ -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; + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts new file mode 100644 index 000000000..e09f40770 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.spec.ts @@ -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 . + */ + +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; + let routingEvents$: Subject; + 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(); + 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(); + }); + }); +}); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts new file mode 100644 index 000000000..af5fa2a16 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input-container/search-ai-input-container.component.ts @@ -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 . + */ + +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(); + + 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(); + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html new file mode 100644 index 000000000..b0c5a0163 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.html @@ -0,0 +1,63 @@ +
+ + + + + {{ agentControl.value?.name }} + +
+ + {{ agent.name }} +
+
+
+ +
+ + + + + + {{ agentControl.value?.name }} + + + + {{ agentControl.value?.description }} + + +
+
+ + diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss new file mode 100644 index 000000000..0e464ac42 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.scss @@ -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; + } + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts new file mode 100644 index 000000000..7434043a7 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.spec.ts @@ -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 . + */ + +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; + let loader: HarnessLoader; + let selectionState: SelectionState; + let store: MockStore; + let agents$: Subject; + 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(); + 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>; + + 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<(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef>; + 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); + 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(); + }); + }); + } +}); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts new file mode 100644 index 000000000..c5cc14624 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-input/search-ai-input.component.ts @@ -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 . + */ + +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(); + + private readonly storedNodesKey = 'knowledgeRetrievalNodes'; + + private _agentControl = new FormControl(null); + private _agents: Agent[] = []; + private onDestroy$ = new Subject(); + private selectedNodesState: SelectionState; + private _queryControl = new FormControl(''); + private _initialsByAgentId: { [key: string]: string } = {}; + + avatarsMocked = true; + + get agentControl(): FormControl { + return this._agentControl; + } + + get agents(): Agent[] { + return this._agents; + } + + get queryControl(): FormControl { + return this._queryControl; + } + + get initialsByAgentId(): { [key: string]: string } { + return this._initialsByAgentId; + } + + constructor( + private store: Store, + 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(); + } + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html new file mode 100644 index 000000000..617e37afc --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.html @@ -0,0 +1,116 @@ + + + +
+
+
+ {{ searchQuery }} +
+
+ +
+
+ {{ queryAnswer?.answer }} +
+ + + + + + +
+

+ {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.RESULTS_PAGE.REFERENCED_DOCUMENTS_HEADER' | translate }} +

+ +
+
+
+
+ {{ 'KNOWLEDGE_RETRIEVAL.SEARCH.ERRORS.LOADING_ERROR' | translate }} + +
+
+
+
+
+ + +
+ + +
+
+
+
diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss new file mode 100644 index 000000000..5a8ff684b --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.scss @@ -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; + } + } + } + } + } + } + } + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts new file mode 100644 index 000000000..ab6def533 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.spec.ts @@ -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 . + */ + +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; + let component: SearchAiResultsComponent; + let userPreferencesService: UserPreferencesService; + let mockQueryParams = new Subject(); + let modalAiService: ModalAiService; + let searchAiService: SearchAiService; + let store: MockStore; + let viewerService: ViewerService; + + afterEach(() => { + store.resetSelectors(); + mockQueryParams = new Subject(); + 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']); + }); + }); +}); diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts new file mode 100644 index 000000000..882d21a76 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-results/search-ai-results.component.ts @@ -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 . + */ + +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) => 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): Observable { + 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); + }) + ); + } +} diff --git a/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts new file mode 100644 index 000000000..9f76d2e74 --- /dev/null +++ b/projects/aca-content/src/lib/components/knowledge-retrieval/search-ai/search-ai-utils.ts @@ -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 . + */ + +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; +}; diff --git a/projects/aca-content/src/lib/components/recent-files/recent-files.component.html b/projects/aca-content/src/lib/components/recent-files/recent-files.component.html index 80cfa618b..2a2a84fb8 100644 --- a/projects/aca-content/src/lib/components/recent-files/recent-files.component.html +++ b/projects/aca-content/src/lib/components/recent-files/recent-files.component.html @@ -1,9 +1,17 @@
-

+ + + +
+

{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.RECENT.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}

- + +
+

diff --git a/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts b/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts index 8db29b52c..c7ef0377a 100644 --- a/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts +++ b/projects/aca-content/src/lib/components/recent-files/recent-files.component.ts @@ -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[] = []; diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html index 9274f94fd..d2b32cba2 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.html +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.html @@ -1,9 +1,15 @@ - +
- - -
- + + +
+ + +
+ +
diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss index 4b42cbfc1..112adcd66 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.scss @@ -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%; } diff --git a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts index 2ddf74b44..0598ca69a 100644 --- a/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts +++ b/projects/aca-content/src/lib/components/search/search-results/search-results.component.ts @@ -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', diff --git a/projects/aca-content/src/lib/components/shared-files/shared-files.component.html b/projects/aca-content/src/lib/components/shared-files/shared-files.component.html index e8f446754..33e6fe8b0 100644 --- a/projects/aca-content/src/lib/components/shared-files/shared-files.component.html +++ b/projects/aca-content/src/lib/components/shared-files/shared-files.component.html @@ -1,10 +1,18 @@
-

+ + + +
+

{{ (selectedRowItemsCount < 1 ? 'APP.BROWSE.SHARED.TITLE' : 'APP.HEADER.SELECTED') | translate: { count: selectedRowItemsCount } }}

- + +
+

diff --git a/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts b/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts index 3c8793aad..d84d0e36f 100644 --- a/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts +++ b/projects/aca-content/src/lib/components/shared-files/shared-files.component.ts @@ -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[] = []; diff --git a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts index 230387591..10c8a8b4e 100644 --- a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts +++ b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.spec.ts @@ -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(); + }); }); }); diff --git a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts index dd4e27405..be559ff4b 100644 --- a/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts +++ b/projects/aca-content/src/lib/components/sidenav/directives/expansion-panel.directive.ts @@ -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)); diff --git a/projects/aca-content/src/lib/services/modal-ai.service.spec.ts b/projects/aca-content/src/lib/services/modal-ai.service.spec.ts new file mode 100644 index 000000000..01f1658bc --- /dev/null +++ b/projects/aca-content/src/lib/services/modal-ai.service.spec.ts @@ -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 . + */ + +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(); + + let service: ModalAiService; + let dialogOpenSpy: jasmine.Spy<(component: typeof UnsavedChangesDialogComponent, config?: MatDialogConfig) => MatDialogRef>; + 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); + 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(); + }); + }); +}); diff --git a/projects/aca-content/src/lib/services/modal-ai.service.ts b/projects/aca-content/src/lib/services/modal-ai.service.ts new file mode 100644 index 000000000..59649af5c --- /dev/null +++ b/projects/aca-content/src/lib/services/modal-ai.service.ts @@ -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 . + */ + +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, { + 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(); + } + }); + } +} diff --git a/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts b/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts new file mode 100644 index 000000000..7da4d1c80 --- /dev/null +++ b/projects/aca-content/src/lib/services/search-ai-navigation.service.spec.ts @@ -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 . + */ + +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>; + + 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 + }); + }); + }); +}); diff --git a/projects/aca-content/src/lib/services/search-ai-navigation.service.ts b/projects/aca-content/src/lib/services/search-ai-navigation.service.ts new file mode 100644 index 000000000..6bc745e3c --- /dev/null +++ b/projects/aca-content/src/lib/services/search-ai-navigation.service.ts @@ -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 . + */ + +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 }); + } +} diff --git a/projects/aca-content/src/lib/store/app-store.module.ts b/projects/aca-content/src/lib/store/app-store.module.ts index 02297d68b..dfeb5d107 100644 --- a/projects/aca-content/src/lib/store/app-store.module.ts +++ b/projects/aca-content/src/lib/store/app-store.module.ts @@ -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 ]) diff --git a/projects/aca-content/src/lib/store/effects.ts b/projects/aca-content/src/lib/store/effects.ts index f8eb84022..423fed8d0 100644 --- a/projects/aca-content/src/lib/store/effects.ts +++ b/projects/aca-content/src/lib/store/effects.ts @@ -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'; diff --git a/projects/aca-content/src/lib/store/effects/search-ai.effects.ts b/projects/aca-content/src/lib/store/effects/search-ai.effects.ts new file mode 100644 index 000000000..4670b8a77 --- /dev/null +++ b/projects/aca-content/src/lib/store/effects/search-ai.effects.ts @@ -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 . + */ + +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(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(SearchAiActionTypes.ToggleAiSearchInput), + map((action) => + this.searchAiService.updateSearchAiInputState({ + active: true, + selectedAgentId: action.agentId + }) + ) + ), + { dispatch: false } + ); +} diff --git a/projects/aca-content/src/lib/ui/application.scss b/projects/aca-content/src/lib/ui/application.scss index 0762b52a4..31eac5710 100644 --- a/projects/aca-content/src/lib/ui/application.scss +++ b/projects/aca-content/src/lib/ui/application.scss @@ -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%; +} diff --git a/projects/aca-content/src/lib/ui/theme.scss b/projects/aca-content/src/lib/ui/theme.scss index 34aa31e10..f34e93429 100644 --- a/projects/aca-content/src/lib/ui/theme.scss +++ b/projects/aca-content/src/lib/ui/theme.scss @@ -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); + } +} diff --git a/projects/aca-content/src/lib/ui/variables/variables.scss b/projects/aca-content/src/lib/ui/variables/variables.scss index aa6ec1d4c..ad1fbe316 100644 --- a/projects/aca-content/src/lib/ui/variables/variables.scss +++ b/projects/aca-content/src/lib/ui/variables/variables.scss @@ -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 diff --git a/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts b/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts index c3fd364e9..b68bdc6e8 100644 --- a/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts +++ b/projects/aca-content/viewer/src/lib/services/viewer.service.spec.ts @@ -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']); + }); }); diff --git a/projects/aca-content/viewer/src/lib/services/viewer.service.ts b/projects/aca-content/viewer/src/lib/services/viewer.service.ts index 3cf9a9aee..ad87ade59 100644 --- a/projects/aca-content/viewer/src/lib/services/viewer.service.ts +++ b/projects/aca-content/viewer/src/lib/services/viewer.service.ts @@ -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; + } + } } diff --git a/projects/aca-content/viewer/src/public-api.ts b/projects/aca-content/viewer/src/public-api.ts index 21c382658..9bd7663c5 100644 --- a/projects/aca-content/viewer/src/public-api.ts +++ b/projects/aca-content/viewer/src/public-api.ts @@ -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'; diff --git a/projects/aca-shared/rules/src/app.rules.spec.ts b/projects/aca-shared/rules/src/app.rules.spec.ts index aebdc0827..d5fe674a9 100644 --- a/projects/aca-shared/rules/src/app.rules.spec.ts +++ b/projects/aca-shared/rules/src/app.rules.spec.ts @@ -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; diff --git a/projects/aca-shared/rules/src/app.rules.ts b/projects/aca-shared/rules/src/app.rules.ts index d97b5f105..1b0db6e7b 100644 --- a/projects/aca-shared/rules/src/app.rules.ts +++ b/projects/aca-shared/rules/src/app.rules.ts @@ -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))); diff --git a/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts b/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts index f404b68f3..1e8b88132 100644 --- a/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts +++ b/projects/aca-shared/src/lib/components/document-base-page/document-base-page.component.ts @@ -22,7 +22,14 @@ * from Hyland Software. If not, see . */ -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) { diff --git a/projects/aca-shared/store/src/actions/search-ai.actions.ts b/projects/aca-shared/store/src/actions/search-ai.actions.ts new file mode 100644 index 000000000..e4d54683e --- /dev/null +++ b/projects/aca-shared/store/src/actions/search-ai.actions.ts @@ -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 . + */ + +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) {} +} diff --git a/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts b/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts new file mode 100644 index 000000000..696d555a3 --- /dev/null +++ b/projects/aca-shared/store/src/models/ai-search-by-term-payload.ts @@ -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 . + */ + +export interface AiSearchByTermPayload { + searchTerm: string; + agentId: string; +} diff --git a/projects/aca-shared/store/src/public-api.ts b/projects/aca-shared/store/src/public-api.ts index e23673eaf..b6ef1adba 100644 --- a/projects/aca-shared/store/src/public-api.ts +++ b/projects/aca-shared/store/src/public-api.ts @@ -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';