diff --git a/libs/designer-v2/package.json b/libs/designer-v2/package.json index 66946ad40e7..3dcdd364bb1 100644 --- a/libs/designer-v2/package.json +++ b/libs/designer-v2/package.json @@ -35,7 +35,7 @@ "react-intl": "6.3.0", "react-markdown": "8.0.5", "react-redux": "8.0.2", - "react-window": "^1.8.11", + "react-window": "^2.2.6", "redux-thunk": "2.4.2", "reselect": "4.1.8", "tabster": "8.5.6", diff --git a/libs/designer-v2/src/lib/ui/panel/recommendation/browse/__test__/connectorBrowse.spec.tsx b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/__test__/connectorBrowse.spec.tsx new file mode 100644 index 00000000000..23c501ad952 --- /dev/null +++ b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/__test__/connectorBrowse.spec.tsx @@ -0,0 +1,263 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { IntlProvider } from 'react-intl'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ConnectorBrowse } from '../connectorBrowse'; +import type { Connector } from '@microsoft/logic-apps-shared'; + +// --- Mocks --- + +const mockDispatch = vi.fn(); +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { ...actual, useDispatch: () => mockDispatch }; +}); + +const mockUseAllConnectors = vi.fn(); +vi.mock('../../../../../core/queries/browse', () => ({ + useAllConnectors: () => mockUseAllConnectors(), +})); + +vi.mock('../../../../../core/state/panel/panelSelectors', () => ({ + useDiscoveryPanelRelationshipIds: vi.fn(() => ({ + graphId: 'root', + parentId: undefined, + childId: undefined, + })), +})); + +vi.mock('../../../../../core/state/designerView/designerViewSelectors', () => ({ + useIsA2AWorkflow: vi.fn(() => false), +})); + +vi.mock('../../../../../core/state/panel/panelSlice', () => ({ + selectOperationGroupId: vi.fn((id: string) => ({ type: 'panel/selectOperationGroupId', payload: id })), +})); + +vi.mock('@microsoft/designer-ui', () => ({ + isBuiltInConnector: vi.fn((c: Connector) => c.id.includes('builtin')), + isCustomConnector: vi.fn((c: Connector) => c.id.includes('custom')), +})); + +vi.mock('../connectorCard', () => ({ + ConnectorCard: vi.fn(({ connector }: { connector: Connector }) => ( +
{connector.properties.displayName}
+ )), +})); + +vi.mock('../styles/ConnectorBrowse.styles', () => ({ + useConnectorBrowseStyles: vi.fn(() => ({ + loadingContainer: 'loading-container', + emptyStateContainer: 'empty-state-container', + })), +})); + +vi.mock('react-window', () => ({ + List: vi.fn(({ rowCount, rowComponent: Row }: any) => ( +
+ {Array.from({ length: rowCount }, (_, i) => ( + + ))} +
+ )), +})); + +// --- Helpers --- + +const makeConnector = (id: string, displayName: string, overrides?: Partial): Connector => + ({ + id, + name: id.split('/').pop() ?? id, + type: 'Microsoft.Web/locations/managedApis', + properties: { + displayName, + capabilities: [], + ...overrides?.properties, + }, + ...overrides, + }) as unknown as Connector; + +const createWrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const store = configureStore({ reducer: { stub: (s = {}) => s } }); + + return ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); +}; + +// --- Tests --- + +describe('ConnectorBrowse', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test('renders loading spinner when data is loading', () => { + mockUseAllConnectors.mockReturnValue({ data: undefined, isLoading: true }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Loading connectors...')).toBeInTheDocument(); + }); + + test('renders empty state when no connectors match', () => { + mockUseAllConnectors.mockReturnValue({ data: [], isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('No connectors found for this category')).toBeInTheDocument(); + }); + + test('renders connector cards when connectors are available', () => { + const connectors = [makeConnector('shared/sql', 'SQL'), makeConnector('shared/outlook', 'Outlook')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('SQL')).toBeInTheDocument(); + expect(screen.getByText('Outlook')).toBeInTheDocument(); + }); + + test('filters out agent connector', () => { + const connectors = [makeConnector('connectionProviders/agent', 'Agent'), makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByTestId('connector-card-connectionProviders/agent')).not.toBeInTheDocument(); + expect(screen.getByText('SQL')).toBeInTheDocument(); + }); + + test('filters out ACA session connector', () => { + const connectors = [makeConnector('/serviceProviders/acasession', 'ACA Session'), makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByTestId('connector-card-/serviceProviders/acasession')).not.toBeInTheDocument(); + expect(screen.getByText('SQL')).toBeInTheDocument(); + }); + + test('filters by runtime=inapp to show only built-in connectors', () => { + const connectors = [makeConnector('builtin/http', 'HTTP'), makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('HTTP')).toBeInTheDocument(); + expect(screen.queryByText('SQL')).not.toBeInTheDocument(); + }); + + test('filters by runtime=custom to show only custom connectors', () => { + const connectors = [makeConnector('custom/myConnector', 'My Custom'), makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('My Custom')).toBeInTheDocument(); + expect(screen.queryByText('SQL')).not.toBeInTheDocument(); + }); + + test('filters by runtime=shared to exclude built-in and custom connectors', () => { + const connectors = [ + makeConnector('builtin/http', 'HTTP'), + makeConnector('custom/myConnector', 'My Custom'), + makeConnector('shared/sql', 'SQL'), + ]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByText('HTTP')).not.toBeInTheDocument(); + expect(screen.queryByText('My Custom')).not.toBeInTheDocument(); + expect(screen.getByText('SQL')).toBeInTheDocument(); + }); + + test('filters by actionType=triggers', () => { + const triggersConnector = makeConnector('shared/trigger', 'Trigger Connector', { + properties: { displayName: 'Trigger Connector', capabilities: ['triggers'] }, + } as any); + const actionsConnector = makeConnector('shared/action', 'Action Connector', { + properties: { displayName: 'Action Connector', capabilities: ['actions'] }, + } as any); + + mockUseAllConnectors.mockReturnValue({ data: [triggersConnector, actionsConnector], isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Trigger Connector')).toBeInTheDocument(); + expect(screen.queryByText('Action Connector')).not.toBeInTheDocument(); + }); + + test('filters by actionType=actions', () => { + const triggersConnector = makeConnector('shared/trigger', 'Trigger Connector', { + properties: { displayName: 'Trigger Connector', capabilities: ['triggers'] }, + } as any); + const actionsConnector = makeConnector('shared/action', 'Action Connector', { + properties: { displayName: 'Action Connector', capabilities: ['actions'] }, + } as any); + + mockUseAllConnectors.mockReturnValue({ data: [triggersConnector, actionsConnector], isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByText('Trigger Connector')).not.toBeInTheDocument(); + expect(screen.getByText('Action Connector')).toBeInTheDocument(); + }); + + test('connectors with no capabilities pass actionType filter', () => { + const noCapsConnector = makeConnector('shared/nocaps', 'No Caps', { + properties: { displayName: 'No Caps', capabilities: [] }, + } as any); + + mockUseAllConnectors.mockReturnValue({ data: [noCapsConnector], isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('No Caps')).toBeInTheDocument(); + }); + + test('sorts priority connectors before others', () => { + const regularConnector = makeConnector('shared/random', 'Random'); + const priorityConnector = makeConnector('shared/managedApis/office365', 'Office 365'); + + mockUseAllConnectors.mockReturnValue({ data: [regularConnector, priorityConnector], isLoading: false }); + + render(, { wrapper: createWrapper() }); + + const cards = screen.getAllByTestId(/connector-card-/); + expect(cards[0]).toHaveTextContent('Office 365'); + expect(cards[1]).toHaveTextContent('Random'); + }); + + test('uses virtualized list for rendering', () => { + const connectors = [makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.getAllByTestId('virtualized-list').length).toBeGreaterThan(0); + }); + + test('does not render loading spinner after data has loaded', () => { + const connectors = [makeConnector('shared/sql', 'SQL')]; + mockUseAllConnectors.mockReturnValue({ data: connectors, isLoading: false }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByText('Loading connectors...')).not.toBeInTheDocument(); + }); +}); diff --git a/libs/designer-v2/src/lib/ui/panel/recommendation/browse/connectorBrowse.tsx b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/connectorBrowse.tsx index fc8f2451a4f..59e9a3e7517 100644 --- a/libs/designer-v2/src/lib/ui/panel/recommendation/browse/connectorBrowse.tsx +++ b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/connectorBrowse.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; import { useAllConnectors } from '../../../../core/queries/browse'; @@ -12,8 +12,7 @@ import { ConnectorCard } from './connectorCard'; import { selectOperationGroupId } from '../../../../core/state/panel/panelSlice'; import type { AppDispatch } from '../../../../core'; import { useConnectorBrowseStyles } from './styles/ConnectorBrowse.styles'; -import type { ListChildComponentProps } from 'react-window'; -import { FixedSizeList } from 'react-window'; +import { List, type RowComponentProps } from 'react-window'; import type { ConnectorFilterTypes } from './helper'; export interface ConnectorBrowseProps { @@ -90,19 +89,6 @@ export const ConnectorBrowse = ({ const isA2AWorkflow = useIsA2AWorkflow(); const isAddingToGraph = useDiscoveryPanelRelationshipIds().graphId === 'root'; - const containerRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - - useEffect(() => { - if (!containerRef.current) { - return; - } - const updateHeight = () => setContainerHeight(containerRef.current!.clientHeight); - updateHeight(); - window.addEventListener('resize', updateHeight); - return () => window.removeEventListener('resize', updateHeight); - }, []); - const { data: allConnectors, isLoading } = useAllConnectors(); const isAgentConnectorAllowed = useCallback((c: Connector) => c.id !== 'connectionProviders/agent', []); @@ -234,27 +220,18 @@ export const ConnectorBrowse = ({ } // --- Row Renderer --- - const Row = ({ index, style }: ListChildComponentProps) => { - const connector = sortedConnectors[index]; - return ( -
- -
- ); - }; + const Row = ({ index, style }: RowComponentProps) => ( +
+ +
+ ); return ( -
- {containerHeight > 0 && ( - - {Row} - - )} -
+ ); }; diff --git a/libs/designer-v2/src/lib/ui/panel/recommendation/browse/styles/ConnectorBrowse.styles.ts b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/styles/ConnectorBrowse.styles.ts index 7e74e30fc61..3c59f0e4187 100644 --- a/libs/designer-v2/src/lib/ui/panel/recommendation/browse/styles/ConnectorBrowse.styles.ts +++ b/libs/designer-v2/src/lib/ui/panel/recommendation/browse/styles/ConnectorBrowse.styles.ts @@ -11,12 +11,4 @@ export const useConnectorBrowseStyles = makeStyles({ padding: '40px', color: tokens.colorNeutralForeground2, }, - connectorGrid: { - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - minHeight: 0, - height: 'calc(100% - 120px)', - }, }); diff --git a/package.json b/package.json index 755a7d489e6..57b3968d040 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@types/react": "18.3.0", "@types/react-dom": "18.3.0", "@types/react-test-renderer": "^18.0.7", - "@types/react-window": "^1.8.8", + "@types/react-window": "^2.0.0", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.29.1", "@vitejs/plugin-react": "^4.4.6", @@ -113,7 +113,7 @@ "find-process": "^1.4.7", "fs-extra": "^11.2.0", "happy-dom": "^20.0.2", - "react-window": "^1.8.11", + "react-window": "^2.2.6", "ts-node": "^10.9.2" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ed7649d9a3..5d5ccb5843f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^20.0.2 version: 20.0.2 react-window: - specifier: ^1.8.11 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^2.2.6 + version: 2.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.12.7)(typescript@5.7.2) @@ -123,8 +123,8 @@ importers: specifier: ^18.0.7 version: 18.0.7 '@types/react-window': - specifier: ^1.8.8 - version: 1.8.8 + specifier: ^2.0.0 + version: 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@typescript-eslint/eslint-plugin': specifier: ^8.15.0 version: 8.15.0(@typescript-eslint/parser@8.29.1(eslint@9.26.0(jiti@1.21.0))(typescript@5.7.2))(eslint@9.26.0(jiti@1.21.0))(typescript@5.7.2) @@ -1567,8 +1567,8 @@ importers: specifier: 8.0.2 version: 8.0.2(@types/react-dom@18.3.0)(@types/react@18.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) react-window: - specifier: ^1.8.11 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^2.2.6 + version: 2.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redux-thunk: specifier: 2.4.2 version: 2.4.2(redux@4.2.1) @@ -3133,7 +3133,7 @@ packages: engines: {node: '>=6.9.0'} '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + resolution: {integrity: sha1-0mekPLGDbcTRgszpOudbqVTvbSs=} engines: {node: '>=6.9.0'} '@babel/template@7.27.2': @@ -7004,8 +7004,9 @@ packages: '@types/react-test-renderer@18.0.7': resolution: {integrity: sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==} - '@types/react-window@1.8.8': - resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} + '@types/react-window@2.0.0': + resolution: {integrity: sha1-n+RQFdb5jsmKBpNiUYyFX8wfqNA=} + deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed. '@types/react@18.3.0': resolution: {integrity: sha512-DiUcKjzE6soLyln8NNZmyhcQjVv+WsUIFSqetMN0p8927OztKT4VTfFTqsbAi5oAGIcgOmOajlfBqyptDDjZRw==} @@ -13128,6 +13129,12 @@ packages: react: 18.3.1 react-dom: 18.3.1 + react-window@2.2.6: + resolution: {integrity: sha1-AMoXQ0a1FG08M6dS2IgYElDHHZ8=} + peerDependencies: + react: 18.3.1 + react-dom: 18.3.1 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -23582,9 +23589,12 @@ snapshots: dependencies: '@types/react': 18.3.0 - '@types/react-window@1.8.8': + '@types/react-window@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@types/react': 18.3.0 + react-window: 2.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - react + - react-dom '@types/react@18.3.0': dependencies: @@ -31182,6 +31192,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-window@2.2.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0