diff --git a/e2e/templates/createWorkflowPanel.spec.ts b/e2e/templates/createWorkflowPanel.spec.ts index 753c62df6c5..f0ac7b9c112 100644 --- a/e2e/templates/createWorkflowPanel.spec.ts +++ b/e2e/templates/createWorkflowPanel.spec.ts @@ -21,7 +21,7 @@ test.describe( await page.locator('[data-testid="msla-templates-workflowName"]').fill(workflowName); await page.getByText('Stateless', { exact: true }).click(); - await page.getByRole('button', { name: 'Next' }).click(); + await page.getByTestId('template-footer-button-0').click(); await expect(page.getByText('BasicWorkflowOnly', { exact: true })).not.toBeVisible(); await expect(page.getByText('Stateful', { exact: true })).not.toBeVisible(); @@ -58,7 +58,7 @@ test.describe( await page.getByRole('tab', { name: 'Parameters' }).click(); await page.locator('[data-testid="msla-templates-parameter-value-LogicMessage_#workflowname#"]').fill(parameterValue); - await page.getByRole('button', { name: 'Next' }).click(); + await page.getByTestId('template-footer-button-0').click(); await expect(page.getByText('----', { exact: true })).not.toBeVisible(); await expect(page.getByText(parameterValue, { exact: true })).toBeVisible(); diff --git a/libs/designer/src/lib/ui/panel/templatePanel/__tests__/createWorkflowPanel.spec.tsx b/libs/designer/src/lib/ui/panel/templatePanel/__tests__/createWorkflowPanel.spec.tsx index 08c1ff5a450..e6cf3043047 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/__tests__/createWorkflowPanel.spec.tsx +++ b/libs/designer/src/lib/ui/panel/templatePanel/__tests__/createWorkflowPanel.spec.tsx @@ -3,16 +3,15 @@ import type { AppStore } from '../../../../core/state/templates/store'; import { setupStore } from '../../../../core/state/templates/store'; import { StandardTemplateService, InitTemplateService, type Template } from '@microsoft/logic-apps-shared'; import { renderWithProviders } from '../../../../__test__/template-test-utils'; -import { screen } from '@testing-library/react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; import type { TemplateState } from '../../../../core/state/templates/templateSlice'; import { TemplatePanelView } from '../../../../core/state/templates/panelSlice'; import constants from '../../../../common/constants'; import { MockHttpClient } from '../../../../__test__/mock-http-client'; import { QueryClientProvider } from '@tanstack/react-query'; import { getReactQueryClient } from '../../../../core'; -// biome-ignore lint/correctness/noUnusedImports: import React from 'react'; -import { CreateWorkflowPanel } from '../createWorkflowPanel/createWorkflowPanel'; +import { CreateWorkflowPanel, CreateWorkflowPanelHeader } from '../createWorkflowPanel/createWorkflowPanel'; describe('panel/templatePanel/createWorkflowPanel', () => { let store: AppStore; @@ -158,7 +157,7 @@ describe('panel/templatePanel/createWorkflowPanel', () => { errors: { workflow: undefined, kind: undefined, - }, + } as any, connectionKeys: [], }, }, @@ -177,7 +176,7 @@ describe('panel/templatePanel/createWorkflowPanel', () => { workflows: {}, parameters: {}, connections: undefined, - }, + } as any, }; const minimalStoreData = { template: templateSliceData, @@ -192,10 +191,12 @@ describe('panel/templatePanel/createWorkflowPanel', () => { beforeEach(() => { const queryClient = getReactQueryClient(); - + const ref = React.createRef(); renderWithProviders( - +
+ +
, { store } ); @@ -276,3 +277,73 @@ describe('panel/templatePanel/createWorkflowPanel', () => { expect(screen.queryByText(constants.TEMPLATE_PANEL_TAB_NAMES.REVIEW_AND_CREATE)).toBeDefined(); }); }); + +describe('panel/templatePanel/CreateWorkflowPanelHeader', () => { + it('Renders header with default title when headerTitle is not provided', () => { + renderWithProviders(, { store: setupStore({}) }); + expect(screen.getByText('Create a new workflow from template')).toBeDefined(); + }); + + it('Renders header with custom headerTitle when provided', () => { + renderWithProviders(, { + store: setupStore({}), + }); + expect(screen.getByText('Custom Header')).toBeDefined(); + }); + + it('Shows close button when onClose is provided', () => { + const onCloseMock = vi.fn(); + renderWithProviders(, { + store: setupStore({}), + }); + expect(screen.getByText('Close Panel')).toBeDefined(); + }); + + it('Does not show close button when onClose is not provided', () => { + renderWithProviders(, { store: setupStore({}) }); + expect(screen.queryByText('Close Panel')).toBeNull(); + }); + + it('Calls onClose when close button is clicked', () => { + const onCloseMock = vi.fn(); + renderWithProviders(, { + store: setupStore({}), + }); + const closeButton = screen.getByText('Close Panel'); + fireEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('Template details section is collapsed by default', () => { + renderWithProviders(, { store: setupStore({}) }); + expect(screen.getByText('Template details')).toBeDefined(); + expect(screen.queryByText('Name')).toBeNull(); + expect(screen.queryByText('Description')).toBeNull(); + }); + + it('Expands template details when toggle is clicked', async () => { + renderWithProviders(, { store: setupStore({}) }); + const toggle = screen.getByText('Template details'); + fireEvent.click(toggle); + await waitFor(() => { + expect(screen.getByText('Name')).toBeDefined(); + expect(screen.getByText('Description')).toBeDefined(); + expect(screen.getByText('Test Template')).toBeDefined(); + expect(screen.getByText('Test Summary')).toBeDefined(); + }); + }); + + it('Collapses template details when toggle is clicked twice', async () => { + renderWithProviders(, { store: setupStore({}) }); + const toggle = screen.getByText('Template details'); + fireEvent.click(toggle); + await waitFor(() => { + expect(screen.getByText('Name')).toBeDefined(); + }); + fireEvent.click(toggle); + await waitFor(() => { + expect(screen.queryByText('Name')).toBeNull(); + expect(screen.queryByText('Description')).toBeNull(); + }); + }); +}); diff --git a/libs/designer/src/lib/ui/panel/templatePanel/__tests__/quickViewPanel.spec.tsx b/libs/designer/src/lib/ui/panel/templatePanel/__tests__/quickViewPanel.spec.tsx index ebe4f6e7f2f..1a2dc482058 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/__tests__/quickViewPanel.spec.tsx +++ b/libs/designer/src/lib/ui/panel/templatePanel/__tests__/quickViewPanel.spec.tsx @@ -202,10 +202,12 @@ describe('panel/templatePanel/quickViewPanel', () => { beforeEach(() => { const queryClient = getReactQueryClient(); - + const ref = React.createRef(); renderWithProviders( - +
+ +
, { store } ); diff --git a/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.styles.ts b/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.styles.ts index 1eb754752c1..59a5adfa962 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.styles.ts +++ b/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.styles.ts @@ -4,6 +4,7 @@ export const useStyles = makeStyles({ drawer: { zIndex: 1000, height: '100%', + position: 'fixed', }, header: { ...shorthands.padding('0', tokens.spacingHorizontalL), diff --git a/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.tsx b/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.tsx index ac91e6c5ad8..1ce0160faf7 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.tsx +++ b/libs/designer/src/lib/ui/panel/templatePanel/createWorkflowPanel/createWorkflowPanel.tsx @@ -30,12 +30,14 @@ export interface CreateWorkflowPanelProps { clearDetailsOnClose?: boolean; panelWidth?: string; showCloseButton?: boolean; + mountNode: HTMLElement | null; onClose?: () => void; } export const CreateWorkflowPanel = ({ createWorkflow, onClose, + mountNode, panelWidth = '50%', clearDetailsOnClose = true, showCloseButton = true, @@ -125,7 +127,13 @@ export const CreateWorkflowPanel = ({ open={isOpen && currentPanelView === TemplatePanelView.CreateWorkflow} onOpenChange={(_, { open }) => !open && shouldCloseByDefault && dismissPanel()} position="end" - style={{ width: panelWidth }} + style={{ + width: panelWidth, + }} + mountNode={{ + element: mountNode, + }} + type={'overlay'} > {onRenderHeaderContent()} diff --git a/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.styles.ts b/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.styles.ts index d54693a082e..6cdbe6be005 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.styles.ts +++ b/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.styles.ts @@ -4,6 +4,7 @@ export const useStyles = makeStyles({ drawer: { zIndex: 1000, height: '100%', + position: 'fixed', }, header: { ...shorthands.padding('0', tokens.spacingHorizontalL), diff --git a/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.tsx b/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.tsx index 9c3e978cfcc..60bb7f63b31 100644 --- a/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.tsx +++ b/libs/designer/src/lib/ui/panel/templatePanel/quickViewPanel/quickViewPanel.tsx @@ -17,6 +17,7 @@ import { useStyles } from './quickViewPanel.styles'; export interface QuickViewPanelProps { showCreate: boolean; workflowId: string; + mountNode: HTMLElement | null; clearDetailsOnClose?: boolean; panelWidth?: string; showCloseButton?: boolean; @@ -27,6 +28,7 @@ export const QuickViewPanel = ({ onClose, showCreate, workflowId, + mountNode, panelWidth = '50%', showCloseButton = true, clearDetailsOnClose = true, @@ -103,6 +105,10 @@ export const QuickViewPanel = ({ onOpenChange={(_, { open }) => !open && shouldCloseByDefault && dismissPanel()} position="end" style={{ width: panelWidth }} + mountNode={{ + element: mountNode, + }} + type={'overlay'} > {onRenderHeaderContent()} diff --git a/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesfullgalleryview.spec.tsx b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesfullgalleryview.spec.tsx new file mode 100644 index 00000000000..0413e739066 --- /dev/null +++ b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesfullgalleryview.spec.tsx @@ -0,0 +1,417 @@ +import { describe, beforeAll, expect, it, vi, beforeEach } from 'vitest'; +import type { AppStore } from '../../../../core/state/templates/store'; +import { setupStore, type RootState } from '../../../../core/state/templates/store'; +import type { Template } from '@microsoft/logic-apps-shared'; +import { renderWithProviders } from '../../../../__test__/template-test-utils'; +import { screen, fireEvent } from '@testing-library/react'; +import { TemplatesFullGalleryView } from '../templatesfullgalleryview'; +import { TemplatePanelView } from '../../../../core/state/templates/panelSlice'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { getReactQueryClient } from '../../../../core'; +// biome-ignore lint/correctness/noUnusedImports: +import React from 'react'; + +describe('ui/templates/gallery/TemplatesFullGalleryView', () => { + let store: AppStore; + let minimalStoreData: Partial; + let template1Manifest: Template.TemplateManifest; + let template2Manifest: Template.TemplateManifest; + let workflow1Manifest: Template.WorkflowManifest; + const createWorkflowCall = vi.fn(); + const defaultDetailFilters = { + Type: { displayName: 'Type', items: [] }, + Industry: { displayName: 'Industry', items: [] }, + }; + + // Helper to render with QueryClientProvider + const renderComponent = (storeInstance: AppStore) => { + const queryClient = getReactQueryClient(); + return renderWithProviders( + + + , + { store: storeInstance } + ); + }; + + const renderComponentWithProps = (storeInstance: AppStore, isWorkflowEmpty: boolean) => { + const queryClient = getReactQueryClient(); + return renderWithProviders( + + + , + { store: storeInstance } + ); + }; + + beforeAll(() => { + template1Manifest = { + id: 'template1Manifest', + title: 'Template 1', + summary: 'Template 1 Description', + skus: ['standard', 'consumption'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Automation', + Category: 'Productivity', + }, + }; + + template2Manifest = { + id: 'template2Manifest', + title: 'Template 2', + summary: 'Template 2 Description', + skus: ['standard'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Integration', + Category: 'Business', + }, + }; + + workflow1Manifest = { + id: 'default', + title: 'Template 1 Workflow', + summary: 'Template 1 Workflow Description', + kinds: ['stateful', 'stateless'], + artifacts: [{ type: 'workflow', file: 'workflow.json' }], + images: { light: '', dark: '' }, + connections: {}, + parameters: [], + }; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Renders TemplatesFullGalleryView with gallery and search components', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.getByText('Template 1')).toBeDefined(); + expect(screen.getByText('Template 2')).toBeDefined(); + }); + + it('Shows blank template card when no tab filter is applied', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.queryByLabelText('Blank workflow')).toBeDefined(); + }); + + it('Hides blank template card when publishedBy tab filter is applied', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: { + publishedBy: [{ value: 'Microsoft', displayName: 'Microsoft' }], + }, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.queryByLabelText('Blank workflow')).toBeNull(); + }); + + it('Opens QuickView panel when a single workflow template is selected', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + panel: { + isOpen: false, + currentPanelView: undefined, + selectedTabId: undefined, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Click on template card + fireEvent.click(screen.getByText('Template 1')); + + // Verify panel is opened with QuickView + expect(store.getState().panel.isOpen).toBe(true); + expect(store.getState().panel.currentPanelView).toBe(TemplatePanelView.QuickView); + }); + + it('Does not render WorkflowView when no template is selected', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + template: { + templateName: undefined, + workflows: {}, + manifest: undefined, + parameterDefinitions: {}, + connections: {}, + errors: { + manifest: {}, + workflows: {}, + parameters: {}, + connections: undefined, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // WorkflowView should not render QuickViewPanel when no template is selected + expect(screen.queryByText('Use this template')).toBeNull(); + }); + + it('Renders WorkflowView with panels when single workflow template is selected', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + template: { + templateName: 'template1', + workflows: { + default: { + id: 'default', + workflowName: '', + kind: undefined, + manifest: workflow1Manifest, + triggerType: '', + workflowDefinition: { + $schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#', + contentVersion: '', + }, + errors: { workflow: undefined, kind: undefined }, + connectionKeys: [], + }, + }, + manifest: template1Manifest, + parameterDefinitions: {}, + connections: {}, + errors: { + manifest: {}, + workflows: {}, + parameters: {}, + connections: undefined, + }, + }, + panel: { + isOpen: true, + currentPanelView: TemplatePanelView.QuickView, + selectedTabId: undefined, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // WorkflowView should render when single workflow template is selected + expect(store.getState().template.templateName).toBe('template1'); + expect(Object.keys(store.getState().template.workflows).length).toBe(1); + }); + + it('Does not render WorkflowView for multi-workflow templates', () => { + const multiWorkflowTemplate: Template.TemplateManifest = { + id: 'multiTemplate', + title: 'Multi Workflow Template', + summary: 'Template with multiple workflows', + skus: ['standard'], + workflows: { + workflow1: { name: 'Workflow 1' }, + workflow2: { name: 'Workflow 2' }, + }, + details: { + By: 'Microsoft', + Type: 'Automation', + Category: 'Productivity', + }, + }; + + minimalStoreData = { + manifest: { + availableTemplateNames: ['multiTemplate'], + availableTemplates: { + multiTemplate: multiWorkflowTemplate, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + template: { + templateName: 'multiTemplate', + workflows: { + workflow1: { + id: 'workflow1', + workflowName: '', + kind: undefined, + manifest: workflow1Manifest, + triggerType: '', + workflowDefinition: { + $schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#', + contentVersion: '', + }, + errors: { workflow: undefined, kind: undefined }, + connectionKeys: [], + }, + workflow2: { + id: 'workflow2', + workflowName: '', + kind: undefined, + manifest: workflow1Manifest, + triggerType: '', + workflowDefinition: { + $schema: 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#', + contentVersion: '', + }, + errors: { workflow: undefined, kind: undefined }, + connectionKeys: [], + }, + }, + manifest: multiWorkflowTemplate, + parameterDefinitions: {}, + connections: {}, + errors: { + manifest: {}, + workflows: {}, + parameters: {}, + connections: undefined, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // WorkflowView should NOT render for multi-workflow templates (workflows.length !== 1) + expect(Object.keys(store.getState().template.workflows).length).toBe(2); + }); + + it('Respects isWorkflowEmpty prop for blank template card', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponentWithProps(store, false); + + // Blank template card should still be present but with isWorkflowEmpty=false + expect(screen.queryByLabelText('Blank workflow')).toBeDefined(); + }); + + it('Renders layer host element for Fluent UI panels', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const { container } = renderComponent(store); + + const layerHost = container.querySelector('#msla-layer-host'); + expect(layerHost).toBeDefined(); + expect(layerHost?.getAttribute('style')).toContain('visibility: hidden'); + }); +}); diff --git a/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallery.spec.tsx b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallery.spec.tsx new file mode 100644 index 00000000000..d2b93d61fb0 --- /dev/null +++ b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallery.spec.tsx @@ -0,0 +1,366 @@ +import { describe, beforeAll, expect, it, vi, beforeEach } from 'vitest'; +import type { AppStore } from '../../../../core/state/templates/store'; +import { setupStore, type RootState } from '../../../../core/state/templates/store'; +import type { Template } from '@microsoft/logic-apps-shared'; +import { renderWithProviders } from '../../../../__test__/template-test-utils'; +import { screen, fireEvent } from '@testing-library/react'; +import { TemplatesGallery } from '../templatesgallery'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { getReactQueryClient } from '../../../../core'; +// biome-ignore lint/correctness/noUnusedImports: +import React from 'react'; + +describe('ui/templates/gallery/TemplatesGallery', () => { + let store: AppStore; + let minimalStoreData: Partial; + let template1Manifest: Template.TemplateManifest; + let template2Manifest: Template.TemplateManifest; + let template3Manifest: Template.TemplateManifest; + const onTemplateSelect = vi.fn(); + + // Helper to render with QueryClientProvider + const renderComponent = (storeInstance: AppStore, props: Partial[0]> = {}) => { + const queryClient = getReactQueryClient(); + return renderWithProviders( + + + , + { store: storeInstance } + ); + }; + + beforeAll(() => { + template1Manifest = { + id: 'template1', + title: 'Template 1', + summary: 'Template 1 Description', + skus: ['standard', 'consumption'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Automation', + Category: 'Productivity', + }, + }; + + template2Manifest = { + id: 'template2', + title: 'Template 2', + summary: 'Template 2 Description', + skus: ['standard'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Integration', + Category: 'Business', + }, + }; + + template3Manifest = { + id: 'template3', + title: 'Template 3', + summary: 'Template 3 Description', + skus: ['consumption'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Partner', + Type: 'Automation', + Category: 'Productivity', + }, + }; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Renders template cards for available templates', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.getByText('Template 1')).toBeDefined(); + expect(screen.getByText('Template 2')).toBeDefined(); + }); + + it('Shows loading skeleton cards when filteredTemplateNames is undefined', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: undefined as any, + availableTemplates: undefined as any, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const { container } = renderComponent(store); + + // Should render 4 skeleton template cards + const skeletonCards = container.querySelectorAll('[class*="templateCard"]'); + expect(skeletonCards.length).toBeGreaterThanOrEqual(0); + }); + + it('Shows empty search message when no templates match filters', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: [], + availableTemplates: {}, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.getByText("Can't find any search results")).toBeDefined(); + expect(screen.getByText('Try a different search term or remove filters')).toBeDefined(); + }); + + it('Renders blank template card when provided', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const blankCard =
Blank Template
; + renderComponent(store, { blankTemplateCard: blankCard }); + + expect(screen.getByTestId('blank-card')).toBeDefined(); + expect(screen.getByText('Blank Template')).toBeDefined(); + }); + + it('Shows pager when templates exist', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2', 'template3'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + template3: template3Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Pager should be present with page 1 + expect(screen.getByText('1')).toBeDefined(); + }); + + it('Does not show pager when no templates exist', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: [], + availableTemplates: {}, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Should show empty state, not pager + expect(screen.getByText("Can't find any search results")).toBeDefined(); + }); + + it('Respects isLightweight prop', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store, { isLightweight: true }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); + + it('Passes cssOverrides to template cards', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const cssOverrides = { list: 'custom-list-class' }; + renderComponent(store, { cssOverrides }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); + + it('Calls onTemplateSelect when template card is clicked', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + fireEvent.click(screen.getByText('Template 1')); + + // Template card click should set template state + expect(store.getState().template.templateName).toBe('template1'); + }); + + it('Respects custom pageCount prop', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2', 'template3'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + template3: template3Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + // With pageCount of 1, should only show 1 template per page + renderComponent(store, { pageCount: 1 }); + + expect(screen.getByText('Template 1')).toBeDefined(); + // Template 2 should not be visible on first page with pageCount=1 + // (depending on how the gallery renders) + }); + + it('Handles page navigation via pager', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2', 'template3'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + template3: template3Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Initial page should be 0 + expect(store.getState().manifest.filters.pageNum).toBe(0); + }); + + it('Shows correct page count information', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Templates should be visible on first page + expect(screen.getByText('Template 1')).toBeDefined(); + expect(screen.getByText('Template 2')).toBeDefined(); + }); +}); diff --git a/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallerywithsearch.spec.tsx b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallerywithsearch.spec.tsx new file mode 100644 index 00000000000..1f413937cfe --- /dev/null +++ b/libs/designer/src/lib/ui/templates/gallery/__tests__/templatesgallerywithsearch.spec.tsx @@ -0,0 +1,264 @@ +import { describe, beforeAll, expect, it, vi, beforeEach } from 'vitest'; +import type { AppStore } from '../../../../core/state/templates/store'; +import { setupStore, type RootState } from '../../../../core/state/templates/store'; +import type { Template } from '@microsoft/logic-apps-shared'; +import { renderWithProviders } from '../../../../__test__/template-test-utils'; +import { screen } from '@testing-library/react'; +import { TemplatesGalleryWithSearch } from '../templatesgallerywithsearch'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { getReactQueryClient } from '../../../../core'; +// biome-ignore lint/correctness/noUnusedImports: +import React from 'react'; + +describe('ui/templates/gallery/TemplatesGalleryWithSearch', () => { + let store: AppStore; + let minimalStoreData: Partial; + let template1Manifest: Template.TemplateManifest; + let template2Manifest: Template.TemplateManifest; + const onTemplateSelect = vi.fn(); + const defaultSearchAndFilterProps = { + detailFilters: { + Type: { displayName: 'Type', items: [] }, + Industry: { displayName: 'Industry', items: [] }, + }, + }; + + // Helper to render with QueryClientProvider + const renderComponent = (storeInstance: AppStore, props: Partial[0]> = {}) => { + const queryClient = getReactQueryClient(); + return renderWithProviders( + + + , + { store: storeInstance } + ); + }; + + beforeAll(() => { + template1Manifest = { + id: 'template1', + title: 'Template 1', + summary: 'Template 1 Description', + skus: ['standard', 'consumption'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Automation', + Category: 'Productivity', + }, + }; + + template2Manifest = { + id: 'template2', + title: 'Template 2', + summary: 'Template 2 Description', + skus: ['standard'], + workflows: { + default: { name: 'default' }, + }, + details: { + By: 'Microsoft', + Type: 'Integration', + Category: 'Business', + }, + }; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Renders TemplatesGalleryWithSearch with search filters and gallery', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + // Should render both search/filter area and template gallery + expect(screen.getByText('Template 1')).toBeDefined(); + expect(screen.getByText('Template 2')).toBeDefined(); + }); + + it('Passes isLightweight prop to TemplatesGallery', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store, { isLightweight: true }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); + + it('Passes pageCount prop to TemplatesGallery', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1', 'template2'], + availableTemplates: { + template1: template1Manifest, + template2: template2Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store, { pageCount: 10 }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); + + it('Renders blankTemplateCard when provided', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const blankCard =
Blank Template
; + renderComponent(store, { blankTemplateCard: blankCard }); + + expect(screen.getByTestId('blank-card')).toBeDefined(); + expect(screen.getByText('Blank Template')).toBeDefined(); + }); + + it('Passes cssOverrides prop to TemplatesGallery', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const cssOverrides = { list: 'custom-class' }; + renderComponent(store, { cssOverrides }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); + + it('Shows empty state when no templates match', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: [], + availableTemplates: {}, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + renderComponent(store); + + expect(screen.getByText("Can't find any search results")).toBeDefined(); + expect(screen.getByText('Try a different search term or remove filters')).toBeDefined(); + }); + + it('Applies wrapper styles from useTemplatesGalleryWithSearchStyles', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const { container } = renderComponent(store); + + // Verify the wrapper div exists + const wrapperDiv = container.firstChild; + expect(wrapperDiv).toBeDefined(); + expect(wrapperDiv?.nodeName).toBe('DIV'); + }); + + it('Renders with different searchAndFilterProps', () => { + minimalStoreData = { + manifest: { + availableTemplateNames: ['template1'], + availableTemplates: { + template1: template1Manifest, + }, + filters: { + sortKey: 'a-to-z', + connectors: undefined, + detailFilters: {}, + pageNum: 0, + }, + }, + }; + store = setupStore(minimalStoreData); + + const customSearchProps = { + detailFilters: { + Category: { displayName: 'Category', items: [] }, + }, + tabFilterKey: 'publishedBy', + }; + + renderComponent(store, { searchAndFilterProps: customSearchProps }); + + expect(screen.getByText('Template 1')).toBeDefined(); + }); +}); diff --git a/libs/designer/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx b/libs/designer/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx index f6e3d44e8ca..963c849d282 100644 --- a/libs/designer/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx +++ b/libs/designer/src/lib/ui/templates/gallery/templatesfullgalleryview.tsx @@ -1,7 +1,7 @@ import type { AppDispatch, RootState } from '../../../core/state/templates/store'; import { useDispatch, useSelector } from 'react-redux'; import { TemplateCard } from '../cards/templateCard'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { setLayerHostSelector } from '@fluentui/react'; import type { CreateWorkflowHandler, TemplatesDesignerProps } from '../TemplatesDesigner'; import { QuickViewPanel } from '../../panel/templatePanel/quickViewPanel/quickViewPanel'; @@ -12,7 +12,6 @@ import { TemplatesGalleryWithSearch } from './templatesgallerywithsearch'; const tabFilterKey = 'publishedBy'; export const TemplatesFullGalleryView = ({ detailFilters, createWorkflowCall, isWorkflowEmpty = true }: TemplatesDesignerProps) => { - useEffect(() => setLayerHostSelector('#msla-layer-host'), []); const dispatch = useDispatch(); const { @@ -28,8 +27,10 @@ export const TemplatesFullGalleryView = ({ detailFilters, createWorkflowCall, is } }; + useEffect(() => setLayerHostSelector('#msla-layer-host'), []); + return ( - <> +
- +
); }; -const WorkflowView = ({ createWorkflowCall }: { createWorkflowCall: CreateWorkflowHandler }) => { +const WorkflowView = ({ + createWorkflowCall, +}: { createWorkflowCall: CreateWorkflowHandler; panelRef?: React.RefObject }) => { const { templateName, workflows } = useSelector((state: RootState) => state.template); - + const containerRef = useRef(null); return templateName === undefined || Object.keys(workflows).length !== 1 ? null : ( - <> - - - +
+ + +
); }; diff --git a/libs/designer/src/lib/ui/templates/templateoverview.tsx b/libs/designer/src/lib/ui/templates/templateoverview.tsx index fb4650c1113..388f12f0774 100644 --- a/libs/designer/src/lib/ui/templates/templateoverview.tsx +++ b/libs/designer/src/lib/ui/templates/templateoverview.tsx @@ -1,5 +1,5 @@ import type { CreateWorkflowHandler } from './TemplatesDesigner'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { DetailsList, type IColumn, SelectionMode, setLayerHostSelector } from '@fluentui/react'; import { useIntl } from 'react-intl'; import { useDispatch, useSelector } from 'react-redux'; @@ -81,8 +81,9 @@ export const TemplateOverview = ({ onClose ).footerContent; + const containerRef = useRef(null); return ( - <> +
- {selectedWorkflow ? ( - setSelectedWorkflow(undefined)} - /> - ) : null} +
+ {selectedWorkflow ? ( + setSelectedWorkflow(undefined)} + /> + ) : null} - {showCreatePanel ? ( - setShowCreatePanel(false)} - clearDetailsOnClose={false} - /> - ) : null} + {showCreatePanel ? ( + setShowCreatePanel(false)} + clearDetailsOnClose={false} + /> + ) : null} +
- +
); }; diff --git a/libs/designer/src/lib/ui/templates/templateview.tsx b/libs/designer/src/lib/ui/templates/templateview.tsx index 0803c437cac..06e97f9233a 100644 --- a/libs/designer/src/lib/ui/templates/templateview.tsx +++ b/libs/designer/src/lib/ui/templates/templateview.tsx @@ -2,7 +2,7 @@ import { useDispatch, useSelector } from 'react-redux'; import type { CreateWorkflowHandler } from './TemplatesDesigner'; import type { AppDispatch, RootState } from '../../core/state/templates/store'; import { isMultiWorkflowTemplate, loadTemplate } from '../../core/actions/bjsworkflow/templates'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { TemplateOverview } from './templateoverview'; import { setLayerHostSelector, Spinner, SpinnerSize, Text } from '@fluentui/react'; import { CreateWorkflowPanel } from '../panel/templatePanel/createWorkflowPanel/createWorkflowPanel'; @@ -73,16 +73,28 @@ const SingleTemplateView = ({ dispatch(openPanelView({ panelView: TemplatePanelView.CreateWorkflow })); } }, [dispatch, showSummary]); + + const containerRef = useRef(null); + return ( - <> - - +
+
+ + +
- +
); };