diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx index b24b3520814..f534a024cb9 100644 --- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/WorkflowAndArtifacts.tsx @@ -821,7 +821,8 @@ export const saveNotesStandard = async (notesData?: Record): Promi export const createOrUpdateConnection = async ( siteResourceId: string, connectionsData: ConnectionsData, - settings: Record | undefined + settings: Record | undefined, + isDraft = false ): Promise => { try { await saveWorkflowStandard( @@ -835,7 +836,8 @@ export const createOrUpdateConnection = async ( /* notes */ undefined, /* mcpServers */ undefined, /* clearDirtyState */ () => {}, - { skipValidation: true, throwError: true } + { skipValidation: true, throwError: true }, + isDraft ); } catch (error) { console.log(error); @@ -992,7 +994,10 @@ export const saveWorkflowStandard = async ( notesData ); } - return; + + if (!connectionsData) { + return; + } } for (const { name, workflow } of workflows) { @@ -1000,7 +1005,7 @@ export const saveWorkflowStandard = async ( } if (connectionsData) { - data.files['connections.json'] = connectionsData; + data.files[isDraftSave ? 'connections-draft.json' : 'connections.json'] = connectionsData; } if (parametersData) { diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/customConnectionParameterEditorServiceV2.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/customConnectionParameterEditorServiceV2.tsx new file mode 100644 index 00000000000..6cfd9d257d3 --- /dev/null +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Services/customConnectionParameterEditorServiceV2.tsx @@ -0,0 +1,38 @@ +import { + equals, + type IConnectionParameterEditorOptions, + type IConnectionParameterEditorService, + type IConnectionParameterInfo, +} from '@microsoft/logic-apps-shared'; +import { ACASessionConnector, CustomOpenAIConnector, CosmosDbConnector } from '@microsoft/logic-apps-designer-v2'; + +export class CustomConnectionParameterEditorServiceV2 implements IConnectionParameterEditorService { + public getConnectionParameterEditor({ + connectorId, + parameterKey, + }: IConnectionParameterInfo): IConnectionParameterEditorOptions | undefined { + if (connectorId === 'connectionProviders/agent') { + if (!equals(parameterKey, 'openAICompletionsModel') && !equals(parameterKey, 'openAIEmbeddingsModel')) { + return { + EditorComponent: CustomOpenAIConnector, + }; + } + + return undefined; + } + + if (connectorId === '/serviceProviders/acasession') { + return { + EditorComponent: ACASessionConnector, + }; + } + + if (connectorId === '/placeholder/knowledgehub') { + return { + EditorComponent: CosmosDbConnector, + }; + } + + return undefined; + } +} diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx index 3c553a68670..08361a7c796 100644 --- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesigner.tsx @@ -13,6 +13,7 @@ import { StandaloneOAuthService } from './Services/OAuthService'; import { getConnectionStandard, getCustomCodeAppFiles, + createOrUpdateConnection, listCallbackUrl, saveWorkflowStandard, fetchAgentUrl, @@ -146,6 +147,9 @@ const DesignerEditor = () => { addOrUpdateAppSettings(connectionAndSetting.settings, settingsData?.properties ?? {}); }; + const persistKnowledgeHubConnection = async (): Promise => + createOrUpdateConnection(siteResourceId, connectionsData, settingsData?.properties); + const getConnectionConfiguration = async (connectionId: string, _manifest: any, useMcpConnections?: boolean): Promise => { if (!connectionId) { return Promise.resolve(); @@ -205,6 +209,7 @@ const DesignerEditor = () => { connectionsData ?? {}, workflowAppData as WorkflowApp, addConnectionDataInternal, + persistKnowledgeHubConnection, getConnectionConfiguration, tenantId, objectId, @@ -566,6 +571,7 @@ const getDesignerServices = ( connectionsData: ConnectionsData, workflowApp: WorkflowApp, addConnection: (data: ConnectionAndAppSetting) => Promise, + persistKnowledgeHubConnection: () => Promise, getConfiguration: (connectionId: string) => Promise, tenantId: string | undefined, objectId: string | undefined, @@ -615,6 +621,7 @@ const getDesignerServices = ( return resolveConnectionsReferences(JSON.stringify(clone(connectionsData ?? {})), undefined, appSettings); }, writeConnection: addConnection as any, + persistKnowledgeHubConnection, connectionCreationClients: { FileSystem: new FileSystemConnectionCreationClient({ baseUrl: armUrl, diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx index e422fd90aa2..5b30069589b 100644 --- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/laDesignerV2.tsx @@ -20,6 +20,7 @@ import { StandaloneOAuthService } from './Services/OAuthService'; import { getConnectionStandard, getCustomCodeAppFiles, + createOrUpdateConnection, listCallbackUrl, saveWorkflowStandard, fetchAgentUrl, @@ -31,6 +32,7 @@ import { useWorkflowApp, validateWorkflowStandard, deployArtifacts, + uploadFileToKnowledgeHub, } from './Services/WorkflowAndArtifacts'; import { ArmParser } from './Utilities/ArmParser'; import { WorkflowUtility, addConnectionInJson, addOrUpdateAppSettings } from './Utilities/Workflow'; @@ -58,6 +60,7 @@ import { isArmResourceId, optional, BaseCognitiveServiceService, + BaseResourceService, AGENT_MSI_REQUIRED_ROLE_DEFINITION_IDS, RoleService, normalizeAgentConnectionResourceIdForRoleAssignment, @@ -91,7 +94,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { QueryClient } from '@tanstack/react-query'; import { useDispatch, useSelector } from 'react-redux'; import CodeViewEditor from './CodeViewV2'; -import { CustomConnectionParameterEditorService } from './Services/customConnectionParameterEditorService'; +import { CustomConnectionParameterEditorServiceV2 } from './Services/customConnectionParameterEditorServiceV2'; import { CustomEditorService } from './Services/customEditorService'; import { FloatingRunButton } from '../../../../../../libs/designer-v2/src/lib/ui/FloatingRunButton'; @@ -225,6 +228,9 @@ const DesignerEditor = () => { addOrUpdateAppSettings(connectionAndSetting.settings, settingsData?.properties ?? {}); }; + const persistKnowledgeHubConnection = async (): Promise => + createOrUpdateConnection(siteResourceId, connectionsData, settingsData?.properties, /* isDraft */ true); + const switchWorkflowMode = useCallback((draftMode: boolean) => { setIsDraftMode(draftMode); }, []); @@ -375,6 +381,7 @@ const DesignerEditor = () => { connectionsData ?? {}, workflowAppData as WorkflowApp, addConnectionDataInternal, + persistKnowledgeHubConnection, getConnectionConfiguration, tenantId, objectId, @@ -821,6 +828,7 @@ const getDesignerServices = ( connectionsData: ConnectionsData, workflowApp: WorkflowApp, addConnection: (data: ConnectionAndAppSetting) => Promise, + persistKnowledgeHubConnection: () => Promise, getConfiguration: (connectionId: string) => Promise, tenantId: string | undefined, objectId: string | undefined, @@ -871,6 +879,7 @@ const getDesignerServices = ( return resolveConnectionsReferences(JSON.stringify(clone(connectionsData ?? {})), undefined, appSettings); }, writeConnection: addConnection as any, + persistKnowledgeHubConnection, connectionCreationClients: { FileSystem: new FileSystemConnectionCreationClient({ baseUrl: armUrl, @@ -1102,6 +1111,7 @@ const getDesignerServices = ( getAgentUrl: (isDraftMode?: boolean) => fetchAgentUrl(siteResourceId, workflowName, workflowApp?.properties?.defaultHostName ?? '', isDraftMode), getAppIdentity: () => workflowApp?.identity, + getLogicAppId: () => siteResourceId, isExplicitAuthRequiredForManagedIdentity: () => true, isSplitOnSupported: () => !!isStateful, resubmitWorkflow: async (runId, actionsToResubmit) => { @@ -1131,6 +1141,8 @@ const getDesignerServices = ( notifyCallbackUrlUpdate: (triggerName, newTriggerId) => { alert(`Callback URL for ${triggerName} trigger updated to ${newTriggerId}`); }, + uploadFileArtifact: uploadFileToKnowledgeHub, + isKnowledgeHubEnabled: () => true, }; const hostService: IHostService = { @@ -1201,8 +1213,9 @@ const getDesignerServices = ( // The proxy handles auth server-side via MSI (production) or Bearer token (local POC). cognitiveServiceService.foundryProxyBaseUrl = `${baseUrl}/foundryProxy`; - const connectionParameterEditorService = new CustomConnectionParameterEditorService(); + const connectionParameterEditorService = new CustomConnectionParameterEditorServiceV2(); const editorService = new CustomEditorService(areCustomEditorsEnabled ?? false); + const resourceService = new BaseResourceService({ baseUrl: armUrl, httpClient, apiVersion }); return { appService, @@ -1226,6 +1239,7 @@ const getDesignerServices = ( cognitiveServiceService, connectionParameterEditorService, editorService, + resourceService, userPreferenceService: new BaseUserPreferenceService(), experimentationService: new BaseExperimentationService(), }; diff --git a/libs/designer-ui/src/lib/builtintools/__test__/__snapshots__/builtintools.spec.tsx.snap b/libs/designer-ui/src/lib/builtintools/__test__/__snapshots__/builtintools.spec.tsx.snap deleted file mode 100644 index 5565ba9199c..00000000000 --- a/libs/designer-ui/src/lib/builtintools/__test__/__snapshots__/builtintools.spec.tsx.snap +++ /dev/null @@ -1,66 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`lib/builtintools > should render with basic props 1`] = ` -
-
- Built-in Tools -
-
-
- - - Code Interpreter - - - - Enable the agent to write and execute JavaScript code for calculations, and data analysis. - -
-
- -
- - - -
-
-
-
-`; diff --git a/libs/designer-ui/src/lib/builtintools/__test__/builtintools.spec.tsx b/libs/designer-ui/src/lib/builtintools/__test__/builtintools.spec.tsx index b5dbce37cc5..8374c2172f9 100644 --- a/libs/designer-ui/src/lib/builtintools/__test__/builtintools.spec.tsx +++ b/libs/designer-ui/src/lib/builtintools/__test__/builtintools.spec.tsx @@ -1,9 +1,13 @@ +/** + * @vitest-environment jsdom + */ import { BuiltinToolsEditor } from '../index'; import type { BuiltinToolOption } from '../index'; -import { render, fireEvent, act } from '@testing-library/react'; +import { render, fireEvent, act, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; -import renderer from 'react-test-renderer'; -import { describe, vi, beforeEach, it, expect } from 'vitest'; +import { describe, vi, beforeEach, afterEach, it, expect } from 'vitest'; import { createLiteralValueSegment } from '../../editor/base/utils/helper'; const TestWrapper = ({ children }: { children: React.ReactNode }) => ( @@ -31,15 +35,18 @@ describe('lib/builtintools', () => { vi.clearAllMocks(); }); + afterEach(() => { + cleanup(); + }); + it('should render with basic props', () => { - const tree = renderer - .create( - - - - ) - .toJSON(); - expect(tree).toMatchSnapshot(); + const { getByRole } = render( + + + + ); + + expect(getByRole('switch')).toBeInTheDocument(); }); it('should render header text', () => { @@ -145,8 +152,9 @@ describe('lib/builtintools', () => { expect(switchEl).toBeDisabled(); }); - it('should not call onChange when readonly and clicked', () => { + it('should not call onChange when readonly and clicked', async () => { const onChange = vi.fn(); + const user = userEvent.setup(); const { getByRole } = render( @@ -154,9 +162,7 @@ describe('lib/builtintools', () => { ); const switchEl = getByRole('switch'); - act(() => { - fireEvent.click(switchEl); - }); + await user.click(switchEl); expect(onChange).not.toHaveBeenCalled(); }); diff --git a/libs/designer-ui/src/lib/builtintools/styles.ts b/libs/designer-ui/src/lib/builtintools/styles.ts index 613476ee7e3..965debd97de 100644 --- a/libs/designer-ui/src/lib/builtintools/styles.ts +++ b/libs/designer-ui/src/lib/builtintools/styles.ts @@ -8,6 +8,7 @@ export const useBuiltinToolsStyles = makeStyles({ border: `1px solid ${tokens.colorNeutralStroke1}`, padding: tokens.spacingVerticalM, gap: tokens.spacingVerticalS, + marginTop: tokens.spacingVerticalXL, }, header: { fontWeight: tokens.fontWeightSemibold, diff --git a/libs/designer-v2/src/lib/common/constants.ts b/libs/designer-v2/src/lib/common/constants.ts index d11784c1931..756c14b9726 100644 --- a/libs/designer-v2/src/lib/common/constants.ts +++ b/libs/designer-v2/src/lib/common/constants.ts @@ -252,6 +252,7 @@ export default { DROPDOWN: 'dropdown', FILEPICKER: 'filepicker', FLOATINGACTIONMENU: 'floatingactionmenu', + KNOWLEDGE_BASE: 'knowledgebase', SCHEMA: 'schema', STRING: 'string', TABLE: 'table', @@ -578,6 +579,10 @@ export default { OPERATIONS: 'OPERATIONS', CONNECTIONS: 'CONNECTIONS', }, + KNOWLEDGE_PANEL_TAB_NAMES: { + BASICS: 'BASICS', + MODEL: 'MODEL', + }, ERRORS_PANEL_TAB_NAMES: { ERRORS: 'ERRORS', WARNINGS: 'WARNINGS', diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/initialize.spec.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/initialize.spec.ts index e82644d68f6..45c9e2c5755 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/initialize.spec.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/__test__/initialize.spec.ts @@ -1,4 +1,4 @@ -import { InitOperationManifestService } from '@microsoft/logic-apps-shared'; +import { InitOperationManifestService, InitWorkflowService } from '@microsoft/logic-apps-shared'; import * as initialize from '../initialize'; import { mockGetMyOffice365ProfileOpenApiManifest, @@ -22,7 +22,11 @@ describe('bjsworkflow initialize', () => { isBuiltInConnector: () => false, getBuiltInConnector: () => ({}) as any, }; + const workflowService = { + isKnowledgeHubEnabled: () => false, + } as any; InitOperationManifestService(operationManifestService); + InitWorkflowService(workflowService); }); test('works for an OpenAPI operation with input parameters and values', () => { diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/knowledge.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/knowledge.ts new file mode 100644 index 00000000000..c1c532607c0 --- /dev/null +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/knowledge.ts @@ -0,0 +1,51 @@ +import { + type IConnectionService, + type IConnectionParameterEditorService, + type ILoggerService, + InitCognitiveServiceService, + InitConnectionService, + InitConnectionParameterEditorService, + InitGatewayService, + InitLoggerService, + InitResourceService, + type IResourceService, + DevLogger, + type ICognitiveServiceService, + type IGatewayService, +} from '@microsoft/logic-apps-shared'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export interface KnowledgeServiceOptions { + cognitiveService: ICognitiveServiceService; + connectionService: IConnectionService; + connectionParameterEditorService: IConnectionParameterEditorService; + gatewayService: IGatewayService; + resourceService: IResourceService; + loggerService?: ILoggerService; +} + +export const initializeData = createAsyncThunk('initializeKnowledgeData', async (services: KnowledgeServiceOptions) => { + initializeServices(services); + return true; +}); + +export const initializeServices = (services: KnowledgeServiceOptions) => { + const { cognitiveService, connectionService, connectionParameterEditorService, gatewayService, resourceService, loggerService } = + services; + + InitCognitiveServiceService(cognitiveService); + InitConnectionService(connectionService); + InitConnectionParameterEditorService(connectionParameterEditorService); + InitGatewayService(gatewayService); + InitResourceService(resourceService); + + const loggerServices: ILoggerService[] = []; + if (loggerService) { + loggerServices.push(loggerService); + } + if (process.env.NODE_ENV !== 'production') { + loggerServices.push(new DevLogger()); + } + + InitLoggerService(loggerServices); +}; diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/__test__/connection.spec.ts b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/connection.spec.ts new file mode 100644 index 00000000000..36dfa3b3dba --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/connection.spec.ts @@ -0,0 +1,312 @@ +import { + getOpenAIConnectionParameters, + getCosmosDbConnectionParameters, + createOrUpdateConnection, + getConnectionParametersForEdit, +} from '../connection'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCreateConnection = vi.fn(); +const mockSetQueryData = vi.fn(); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + ConnectionService: vi.fn(() => ({ + createConnection: mockCreateConnection, + })), + getIntl: vi.fn(() => ({ + formatMessage: vi.fn(({ defaultMessage }, values) => { + if (!values) { + return defaultMessage; + } + + return defaultMessage.replace(/\{([^}]+)\}/g, (_: any, key: string | number) => values[key] ?? `{${key}}`); + }), + })), + getPropertyValue: vi.fn((obj, key) => obj?.[key]), + getObjectPropertyValue: vi.fn((obj, path) => path.reduce((acc: any, currentKey: string) => acc?.[currentKey], obj)), + ConnectionType: { + KnowledgeHub: 'KnowledgeHub', + }, + LogEntryLevel: { + Error: 'Error', + Warning: 'Warning', + Debug: 'Debug', + Trace: 'Trace', + Verbose: 'Verbose', + }, + LoggerService: vi.fn(() => ({ + log: vi.fn(), + })), +})); + +vi.mock('../../../ReactQueryProvider', () => ({ + getReactQueryClient: vi.fn(() => ({ + setQueryData: mockSetQueryData, + })), +})); + +const intl = { + formatMessage: ({ defaultMessage }: any) => defaultMessage, +} as any; + +describe('knowledge connection utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getOpenAIConnectionParameters', () => { + it('returns OpenAI connection parameter sets for key and managed identity auth', () => { + const result = getOpenAIConnectionParameters(intl); + + expect(result.values).toHaveLength(2); + expect(result.values[0].name).toBe('Key'); + expect(result.values[1].name).toBe('ManagedServiceIdentity'); + }); + + it('includes openAIKey parameter in Key authentication', () => { + const result = getOpenAIConnectionParameters(intl); + + expect(result.values[0].parameters).toHaveProperty('openAIKey'); + expect(result.values[0].parameters).toHaveProperty('cognitiveServiceAccountId'); + expect(result.values[0].parameters).toHaveProperty('openAIEndpoint'); + expect(result.values[0].parameters).toHaveProperty('openAICompletionsModel'); + expect(result.values[0].parameters).toHaveProperty('openAIEmbeddingsModel'); + }); + + it('excludes openAIKey parameter from ManagedServiceIdentity authentication', () => { + const result = getOpenAIConnectionParameters(intl); + + expect(result.values[1].parameters).not.toHaveProperty('openAIKey'); + expect(result.values[1].parameters).toHaveProperty('cognitiveServiceAccountId'); + expect(result.values[1].parameters).toHaveProperty('openAIEndpoint'); + expect(result.values[1].parameters).toHaveProperty('openAICompletionsModel'); + expect(result.values[1].parameters).toHaveProperty('openAIEmbeddingsModel'); + }); + + it('has proper uiDefinition for authentication type', () => { + const result = getOpenAIConnectionParameters(intl); + + expect(result.uiDefinition).toBeDefined(); + expect(result.uiDefinition?.displayName).toBe('Authentication type'); + expect(result.uiDefinition?.description).toBe('Type of authentication to use'); + }); + + it('has proper uiDefinitions for parameter set values', () => { + const result = getOpenAIConnectionParameters(intl); + + expect(result.values[0].uiDefinition?.displayName).toBe('URL and key-based authentication'); + expect(result.values[1].uiDefinition?.displayName).toBe('Managed Service Identity'); + }); + }); + + describe('getCosmosDbConnectionParameters', () => { + it('returns Cosmos DB connection parameter sets for key and managed identity auth', () => { + const result = getCosmosDbConnectionParameters(intl); + + expect(result.values).toHaveLength(2); + expect(result.values[0].name).toBe('Key'); + expect(result.values[1].name).toBe('ManagedServiceIdentity'); + }); + + it('includes cosmosDBKey parameter in Key authentication', () => { + const result = getCosmosDbConnectionParameters(intl); + + expect(result.values[0].parameters).toHaveProperty('cosmosDBKey'); + expect(result.values[0].parameters).toHaveProperty('cosmosDbServiceAccountId'); + expect(result.values[0].parameters).toHaveProperty('cosmosDBEndpoint'); + }); + + it('excludes cosmosDBKey parameter from ManagedServiceIdentity authentication', () => { + const result = getCosmosDbConnectionParameters(intl); + + expect(result.values[1].parameters).not.toHaveProperty('cosmosDBKey'); + expect(result.values[1].parameters).toHaveProperty('cosmosDbServiceAccountId'); + expect(result.values[1].parameters).toHaveProperty('cosmosDBEndpoint'); + }); + + it('has proper uiDefinition for authentication type', () => { + const result = getCosmosDbConnectionParameters(intl); + + expect(result.uiDefinition).toBeDefined(); + expect(result.uiDefinition?.displayName).toBe('Authentication type'); + expect(result.uiDefinition?.description).toBe('Type of authentication to use'); + }); + + it('has proper uiDefinitions for parameter set values', () => { + const result = getCosmosDbConnectionParameters(intl); + + expect(result.values[0].uiDefinition?.displayName).toBe('Key-based'); + expect(result.values[1].uiDefinition?.displayName).toBe('Managed Service Identity'); + }); + }); + + describe('createOrUpdateConnection', () => { + it('creates a knowledge connection with provided parameters', async () => { + const createdConnection = { id: '/connections/knowledgeHub' }; + mockCreateConnection.mockResolvedValue(createdConnection); + + const parameterValues = { + displayName: 'Hub connection', + openAIEndpoint: 'https://openai.endpoint', + openAIKey: 'secret', + openAICompletionsModel: 'gpt-4o-mini', + openAIEmbeddingsModel: 'text-embedding-3-small', + cognitiveServiceAccountId: '/subscriptions/1/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/openai', + cosmosDBEndpoint: 'https://cosmos.documents.azure.com', + cosmosDBKey: 'cosmos-secret', + cosmosDbServiceAccountId: '/subscriptions/1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/db', + }; + + const result = await createOrUpdateConnection(parameterValues); + + expect(result).toEqual(createdConnection); + expect(mockCreateConnection).toHaveBeenCalledTimes(1); + }); + + it('calls ConnectionService.createConnection with correct arguments', async () => { + const createdConnection = { id: '/connections/knowledgeHub' }; + mockCreateConnection.mockResolvedValue(createdConnection); + + const parameterValues = { + displayName: 'My Connection', + openAIEndpoint: 'https://api.openai.com', + }; + + await createOrUpdateConnection(parameterValues); + + const [name, connector, connectionInfo, options] = mockCreateConnection.mock.calls[0]; + expect(name).toBe('HubConnection'); + expect(connector).toEqual({ id: '/dummy/knowledgehub' }); + expect(connectionInfo.displayName).toBe('My Connection'); + expect(connectionInfo.connectionParameters).toBe(parameterValues); + expect(options.connectionMetadata).toEqual({ required: true, type: 'KnowledgeHub' }); + }); + + it('updates query cache after successful connection creation', async () => { + const createdConnection = { id: '/connections/knowledgeHub' }; + mockCreateConnection.mockResolvedValue(createdConnection); + + await createOrUpdateConnection({ displayName: 'Test' }); + + expect(mockSetQueryData).toHaveBeenCalledTimes(1); + expect(mockSetQueryData).toHaveBeenCalledWith(['knowledgeconnection'], expect.any(Function)); + + const updater = mockSetQueryData.mock.calls[0][1]; + expect(updater()).toEqual(createdConnection); + }); + + it('throws formatted error when connection creation fails with nested error', async () => { + mockCreateConnection.mockRejectedValue({ error: { message: 'service unavailable' } }); + + await expect(createOrUpdateConnection({ displayName: 'Hub connection' })).rejects.toThrow( + 'Failed to create connection: service unavailable' + ); + }); + + it('throws formatted error when connection creation fails with top-level message', async () => { + mockCreateConnection.mockRejectedValue({ message: 'network error' }); + + await expect(createOrUpdateConnection({ displayName: 'Hub connection' })).rejects.toThrow( + 'Failed to create connection: network error' + ); + }); + + it('throws error with placeholder when no error message available', async () => { + mockCreateConnection.mockRejectedValue({}); + + await expect(createOrUpdateConnection({ displayName: 'Hub connection' })).rejects.toThrow( + 'Failed to create connection: {errorMessage}' + ); + }); + }); + + describe('getConnectionParametersForEdit', () => { + it('returns empty parameter values when connection is undefined', () => { + const result = getConnectionParametersForEdit(intl, undefined); + + expect(result.parameterValues).toBeDefined(); + expect(result.connectionParameters).toBeDefined(); + }); + + it('returns empty parameter values when connection is null', () => { + const result = getConnectionParametersForEdit(intl, null); + + expect(result.parameterValues).toBeDefined(); + expect(result.connectionParameters).toBeDefined(); + }); + + it('extracts displayName from connection properties', () => { + const connection = { + properties: { + displayName: 'My Knowledge Hub', + connectionParameters: {}, + }, + } as any; + + const result = getConnectionParametersForEdit(intl, connection); + + expect(result.parameterValues.displayName).toBe('My Knowledge Hub'); + }); + + it('returns connection parameters excluding non-serializable ones', () => { + const result = getConnectionParametersForEdit(intl, undefined); + + // cosmosDbServiceAccountId and cognitiveServiceAccountId have serialize: false + expect(result.connectionParameters).not.toHaveProperty('cosmosDbServiceAccountId'); + expect(result.connectionParameters).not.toHaveProperty('cognitiveServiceAccountId'); + + // These should be present as they are serializable + expect(result.connectionParameters).toHaveProperty('cosmosDBEndpoint'); + expect(result.connectionParameters).toHaveProperty('cosmosDBKey'); + expect(result.connectionParameters).toHaveProperty('openAIEndpoint'); + expect(result.connectionParameters).toHaveProperty('openAIKey'); + }); + + it('extracts parameter values from connection metadata using serialization path', () => { + const connection = { + properties: { + displayName: 'Test Hub', + connectionParameters: { + data: { + metadata: { + value: { + cosmosDB: { + endpoint: 'https://cosmos.test.com', + authentication: { + type: 'Key', + key: 'cosmos-secret-key', + }, + }, + openAI: { + endpoint: 'https://openai.test.com', + authentication: { + type: 'ManagedServiceIdentity', + key: 'openai-secret-key', + }, + }, + completionsOpenAI: { + completionsModel: 'gpt-4', + }, + embeddingsOpenAI: { + embeddingsModel: 'text-embedding-ada-002', + }, + }, + }, + }, + }, + }, + } as any; + + const result = getConnectionParametersForEdit(intl, connection); + + expect(result.parameterValues.cosmosDBEndpoint).toBe('https://cosmos.test.com'); + expect(result.parameterValues.cosmosDBKey).toBe('cosmos-secret-key'); + expect(result.parameterValues.cosmosDBAuthenticationType).toBe('Key'); + expect(result.parameterValues.openAIEndpoint).toBe('https://openai.test.com'); + expect(result.parameterValues.openAIKey).toBe('openai-secret-key'); + expect(result.parameterValues.openAIAuthenticationType).toBe('ManagedServiceIdentity'); + expect(result.parameterValues.openAICompletionsModel).toBe('gpt-4'); + expect(result.parameterValues.openAIEmbeddingsModel).toBe('text-embedding-ada-002'); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/__test__/helper.spec.ts b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/helper.spec.ts new file mode 100644 index 00000000000..80ae4dc75e2 --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/helper.spec.ts @@ -0,0 +1,317 @@ +import { createKnowledgeHub, deleteKnowledgeHubArtifacts, validateHubNameAvailability, validateArtifactNameAvailability } from '../helper'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockExecuteResourceAction = vi.fn(); +const mockLog = vi.fn(); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + ResourceService: vi.fn(() => ({ + executeResourceAction: mockExecuteResourceAction, + })), + LoggerService: vi.fn(() => ({ + log: mockLog, + })), + LogEntryLevel: { + Error: 'Error', + }, + getIntl: () => ({ + formatMessage: ({ defaultMessage }: { defaultMessage: string }) => defaultMessage, + }), + isNullOrEmpty: (value: string | undefined | null) => value === undefined || value === null || value === '', + equals: (a: string, b: string) => a?.toLowerCase() === b?.toLowerCase(), + getObjectPropertyValue: (obj: any, path: string[]) => { + let current = obj; + for (const key of path) { + if (current === null || current === undefined) { + return undefined; + } + current = current[key]; + } + return current; + }, +})); + +describe('knowledge helper utils', () => { + const siteResourceId = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Web/sites/myApp'; + const groupName = 'my-knowledge-hub'; + const description = 'Test knowledge hub description'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createKnowledgeHub', () => { + it('should call ResourceService with correct parameters', async () => { + mockExecuteResourceAction.mockResolvedValue({ name: groupName }); + + await createKnowledgeHub(siteResourceId, groupName, description); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(1); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${groupName}`, + 'PUT', + { + 'api-version': '2018-11-01', + 'Content-Type': 'application/json', + }, + { description } + ); + }); + + it('should return response on success', async () => { + const response = { name: groupName, id: 'hub-id' }; + mockExecuteResourceAction.mockResolvedValue(response); + + const result = await createKnowledgeHub(siteResourceId, groupName, description); + + expect(result).toEqual(response); + }); + + it('should log error and throw when API call fails', async () => { + const errorResponse = { error: { code: 'BadRequest', message: 'Invalid hub name' } }; + mockExecuteResourceAction.mockRejectedValue(errorResponse); + + await expect(createKnowledgeHub(siteResourceId, groupName, description)).rejects.toThrow('Invalid hub name'); + + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog).toHaveBeenCalledWith({ + level: 'Error', + area: 'KnowledgeHub.createKnowledgeHub', + error: errorResponse.error, + message: `Error while creating knowledge hub for the app: ${siteResourceId}`, + }); + }); + + it('should throw error when API call fails', async () => { + mockExecuteResourceAction.mockRejectedValue({ error: { message: 'Failed' } }); + + await expect(createKnowledgeHub(siteResourceId, groupName, description)).rejects.toThrow('Failed'); + }); + + it('should handle error response without error property', async () => { + mockExecuteResourceAction.mockRejectedValue({ message: 'Network error' }); + + await expect(createKnowledgeHub(siteResourceId, groupName, description)).rejects.toThrow('Network error'); + + expect(mockLog).toHaveBeenCalledWith({ + level: 'Error', + area: 'KnowledgeHub.createKnowledgeHub', + error: { message: 'Network error' }, + message: `Error while creating knowledge hub for the app: ${siteResourceId}`, + }); + }); + }); + + describe('deleteKnowledgeHubArtifacts', () => { + it('should call ResourceService to delete each hub', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs = ['hub1', 'hub2']; + const artifacts = {}; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hub1`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hub2`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should call ResourceService to delete each artifact', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs: string[] = []; + const artifacts = { artifact1: 'hubA', artifact2: 'hubB' }; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubA/artifacts/artifact1`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubB/artifacts/artifact2`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should delete both hubs and artifacts when both are provided', async () => { + mockExecuteResourceAction.mockResolvedValue({}); + const hubs = ['hubToDelete']; + const artifacts = { 'my-artifact': 'hubWithArtifact' }; + + await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(mockExecuteResourceAction).toHaveBeenCalledTimes(2); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubToDelete`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + expect(mockExecuteResourceAction).toHaveBeenCalledWith( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/hubWithArtifact/artifacts/my-artifact`, + 'DELETE', + { 'api-version': '2018-11-01' } + ); + }); + + it('should return empty array when no hubs or artifacts are provided', async () => { + const result = await deleteKnowledgeHubArtifacts(siteResourceId, [], {}); + + expect(mockExecuteResourceAction).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should return all resolved promises', async () => { + const response1 = { deleted: 'hub1' }; + const response2 = { deleted: 'artifact1' }; + mockExecuteResourceAction.mockResolvedValueOnce(response1).mockResolvedValueOnce(response2); + + const hubs = ['hub1']; + const artifacts = { artifact1: 'hubA' }; + + const result = await deleteKnowledgeHubArtifacts(siteResourceId, hubs, artifacts); + + expect(result).toEqual([response1, response2]); + }); + + it('should reject when any delete operation fails', async () => { + const error = new Error('Delete failed'); + mockExecuteResourceAction.mockResolvedValueOnce({}).mockRejectedValueOnce(error); + + const hubs = ['hub1', 'hub2']; + + await expect(deleteKnowledgeHubArtifacts(siteResourceId, hubs, {})).rejects.toThrow('Delete failed'); + }); + }); + + describe('validateHubNameAvailability', () => { + it('should return error when hub name is empty', () => { + const result = validateHubNameAvailability('', []); + expect(result).toBe('Requires a unique hub name under 244 characters with only letters and numbers.'); + }); + + it('should return error when hub name already exists (case-insensitive)', () => { + const existingNames = ['MyHub', 'AnotherHub']; + const result = validateHubNameAvailability('myhub', existingNames); + expect(result).toBe('A hub with this name already exists.'); + }); + + it('should return error when hub name is too long', () => { + const longName = 'a'.repeat(245); + const result = validateHubNameAvailability(longName, []); + expect(result).toBe(`Hub name can't exceed 244 characters.`); + }); + + it('should return error when hub name contains special characters', () => { + const result = validateHubNameAvailability('my-hub', []); + expect(result).toBe('Enter a unique name under 244 characters with only letters and numbers.'); + }); + + it('should return error when hub name contains spaces', () => { + const result = validateHubNameAvailability('my hub', []); + expect(result).toBe('Enter a unique name under 244 characters with only letters and numbers.'); + }); + + it('should return error when hub name contains underscores', () => { + const result = validateHubNameAvailability('my_hub', []); + expect(result).toBe('Enter a unique name under 244 characters with only letters and numbers.'); + }); + + it('should return undefined for valid hub name', () => { + const result = validateHubNameAvailability('MyValidHub123', []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for valid hub name with max length', () => { + const validName = 'a'.repeat(244); + const result = validateHubNameAvailability(validName, []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for single character hub name', () => { + const result = validateHubNameAvailability('H', []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for numeric hub name', () => { + const result = validateHubNameAvailability('123456', []); + expect(result).toBeUndefined(); + }); + + it('should allow hub name not in existing list', () => { + const existingNames = ['Hub1', 'Hub2']; + const result = validateHubNameAvailability('Hub3', existingNames); + expect(result).toBeUndefined(); + }); + }); + + describe('validateArtifactNameAvailability', () => { + it('should return error when artifact name is empty', () => { + const result = validateArtifactNameAvailability('', []); + expect(result).toBe('Requires a unique file artifact name under 80 characters with only letters and numbers.'); + }); + + it('should return error when artifact name already exists (case-insensitive)', () => { + const existingNames = ['MyArtifact', 'AnotherArtifact']; + const result = validateArtifactNameAvailability('myartifact', existingNames); + expect(result).toBe('An artifact with this name already exists in the hub.'); + }); + + it('should return error when artifact name is too long', () => { + const longName = 'a'.repeat(81); + const result = validateArtifactNameAvailability(longName, []); + expect(result).toBe(`File artifact name can't exceed 80 characters.`); + }); + + it('should return error when artifact name contains special characters', () => { + const result = validateArtifactNameAvailability('my-artifact', []); + expect(result).toBe('Enter a unique name under 80 characters with only letters and numbers.'); + }); + + it('should return error when artifact name contains periods', () => { + const result = validateArtifactNameAvailability('my.artifact', []); + expect(result).toBe('Enter a unique name under 80 characters with only letters and numbers.'); + }); + + it('should return error when artifact name contains spaces', () => { + const result = validateArtifactNameAvailability('my artifact', []); + expect(result).toBe('Enter a unique name under 80 characters with only letters and numbers.'); + }); + + it('should return undefined for valid artifact name', () => { + const result = validateArtifactNameAvailability('MyValidArtifact123', []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for valid artifact name with max length', () => { + const validName = 'b'.repeat(80); + const result = validateArtifactNameAvailability(validName, []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for single character artifact name', () => { + const result = validateArtifactNameAvailability('A', []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for numeric artifact name', () => { + const result = validateArtifactNameAvailability('789012', []); + expect(result).toBeUndefined(); + }); + + it('should allow artifact name not in existing list', () => { + const existingNames = ['Artifact1', 'Artifact2']; + const result = validateArtifactNameAvailability('Artifact3', existingNames); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/__test__/queries.spec.ts b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/queries.spec.ts new file mode 100644 index 00000000000..1735c734d2e --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/__test__/queries.spec.ts @@ -0,0 +1,266 @@ +/** + * @vitest-environment jsdom + */ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useAllKnowledgeHubs, useConnection, getCosmosDbEndpoint } from '../queries'; +import React from 'react'; + +const mockExecuteResourceAction = vi.fn(); +const mockGetResource = vi.fn(); +const mockGetConnections = vi.fn(); +const mockLog = vi.fn(); + +let queryClient: QueryClient; + +vi.mock('@microsoft/logic-apps-shared', () => ({ + ResourceService: vi.fn(() => ({ + executeResourceAction: mockExecuteResourceAction, + getResource: mockGetResource, + })), + ConnectionService: vi.fn(() => ({ + getConnections: mockGetConnections, + })), + LoggerService: vi.fn(() => ({ + log: mockLog, + })), + LogEntryLevel: { + Error: 'Error', + }, + equals: vi.fn((a: string, b: string) => a?.toLowerCase() === b?.toLowerCase()), +})); + +vi.mock('../../../ReactQueryProvider', () => ({ + getReactQueryClient: vi.fn(() => queryClient), +})); + +describe('knowledge queries', () => { + const siteResourceId = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Web/sites/myApp'; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const createWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + describe('useAllKnowledgeHubs', () => { + const mockHubs = [ + { name: 'hub-b', description: 'Second hub' }, + { name: 'hub-a', description: 'First hub' }, + ]; + + test('should fetch and sort knowledge hubs alphabetically', async () => { + mockGetResource.mockResolvedValueOnce(mockHubs); + + const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetResource).toHaveBeenCalledWith(`${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgehubs`, { + 'api-version': '2018-11-01', + }); + + // Hubs should be sorted alphabetically + expect(result.current.data?.[0].name).toBe('hub-a'); + expect(result.current.data?.[1].name).toBe('hub-b'); + expect(result.current.data).toHaveLength(2); + }); + + test('should return empty array and log error on failure', async () => { + const error = { code: 'NotFound', message: 'Resource not found' }; + mockGetResource.mockRejectedValue({ error }); + + const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + expect(mockLog).toHaveBeenCalledWith({ + level: 'Error', + area: 'KnowledgeHub.listKnowledgeHubs', + error, + message: `Error while fetching knowledge hubs for the app: ${siteResourceId}`, + }); + }); + + test('should be disabled when siteResourceId is empty', async () => { + const { result } = renderHook(() => useAllKnowledgeHubs(''), { + wrapper: createWrapper, + }); + + // Query should not run + expect(result.current.fetchStatus).toBe('idle'); + expect(mockGetResource).not.toHaveBeenCalled(); + }); + + test('should handle empty hubs response', async () => { + mockGetResource.mockResolvedValueOnce([]); + + const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + + test('should handle null response', async () => { + mockGetResource.mockResolvedValueOnce(null); + + const { result } = renderHook(() => useAllKnowledgeHubs(siteResourceId), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + }); + + describe('useConnection', () => { + test('should find and return knowledge hub connection', async () => { + const mockConnections = [ + { id: 'conn1', type: 'connections/sql', name: 'SQL Connection' }, + { id: 'conn2', type: 'connections/knowledgehub', name: 'Knowledge Hub' }, + { id: 'conn3', type: 'connections/servicebus', name: 'Service Bus' }, + ]; + mockGetConnections.mockResolvedValue(mockConnections); + + const { result } = renderHook(() => useConnection(), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockConnections[1]); + }); + + test('should return null when no knowledge hub connection exists', async () => { + const mockConnections = [{ id: 'conn1', type: 'connections/sql', name: 'SQL Connection' }]; + mockGetConnections.mockResolvedValue(mockConnections); + + const { result } = renderHook(() => useConnection(), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeNull(); + }); + + test('should return null and log error on failure', async () => { + const error = { code: 'Unauthorized', message: 'Access denied' }; + mockGetConnections.mockRejectedValue({ error }); + + const { result } = renderHook(() => useConnection(), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeNull(); + expect(mockLog).toHaveBeenCalledWith({ + level: 'Error', + area: 'KnowledgeHub.getConnection', + error, + message: 'Error while fetching knowledge hub connection', + }); + }); + + test('should return null when connections list is empty', async () => { + mockGetConnections.mockResolvedValue([]); + + const { result } = renderHook(() => useConnection(), { + wrapper: createWrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeNull(); + }); + }); + + describe('getCosmosDbEndpoint', () => { + test('should fetch and return Cosmos DB endpoint', async () => { + const database = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myDb-success'; + const endpoint = 'https://mydb.documents.azure.com:443/'; + mockGetResource.mockResolvedValue({ properties: { endpoint } }); + + const result = await getCosmosDbEndpoint(database); + + expect(mockGetResource).toHaveBeenCalledWith(`${database}/listConnectionStrings`, { 'api-version': '2025-11-01' }); + expect(result).toBe(endpoint); + }); + + test('should return undefined and log error on failure', async () => { + const database = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myDb-error'; + const error = { code: 'NotFound', message: 'Database not found' }; + mockGetResource.mockRejectedValue({ error }); + + const result = await getCosmosDbEndpoint(database); + + expect(result).toBeUndefined(); + expect(mockLog).toHaveBeenCalledWith({ + level: 'Error', + area: 'KnowledgeHub.getCosmosDbEndpoint', + error, + message: `Error while fetching Cosmos DB endpoint for database: ${database}`, + }); + }); + + test('should return undefined when response has no endpoint', async () => { + const database = '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myDb-no-endpoint'; + mockGetResource.mockResolvedValue({ properties: {} }); + + const result = await getCosmosDbEndpoint(database); + + expect(result).toBeUndefined(); + }); + + test('should use lowercase database in cache key', async () => { + const mixedCaseDb = '/Subscriptions/SUB1/ResourceGroups/RG/Providers/Microsoft.DocumentDB/databaseAccounts/MyDb-case'; + mockGetResource.mockResolvedValue({ properties: { endpoint: 'https://test.com' } }); + + await getCosmosDbEndpoint(mixedCaseDb); + + // Verify cache key uses lowercase + const cacheKey = ['cosmosdbendpoint', mixedCaseDb.toLowerCase()]; + const cachedData = queryClient.getQueryData(cacheKey); + expect(cachedData).toBe('https://test.com'); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/connection.ts b/libs/designer-v2/src/lib/core/knowledge/utils/connection.ts new file mode 100644 index 00000000000..a75dcda1d4a --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/connection.ts @@ -0,0 +1,439 @@ +import type { + ConnectionParameterSets, + ConnectionParameter, + ConnectionParameterSetParameter, + Connector, + Connection, +} from '@microsoft/logic-apps-shared'; +import { + ConnectionService, + getIntl, + ConnectionType, + getObjectPropertyValue, + LogEntryLevel, + LoggerService, +} from '@microsoft/logic-apps-shared'; +import type { IntlShape } from 'react-intl'; +import { getReactQueryClient } from '../../ReactQueryProvider'; + +const getAllConnectionParameters = (intl: IntlShape) => { + return { + cosmosDBAuthenticationType: { + type: 'string', + uiDefinition: { + displayName: 'Authentication type', + description: 'Authentication type', + constraints: { + clearText: true, + required: 'true', + serializationPath: ['cosmosDB', 'authentication', 'type'], + }, + }, + }, + cosmosDbServiceAccountId: { + type: 'string', + uiDefinition: { + displayName: 'Azure Cosmos DB Service Account', + description: 'Select the Azure Cosmos DB Service Account to use for this connection', + tooltip: 'Select the Azure Cosmos DB Service Account to use for this connection', + constraints: { + clearText: true, + required: 'true', + serialize: false, + }, + }, + } as ConnectionParameter, + cosmosDBEndpoint: { + type: 'string', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'lx2rJW', + defaultMessage: 'URL endpoint', + description: 'Label for URL endpoint connection parameter', + }), + description: intl.formatMessage({ + id: 'QBAwgb', + defaultMessage: 'Endpoint will be filled automatically.', + description: 'Description for URL endpoint connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'leA1MT', + defaultMessage: 'The endpoint of the Cosmos DB account', + description: 'Tooltip for URL endpoint connection parameter', + }), + constraints: { + clearText: true, + required: 'true', + serializationPath: ['cosmosDB', 'endpoint'], + }, + }, + } as ConnectionParameter, + cosmosDBKey: { + type: 'securestring', + parameterSource: 'AppConfiguration', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'G8AUbT', + defaultMessage: 'Key', + description: 'Label for key connection parameter', + }), + description: intl.formatMessage({ + id: 'L84XBq', + defaultMessage: 'Key will be filled automatically.', + description: 'Description for key connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'sP4DTE', + defaultMessage: 'The key to access the resource that hosts the AI model', + description: 'Tooltip for key connection parameter', + }), + constraints: { + clearText: false, + required: 'true', + serializationPath: ['cosmosDB', 'authentication', 'key'], + }, + }, + } as ConnectionParameter, + openAIAuthenticationType: { + type: 'string', + uiDefinition: { + displayName: 'Authentication type', + description: 'Authentication type', + constraints: { + clearText: true, + required: 'true', + serializationPath: ['openAI', 'authentication', 'type'], + }, + }, + }, + cognitiveServiceAccountId: { + type: 'string', + uiDefinition: { + displayName: 'Azure Cognitive Service Account', + description: 'Select the Azure Cognitive Service Account to use for this connection', + tooltip: 'Select the Azure Cognitive Service Account to use for this connection', + constraints: { + clearText: true, + required: 'true', + serialize: false, + }, + }, + } as ConnectionParameter, + openAIEndpoint: { + type: 'string', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'tWAk7P', + defaultMessage: 'API endpoint', + description: 'Label for API endpoint connection parameter', + }), + description: intl.formatMessage({ + id: '+K0G5q', + defaultMessage: 'Endpoint will be filled automatically.', + description: 'Description for API endpoint connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'GfHVO/', + defaultMessage: 'The endpoint of the resource that hosts the AI model', + description: 'Tooltip for API endpoint connection parameter', + }), + constraints: { + clearText: true, + required: 'true', + serializationPath: ['openAI', 'endpoint'], + }, + }, + } as ConnectionParameter, + openAIKey: { + type: 'securestring', + parameterSource: 'AppConfiguration', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'ZNPMjo', + defaultMessage: 'API key', + description: 'Label for API key connection parameter', + }), + description: intl.formatMessage({ + id: 'fRWxou', + defaultMessage: 'Key will be filled automatically.', + description: 'Description for API key connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'tTSsMz', + defaultMessage: 'The API key to access the resource that hosts the AI model', + description: 'Tooltip for API key connection parameter', + }), + constraints: { + clearText: false, + required: 'true', + serializationPath: ['openAI', 'authentication', 'key'], + }, + }, + } as ConnectionParameter, + openAICompletionsModel: { + type: 'string', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'E7PMTh', + defaultMessage: 'Completions model', + description: 'Label for completions model connection parameter', + }), + description: intl.formatMessage({ + id: 'ChIvwj', + defaultMessage: 'Select the completions model to use for this connection', + description: 'Description for completions model connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'die3ro', + defaultMessage: 'Select the completions model to use for this connection', + description: 'Tooltip for completions model connection parameter', + }), + constraints: { + clearText: true, + required: 'true', + serializationPath: ['completionsOpenAI', 'completionsModel'], + }, + }, + } as ConnectionParameter, + openAIEmbeddingsModel: { + type: 'string', + uiDefinition: { + displayName: intl.formatMessage({ + id: 'nsr+K2', + defaultMessage: 'Embeddings model', + description: 'Label for embeddings model connection parameter', + }), + description: intl.formatMessage({ + id: 'bAzuvE', + defaultMessage: 'Select the embeddings model to use for this connection', + description: 'Description for embeddings model connection parameter', + }), + tooltip: intl.formatMessage({ + id: 'BQY4w7', + defaultMessage: 'Select the embeddings model to use for this connection', + description: 'Tooltip for embeddings model connection parameter', + }), + constraints: { + clearText: true, + required: 'true', + serializationPath: ['embeddingsOpenAI', 'embeddingsModel'], + }, + }, + } as ConnectionParameter, + }; +}; + +export const getOpenAIConnectionParameters = (intl: IntlShape): ConnectionParameterSets => { + const allParameters = getAllConnectionParameters(intl); + return { + uiDefinition: { + displayName: intl.formatMessage({ + id: 'IGxGlO', + defaultMessage: 'Authentication type', + description: 'Label for authentication type connection parameter', + }), + description: intl.formatMessage({ + id: 'rQxmJR', + defaultMessage: 'Type of authentication to use', + description: 'Description for authentication type connection parameter', + }), + }, + values: [ + { + name: 'Key', + parameters: { + cognitiveServiceAccountId: allParameters.cognitiveServiceAccountId as ConnectionParameterSetParameter, + openAIEndpoint: allParameters.openAIEndpoint as ConnectionParameterSetParameter, + openAIKey: allParameters.openAIKey as ConnectionParameterSetParameter, + openAICompletionsModel: allParameters.openAICompletionsModel as ConnectionParameterSetParameter, + openAIEmbeddingsModel: allParameters.openAIEmbeddingsModel as ConnectionParameterSetParameter, + }, + uiDefinition: { + displayName: intl.formatMessage({ + id: 'GdaJgz', + defaultMessage: 'URL and key-based authentication', + description: 'Display name for URL and key-based authentication', + }), + tooltip: intl.formatMessage({ + id: 'E+cyaO', + defaultMessage: 'URL and key-based authentication', + description: 'Tooltip for URL and key-based authentication', + }), + description: intl.formatMessage({ + id: 'GD79s3', + defaultMessage: 'URL and key-based authentication', + description: 'Description for URL and key-based authentication', + }), + }, + }, + { + name: 'ManagedServiceIdentity', + parameters: { + cognitiveServiceAccountId: allParameters.cognitiveServiceAccountId as ConnectionParameterSetParameter, + openAIEndpoint: allParameters.openAIEndpoint as ConnectionParameterSetParameter, + openAICompletionsModel: allParameters.openAICompletionsModel as ConnectionParameterSetParameter, + openAIEmbeddingsModel: allParameters.openAIEmbeddingsModel as ConnectionParameterSetParameter, + }, + uiDefinition: { + displayName: intl.formatMessage({ + id: '0147jq', + defaultMessage: 'Managed Service Identity', + description: 'Display name for Managed Service Identity authentication', + }), + tooltip: intl.formatMessage({ + id: 'iQK/gD', + defaultMessage: 'Managed Service Identity', + description: 'Tooltip for Managed Service Identity authentication', + }), + description: intl.formatMessage({ + id: '6TZBof', + defaultMessage: 'Managed Service Identity', + description: 'Description for Managed Service Identity authentication', + }), + }, + }, + ], + }; +}; + +export const getCosmosDbConnectionParameters = (intl: IntlShape): ConnectionParameterSets => { + const allParameters = getAllConnectionParameters(intl); + return { + uiDefinition: { + displayName: intl.formatMessage({ + id: 'IGxGlO', + defaultMessage: 'Authentication type', + description: 'Label for authentication type connection parameter', + }), + description: intl.formatMessage({ + id: 'rQxmJR', + defaultMessage: 'Type of authentication to use', + description: 'Description for authentication type connection parameter', + }), + }, + values: [ + { + name: 'Key', + parameters: { + cosmosDbServiceAccountId: allParameters.cosmosDbServiceAccountId as ConnectionParameterSetParameter, + cosmosDBEndpoint: allParameters.cosmosDBEndpoint as ConnectionParameterSetParameter, + cosmosDBKey: allParameters.cosmosDBKey as ConnectionParameterSetParameter, + }, + uiDefinition: { + displayName: intl.formatMessage({ + id: 'dDCpCR', + defaultMessage: 'Key-based', + description: 'Display name for key-based authentication', + }), + tooltip: intl.formatMessage({ + id: 'o2Qop6', + defaultMessage: 'Key-based authentication', + description: 'Tooltip for key-based authentication', + }), + description: intl.formatMessage({ + id: '80z7j2', + defaultMessage: 'Key-based authentication', + description: 'Description for key-based authentication', + }), + }, + }, + { + name: 'ManagedServiceIdentity', + parameters: { + cosmosDbServiceAccountId: allParameters.cosmosDbServiceAccountId as ConnectionParameterSetParameter, + cosmosDBEndpoint: allParameters.cosmosDBEndpoint as ConnectionParameterSetParameter, + }, + uiDefinition: { + displayName: intl.formatMessage({ + id: '0147jq', + defaultMessage: 'Managed Service Identity', + description: 'Display name for Managed Service Identity authentication', + }), + tooltip: intl.formatMessage({ + id: 'iQK/gD', + defaultMessage: 'Managed Service Identity', + description: 'Tooltip for Managed Service Identity authentication', + }), + description: intl.formatMessage({ + id: '6TZBof', + defaultMessage: 'Managed Service Identity', + description: 'Description for Managed Service Identity authentication', + }), + }, + }, + ], + }; +}; + +export const createOrUpdateConnection = async (parameterValues: Record, isCreate = true) => { + const intl = getIntl(); + const connectionParameters = getAllConnectionParameters(intl) as unknown as Record; + const displayName = parameterValues.displayName; + + try { + const connection = await ConnectionService().createConnection( + 'HubConnection', + { id: '/dummy/knowledgehub' } as unknown as Connector, + { displayName, connectionParameters: parameterValues }, + { connectionParameters, connectionMetadata: { required: true, type: ConnectionType.KnowledgeHub } } + ); + + // Add the new connection to the query cache. + getReactQueryClient().setQueryData(['knowledgeconnection'], () => connection); + + return connection; + } catch (error: any) { + const errorMessage = getObjectPropertyValue(error, ['error', 'message']) ?? getObjectPropertyValue(error, ['message']); + + LoggerService().log({ + level: LogEntryLevel.Error, + area: `KnowledgeHub.${isCreate ? 'Create' : 'Update'}Connection`, + error, + message: `Failed to ${isCreate ? 'create' : 'update'} connection with display name ${displayName}`, + }); + + throw new Error( + intl.formatMessage( + { + id: 'y8JeCD', + defaultMessage: 'Failed to create connection: {errorMessage}', + description: 'Error message when connection creation fails', + }, + { errorMessage } + ) + ); + } +}; + +export const getConnectionParametersForEdit = (intl: IntlShape, connection: Connection | undefined | null) => { + const allParameters = getAllConnectionParameters(intl) as Record; + const parameterValues: Record = {}; + const { connectionParameters, displayName } = connection?.properties ?? {}; + const valueFromConnection = getObjectPropertyValue(connectionParameters ?? {}, ['data', 'metadata', 'value']); + + for (const parameterName of Object.keys(allParameters)) { + const parameter = allParameters[parameterName]; + if (parameter?.uiDefinition?.constraints?.serialize !== false) { + const propertyPath = parameter.uiDefinition?.constraints?.serializationPath ?? [ + ...(parameter?.uiDefinition?.constraints?.propertyPath ?? []), + parameterName, + ]; + parameterValues[parameterName] = getObjectPropertyValue(valueFromConnection ?? {}, propertyPath); + } + } + + parameterValues['displayName'] = displayName; + + const connectionParametersForUI = Object.keys(allParameters).reduce( + (result, parameterName) => { + const parameter = allParameters[parameterName]; + if (parameter?.uiDefinition?.constraints?.serialize !== false) { + result[parameterName] = allParameters[parameterName]; + } + return result; + }, + {} as Record + ); + + return { connectionParameters: connectionParametersForUI, parameterValues }; +}; diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/helper.ts b/libs/designer-v2/src/lib/core/knowledge/utils/helper.ts new file mode 100644 index 00000000000..4e41220acfb --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/helper.ts @@ -0,0 +1,142 @@ +import { + ResourceService, + LoggerService, + LogEntryLevel, + getIntl, + isNullOrEmpty, + equals, + getObjectPropertyValue, +} from '@microsoft/logic-apps-shared'; + +export const createKnowledgeHub = async (siteResourceId: string, groupName: string, description: string) => { + try { + const response = await ResourceService().executeResourceAction( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${groupName}`, + 'PUT', + { + 'api-version': '2018-11-01', + 'Content-Type': 'application/json', + }, + { description } + ); + + return response; + } catch (errorResponse: any) { + const errorMessage = getObjectPropertyValue(errorResponse, ['error', 'message']) ?? getObjectPropertyValue(errorResponse, ['message']); + const error = errorResponse?.error || errorResponse; + // For now log the error + LoggerService().log({ + level: LogEntryLevel.Error, + area: 'KnowledgeHub.createKnowledgeHub', + error, + message: `Error while creating knowledge hub for the app: ${siteResourceId}`, + }); + + throw new Error(errorMessage); + } +}; + +export const deleteKnowledgeHubArtifacts = async (siteResourceId: string, hubs: string[], artifacts: Record) => { + const promises: Promise[] = []; + + for (const hubName of hubs) { + promises.push( + ResourceService().executeResourceAction( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${hubName}`, + 'DELETE', + { 'api-version': '2018-11-01' } + ) + ); + } + + for (const artifactName of Object.keys(artifacts)) { + const hubName = artifacts[artifactName]; + promises.push( + ResourceService().executeResourceAction( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgeHubs/${hubName}/artifacts/${artifactName}`, + 'DELETE', + { 'api-version': '2018-11-01' } + ) + ); + } + + return Promise.all(promises); +}; + +export const validateHubNameAvailability = (hubName: string, existingNames: string[]): string | undefined => { + const intl = getIntl(); + + if (isNullOrEmpty(hubName)) { + return intl.formatMessage({ + defaultMessage: 'Requires a unique hub name under 244 characters with only letters and numbers.', + id: '1KFRSn', + description: 'Error message when the hub name is empty.', + }); + } + + if (existingNames.some((name) => equals(name, hubName))) { + return intl.formatMessage({ + defaultMessage: 'A hub with this name already exists.', + id: 'M62KKW', + description: 'Error message when the hub name is not unique.', + }); + } + + const regex = /^[a-zA-Z0-9]{1,244}$/; + if (!regex.test(hubName)) { + if (hubName.length > 244) { + return intl.formatMessage({ + defaultMessage: `Hub name can't exceed 244 characters.`, + id: 'EHLy1u', + description: 'Error message when the hub name exceeds maximum length.', + }); + } + + return intl.formatMessage({ + defaultMessage: 'Enter a unique name under 244 characters with only letters and numbers.', + id: 'huLRj0', + description: 'Error message when the hub name is invalid regex.', + }); + } + + return undefined; +}; + +export const validateArtifactNameAvailability = (fileName: string, existingNames: string[]): string | undefined => { + const intl = getIntl(); + + if (isNullOrEmpty(fileName)) { + return intl.formatMessage({ + defaultMessage: 'Requires a unique file artifact name under 80 characters with only letters and numbers.', + id: 'trESjR', + description: 'Error message when the file artifact name is empty.', + }); + } + + if (existingNames.some((name) => equals(name, fileName))) { + return intl.formatMessage({ + defaultMessage: 'An artifact with this name already exists in the hub.', + id: 'rNJguF', + description: 'Error message when the file artifact name is not unique.', + }); + } + + const regex = /^[a-zA-Z0-9]{1,80}$/; + if (!regex.test(fileName)) { + if (fileName.length > 80) { + return intl.formatMessage({ + defaultMessage: `File artifact name can't exceed 80 characters.`, + id: '7Djtki', + description: 'Error message when the file artifact name exceeds maximum length.', + }); + } + + return intl.formatMessage({ + defaultMessage: 'Enter a unique name under 80 characters with only letters and numbers.', + id: 'i1qktD', + description: 'Error message when the file artifact name is invalid regex.', + }); + } + + return undefined; +}; diff --git a/libs/designer-v2/src/lib/core/knowledge/utils/queries.ts b/libs/designer-v2/src/lib/core/knowledge/utils/queries.ts new file mode 100644 index 00000000000..fa75118107b --- /dev/null +++ b/libs/designer-v2/src/lib/core/knowledge/utils/queries.ts @@ -0,0 +1,95 @@ +import { + type Connection, + ConnectionService, + equals, + type KnowledgeHub, + type KnowledgeHubExtended, + LogEntryLevel, + LoggerService, + ResourceService, +} from '@microsoft/logic-apps-shared'; +import { useQuery } from '@tanstack/react-query'; +import { getReactQueryClient } from '../../ReactQueryProvider'; + +const queryOpts = { + cacheTime: 1000 * 60 * 60 * 24, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +}; + +export const useAllKnowledgeHubs = (siteResourceId: string) => { + return useQuery({ + queryKey: ['knowledgehubs', siteResourceId.toLowerCase()], + queryFn: async (): Promise => { + try { + const response: any = await ResourceService().getResource( + `${siteResourceId}/hostruntime/runtime/webhooks/workflow/api/management/knowledgehubs`, + { 'api-version': '2018-11-01' } + ); + + const hubs = (response ?? []).sort((a: KnowledgeHub, b: KnowledgeHub) => a.name.localeCompare(b.name)); + + return hubs; + } catch (errorResponse: any) { + const error = errorResponse?.error || {}; + + // For now log the error and return empty list + LoggerService().log({ + level: LogEntryLevel.Error, + area: 'KnowledgeHub.listKnowledgeHubs', + error, + message: `Error while fetching knowledge hubs for the app: ${siteResourceId}`, + }); + return []; + } + }, + enabled: !!siteResourceId, + ...queryOpts, + }); +}; + +export const useConnection = () => { + return useQuery({ + queryKey: ['knowledgeconnection'], + queryFn: async (): Promise => { + try { + const allConnections = await ConnectionService().getConnections(); + return allConnections.find((connection) => equals(connection.type, 'connections/knowledgehub')) || null; + } catch (errorResponse: any) { + const error = errorResponse?.error || {}; + + // For now log the error and return empty list + LoggerService().log({ + level: LogEntryLevel.Error, + area: 'KnowledgeHub.getConnection', + error, + message: 'Error while fetching knowledge hub connection', + }); + + return null; + } + }, + ...queryOpts, + }); +}; + +export const getCosmosDbEndpoint = async (database: string): Promise => { + const queryClient = getReactQueryClient(); + + return queryClient.fetchQuery(['cosmosdbendpoint', database.toLowerCase()], async (): Promise => { + try { + const response = await ResourceService().getResource(`${database}/listConnectionStrings`, { 'api-version': '2025-11-01' }); + return response?.properties.endpoint; + } catch (errorResponse: any) { + const error = errorResponse?.error || {}; + LoggerService().log({ + level: LogEntryLevel.Error, + area: 'KnowledgeHub.getCosmosDbEndpoint', + error, + message: `Error while fetching Cosmos DB endpoint for database: ${database}`, + }); + return undefined; + } + }); +}; diff --git a/libs/designer-v2/src/lib/core/state/__test__/modalSlice.spec.ts b/libs/designer-v2/src/lib/core/state/__test__/modalSlice.spec.ts index 714cb2c82ca..c08eca73b00 100644 --- a/libs/designer-v2/src/lib/core/state/__test__/modalSlice.spec.ts +++ b/libs/designer-v2/src/lib/core/state/__test__/modalSlice.spec.ts @@ -11,6 +11,8 @@ describe('modalSlice', () => { const initialState: ModalState = { isCombineVariableOpen: false, isTriggerDescriptionOpen: false, + isKnowledgeConnectionOpen: false, + kindChangeDialogType: undefined, }; beforeEach(() => { diff --git a/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsInterfaces.ts b/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsInterfaces.ts index f228d8b9d0f..e918572bdcc 100644 --- a/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsInterfaces.ts +++ b/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsInterfaces.ts @@ -25,6 +25,7 @@ import type { IExperimentationService, ICognitiveServiceService, ICopilotWorkflowEditorService, + IResourceService, } from '@microsoft/logic-apps-shared'; import type { MaximumWaitingRunsMetadata } from '../../../ui/settings'; @@ -88,4 +89,5 @@ export interface ServiceOptions { experimentationService?: IExperimentationService; cognitiveServiceService?: ICognitiveServiceService; copilotWorkflowEditorService?: ICopilotWorkflowEditorService; + resourceService?: IResourceService; } diff --git a/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsSlice.ts b/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsSlice.ts index 160ae838483..b64e6e1a538 100644 --- a/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsSlice.ts +++ b/libs/designer-v2/src/lib/core/state/designerOptions/designerOptionsSlice.ts @@ -26,6 +26,7 @@ import { InitExperimentationServiceService, InitCognitiveServiceService, InitCopilotWorkflowEditorService, + InitResourceService, } from '@microsoft/logic-apps-shared'; import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; @@ -80,6 +81,7 @@ export const initializeServices = createAsyncThunk( experimentationService, cognitiveServiceService, copilotWorkflowEditorService, + resourceService, }: ServiceOptions) => { const loggerServices: ILoggerService[] = []; if (loggerService) { @@ -148,6 +150,10 @@ export const initializeServices = createAsyncThunk( InitCopilotWorkflowEditorService(copilotWorkflowEditorService); } + if (resourceService) { + InitResourceService(resourceService); + } + // Experimentation service is being used to A/B test features in the designer so in case client does not want to use the A/B test feature, // we are always defaulting to the false implementation of the experimentation service. InitExperimentationServiceService(experimentationService); diff --git a/libs/designer-v2/src/lib/core/state/knowledge/optionsSlice.ts b/libs/designer-v2/src/lib/core/state/knowledge/optionsSlice.ts new file mode 100644 index 00000000000..86291ddfa1e --- /dev/null +++ b/libs/designer-v2/src/lib/core/state/knowledge/optionsSlice.ts @@ -0,0 +1,43 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { initializeData } from '../../actions/bjsworkflow/knowledge'; + +export interface ServerNotificationData { + title: string; + content: string; +} + +export interface OptionsState { + servicesInitialized: boolean; + isDarkMode?: boolean; + notification?: ServerNotificationData; +} + +const initialState: OptionsState = { + servicesInitialized: false, + isDarkMode: false, + notification: undefined, +}; + +export const optionsSlice = createSlice({ + name: 'knowledgeHubOptions', + initialState, + reducers: { + setDarkMode: (state, action) => { + state.isDarkMode = action.payload; + }, + setNotification: (state, action) => { + state.notification = action.payload; + }, + clearNotification: (state) => { + state.notification = undefined; + }, + }, + extraReducers: (builder) => { + builder.addCase(initializeData.fulfilled, (state, action) => { + state.servicesInitialized = action.payload; + }); + }, +}); + +export const { setDarkMode, setNotification, clearNotification } = optionsSlice.actions; +export default optionsSlice.reducer; diff --git a/libs/designer-v2/src/lib/core/state/knowledge/panelSlice.ts b/libs/designer-v2/src/lib/core/state/knowledge/panelSlice.ts new file mode 100644 index 00000000000..09322b4d1d4 --- /dev/null +++ b/libs/designer-v2/src/lib/core/state/knowledge/panelSlice.ts @@ -0,0 +1,52 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +export const KnowledgePanelView = { + CreateConnection: 'createConnection', + EditConnection: 'editConnection', + AddFiles: 'addFiles', +} as const; +export type ConfigPanelView = (typeof KnowledgePanelView)[keyof typeof KnowledgePanelView]; + +export interface PanelState { + isOpen: boolean; + currentPanelView?: ConfigPanelView; + selectedTabId?: string; + autoOpenPanel?: boolean; +} + +const initialState: PanelState = { + isOpen: false, +}; + +const closePanelReducer = (state: typeof initialState) => { + state.isOpen = false; + state.currentPanelView = undefined; + state.selectedTabId = undefined; +}; + +export const panelSlice = createSlice({ + name: 'knowledgeHubPanel', + initialState, + reducers: { + openPanelView: ( + state, + action: PayloadAction<{ + panelView: ConfigPanelView; + selectedTabId?: string; + }> + ) => { + state.currentPanelView = action.payload.panelView; + state.selectedTabId = action.payload.selectedTabId; + state.isOpen = true; + }, + selectPanelTab: (state, action: PayloadAction) => { + state.selectedTabId = action.payload; + }, + closePanel: closePanelReducer, + }, +}); + +export const { openPanelView, selectPanelTab, closePanel } = panelSlice.actions; + +export default panelSlice.reducer; diff --git a/libs/designer-v2/src/lib/core/state/knowledge/store.ts b/libs/designer-v2/src/lib/core/state/knowledge/store.ts new file mode 100644 index 00000000000..30601fa77e3 --- /dev/null +++ b/libs/designer-v2/src/lib/core/state/knowledge/store.ts @@ -0,0 +1,24 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import optionsReducer from './optionsSlice'; +import panelReducer from './panelSlice'; +import connectionReducer from '../connection/connectionSlice'; +import resourceReducer from '../mcp/resourceSlice'; + +const rootReducer = combineReducers({ + resource: resourceReducer, + connection: connectionReducer, + options: optionsReducer, + knowledgeHubPanel: panelReducer, +}); + +export const setupStore = (preloadedState?: Partial) => { + return configureStore({ + reducer: rootReducer, + preloadedState, + }); +}; + +export const knowledgeStore = setupStore(); +export type RootState = ReturnType; +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; diff --git a/libs/designer-v2/src/lib/core/state/modal/modalSlice.ts b/libs/designer-v2/src/lib/core/state/modal/modalSlice.ts index abd84b84e0f..7549f6b6818 100644 --- a/libs/designer-v2/src/lib/core/state/modal/modalSlice.ts +++ b/libs/designer-v2/src/lib/core/state/modal/modalSlice.ts @@ -5,12 +5,14 @@ export interface ModalState { isCombineVariableOpen: boolean; resolveCombineVariable?: (useCombined: boolean) => void; isTriggerDescriptionOpen: boolean; + isKnowledgeConnectionOpen: boolean; kindChangeDialogType?: string; } const initialState: ModalState = { isCombineVariableOpen: false, isTriggerDescriptionOpen: false, + isKnowledgeConnectionOpen: false, kindChangeDialogType: undefined, }; @@ -41,6 +43,12 @@ const modalSlice = createSlice({ closeKindChangeDialog: (state) => { state.kindChangeDialogType = undefined; }, + openKnowledgeConnectionModal: (state) => { + state.isKnowledgeConnectionOpen = true; + }, + closeKnowledgeConnectionModal: (state) => { + state.isKnowledgeConnectionOpen = false; + }, }, }); @@ -51,5 +59,7 @@ export const { closeTriggerDescriptionModal, openKindChangeDialog, closeKindChangeDialog, + openKnowledgeConnectionModal, + closeKnowledgeConnectionModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/libs/designer-v2/src/lib/core/store.ts b/libs/designer-v2/src/lib/core/store.ts index c26f503bdbe..db9ba60fcc4 100644 --- a/libs/designer-v2/src/lib/core/store.ts +++ b/libs/designer-v2/src/lib/core/store.ts @@ -13,6 +13,7 @@ import workflowReducer from './state/workflow/workflowSlice'; import workflowParametersReducer from './state/workflowparameters/workflowparametersSlice'; import modalReducer from './state/modal/modalSlice'; import notesReducer from './state/notes/notesSlice'; +import knowledgeHubOptionsReducer from './state/knowledge/optionsSlice'; import { configureStore } from '@reduxjs/toolkit'; import type {} from 'redux-thunk'; @@ -40,6 +41,7 @@ export const store = configureStore({ undoRedo: undoRedoReducer, modal: modalReducer, notes: notesReducer, + knowledgeHubOptions: knowledgeHubOptionsReducer, // if is in dev environment, add devSlice to store ...(process.env.NODE_ENV === 'development' ? { dev: devReducer } : {}), }, diff --git a/libs/designer-v2/src/lib/core/templates/utils/__test__/parametershelper.spec.ts b/libs/designer-v2/src/lib/core/templates/utils/__test__/parametershelper.spec.ts index 5699bd16545..7770cde9e26 100644 --- a/libs/designer-v2/src/lib/core/templates/utils/__test__/parametershelper.spec.ts +++ b/libs/designer-v2/src/lib/core/templates/utils/__test__/parametershelper.spec.ts @@ -1,4 +1,10 @@ -import { ConsumptionOperationManifestService, InitConnectionService, InitOperationManifestService } from '@microsoft/logic-apps-shared'; +import { + ConsumptionOperationManifestService, + InitConnectionService, + InitLoggerService, + InitOperationManifestService, + InitWorkflowService, +} from '@microsoft/logic-apps-shared'; import { afterEach, describe, expect, test, vitest } from 'vitest'; import { getReactQueryClient } from '../../../ReactQueryProvider'; import { testSwagger } from '../../../utils/parameters/__test__/mocks'; @@ -30,6 +36,8 @@ describe('Templates Parameters Helper', () => { test('should initialize operations and template metadata correctly in store when template parameters have dynamic data', async () => { InitOperationManifestService(manifestService); InitConnectionService(connectionService); + InitLoggerService([]); + InitWorkflowService({} as any); const templateParameters = testTemplateManifest.parameters.reduce((result, current) => { result[current.name] = current; diff --git a/libs/designer-v2/src/lib/core/utils/parameters/__test__/dynamicdata.spec.ts b/libs/designer-v2/src/lib/core/utils/parameters/__test__/dynamicdata.spec.ts index cdd7c6c363a..09237402759 100644 --- a/libs/designer-v2/src/lib/core/utils/parameters/__test__/dynamicdata.spec.ts +++ b/libs/designer-v2/src/lib/core/utils/parameters/__test__/dynamicdata.spec.ts @@ -1,6 +1,6 @@ import { DynamicLoadStatus, isBuiltInConnector } from '@microsoft/designer-ui'; import { getDynamicInputsFromSchema, getDynamicOutputsFromSchema, getDynamicValues } from '../dynamicdata'; -import { InitConnectionService, InitOperationManifestService } from '@microsoft/logic-apps-shared'; +import { InitConnectionService, InitOperationManifestService, InitWorkflowService } from '@microsoft/logic-apps-shared'; import { expect, describe, test, afterEach, vitest } from 'vitest'; import * as ConnectorQueries from '../../../queries/connector'; import { getReactQueryClient } from '../../../ReactQueryProvider'; @@ -304,9 +304,11 @@ describe('DynamicData', () => { getConnector: () => Promise.resolve({ id: operationInfo.connectorId }), getConnections: () => Promise.resolve([{ id: connectionReference.connection.id }]), }; + const workflowService: any = {}; test('should make dynamic calls with non referenced default parameters in dynamic operation', async () => { InitConnectionService(connectionService); InitOperationManifestService({ isBuiltInConnector: () => false } as any); + InitWorkflowService(workflowService); const spy = vitest.spyOn(ConnectorQueries, 'getLegacyDynamicValues').mockResolvedValueOnce([{ value: 'test', displayName: 'test' }]); await getDynamicValues(dependencyInfo, nodeInputs, operationInfo, connectionReference, {}, {}); diff --git a/libs/designer-v2/src/lib/core/utils/parameters/__test__/helper-agentParams.spec.ts b/libs/designer-v2/src/lib/core/utils/parameters/__test__/helper-agentParams.spec.ts index e0fde9f0b8b..ac2556ecda2 100644 --- a/libs/designer-v2/src/lib/core/utils/parameters/__test__/helper-agentParams.spec.ts +++ b/libs/designer-v2/src/lib/core/utils/parameters/__test__/helper-agentParams.spec.ts @@ -1,7 +1,16 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isParameterRequired, createParameterInfo, toParameterInfoMap, shouldUseParameterInGroup } from '../helper'; import type { InputParameter, ParameterInfo, ResolvedParameter } from '@microsoft/logic-apps-shared'; - +import * as LogicAppsShared from '@microsoft/logic-apps-shared'; + +// Mock WorkflowService +vi.mock('@microsoft/logic-apps-shared', async (importOriginal) => { + const original = (await importOriginal()) as object; + return { + ...original, + WorkflowService: vi.fn(), + }; +}); describe('Parameter validation logic for Agent operations', () => { describe('shouldUseParameterInGroup - visibility controls serialization inclusion', () => { const makeAgentModelTypeParam = (modelTypeValue: string): ParameterInfo => @@ -409,91 +418,311 @@ describe('Parameter validation logic for Agent operations', () => { }); }); - describe('toParameterInfoMap - knowledgebase parameter hiding', () => { - it('should exclude parameters with editor: "knowledgebase" from the result', () => { + describe('toParameterInfoMap - KnowledgeHub enabled/disabled scenarios', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createInputParameter = (overrides: Partial = {}): InputParameter => + ({ + name: 'testParam', + key: 'inputs.$.testParam', + type: 'string', + title: 'Test Parameter', + required: false, + editor: 'textbox', + schema: { type: 'string' }, + ...overrides, + }) as InputParameter; + + const mockWorkflowService = (isKnowledgeHubEnabled: boolean | undefined) => { + if (isKnowledgeHubEnabled === undefined) { + (LogicAppsShared.WorkflowService as ReturnType).mockReturnValue(undefined); + } else { + (LogicAppsShared.WorkflowService as ReturnType).mockReturnValue({ + isKnowledgeHubEnabled: () => isKnowledgeHubEnabled, + getLogicAppId: () => 'test-logic-app-id', + getAppIdentity: () => undefined, + }); + } + }; + + it('should include knowledgebase parameter when KnowledgeHub is enabled', () => { + mockWorkflowService(true); + const inputParameters: InputParameter[] = [ - { + createInputParameter({ name: 'agentKnowledge', key: 'inputs.$.agentKnowledge', - type: 'object', - title: 'Agent Knowledge', - required: false, editor: 'knowledgebase', - schema: { - type: 'object', - }, - } as any, - { + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].parameterName).toBe('agentKnowledge'); + }); + + it('should exclude knowledgebase parameter when KnowledgeHub is disabled', () => { + mockWorkflowService(false); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'agentKnowledge', + key: 'inputs.$.agentKnowledge', + editor: 'knowledgebase', + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + expect(result).toHaveLength(0); + }); + + it('should include non-knowledgebase parameters when KnowledgeHub is enabled', () => { + mockWorkflowService(true); + + const inputParameters: InputParameter[] = [ + createInputParameter({ name: 'messages', key: 'inputs.$.messages', - type: 'array', - title: 'Messages', - required: true, editor: 'array', - schema: { - type: 'array', - }, - } as any, + }), ]; const result = toParameterInfoMap(inputParameters, undefined, true); - // Should only contain 'messages', not 'agentKnowledge' expect(result).toHaveLength(1); expect(result[0].parameterName).toBe('messages'); }); - it('should exclude parameters with editor: "knowledgebase" regardless of case', () => { + it('should include non-knowledgebase parameters when KnowledgeHub is disabled', () => { + mockWorkflowService(false); + const inputParameters: InputParameter[] = [ - { + createInputParameter({ + name: 'messages', + key: 'inputs.$.messages', + editor: 'array', + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].parameterName).toBe('messages'); + }); + + it('should default to KnowledgeHub enabled when WorkflowService returns undefined', () => { + mockWorkflowService(undefined); + + // Test with non-knowledgebase parameter since knowledgebase requires WorkflowService for other methods + // The key behavior being tested is that isKnowledgeHubEnabled defaults to true + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'messages', + key: 'inputs.$.messages', + editor: 'array', + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should include parameter because isKnowledgeHubEnabled defaults to true when service is undefined + expect(result).toHaveLength(1); + expect(result[0].parameterName).toBe('messages'); + }); + + it('should default to KnowledgeHub enabled when isKnowledgeHubEnabled method is not defined', () => { + // WorkflowService exists but doesn't have isKnowledgeHubEnabled method + // This should still include knowledgebase parameters (defaults to enabled) + (LogicAppsShared.WorkflowService as ReturnType).mockReturnValue({ + getLogicAppId: () => 'test-logic-app-id', + getAppIdentity: () => undefined, + }); + + const inputParameters: InputParameter[] = [ + createInputParameter({ name: 'agentKnowledge', key: 'inputs.$.agentKnowledge', - type: 'object', - title: 'Agent Knowledge', - required: false, - editor: 'Knowledgebase', // Different casing - schema: { - type: 'object', - }, - } as any, + editor: 'knowledgebase', + }), ]; const result = toParameterInfoMap(inputParameters, undefined, true); - // Should be empty since knowledgebase parameters are hidden - expect(result).toHaveLength(0); + // Should include knowledgebase parameter because default is true when method is undefined + expect(result).toHaveLength(1); + expect(result[0].parameterName).toBe('agentKnowledge'); }); - it('should include all parameters when none have editor: "knowledgebase"', () => { + it('should filter mixed parameters correctly when KnowledgeHub is disabled', () => { + mockWorkflowService(false); + const inputParameters: InputParameter[] = [ - { - name: 'deploymentId', - key: 'inputs.$.deploymentId', - type: 'string', - title: 'Deployment ID', - required: false, + createInputParameter({ + name: 'agentKnowledge', + key: 'inputs.$.agentKnowledge', + editor: 'knowledgebase', + }), + createInputParameter({ + name: 'messages', + key: 'inputs.$.messages', + editor: 'array', + }), + createInputParameter({ + name: 'temperature', + key: 'inputs.$.temperature', editor: 'textbox', - schema: { - type: 'string', - }, - } as any, - { + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should exclude knowledgebase parameter but include others + expect(result).toHaveLength(2); + expect(result.map((p) => p.parameterName)).toEqual(['messages', 'temperature']); + }); + + it('should include all parameters when KnowledgeHub is enabled with mixed editors', () => { + mockWorkflowService(true); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'agentKnowledge', + key: 'inputs.$.agentKnowledge', + editor: 'knowledgebase', + }), + createInputParameter({ + name: 'messages', + key: 'inputs.$.messages', + editor: 'array', + }), + createInputParameter({ name: 'temperature', key: 'inputs.$.temperature', - type: 'number', - title: 'Temperature', - required: false, editor: 'textbox', - schema: { - type: 'number', - }, - } as any, + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should include all parameters + expect(result).toHaveLength(3); + expect(result.map((p) => p.parameterName)).toEqual(['agentKnowledge', 'messages', 'temperature']); + }); + + it('should always exclude parameters with dynamicSchema regardless of KnowledgeHub state', () => { + mockWorkflowService(true); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'dynamicParam', + key: 'inputs.$.dynamicParam', + editor: 'textbox', + dynamicSchema: { type: 'object' }, + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + expect(result).toHaveLength(0); + }); + + it('should exclude both dynamicSchema and knowledgebase parameters when KnowledgeHub is disabled', () => { + mockWorkflowService(false); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'dynamicParam', + key: 'inputs.$.dynamicParam', + editor: 'textbox', + dynamicSchema: { type: 'object' }, + }), + createInputParameter({ + name: 'agentKnowledge', + key: 'inputs.$.agentKnowledge', + editor: 'knowledgebase', + }), + createInputParameter({ + name: 'normalParam', + key: 'inputs.$.normalParam', + editor: 'textbox', + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should only include normalParam + expect(result).toHaveLength(1); + expect(result[0].parameterName).toBe('normalParam'); + }); + + it('should handle case-insensitive knowledgebase editor value', () => { + mockWorkflowService(false); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'agentKnowledge', + key: 'inputs.$.agentKnowledge', + editor: 'KnowledgeBase', // Different casing + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should be excluded due to case-insensitive comparison + expect(result).toHaveLength(0); + }); + + it('should handle multiple knowledgebase parameters when KnowledgeHub is disabled', () => { + mockWorkflowService(false); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'agentKnowledge1', + key: 'inputs.$.agentKnowledge1', + editor: 'knowledgebase', + }), + createInputParameter({ + name: 'agentKnowledge2', + key: 'inputs.$.agentKnowledge2', + editor: 'knowledgebase', + }), + ]; + + const result = toParameterInfoMap(inputParameters, undefined, true); + + // Should exclude all knowledgebase parameters + expect(result).toHaveLength(0); + }); + + it('should handle multiple knowledgebase parameters when KnowledgeHub is enabled', () => { + mockWorkflowService(true); + + const inputParameters: InputParameter[] = [ + createInputParameter({ + name: 'agentKnowledge1', + key: 'inputs.$.agentKnowledge1', + editor: 'knowledgebase', + }), + createInputParameter({ + name: 'agentKnowledge2', + key: 'inputs.$.agentKnowledge2', + editor: 'knowledgebase', + }), ]; const result = toParameterInfoMap(inputParameters, undefined, true); + // Should include all knowledgebase parameters expect(result).toHaveLength(2); - expect(result.map((p) => p.parameterName)).toEqual(['deploymentId', 'temperature']); + expect(result.map((p) => p.parameterName)).toEqual(['agentKnowledge1', 'agentKnowledge2']); }); }); }); diff --git a/libs/designer-v2/src/lib/core/utils/parameters/helper.ts b/libs/designer-v2/src/lib/core/utils/parameters/helper.ts index 06a6f4b9cb7..971ba7dbd34 100644 --- a/libs/designer-v2/src/lib/core/utils/parameters/helper.ts +++ b/libs/designer-v2/src/lib/core/utils/parameters/helper.ts @@ -169,6 +169,7 @@ import type { import { createAsyncThunk, type Dispatch } from '@reduxjs/toolkit'; import { getAllVariables } from '../variables'; import { UncastingUtility } from './uncast'; +import { KnowledgeHubEditor } from '../../../ui/knowledge/editor'; export const ParameterBrandColor = '#916F6F'; export const ParameterIcon = @@ -332,9 +333,10 @@ export function toParameterInfoMap( shouldEncodeBasedOnMetadata = true ): ParameterInfo[] { const metadata = stepDefinition && stepDefinition.metadata; + const isKnowledgeHubEnabled = WorkflowService()?.isKnowledgeHubEnabled ? WorkflowService()?.isKnowledgeHubEnabled?.() : true; const result: ParameterInfo[] = []; for (const inputParameter of inputParameters) { - if (!inputParameter.dynamicSchema && !equals(inputParameter.editor, 'knowledgebase')) { + if (!inputParameter.dynamicSchema && !(!isKnowledgeHubEnabled && equals(inputParameter.editor, constants.EDITOR.KNOWLEDGE_BASE))) { const parameter = createParameterInfo(inputParameter, metadata, shouldEncodeBasedOnMetadata); result.push(parameter); } @@ -521,6 +523,14 @@ export function getParameterEditorProps( } } else if (editor === constants.EDITOR.INITIALIZE_VARIABLE) { editorViewModel = { hideParameterErrors: true }; + } else if (editor === constants.EDITOR.KNOWLEDGE_BASE) { + editorOptions = { + ...editorOptions, + hideLabel: true, + hubName: parameterValue.length === 1 && isLiteralValueSegment(parameterValue[0]) ? parameterValue[0].value : undefined, + logicAppId: WorkflowService().getLogicAppId?.() ?? '', + EditorComponent: KnowledgeHubEditor, + }; } else if (!editor) { if (format === constants.EDITOR.HTML) { editor = constants.EDITOR.HTML; diff --git a/libs/designer-v2/src/lib/ui/Designer.tsx b/libs/designer-v2/src/lib/ui/Designer.tsx index 59538039eff..790a21b852e 100644 --- a/libs/designer-v2/src/lib/ui/Designer.tsx +++ b/libs/designer-v2/src/lib/ui/Designer.tsx @@ -18,6 +18,7 @@ import Controls from './Controls'; import Minimap from './Minimap'; import DeleteModal from './common/DeleteModal/DeleteModal'; import { MultiSelectDeleteModal } from './common/DeleteModal/MultiSelectDeleteModal'; +import { DesignerDialog } from './DesignerDialog'; import { PanelRoot } from './panel/panelRoot'; import { css, setLayerHostSelector } from '@fluentui/react'; import { mergeClasses, PanelLocation, MultiTriggerUnsupportedMessage } from '@microsoft/designer-ui'; @@ -273,6 +274,7 @@ export const Designer = (props: DesignerProps) => { )} + diff --git a/libs/designer-v2/src/lib/ui/DesignerDialog.tsx b/libs/designer-v2/src/lib/ui/DesignerDialog.tsx new file mode 100644 index 00000000000..6efda89764f --- /dev/null +++ b/libs/designer-v2/src/lib/ui/DesignerDialog.tsx @@ -0,0 +1,11 @@ +import { useSelector } from 'react-redux'; +import { CreateConnectionModal } from './knowledge/editor/connection'; +import type { RootState } from '../core/store'; + +export const DesignerDialog = ({ containerRef }: { containerRef: React.MutableRefObject }) => { + const { isKnowledgeConnectionOpen } = useSelector((state: RootState) => ({ + isKnowledgeConnectionOpen: state.modal.isKnowledgeConnectionOpen, + })); + + return isKnowledgeConnectionOpen ? : null; +}; diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineButtons.test.tsx b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineButtons.test.tsx index bec6ed5bc8d..502a19025fd 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineButtons.test.tsx +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineButtons.test.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { IntlProvider } from 'react-intl'; import TimelineButtons from '../TimelineButtons'; @@ -38,6 +38,14 @@ describe('TimelineButtons', () => { ); }; + const renderForInteraction = (props: TimelineButtonsProps) => { + let component: renderer.ReactTestRenderer; + act(() => { + component = renderWithIntl(props); + }); + return component!; + }; + it('should render with default props', () => { const tree = renderWithIntl(defaultProps).toJSON(); expect(tree).toMatchSnapshot(); @@ -84,25 +92,29 @@ describe('TimelineButtons', () => { }); it('should call handleSelectRepetition with correct params when previous button clicked', () => { - const component = renderWithIntl(defaultProps); + const component = renderForInteraction(defaultProps); // Simulate clicking the previous button const buttons = component.root.findAllByType('button'); const previousButton = buttons[0]; // First button is previous - previousButton.props.onClick({ preventDefault: () => {} }); + act(() => { + previousButton.props.onClick({ preventDefault: () => {} }); + }); expect(mockHandleSelectRepetition).toHaveBeenCalledWith(0, 0); // transitionIndex - 1, 0 }); it('should call handleSelectRepetition with correct params when next button clicked', () => { - const component = renderWithIntl(defaultProps); + const component = renderForInteraction(defaultProps); // Simulate clicking the next button const buttons = component.root.findAllByType('button'); const nextButton = buttons[1]; // Second button is next - nextButton.props.onClick({ preventDefault: () => {} }); + act(() => { + nextButton.props.onClick({ preventDefault: () => {} }); + }); expect(mockHandleSelectRepetition).toHaveBeenCalledWith(2, 0); // transitionIndex + 1, 0 }); diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineContent.test.tsx b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineContent.test.tsx index 3ce49e77a15..1efb3e25fa1 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineContent.test.tsx +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineContent.test.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { IntlProvider } from 'react-intl'; import TimelineContent from '../TimelineContent'; import type { TimelineRepetitionWithActions } from '../helpers'; @@ -95,6 +95,14 @@ describe('TimelineContent', () => { ); }; + const renderForInteraction = (props: TimelineContentProps) => { + let component: renderer.ReactTestRenderer; + act(() => { + component = renderWithIntl(props); + }); + return component!; + }; + it('should render with default props', () => { const tree = renderWithIntl(defaultProps).toJSON(); expect(tree).toMatchSnapshot(); @@ -167,7 +175,7 @@ describe('TimelineContent', () => { }); it('should render slider with correct values when expanded', () => { - const component = renderWithIntl(defaultProps); + const component = renderForInteraction(defaultProps); const sliders = component.root.findAllByType('input'); expect(sliders).toHaveLength(1); @@ -178,14 +186,16 @@ describe('TimelineContent', () => { }); it('should call handleSelectRepetition when slider changes', () => { - const component = renderWithIntl(defaultProps); + const component = renderForInteraction(defaultProps); const sliders = component.root.findAllByType('input'); const slider = sliders[0]; // Simulate slider change with proper event structure const mockEvent = { target: { value: '1' } }; const mockData = { value: 1 }; - slider.props.onChange(mockEvent, mockData); + act(() => { + slider.props.onChange(mockEvent, mockData); + }); expect(mockHandleSelectRepetition).toHaveBeenCalledWith(1, 0); }); diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineGroup.test.tsx b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineGroup.test.tsx index e72dc316159..dce648bd2a7 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineGroup.test.tsx +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineGroup.test.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { IntlProvider } from 'react-intl'; import TimelineGroup from '../TimelineGroup'; import type { TimelineRepetitionWithActions } from '../helpers'; @@ -72,6 +72,14 @@ describe('TimelineGroup', () => { ); }; + const renderForInteraction = (props: TimelineGroupProps) => { + let component: renderer.ReactTestRenderer; + act(() => { + component = renderWithIntl(props); + }); + return component!; + }; + it('should render with default props when timeline is expanded', () => { const tree = renderWithIntl(defaultProps).toJSON(); expect(tree).toMatchSnapshot(); @@ -134,7 +142,7 @@ describe('TimelineGroup', () => { }); it('should call handleSelectRepetition when expand button is clicked', () => { - const component = renderWithIntl({ + const component = renderForInteraction({ ...defaultProps, transitionIndex: 1, // different from taskId to start collapsed }); @@ -143,14 +151,16 @@ describe('TimelineGroup', () => { expect(buttons).toHaveLength(1); // Click expand button - buttons[0].props.onClick(); + act(() => { + buttons[0].props.onClick(); + }); // Should not call handleSelectRepetition for expand action expect(mockHandleSelectRepetition).not.toHaveBeenCalled(); }); it('should call handleSelectRepetition when timeline node is selected', () => { - const component = renderWithIntl({ + const component = renderForInteraction({ ...defaultProps, transitionIndex: 0, // same as taskId to start expanded }); @@ -158,15 +168,19 @@ describe('TimelineGroup', () => { // First need to expand the group manually const buttons = component.root.findAllByType('button'); if (buttons.length > 0) { - buttons[0].props.onClick(); // Expand the group + act(() => { + buttons[0].props.onClick(); // Expand the group + }); } // Re-render to get the updated state - component.update( - - - - ); + act(() => { + component.update( + + + + ); + }); // Find TimelineNode components by their mock implementation const timelineNodes = component.root.findAllByProps({ 'data-testid': 'timeline-node' }); @@ -175,7 +189,9 @@ describe('TimelineGroup', () => { // The onClick handler is actually on the wrapping div const nodeContainer = timelineNodes[0].parent; if (nodeContainer?.props.onClick) { - nodeContainer.props.onClick(); + act(() => { + nodeContainer.props.onClick(); + }); expect(mockHandleSelectRepetition).toHaveBeenCalledWith(0, 0); // taskId, index } } @@ -183,7 +199,7 @@ describe('TimelineGroup', () => { it('should show selected repetition correctly', () => { const selectedRep = createMockRepetition(1, 0); - const component = renderWithIntl({ + const component = renderForInteraction({ ...defaultProps, selectedRepetition: selectedRep, transitionIndex: 0, // same as taskId to start expanded @@ -192,15 +208,19 @@ describe('TimelineGroup', () => { // First need to expand the group manually since useEffect doesn't trigger in tests the same way const buttons = component.root.findAllByType('button'); if (buttons.length > 0) { - buttons[0].props.onClick(); // Expand the group + act(() => { + buttons[0].props.onClick(); // Expand the group + }); } // Re-render to apply state changes - component.update( - - - - ); + act(() => { + component.update( + + + + ); + }); const timelineNodes = component.root.findAllByProps({ 'data-testid': 'timeline-node' }); diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineHeader.test.tsx b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineHeader.test.tsx index 41ef853b9d5..c181b9d31bb 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineHeader.test.tsx +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/TimelineHeader.test.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import renderer from 'react-test-renderer'; +import renderer, { act } from 'react-test-renderer'; import { IntlProvider } from 'react-intl'; import TimelineHeader from '../TimelineHeader'; @@ -26,11 +26,15 @@ describe('TimelineHeader', () => { }); const renderWithIntl = (props: TimelineHeaderProps) => { - return renderer.create( - - - - ); + let component: renderer.ReactTestRenderer; + act(() => { + component = renderer.create( + + + + ); + }); + return component!; }; it('should render with default props when expanded', () => { @@ -56,7 +60,9 @@ describe('TimelineHeader', () => { const refreshButton = buttons[0]; // Click the refresh button - refreshButton.props.onClick(); + act(() => { + refreshButton.props.onClick(); + }); expect(mockRefetchTimelineRepetitions).toHaveBeenCalledTimes(1); }); @@ -77,29 +83,23 @@ describe('TimelineHeader', () => { isExpanded: false, }); - // Should only find the icon, not any text elements const tree = component.toJSON(); expect(tree).toMatchSnapshot(); }); it('should render timeline icon in both expanded and collapsed states', () => { - // Only count rendered host elements (e.g. the icon's /), not the wrapping - // component instance, which also carries the same className prop and would otherwise - // be double-counted by findAllByProps. - const isHostElement = (instance: { type: unknown }) => typeof instance.type === 'string'; - // Test expanded const expandedComponent = renderWithIntl(defaultProps); - const expandedIcons = expandedComponent.root.findAllByProps({ className: 'timeline-icon' }).filter(isHostElement); - expect(expandedIcons).toHaveLength(1); + const expandedIcons = expandedComponent.root.findAllByProps({ className: 'timeline-icon' }); + expect(expandedIcons.length).toBeGreaterThan(0); // Test collapsed const collapsedComponent = renderWithIntl({ ...defaultProps, isExpanded: false, }); - const collapsedIcons = collapsedComponent.root.findAllByProps({ className: 'timeline-icon' }).filter(isHostElement); - expect(collapsedIcons).toHaveLength(1); + const collapsedIcons = collapsedComponent.root.findAllByProps({ className: 'timeline-icon' }); + expect(collapsedIcons.length).toBeGreaterThan(0); }); it('should have correct minimum width when expanded', () => { diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineButtons.test.tsx.snap b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineButtons.test.tsx.snap index b5e8caa8074..0bcc9f45cfa 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineButtons.test.tsx.snap +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineButtons.test.tsx.snap @@ -13,9 +13,34 @@ exports[`TimelineButtons > should render collapsed (not expanded) 1`] = ` - + + + + + + @@ -48,9 +98,34 @@ exports[`TimelineButtons > should render with default props 1`] = ` - + + + + + + Previous task @@ -63,9 +138,34 @@ exports[`TimelineButtons > should render with default props 1`] = ` - + + + + + + Next task @@ -85,9 +185,34 @@ exports[`TimelineButtons > should render with disabled buttons when fetching rep - + + + + + + Previous task @@ -100,9 +225,34 @@ exports[`TimelineButtons > should render with disabled buttons when fetching rep - + + + + + + Next task @@ -122,9 +272,34 @@ exports[`TimelineButtons > should render with disabled buttons when no repetitio - + + + + + + Previous task @@ -137,9 +312,34 @@ exports[`TimelineButtons > should render with disabled buttons when no repetitio - + + + + + + Next task @@ -159,9 +359,34 @@ exports[`TimelineButtons > should render with disabled next button at last task - + + + + + + Previous task @@ -174,9 +399,34 @@ exports[`TimelineButtons > should render with disabled next button at last task - + + + + + + Next task @@ -196,9 +446,34 @@ exports[`TimelineButtons > should render with disabled previous button at first - + + + + + + Previous task @@ -211,9 +486,34 @@ exports[`TimelineButtons > should render with disabled previous button at first - + + + + + + Next task diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineContent.test.tsx.snap b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineContent.test.tsx.snap index b939127e101..be4da623a7f 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineContent.test.tsx.snap +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineContent.test.tsx.snap @@ -107,10 +107,20 @@ exports[`TimelineContent > should render error carets for failed repetitions 1`]
- + + +
@@ -188,8 +198,11 @@ exports[`TimelineContent > should render no repetitions state 1`] = ` } } > - should render no repetitions state 1`] = ` "width": "30px", } } - /> + viewBox="0 0 20 20" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + + should render no repetitions state when collapsed 1`] } } > - should render no repetitions state when collapsed 1`] "width": "30px", } } - /> + viewBox="0 0 20 20" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + + `; @@ -308,10 +340,20 @@ exports[`TimelineContent > should render with default props 1`] = ` >
- + + +
`; @@ -447,10 +489,20 @@ exports[`TimelineContent > should render with multiple task groups 1`] = `
- + + +
`; diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineGroup.test.tsx.snap b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineGroup.test.tsx.snap index 2dd983efd62..5bef0c7cde1 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineGroup.test.tsx.snap +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineGroup.test.tsx.snap @@ -23,9 +23,34 @@ exports[`TimelineGroup > should render as collapsed group initially 1`] = ` - + + + + + + Task #1 @@ -55,9 +80,34 @@ exports[`TimelineGroup > should render as expanded group when taskId matches tra - + + + + + + Task #1 @@ -149,9 +199,34 @@ exports[`TimelineGroup > should render with default props when timeline is expan - + + + + + + Task #1 @@ -181,9 +256,34 @@ exports[`TimelineGroup > should render with different task number 1`] = ` - + + + + + + Task #6 @@ -213,9 +313,34 @@ exports[`TimelineGroup > should render with empty repetitions 1`] = ` - + + + + + + Task #1 @@ -245,9 +370,34 @@ exports[`TimelineGroup > should render with no selected repetition 1`] = ` - + + + + + + Task #1 @@ -277,9 +427,34 @@ exports[`TimelineGroup > should render with single repetition 1`] = ` - + + + + + + Task #1 diff --git a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineHeader.test.tsx.snap b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineHeader.test.tsx.snap index 25b7099d475..0345f9f4030 100644 --- a/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineHeader.test.tsx.snap +++ b/libs/designer-v2/src/lib/ui/MonitoringTimeline/__tests__/__snapshots__/TimelineHeader.test.tsx.snap @@ -11,10 +11,20 @@ exports[`TimelineHeader > should not render title text when collapsed 1`] = ` } } > - + + +
`; @@ -29,10 +39,20 @@ exports[`TimelineHeader > should render when collapsed 1`] = ` } } > - + + + `; @@ -48,10 +68,20 @@ exports[`TimelineHeader > should render with default props when expanded 1`] = ` } } > - + + + should render with default props when expanded 1`] = ` - + + + + + + diff --git a/libs/designer-v2/src/lib/ui/__test__/Designer.spec.tsx b/libs/designer-v2/src/lib/ui/__test__/Designer.spec.tsx index fd27467fe01..6ef92108d52 100644 --- a/libs/designer-v2/src/lib/ui/__test__/Designer.spec.tsx +++ b/libs/designer-v2/src/lib/ui/__test__/Designer.spec.tsx @@ -46,7 +46,10 @@ vi.mock('react-redux', () => ({ // which we special-case here since it isn't backed by a named/mockable selector hook. useSelector: vi.fn((selector: (state: any) => unknown) => { try { - return selector({ workflow: { workflowKind: mockWorkflowKind } }); + return selector({ + workflow: { workflowKind: mockWorkflowKind }, + modal: { isKnowledgeConnectionOpen: false }, + }); } catch { return undefined; } diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/connection.spec.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/connection.spec.tsx new file mode 100644 index 00000000000..7216fc7dfde --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/connection.spec.tsx @@ -0,0 +1,219 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { CreateConnectionModal } from '../connection'; + +// Mock styles +vi.mock('../styles', () => ({ + useConnectionStyles: () => ({ + content: 'mock-content', + }), +})); + +// Mock Fluent UI components +vi.mock('@fluentui/react-components', () => ({ + Button: ({ children, onClick, 'aria-label': ariaLabel }: any) => ( + + ), + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => (open ?
{children}
: null), + DialogActions: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogBody: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogSurface: ({ children }: { children: React.ReactNode; mountNode?: any }) =>
{children}
, + DialogTitle: ({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) => ( +
+ {children} + {action} +
+ ), + DialogTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + MessageBar: ({ children, intent, style }: { children: React.ReactNode; intent?: string; style?: React.CSSProperties }) => ( +
+ {children} +
+ ), + MessageBarBody: ({ children }: { children: React.ReactNode }) =>
{children}
, + MessageBarTitle: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// Mock useCreateConnectionPanelTabs +let capturedOnError: ((data: { title: string; content: string } | null) => void) | undefined; + +const mockPanelTabs = [ + { + id: 'basics', + title: 'Basics', + content:
Basics Content
, + footerContent: { + primaryButtonText: 'Next', + primaryButtonOnClick: vi.fn(), + primaryButtonDisabled: false, + }, + }, + { + id: 'review', + title: 'Review', + content:
Review Content
, + footerContent: { + primaryButtonText: 'Create', + primaryButtonOnClick: vi.fn(), + primaryButtonDisabled: false, + }, + }, +]; + +vi.mock('../../panel/connection/usepaneltabs', () => ({ + useCreateConnectionPanelTabs: ({ onError }: { onError: (data: { title: string; content: string } | null) => void }) => { + capturedOnError = onError; + return mockPanelTabs; + }, +})); + +// Mock TemplateContent and TemplatesPanelFooter from designer-ui +vi.mock('@microsoft/designer-ui', () => ({ + TemplateContent: ({ tabs, selectedTab, selectTab }: { tabs: any[]; selectedTab: string; selectTab: (id: string) => void }) => ( +
+ {selectedTab} + {tabs.map((tab) => ( + + ))} +
+ ), + TemplatesPanelFooter: ({ primaryButtonText }: { primaryButtonText?: string }) => ( +
+ +
+ ), + KnowledgeTabProps: {}, +})); + +// Mock constants +vi.mock('../../../../common/constants', () => ({ + default: { + KNOWLEDGE_PANEL_TAB_NAMES: { + BASICS: 'basics', + REVIEW: 'review', + }, + }, +})); + +describe('CreateConnectionModal', () => { + const createMockStore = (isKnowledgeConnectionOpen = true) => { + return configureStore({ + reducer: { + modal: () => ({ + isKnowledgeConnectionOpen, + }), + }, + }); + }; + + const renderComponent = (store = createMockStore(), mountNode: HTMLElement | null = null) => { + return render( + + + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('renders the modal with title', () => { + renderComponent(); + + expect(screen.getByText('Create Connection')).toBeInTheDocument(); + }); + + it('renders template content with tabs', () => { + renderComponent(); + + expect(screen.getByTestId('template-content')).toBeInTheDocument(); + expect(screen.getByTestId('tab-basics')).toBeInTheDocument(); + expect(screen.getByTestId('tab-review')).toBeInTheDocument(); + }); + + it('renders panel footer', () => { + renderComponent(); + + expect(screen.getByTestId('panel-footer')).toBeInTheDocument(); + }); + + it('shows basics tab as selected by default', () => { + renderComponent(); + + expect(screen.getByTestId('selected-tab')).toHaveTextContent('basics'); + }); + }); + + describe('Tab Navigation', () => { + it('switches to review tab when clicked', () => { + renderComponent(); + + const reviewTab = screen.getByTestId('tab-review'); + fireEvent.click(reviewTab); + + expect(screen.getByTestId('selected-tab')).toHaveTextContent('review'); + }); + }); + + describe('Close Button', () => { + it('renders close button', () => { + renderComponent(); + + const closeButton = screen.getByLabelText('close'); + expect(closeButton).toBeInTheDocument(); + }); + }); + + describe('Error Message Bar', () => { + it('does not render error message bar when there is no error', () => { + renderComponent(); + + expect(screen.queryByTestId('error-message-bar')).not.toBeInTheDocument(); + }); + + it('renders error message bar when createError is set', async () => { + const { rerender } = renderComponent(); + + // Simulate error by calling the captured onError callback + act(() => { + capturedOnError?.({ title: 'Connection Error', content: 'Failed to create connection' }); + }); + + // Re-render to pick up state change + rerender( + + + + + + ); + + expect(screen.getByTestId('error-message-bar')).toBeInTheDocument(); + expect(screen.getByTestId('error-message-bar')).toHaveAttribute('data-intent', 'error'); + expect(screen.getByTestId('error-message-title')).toHaveTextContent('Connection Error'); + expect(screen.getByTestId('error-message-body')).toHaveTextContent('Failed to create connection'); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/files.spec.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/files.spec.tsx new file mode 100644 index 00000000000..eae4aa3fa0d --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/files.spec.tsx @@ -0,0 +1,309 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { AddFilesModal } from '../files'; + +// Mock LoggerService +vi.mock('@microsoft/logic-apps-shared', async () => { + const actual = await vi.importActual('@microsoft/logic-apps-shared'); + return { + ...actual, + LoggerService: () => ({ + log: vi.fn(), + }), + LogEntryLevel: { + Error: 'Error', + }, + }; +}); + +// Mock FileUpload component +vi.mock('../../panel/files/uploadfile', () => ({ + FileUpload: ({ + resourceId, + selectedHub, + setDetails, + }: { + resourceId: string; + selectedHub: string; + setDetails: (details: any) => void; + }) => ( +
+ {resourceId} + {selectedHub} + + + +
+ ), +})); + +describe('AddFilesModal', () => { + const mockOnUploadArtifact = vi.fn(); + const mockOnDismiss = vi.fn(); + + const defaultProps = { + resourceId: 'test-resource-id', + selectedHub: 'test-hub', + onUploadArtifact: mockOnUploadArtifact, + onDismiss: mockOnDismiss, + }; + + const renderComponent = (props = defaultProps) => { + return render( + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('renders the modal with title', () => { + renderComponent(); + + expect(screen.getByText('Upload Files')).toBeInTheDocument(); + }); + + it('renders FileUpload component', () => { + renderComponent(); + + expect(screen.getByTestId('file-upload')).toBeInTheDocument(); + }); + + it('renders Add and Cancel buttons', () => { + renderComponent(); + + expect(screen.getByText('Add')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('passes resourceId to FileUpload', () => { + renderComponent(); + + expect(screen.getByTestId('file-upload-resource')).toHaveTextContent('test-resource-id'); + }); + + it('passes selectedHub to FileUpload', () => { + renderComponent(); + + expect(screen.getByTestId('file-upload-hub')).toHaveTextContent('test-hub'); + }); + }); + + describe('Button States', () => { + it('disables Add button when no files are selected', () => { + renderComponent(); + + const addButton = screen.getByText('Add'); + expect(addButton).toBeDisabled(); + }); + + it('enables Add button when valid file is selected', () => { + renderComponent(); + + const setFileButton = screen.getByTestId('set-file-details'); + fireEvent.click(setFileButton); + + const addButton = screen.getByText('Add'); + expect(addButton).not.toBeDisabled(); + }); + + it('disables Add button when file size exceeds limit', () => { + renderComponent(); + + const setLargeFileButton = screen.getByTestId('set-large-file'); + fireEvent.click(setLargeFileButton); + + const addButton = screen.getByText('Add'); + expect(addButton).toBeDisabled(); + }); + + it('disables Add button when filename is empty', () => { + renderComponent(); + + const setEmptyFilenameButton = screen.getByTestId('set-empty-filename'); + fireEvent.click(setEmptyFilenameButton); + + const addButton = screen.getByText('Add'); + expect(addButton).toBeDisabled(); + }); + }); + + describe('Upload Functionality', () => { + it('calls onUploadArtifact when Add button is clicked', async () => { + mockOnUploadArtifact.mockResolvedValue(undefined); + renderComponent(); + + // Set valid file details + const setFileButton = screen.getByTestId('set-file-details'); + fireEvent.click(setFileButton); + + // Click Add button + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + await waitFor(() => { + expect(mockOnUploadArtifact).toHaveBeenCalled(); + }); + }); + + it('calls onDismiss after successful upload', async () => { + mockOnUploadArtifact.mockResolvedValue(undefined); + renderComponent(); + + // Set valid file details + const setFileButton = screen.getByTestId('set-file-details'); + fireEvent.click(setFileButton); + + // Click Add button + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + await waitFor(() => { + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + it('shows Adding... text while uploading', async () => { + let resolvePromise: () => void; + const uploadPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockOnUploadArtifact.mockImplementation(async (_1, _2, _3, setIsLoading) => { + setIsLoading(true); + await uploadPromise; + }); + + renderComponent(); + + // Set valid file details + const setFileButton = screen.getByTestId('set-file-details'); + fireEvent.click(setFileButton); + + // Click Add button + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + // The button should show "Adding..." while uploading + await waitFor(() => { + expect(screen.getByText('Adding...')).toBeInTheDocument(); + }); + + // Resolve the upload + resolvePromise!(); + }); + }); + + describe('Cancel Functionality', () => { + it('calls onDismiss when Cancel button is clicked', () => { + renderComponent(); + + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); + + expect(mockOnDismiss).toHaveBeenCalled(); + }); + + it('disables Cancel and prevents dialog dismissal while uploading', async () => { + let resolvePromise: () => void; + const uploadPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockOnUploadArtifact.mockImplementation(async (_1, _2, _3, setIsLoading) => { + setIsLoading(true); + await uploadPromise; + }); + + renderComponent(); + + // Set valid file details + const setFileButton = screen.getByTestId('set-file-details'); + fireEvent.click(setFileButton); + + // Click Add button to start upload + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + await waitFor(() => { + const cancelButton = screen.getByText('Cancel'); + expect(cancelButton).toBeDisabled(); + }); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnDismiss).not.toHaveBeenCalled(); + + // Resolve the upload + resolvePromise!(); + }); + }); + + describe('Without Selected Hub', () => { + it('renders with empty hub when selectedHub is not provided', () => { + renderComponent({ + ...defaultProps, + selectedHub: '', + }); + + expect(screen.getByTestId('file-upload-hub')).toHaveTextContent(''); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/index.spec.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/index.spec.tsx new file mode 100644 index 00000000000..7859707d1cf --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/__test__/index.spec.tsx @@ -0,0 +1,676 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { KnowledgeHubEditor } from '../index'; +import { ArtifactCreationStatus } from '@microsoft/logic-apps-shared'; + +// Mock styles +vi.mock('../styles', () => ({ + useKnowledgeStyles: () => ({ + container: 'mock-container', + header: 'mock-header', + link: 'mock-link', + sectionContent: 'mock-section-content', + sourcesRow: 'mock-sources-row', + uploadButton: 'mock-upload-button', + createButton: 'mock-create-button', + optionRoot: 'mock-option-root', + optionContainer: 'mock-option-container', + artifactsList: 'mock-artifacts-list', + artifactItem: 'mock-artifact-item', + artifactName: 'mock-artifact-name', + statusBadge: 'mock-status-badge', + }), +})); + +// Mock queries +const mockUseAllKnowledgeHubs = vi.fn(); +const mockUseConnection = vi.fn(); +const mockRefetch = vi.fn(); + +vi.mock('../../../../core/knowledge/utils/queries', () => ({ + useAllKnowledgeHubs: (...args: any[]) => mockUseAllKnowledgeHubs(...args), + useConnection: () => mockUseConnection(), +})); + +// Mock designer-ui +vi.mock('@microsoft/designer-ui', () => ({ + createLiteralValueSegment: (value: string) => ({ type: 'literal', value }), + NavigateIcon: () => →, +})); + +// Mock AddFilesModal +vi.mock('../files', () => ({ + AddFilesModal: ({ onDismiss }: { onDismiss: () => void }) => ( +
+ +
+ ), +})); + +vi.mock('../../notification', () => ({ + ToasterNotification: ({ title, content }: { title: string; content: string }) => ( +
+ {title}: {content} +
+ ), +})); + +// Mock openKnowledgeConnectionModal +const mockOpenKnowledgeConnectionModal = vi.fn(() => ({ type: 'modal/openKnowledgeConnectionModal' })); +vi.mock('../../../../core/state/modal/modalSlice', () => ({ + openKnowledgeConnectionModal: () => mockOpenKnowledgeConnectionModal(), +})); + +// Mock isLiteralValueSegment +vi.mock('../../../../core/utils/parameters/segment', () => ({ + isLiteralValueSegment: (segment: any) => segment?.type === 'literal', +})); + +// Mock WorkflowService +vi.mock('@microsoft/logic-apps-shared', async () => { + const actual = await vi.importActual('@microsoft/logic-apps-shared'); + return { + ...actual, + WorkflowService: () => ({ + uploadFileArtifact: vi.fn(), + }), + }; +}); + +describe('KnowledgeHubEditor', () => { + const createMockStore = () => { + return configureStore({ + reducer: { + modal: () => ({}), + knowledgeHubOptions: () => ({ notification: undefined }), + }, + }); + }; + + const defaultProps = { + editorOptions: { logicAppId: 'test-logic-app-id' }, + onValueChange: vi.fn(), + value: [], + renderDefaultEditor: () =>
Default Editor
, + }; + + const renderComponent = (props = defaultProps, store = createMockStore()) => { + return render( + + + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockRefetch.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('renders a notification from the main designer store', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ data: [], isLoading: false, refetch: mockRefetch }); + mockUseConnection.mockReturnValue({ data: { name: 'test-connection' }, isLoading: false }); + const store = configureStore({ + reducer: { + modal: () => ({}), + knowledgeHubOptions: () => ({ + notification: { title: 'Successfully created the group.', content: 'Group NewGroup has been created and selected.' }, + }), + }, + }); + + renderComponent(defaultProps, store); + + expect(screen.getByTestId('knowledge-notification')).toHaveTextContent( + 'Successfully created the group.: Group NewGroup has been created and selected.' + ); + }); + + it('renders the title and description', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Knowledge base')).toBeInTheDocument(); + expect( + screen.getByText('Create a connection and add knowledge hub sources your agent will use to generate responses.') + ).toBeInTheDocument(); + }); + + it('renders Learn more link', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Learn more')).toBeInTheDocument(); + expect(screen.getByTestId('navigate-icon')).toBeInTheDocument(); + }); + + it('renders connection section label', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Connection')).toBeInTheDocument(); + }); + + it('renders sources section label', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Sources')).toBeInTheDocument(); + }); + }); + + describe('Connection Section', () => { + it('shows Create button when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('shows connection name input when connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'my-connection' }, + isLoading: false, + }); + + renderComponent(); + + const input = screen.getByRole('textbox', { name: 'Connection' }); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('my-connection'); + expect(input).toBeDisabled(); + }); + + it('dispatches openKnowledgeConnectionModal when Create button is clicked', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + const createButton = screen.getByText('Create'); + fireEvent.click(createButton); + + expect(mockOpenKnowledgeConnectionModal).toHaveBeenCalled(); + }); + }); + + describe('Sources Section', () => { + it('renders dropdown for selecting knowledge hub', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByRole('combobox', { name: 'Sources' })).toBeInTheDocument(); + }); + + it('disables dropdown when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + expect(dropdown).toBeDisabled(); + }); + + it('disables dropdown when loading', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: true, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + expect(dropdown).toBeDisabled(); + }); + + it('shows placeholder text when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Create a connection to add knowledge hubs.')).toBeInTheDocument(); + }); + + it('shows placeholder text when connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText('Select a knowledge hub')).toBeInTheDocument(); + }); + + it('renders Upload button', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByLabelText('Upload')).toBeInTheDocument(); + }); + + it('disables Upload button when no connection exists', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: null, + isLoading: false, + }); + + renderComponent(); + + const uploadButton = screen.getByLabelText('Upload'); + expect(uploadButton).toBeDisabled(); + }); + }); + + describe('File Upload Modal', () => { + it('opens file upload modal when Upload button is clicked', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + const uploadButton = screen.getByLabelText('Upload'); + fireEvent.click(uploadButton); + + expect(screen.getByTestId('add-files-modal')).toBeInTheDocument(); + }); + + it('closes file upload modal when dismiss is called', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + // Open modal + const uploadButton = screen.getByLabelText('Upload'); + fireEvent.click(uploadButton); + + // Close modal + const closeButton = screen.getByTestId('close-files-modal'); + fireEvent.click(closeButton); + + expect(screen.queryByTestId('add-files-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Hub Selection', () => { + it('calls onValueChange when hub is selected', async () => { + const mockOnValueChange = vi.fn(); + const hubs = [ + { + id: 'hub-1', + name: 'Test Hub 1', + artifacts: [], + }, + ]; + + mockUseAllKnowledgeHubs.mockReturnValue({ + data: hubs, + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent({ + ...defaultProps, + onValueChange: mockOnValueChange, + }); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + // Select an option + await waitFor(() => { + const option = screen.getByText('Test Hub 1'); + fireEvent.click(option); + }); + + expect(mockOnValueChange).toHaveBeenCalled(); + }); + }); + + describe('Empty Hubs Message', () => { + it('shows empty message when no hubs exist', async () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent(); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + await waitFor(() => { + expect( + screen.getByText(`Can't find knowledge base artifacts. Create a knowledge base and upload files to get started.`) + ).toBeInTheDocument(); + }); + }); + }); + + describe('With Initial Hub Value', () => { + it('displays selected hub when value is provided', () => { + const hubs = [ + { + id: 'hub-1', + name: 'Test Hub 1', + artifacts: [], + }, + ]; + + mockUseAllKnowledgeHubs.mockReturnValue({ + data: hubs, + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + renderComponent({ + ...defaultProps, + value: [{ type: 'literal', value: 'Test Hub 1' }] as any, + }); + + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + expect(dropdown).toHaveTextContent('Test Hub 1'); + }); + }); +}); + +describe('HubOption Component', () => { + const createMockStore = () => { + return configureStore({ + reducer: { + modal: () => ({}), + knowledgeHubOptions: () => ({ notification: undefined }), + }, + }); + }; + + const renderKnowledgeHubEditor = (hubs: any[]) => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: hubs, + isLoading: false, + refetch: mockRefetch, + }); + mockUseConnection.mockReturnValue({ + data: { name: 'test-connection' }, + isLoading: false, + }); + + return render( + + +
Default Editor
} + /> +
+
+ ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockRefetch.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders hub name in dropdown options', async () => { + const hubs = [ + { + id: 'hub-1', + name: 'My Knowledge Hub', + artifacts: [{ id: 'artifact-1', name: 'Document 1', uploadStatus: ArtifactCreationStatus.Completed }], + }, + ]; + + renderKnowledgeHubEditor(hubs); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + await waitFor(() => { + expect(screen.getByText('My Knowledge Hub')).toBeInTheDocument(); + }); + }); + + it('shows expand/collapse button for hub with artifacts', async () => { + const hubs = [ + { + id: 'hub-1', + name: 'My Knowledge Hub', + artifacts: [{ id: 'artifact-1', name: 'Document 1', uploadStatus: ArtifactCreationStatus.Completed }], + }, + ]; + + renderKnowledgeHubEditor(hubs); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + await waitFor(() => { + const expandButton = screen.getByLabelText('Expand'); + expect(expandButton).toBeInTheDocument(); + }); + }); + + it('expands to show artifacts when expand button is clicked', async () => { + const hubs = [ + { + id: 'hub-1', + name: 'My Knowledge Hub', + artifacts: [{ id: 'artifact-1', name: 'Document 1', uploadStatus: ArtifactCreationStatus.Completed }], + }, + ]; + + renderKnowledgeHubEditor(hubs); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + await waitFor(() => { + const expandButton = screen.getByLabelText('Expand'); + fireEvent.click(expandButton); + }); + + await waitFor(() => { + expect(screen.getByText('Document 1')).toBeInTheDocument(); + }); + }); + + it('collapses artifacts when collapse button is clicked', async () => { + const hubs = [ + { + id: 'hub-1', + name: 'My Knowledge Hub', + artifacts: [{ id: 'artifact-1', name: 'Document 1', uploadStatus: ArtifactCreationStatus.Completed }], + }, + ]; + + renderKnowledgeHubEditor(hubs); + + // Click dropdown to open + const dropdown = screen.getByRole('combobox', { name: 'Sources' }); + fireEvent.click(dropdown); + + // Expand + await waitFor(() => { + const expandButton = screen.getByLabelText('Expand'); + fireEvent.click(expandButton); + }); + + // Verify expanded + await waitFor(() => { + expect(screen.getByText('Document 1')).toBeInTheDocument(); + }); + + // Collapse + const collapseButton = screen.getByLabelText('Collapse'); + fireEvent.click(collapseButton); + + // Verify collapsed (Document 1 should not be visible) + await waitFor(() => { + expect(screen.queryByText('Document 1')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/connection.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/connection.tsx new file mode 100644 index 00000000000..4aaab688750 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/connection.tsx @@ -0,0 +1,102 @@ +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + MessageBar, + MessageBarBody, + MessageBarTitle, +} from '@fluentui/react-components'; +import { Dismiss24Regular } from '@fluentui/react-icons'; +import { useCallback, useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useCreateConnectionPanelTabs } from '../panel/connection/usepaneltabs'; +import { type KnowledgeTabProps, TemplateContent, TemplatesPanelFooter } from '@microsoft/designer-ui'; +import Constants from '../../../common/constants'; +import { useConnectionStyles } from './styles'; +import type { AppDispatch, RootState } from '../../../core/store'; +import { useDispatch, useSelector } from 'react-redux'; +import { closeKnowledgeConnectionModal } from '../../../core/state/modal/modalSlice'; +import type { ServerNotificationData } from '../../../core/state/knowledge/optionsSlice'; + +export const CreateConnectionModal = ({ mountNode }: { mountNode: HTMLElement | null }) => { + const styles = useConnectionStyles(); + const intl = useIntl(); + const INTL_TEXT = useMemo( + () => ({ + title: intl.formatMessage({ + defaultMessage: 'Create Connection', + id: 'ub55NF', + description: 'Title for the create connection modal', + }), + }), + [intl] + ); + + const { isKnowledgeConnectionOpen } = useSelector((state: RootState) => ({ + isKnowledgeConnectionOpen: state.modal.isKnowledgeConnectionOpen, + })); + const dispatch = useDispatch(); + + const onDismiss = useCallback(() => { + if (isKnowledgeConnectionOpen) { + dispatch(closeKnowledgeConnectionModal()); + } + }, [dispatch, isKnowledgeConnectionOpen]); + + const [createError, setCreateError] = useState(null); + const [selectedTabId, setSelectedTabId] = useState(Constants.KNOWLEDGE_PANEL_TAB_NAMES.BASICS); + const panelTabs: KnowledgeTabProps[] = useCreateConnectionPanelTabs({ + selectTab: setSelectedTabId, + close: onDismiss, + onError: setCreateError, + }); + + const selectedTabProps = useMemo( + () => (selectedTabId ? panelTabs?.find((tab) => tab.id === selectedTabId) : panelTabs[0]), + [selectedTabId, panelTabs] + ); + + return ( + + + + + + ); +}; diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/files.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/files.tsx new file mode 100644 index 00000000000..952572b75f1 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/files.tsx @@ -0,0 +1,115 @@ +import { + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + MessageBar, + MessageBarBody, + MessageBarTitle, +} from '@fluentui/react-components'; +import type { UploadFileHandler } from '@microsoft/logic-apps-shared'; +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { FileUpload } from '../panel/files/uploadfile'; +import { useFileHooks } from '../panel/files/useFileHooks'; +import { TemplatesPanelFooter } from '@microsoft/designer-ui'; + +export const AddFilesModal = ({ + resourceId, + selectedHub, + onUploadArtifact, + onDismiss, +}: { + resourceId: string; + selectedHub?: string; + onUploadArtifact: UploadFileHandler; + onDismiss: () => void; +}) => { + const intl = useIntl(); + const INTL_TEXT = useMemo( + () => ({ + title: intl.formatMessage({ + defaultMessage: 'Upload Files', + id: 'aGKyI3', + description: 'Title for the upload files modal', + }), + largeFileError: intl.formatMessage({ + id: 'zuszqq', + defaultMessage: 'File size must be less than 16 MB.', + description: 'Error message when uploaded file exceeds size limit in add files panel', + }), + addButton: intl.formatMessage({ + id: '9EmZWH', + defaultMessage: 'Add', + description: 'Button text for adding files to knowledge base in add files panel', + }), + addingButton: intl.formatMessage({ + id: 'Lzm9eC', + defaultMessage: 'Adding...', + description: 'Button text for adding files to knowledge base in add files panel when upload is in progress', + }), + cancelButton: intl.formatMessage({ + id: 'K4G+Zo', + defaultMessage: 'Cancel', + description: 'Button text for canceling adding files to knowledge base in add files panel', + }), + closeAriaLabel: intl.formatMessage({ + id: 'kdCuJZ', + defaultMessage: 'Close panel', + description: 'Aria label for close button', + }), + parameterEmptyErrorMessage: intl.formatMessage({ + id: 'nX3iRl', + defaultMessage: 'User input must not be empty.', + description: 'Error message for parameter is empty', + }), + errorTitle: intl.formatMessage({ + id: 'K550WF', + defaultMessage: 'File upload failed', + description: 'Title for error message when file upload fails in add files panel', + }), + }), + [intl] + ); + + const { footerContent, handleSetFileDetails, groupName, isUploading, uploadError } = useFileHooks( + resourceId, + selectedHub, + onDismiss, + onUploadArtifact + ); + return ( + { + if (!data.open && !isUploading) { + onDismiss(); + } + }} + > + + + {INTL_TEXT.title} + + {uploadError ? ( +
+ + + {INTL_TEXT.errorTitle} + {uploadError} + + +
+ ) : null} + +
+ + + +
+
+
+ ); +}; diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/index.tsx b/libs/designer-v2/src/lib/ui/knowledge/editor/index.tsx new file mode 100644 index 00000000000..9c5a3d8e2f7 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/index.tsx @@ -0,0 +1,341 @@ +import { + ArtifactCreationStatus, + WorkflowService, + type IEditorProps, + type KnowledgeHubExtended, + type UploadFile, +} from '@microsoft/logic-apps-shared'; +import { useKnowledgeStyles } from './styles'; +import { Button, Dropdown, Input, Option, Text, Field, Label, Link, Badge } from '@fluentui/react-components'; +import type { DropdownProps } from '@fluentui/react-components'; +import { + Add20Regular, + ChevronRight16Regular, + ChevronDown16Regular, + ArrowSyncCircle16Regular, + CheckmarkCircle16Regular, + SubtractCircle16Regular, +} from '@fluentui/react-icons'; +import { useState, useCallback, useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { useAllKnowledgeHubs, useConnection } from '../../../core/knowledge/utils/queries'; +import { createLiteralValueSegment, NavigateIcon } from '@microsoft/designer-ui'; +import { AddFilesModal } from './files'; +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from '../../../core/store'; +import { openKnowledgeConnectionModal } from '../../../core/state/modal/modalSlice'; +import { isLiteralValueSegment } from '../../../core/utils/parameters/segment'; +import { clearNotification } from '../../../core/state/knowledge/optionsSlice'; +import { ToasterNotification } from '../notification'; + +interface KnowledgeHubEditorOptions { + logicAppId: string; +} + +export const KnowledgeHubEditor = ({ editorOptions, onValueChange, value }: IEditorProps) => { + const styles = useKnowledgeStyles(); + const intl = useIntl(); + const dispatch = useDispatch(); + const notification = useSelector((state: RootState) => state.knowledgeHubOptions.notification); + const { logicAppId } = editorOptions as KnowledgeHubEditorOptions; + const hubName = useMemo(() => (value.length === 1 && isLiteralValueSegment(value[0]) ? value[0].value : undefined), [value]); + const { data: connection, isLoading: isConnectionLoading } = useConnection(); + const { data: hubs, isLoading, refetch } = useAllKnowledgeHubs(logicAppId); + + const [isFileUploadModalOpen, setIsFileUploadModalOpen] = useState(false); + + const INTL_TEXT = useMemo( + () => ({ + title: intl.formatMessage({ + defaultMessage: 'Knowledge base', + id: '8/Vjz3', + description: 'Title for knowledge hub editor', + }), + description: intl.formatMessage({ + defaultMessage: 'Create a connection and add knowledge hub sources your agent will use to generate responses.', + id: 'uMVVuc', + description: 'Description for knowledge hub editor', + }), + connectionSectionLabel: intl.formatMessage({ + defaultMessage: 'Connection', + id: 'Ij0UEU', + description: 'Label for connection section', + }), + createConnectionButtonText: intl.formatMessage({ + defaultMessage: 'Create', + id: 'IxkaoV', + description: 'Text for create connection button', + }), + sourcesSectionLabel: intl.formatMessage({ + defaultMessage: 'Sources', + id: 'w63mKE', + description: 'Label for sources section', + }), + selectKnowledgeHubPlaceholder: intl.formatMessage({ + defaultMessage: 'Select a knowledge hub', + id: '5Vbd0e', + description: 'Placeholder text for knowledge hub dropdown', + }), + uploadFilesButtonText: intl.formatMessage({ + defaultMessage: 'Upload', + id: 'HMSDoJ', + description: 'Text for upload files button', + }), + connectionModalTitle: intl.formatMessage({ + defaultMessage: 'Create Connection', + id: 'so2OVS', + description: 'Title for create connection modal', + }), + uploadModalTitle: intl.formatMessage({ + defaultMessage: 'Upload Files', + id: '874l4V', + description: 'Title for upload files modal', + }), + learnMore: intl.formatMessage({ + defaultMessage: 'Learn more', + id: '1ZDLZA', + description: 'Text for learn more link', + }), + emptyArtifacts: intl.formatMessage({ + defaultMessage: `Can't find knowledge base artifacts. Create a knowledge base and upload files to get started.`, + id: 'kIxrfq', + description: 'Text to indicate that there are no artifacts in the knowledge hub', + }), + noConnectionMessage: intl.formatMessage({ + defaultMessage: 'Create a connection to add knowledge hubs.', + id: 'wwXFYB', + description: 'Text to indicate that there is no connection', + }), + }), + [intl] + ); + + const handleOpenConnectionModal = useCallback(() => { + dispatch(openKnowledgeConnectionModal()); + }, [dispatch]); + + const handleOpenFileUploadModal = useCallback(() => { + setIsFileUploadModalOpen(true); + }, []); + + const handleCloseFileUploadModal = useCallback(() => { + setIsFileUploadModalOpen(false); + }, []); + + const handleClearNotification = useCallback(() => dispatch(clearNotification()), [dispatch]); + + const handleUploadArtifact = useCallback( + async ( + resourceId: string, + hubName: string, + content: { file: UploadFile; name: string; description?: string }, + setIsLoading: (isLoading: boolean) => void + ) => { + const uploadFileArtifact = WorkflowService().uploadFileArtifact; + if (!uploadFileArtifact) { + throw new Error('File upload is not supported by the current host.'); + } + + await uploadFileArtifact(resourceId, hubName, content, setIsLoading); + await refetch(); + }, + [refetch] + ); + + const [selectedHub, setSelectedHub] = useState(hubName ?? ''); + const handleHubSelect = useCallback>( + (_event, data) => { + if (data.optionValue) { + const hubName = data.optionValue; + setSelectedHub(hubName ?? ''); + onValueChange?.({ value: [createLiteralValueSegment(hubName ?? '')] }); + } + }, + [onValueChange] + ); + + return ( +
+ {notification ? ( + + ) : null} +
+ + {INTL_TEXT.title} + +
+ {INTL_TEXT.description} + + {INTL_TEXT.learnMore} + + +
+
+ {/* Connection Section */} + + + {connection ? ( + + ) : ( + + )} + + + {/* Sources Section */} +
+ + + + {hubs?.length === 0 ? ( + + ) : ( + hubs?.map((hub) => ( + + )) + )} + + + +
+ + {isFileUploadModalOpen && ( + + )} +
+ ); +}; + +const HubOption = ({ hub }: { hub: KnowledgeHubExtended }) => { + const styles = useKnowledgeStyles(); + const [isExpanded, setIsExpanded] = useState(false); + + const handleExpandClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setIsExpanded((prev) => !prev); + }, []); + + return ( +
+
+
+ {isExpanded && ( +
+ {(hub.artifacts ?? []).map((artifact) => ( +
+ {artifact.name} + +
+ ))} +
+ )} +
+ ); +}; + +const BadgeArtifact = ({ status }: { status: ArtifactCreationStatus }) => { + let icon: React.ReactNode | null = null; + let text: string; + let color: 'brand' | 'danger' | 'success'; + const styles = useKnowledgeStyles(); + const style: React.CSSProperties = { width: '20%' }; + + const intl = useIntl(); + const INTL_TEXT = useMemo( + () => ({ + inProgressStatus: intl.formatMessage({ + defaultMessage: 'In progress', + id: 'gyfZhJ', + description: 'Text to indicate that the artifact upload is in progress', + }), + completedStatus: intl.formatMessage({ + defaultMessage: 'Complete', + id: '9euy52', + description: 'Text to indicate that the artifact upload is completed', + }), + failedStatus: intl.formatMessage({ + defaultMessage: 'Error', + id: 'fs92Nu', + description: 'Text to indicate that the artifact upload has failed', + }), + }), + [intl] + ); + + switch (status) { + case ArtifactCreationStatus.InProgress: { + icon = ; + text = INTL_TEXT.inProgressStatus; + color = 'brand'; + break; + } + case ArtifactCreationStatus.Completed: { + icon = ; + text = INTL_TEXT.completedStatus; + color = 'success'; + break; + } + case ArtifactCreationStatus.Failed: { + icon = ; + text = INTL_TEXT.failedStatus; + color = 'danger'; + style.width = '14%'; + break; + } + default: { + text = status; + color = 'brand'; + } + } + + return ( + + {text} + + ); +}; diff --git a/libs/designer-v2/src/lib/ui/knowledge/editor/styles.ts b/libs/designer-v2/src/lib/ui/knowledge/editor/styles.ts new file mode 100644 index 00000000000..b20dd39e216 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/editor/styles.ts @@ -0,0 +1,86 @@ +import { makeStyles, tokens } from '@fluentui/react-components'; + +export const useKnowledgeStyles = makeStyles({ + container: { + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + paddingTop: tokens.spacingVerticalM, + }, + header: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + }, + sectionLabel: { + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + link: { + paddingLeft: tokens.spacingHorizontalXS, + fontSize: tokens.fontSizeBase200, + fontStyle: 'italic', + }, + sectionContent: { + display: 'flex', + flexDirection: 'column', + width: '100%', + gap: tokens.spacingVerticalXS, + }, + createButton: { + minWidth: '80px', + width: '15%', + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + }, + sourcesRow: { + display: 'flex', + flexDirection: 'row', + gap: tokens.spacingHorizontalS, + alignItems: 'center', + }, + uploadButton: { + marginTop: tokens.spacingVerticalXL, + }, + optionRoot: { + width: '100%', + }, + optionContainer: { + display: 'flex', + alignItems: 'center', + }, + artifactsList: { + padding: '6px 0 0 24px', + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginTop: '4px', + width: '90%', + }, + artifactItem: { + display: 'flex', + paddingRight: '20px', + }, + artifactName: { + width: '80%', + marginTop: '2px', + }, + statusBadge: { + height: '100%', + padding: '2px', + }, +}); + +export const useConnectionStyles = makeStyles({ + root: { + height: '100vh', + }, + + container: { + padding: '10px', + }, + + content: { + height: '79vh', + }, +}); diff --git a/libs/designer-v2/src/lib/ui/knowledge/modals/__test__/creategroup.spec.tsx b/libs/designer-v2/src/lib/ui/knowledge/modals/__test__/creategroup.spec.tsx new file mode 100644 index 00000000000..2a646ec6fda --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/modals/__test__/creategroup.spec.tsx @@ -0,0 +1,430 @@ +/** + * @vitest-environment jsdom + */ +import { describe, vi, expect, it, beforeEach, afterEach } from 'vitest'; +// biome-ignore lint/correctness/noUnusedImports: using react for render +import React from 'react'; +import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IntlProvider } from 'react-intl'; +import { CreateGroup } from '../creategroup'; + +const mockCreateKnowledgeHub = vi.fn(); +const mockValidateHubNameAvailability = vi.fn(); +const mockPersistKnowledgeHubConnection = vi.fn(); +const mockDelay = vi.fn(); + +vi.mock('@microsoft/logic-apps-shared', () => ({ + ConnectionService: () => ({ persistKnowledgeHubConnection: mockPersistKnowledgeHubConnection }), + delay: (...args: any[]) => mockDelay(...args), +})); + +vi.mock('../../../../core/knowledge/utils/helper', () => ({ + createKnowledgeHub: (...args: any[]) => mockCreateKnowledgeHub(...args), + validateHubNameAvailability: (...args: any[]) => mockValidateHubNameAvailability(...args), +})); + +const mockUseAllKnowledgeHubs = vi.fn(); + +vi.mock('../../../../core/knowledge/utils/queries', () => ({ + useAllKnowledgeHubs: (...args: any[]) => mockUseAllKnowledgeHubs(...args), +})); + +// Mock styles +vi.mock('../styles', () => ({ + useModalStyles: () => ({ + groupContainer: 'mock-group-container', + groupSection: 'mock-group-section', + actions: 'mock-actions', + }), +})); + +describe('CreateGroup Component', () => { + const defaultProps = { + resourceId: '/subscriptions/sub1/resourceGroups/rg/providers/Microsoft.Web/sites/myApp', + onDismiss: vi.fn(), + onCreate: vi.fn(), + }; + + const renderComponent = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return render( + + + + ); + }; + + // Helper to get elements within the non-hidden dialog + const getDialog = () => { + const dialogs = screen.getAllByRole('alertdialog'); + return dialogs.find((d) => !d.closest('[aria-hidden="true"]'))!; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPersistKnowledgeHubConnection.mockResolvedValue(undefined); + mockDelay.mockResolvedValue(undefined); + mockCreateKnowledgeHub.mockResolvedValue({}); + mockValidateHubNameAvailability.mockReturnValue(undefined); + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + }); + }); + + afterEach(() => { + cleanup(); + }); + + describe('Rendering', () => { + it('renders with correct title and subtitle', () => { + renderComponent(); + + expect(screen.getByText('Create a new group')).toBeInTheDocument(); + expect(screen.getByText('Provide details to create a new group.')).toBeInTheDocument(); + }); + + it('renders name and description input fields', () => { + renderComponent(); + + const dialog = getDialog(); + expect(within(dialog).getByText('Name')).toBeInTheDocument(); + expect(within(dialog).getByText('Description')).toBeInTheDocument(); + }); + + it('renders as an open dialog', () => { + renderComponent(); + + const dialog = getDialog(); + expect(dialog).toBeInTheDocument(); + }); + + it('renders Create and Cancel buttons', () => { + renderComponent(); + + const dialog = getDialog(); + expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders close button in dialog title', () => { + renderComponent(); + + const dialog = getDialog(); + const closeButton = within(dialog).getByRole('button', { name: 'close' }); + expect(closeButton).toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading spinner when hubs are loading', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + renderComponent(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('hides input fields when loading', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + renderComponent(); + + const dialog = getDialog(); + expect(within(dialog).queryByRole('textbox', { name: /name/i })).not.toBeInTheDocument(); + }); + + it('shows input fields when loading completes', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + }); + + renderComponent(); + + const dialog = getDialog(); + expect(within(dialog).getByRole('textbox', { name: /name/i })).toBeInTheDocument(); + expect(within(dialog).getByRole('textbox', { name: /description/i })).toBeInTheDocument(); + }); + }); + + describe('Create Button State', () => { + it('disables create button when name is empty', () => { + renderComponent(); + + const dialog = getDialog(); + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + }); + + it('enables create button when name is provided', () => { + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'MyGroup' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + expect(createButton).not.toBeDisabled(); + }); + + it('disables create button when name has validation error', () => { + mockValidateHubNameAvailability.mockReturnValue('Name already exists'); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'ExistingGroup' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + }); + }); + + describe('Name Validation', () => { + it('calls validateHubNameAvailability when name changes', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'ExistingHub' }], + isLoading: false, + }); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'NewGroup' } }); + + expect(mockValidateHubNameAvailability).toHaveBeenCalledWith('NewGroup', ['existinghub']); + }); + + it('displays validation error message', () => { + mockValidateHubNameAvailability.mockReturnValue('Name already exists'); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'ExistingGroup' } }); + + expect(screen.getByText('Name already exists')).toBeInTheDocument(); + }); + + it('clears validation error when name becomes valid', () => { + mockValidateHubNameAvailability.mockReturnValueOnce('Name already exists').mockReturnValueOnce(undefined); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + + fireEvent.change(nameInput, { target: { value: 'ExistingGroup' } }); + expect(screen.getByText('Name already exists')).toBeInTheDocument(); + + fireEvent.change(nameInput, { target: { value: 'ValidGroup' } }); + expect(screen.queryByText('Name already exists')).not.toBeInTheDocument(); + }); + }); + + describe('Create Functionality', () => { + it('calls createKnowledgeHub with correct parameters when create is clicked', async () => { + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + const descriptionInput = within(dialog).getByRole('textbox', { name: /description/i }); + + fireEvent.change(nameInput, { target: { value: 'TestGroup' } }); + fireEvent.change(descriptionInput, { target: { value: 'Test description' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockPersistKnowledgeHubConnection).toHaveBeenCalledTimes(1); + expect(mockCreateKnowledgeHub).toHaveBeenCalledWith(defaultProps.resourceId, 'TestGroup', 'Test description'); + expect(mockDelay).not.toHaveBeenCalled(); + expect(mockPersistKnowledgeHubConnection.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateKnowledgeHub.mock.invocationCallOrder[0] + ); + }); + }); + + it('retries createKnowledgeHub twice with a 15 second delay', async () => { + const mockOnCreate = vi.fn(); + mockCreateKnowledgeHub.mockRejectedValueOnce(new Error('Service restarting')).mockRejectedValueOnce(new Error('Service restarting')); + renderComponent({ onCreate: mockOnCreate }); + + const dialog = getDialog(); + fireEvent.change(within(dialog).getByRole('textbox', { name: /name/i }), { target: { value: 'RetryGroup' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mockCreateKnowledgeHub).toHaveBeenCalledTimes(3); + expect(mockDelay).toHaveBeenCalledTimes(2); + expect(mockDelay).toHaveBeenNthCalledWith(1, 15_000); + expect(mockDelay).toHaveBeenNthCalledWith(2, 15_000); + expect(mockPersistKnowledgeHubConnection).toHaveBeenCalledTimes(1); + expect(mockOnCreate).toHaveBeenCalledWith('RetryGroup', ''); + }); + }); + + it('calls onCreate callback with group name and description after successful creation', async () => { + const mockOnCreate = vi.fn(); + renderComponent({ onCreate: mockOnCreate }); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + const descriptionInput = within(dialog).getByRole('textbox', { name: /description/i }); + + fireEvent.change(nameInput, { target: { value: 'NewGroup' } }); + fireEvent.change(descriptionInput, { target: { value: 'New description' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreate).toHaveBeenCalledWith('NewGroup', 'New description'); + }); + }); + + it('shows "Creating..." text while creation is in progress', async () => { + mockCreateKnowledgeHub.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'TestGroup' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(screen.getByText('Creating...')).toBeInTheDocument(); + }); + }); + + it('disables cancel button while creation is in progress', async () => { + mockCreateKnowledgeHub.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'TestGroup' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + fireEvent.click(createButton); + + await waitFor(() => { + const cancelButton = within(dialog).getByRole('button', { name: 'Cancel' }); + expect(cancelButton).toBeDisabled(); + }); + }); + + it('creates group with empty description when description is not provided', async () => { + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'GroupWithoutDesc' } }); + + const createButton = within(dialog).getByRole('button', { name: 'Create' }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateKnowledgeHub).toHaveBeenCalledWith(defaultProps.resourceId, 'GroupWithoutDesc', ''); + }); + }); + }); + + describe('Input Changes', () => { + it('updates name state when input changes', () => { + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'UpdatedName' } }); + + expect(nameInput.value).toBe('UpdatedName'); + }); + + it('updates description state when textarea changes', () => { + renderComponent(); + + const dialog = getDialog(); + const descriptionInput = within(dialog).getByRole('textbox', { name: /description/i }) as HTMLTextAreaElement; + fireEvent.change(descriptionInput, { target: { value: 'Updated description' } }); + + expect(descriptionInput.value).toBe('Updated description'); + }); + }); + + describe('Cancel and Dismiss', () => { + it('calls onDismiss when cancel button is clicked', () => { + const mockOnDismiss = vi.fn(); + renderComponent({ onDismiss: mockOnDismiss }); + + const dialog = getDialog(); + const cancelButton = within(dialog).getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + + describe('Existing Hubs Integration', () => { + it('passes existing hub names to validation', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [{ name: 'Hub1' }, { name: 'Hub2' }, { name: 'TestHub' }], + isLoading: false, + }); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'NewHub' } }); + + expect(mockValidateHubNameAvailability).toHaveBeenCalledWith('NewHub', ['hub1', 'hub2', 'testhub']); + }); + + it('handles empty hubs array', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: [], + isLoading: false, + }); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'FirstHub' } }); + + expect(mockValidateHubNameAvailability).toHaveBeenCalledWith('FirstHub', []); + }); + + it('handles undefined hubs data', () => { + mockUseAllKnowledgeHubs.mockReturnValue({ + data: undefined, + isLoading: false, + }); + + renderComponent(); + + const dialog = getDialog(); + const nameInput = within(dialog).getByRole('textbox', { name: /name/i }); + fireEvent.change(nameInput, { target: { value: 'NewHub' } }); + + expect(mockValidateHubNameAvailability).toHaveBeenCalledWith('NewHub', []); + }); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/knowledge/modals/creategroup.tsx b/libs/designer-v2/src/lib/ui/knowledge/modals/creategroup.tsx new file mode 100644 index 00000000000..bcdb3c365c9 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/knowledge/modals/creategroup.tsx @@ -0,0 +1,216 @@ +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Text, + Field, + Input, + Textarea, + Spinner, + MessageBar, + MessageBarBody, + MessageBarTitle, +} from '@fluentui/react-components'; +import { useIntl } from 'react-intl'; +import { Dismiss24Regular } from '@fluentui/react-icons'; +import { useCallback, useState, useMemo } from 'react'; +import { createKnowledgeHub, validateHubNameAvailability } from '../../../core/knowledge/utils/helper'; +import { useAllKnowledgeHubs } from '../../../core/knowledge/utils/queries'; +import { useModalStyles } from './styles'; +import type { ServerNotificationData } from '../../../core/state/knowledge/optionsSlice'; +import { ConnectionService, delay } from '@microsoft/logic-apps-shared'; + +const CREATE_KNOWLEDGE_HUB_RETRY_COUNT = 2; +const CREATE_KNOWLEDGE_HUB_RETRY_DELAY_MS = 15000; + +const createKnowledgeHubWithRetry = async (resourceId: string, name: string, description: string): Promise => { + for (let attempt = 0; attempt <= CREATE_KNOWLEDGE_HUB_RETRY_COUNT; attempt++) { + try { + await createKnowledgeHub(resourceId, name, description); + return; + } catch (error) { + if (attempt === CREATE_KNOWLEDGE_HUB_RETRY_COUNT) { + throw error; + } + await delay(CREATE_KNOWLEDGE_HUB_RETRY_DELAY_MS); + } + } +}; + +export const CreateGroup = ({ + resourceId, + onDismiss, + onCreate, +}: { resourceId: string; onDismiss: () => void; onCreate?: (groupName: string, groupDescription: string) => void }) => { + const styles = useModalStyles(); + const intl = useIntl(); + const INTL_TEXT = useMemo( + () => ({ + title: intl.formatMessage({ + defaultMessage: 'Create a new group', + id: '4eYp8/', + description: 'Title for the create group modal', + }), + subtitle: intl.formatMessage({ + defaultMessage: 'Provide details to create a new group.', + id: 'fFhnXC', + description: 'Subtitle for the create group modal', + }), + loadingText: intl.formatMessage({ + defaultMessage: 'Loading...', + id: 'Z4zCo6', + description: 'Text displayed while loading existing groups in the create group modal', + }), + nameLabel: intl.formatMessage({ + defaultMessage: 'Name', + id: 'SOqf2M', + description: 'Label for the group name input field', + }), + namePlaceholder: intl.formatMessage({ + defaultMessage: 'Enter a group name', + id: 'yGPRus', + description: 'Placeholder for the group name input field', + }), + descriptionLabel: intl.formatMessage({ + defaultMessage: 'Description', + id: 'Cb02hn', + description: 'Label for the group description input field', + }), + descriptionPlaceholder: intl.formatMessage({ + defaultMessage: 'Enter a description', + id: 'gcn3Jg', + description: 'Placeholder for the group description input field', + }), + createButton: intl.formatMessage({ + defaultMessage: 'Create', + id: '9gb/xS', + description: 'Button text for creating a group', + }), + creatingButton: intl.formatMessage({ + defaultMessage: 'Creating...', + id: 'RsXKPH', + description: 'Button text for creating a group when creation is in progress', + }), + cancelButton: intl.formatMessage({ + defaultMessage: 'Cancel', + id: '59OCrz', + description: 'Button text for canceling group creation', + }), + }), + [intl] + ); + + const { data: hubs, isLoading } = useAllKnowledgeHubs(resourceId); + const existingGroupNames = useMemo(() => hubs?.map((hub) => hub.name.toLowerCase()) || [], [hubs]); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [nameError, setNameError] = useState(undefined); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const handleNameChange = useCallback( + (e: React.ChangeEvent) => { + const errorMessage = validateHubNameAvailability(e.target.value, existingGroupNames); + setName(e.target.value); + setNameError(errorMessage); + }, + [existingGroupNames] + ); + + const handleCreate = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + setIsCreating(true); + await ConnectionService().persistKnowledgeHubConnection?.(); + await createKnowledgeHubWithRetry(resourceId, name, description); + onCreate?.(name, description); + setCreateError(null); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setCreateError({ + title: intl.formatMessage({ + defaultMessage: 'Failed to create group', + id: 'GyPgdO', + description: 'Error title when group creation fails', + }), + content: intl.formatMessage( + { + id: 'isF2bJ', + defaultMessage: 'Failed to create group: {errorMessage}', + description: 'Error message when group creation fails', + }, + { errorMessage } + ), + }); + } finally { + setIsCreating(false); + } + }, + [description, intl, name, onCreate, resourceId] + ); + + return ( + + + + +