diff --git a/packages/ui-tests/cypress/e2e/datamapper/nodeActions.cy.ts b/packages/ui-tests/cypress/e2e/datamapper/nodeActions.cy.ts index e51411bf8..15dc196e5 100644 --- a/packages/ui-tests/cypress/e2e/datamapper/nodeActions.cy.ts +++ b/packages/ui-tests/cypress/e2e/datamapper/nodeActions.cy.ts @@ -9,7 +9,10 @@ describe('Test for DataMapper : datamapper node actions', { browser: '!firefox' it('Datamapper - drag and drop datamapper step ', () => { cy.uploadFixture('flows/camelRoute/datamapper.yaml'); cy.openDesignPage(); - cy.DnD('route.from.steps.2.step:kaoto-datamapper', 'route.from.steps.1.marshal'); + cy.DnDOnEdge( + 'route.from.steps.2.step:kaoto-datamapper', + 'camel-route|route.from.steps.0.setHeader >>> route.from.steps.1.marshal', + ); const yamlRoute = [ 'id: camel-route', diff --git a/packages/ui-tests/cypress/e2e/designer/DnD/dragAndDrop.cy.ts b/packages/ui-tests/cypress/e2e/designer/DnD/dragAndDrop.cy.ts index 3cbcc782a..34cd90cd5 100644 --- a/packages/ui-tests/cypress/e2e/designer/DnD/dragAndDrop.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/DnD/dragAndDrop.cy.ts @@ -6,26 +6,22 @@ describe('Canvas nodes Drag and Drop', () => { }); // FF not supported - https://github.com/dmtrKovalenko/cypress-real-events?tab=readme-ov-file#requirements - it('D&D - basic drag and drop', { browser: '!firefox' }, () => { + it('D&D - basic drag and drop on edge', { browser: '!firefox' }, () => { cy.uploadFixture('flows/camelRoute/basic.yaml'); cy.openDesignPage(); - cy.DnD('route.from.steps.0.setHeader', 'route.from.steps.1.marshal'); - - const yamlRoute = [ - 'id: camel-route', - 'from:', - 'uri: timer:test', - 'steps:', - '- marshal:', - 'id: marshal-3801', - '- setHeader:', - 'constant: test', - 'name: test', - '- to:', - 'uri: log:test', - ]; + cy.DnDOnEdge('route.from.steps.0.setHeader', 'camel-route|route.from.steps.1.marshal >>> route.from.steps.2.to'); + cy.openSourceCode(); - cy.checkMultiLineContent(yamlRoute); + cy.compareFileWithMonacoEditor('flows/camelRoute/basic-updated.yaml'); + }); + + it('D&D - basic drag and drop on Node', { browser: '!firefox' }, () => { + cy.uploadFixture('flows/camelRoute/basic.yaml'); + cy.openDesignPage(); + cy.DnDOnNode('route.from.steps.0.setHeader', 'camel-route|route.from.steps.3.placeholder'); + + cy.openSourceCode(); + cy.compareFileWithMonacoEditor('flows/camelRoute/basic-updated2.yaml'); }); it('D&D - drag and drop with node fields configured', { browser: '!firefox' }, () => { @@ -39,73 +35,42 @@ describe('Canvas nodes Drag and Drop', () => { cy.interactWithExpressionInputObject('constant.expression', 'testConstantExpression'); cy.interactWithExpressionInputObject('constant.resultType', 'testConstantResultType'); - cy.DnD('route.from.steps.0.setHeader', 'route.from.steps.1.marshal'); - - const yamlRoute = [ - 'id: camel-route', - 'from:', - 'uri: timer:test', - 'steps:', - '- marshal:', - 'id: marshal-3801', - '- setHeader:', - 'constant:', - 'id: testConstantId', - 'expression: testConstantExpression', - 'resultType: testConstantResultType', - 'name: testName', - '- to:', - 'uri: log:test', - ]; + cy.DnDOnEdge('route.from.steps.0.setHeader', 'camel-route|route.from.steps.1.marshal >>> route.from.steps.2.to'); + cy.openSourceCode(); - cy.checkMultiLineContent(yamlRoute); + cy.compareFileWithMonacoEditor('flows/camelRoute/basic-configured-updated.yaml'); }); - it('D&D - drag and drop between two routes', { browser: '!firefox' }, () => { + it('D&D - drag and drop on Edge between two routes', { browser: '!firefox' }, () => { cy.uploadFixture('flows/camelRoute/multiflowDnD.yaml'); cy.openDesignPage(); - cy.DnD('route.from.steps.0.setHeader', 'route.from.steps.0.marshal'); - - const yamlRoute = [ - 'id: route-4321', - 'from:', - 'id: from-3576', - 'uri: timer:template', - 'parameters:', - 'period: "1000"', - 'steps:', - '- marshal:', - 'id: marshal-4048', - '- setHeader:', - 'id: setHeader-3105', - 'expression:', - 'simple: {}', - '- log:', - 'id: log-2966', - 'message: ${body}', - ]; + cy.DnDOnEdge('route.from.steps.0.setHeader', 'route-4321|route.from.steps.0.marshal >>> route.from.steps.1.log'); + cy.openSourceCode(); - cy.checkMultiLineContent(yamlRoute); + cy.compareFileWithMonacoEditor('flows/camelRoute/multiflowDnD-updated.yaml'); }); it('D&D - drag and drop with choice', { browser: '!firefox' }, () => { - cy.uploadFixture('flows/camelRoute/complexMultiFlow.yaml'); + cy.uploadFixture('flows/camelRoute/complex.yaml'); cy.openDesignPage(); - cy.DnD('route.from.steps.0.choice.when.0.steps.0.setHeader', 'route.from.steps.0.choice.when.1.steps.0.log'); - const yamlRoute = [ - '- description: when-log', - 'steps:', - '- log:', - 'message: ${body}', - '- setHeader:', - 'name: setHeader', - 'simple:', - 'expression: foo', - ]; + cy.toggleExpandGroup('when-setHeader'); + cy.toggleExpandGroup('when-log'); + + cy.DnDOnNode('route.from.steps.0.choice.when.0', 'route.from.steps.0.choice.when.1'); + + cy.openSourceCode(); + cy.compareFileWithMonacoEditor('flows/camelRoute/complex-moved.yaml'); + }); + + it('D&D - drag and drop on placeholder nodes between two routes', { browser: '!firefox' }, () => { + cy.uploadFixture('flows/camelRoute/multiflowDnD.yaml'); + cy.openDesignPage(); + + cy.DnDOnNode('route.from.steps.0.setHeader', 'route-4321|route.from.steps.2.placeholder'); + cy.openSourceCode(); - cy.editorScrollToTop(); - cy.checkMultiLineContent(yamlRoute); + cy.compareFileWithMonacoEditor('flows/camelRoute/multiflowDnD-updated2.yaml'); }); }); diff --git a/packages/ui-tests/cypress/e2e/designer/basicNodeActions/stepAddition.cy.ts b/packages/ui-tests/cypress/e2e/designer/basicNodeActions/stepAddition.cy.ts index 3e4f0adf4..baacb1ea4 100644 --- a/packages/ui-tests/cypress/e2e/designer/basicNodeActions/stepAddition.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/basicNodeActions/stepAddition.cy.ts @@ -42,15 +42,15 @@ describe('Tests for Design page', () => { cy.openDesignPage(); cy.openStepConfigurationTab('log'); - cy.quickAppend(); + cy.quickAppendStep('route.from.steps.3.placeholder'); cy.chooseFromCatalog('processor', 'choice'); cy.openGroupConfigurationTab('choice'); - cy.quickAppend(); + cy.quickAppendStep('route.from.steps.4.placeholder'); cy.chooseFromCatalog('component', 'as2'); cy.openStepConfigurationTab('as2'); - cy.quickAppend(); + cy.quickAppendStep('route.from.steps.5.placeholder'); cy.chooseFromCatalog('component', 'amqp'); cy.openSourceCode(); diff --git a/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-configured-updated.yaml b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-configured-updated.yaml new file mode 100644 index 000000000..57cc9c8a6 --- /dev/null +++ b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-configured-updated.yaml @@ -0,0 +1,15 @@ +- route: + id: camel-route + from: + uri: timer:test + steps: + - marshal: + id: marshal-3801 + - setHeader: + constant: + id: testConstantId + expression: testConstantExpression + resultType: testConstantResultType + name: testName + - to: + uri: log:test diff --git a/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated.yaml b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated.yaml new file mode 100644 index 000000000..6c473840f --- /dev/null +++ b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated.yaml @@ -0,0 +1,12 @@ +- route: + id: camel-route + from: + uri: timer:test + steps: + - marshal: + id: marshal-3801 + - setHeader: + constant: test + name: test + - to: + uri: log:test diff --git a/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated2.yaml b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated2.yaml new file mode 100644 index 000000000..766d5d7b9 --- /dev/null +++ b/packages/ui-tests/cypress/fixtures/flows/camelRoute/basic-updated2.yaml @@ -0,0 +1,12 @@ +- route: + id: camel-route + from: + uri: timer:test + steps: + - marshal: + id: marshal-3801 + - to: + uri: log:test + - setHeader: + constant: test + name: test diff --git a/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated.yaml b/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated.yaml new file mode 100644 index 000000000..fbfd08066 --- /dev/null +++ b/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated.yaml @@ -0,0 +1,28 @@ +- route: + id: route-1234 + from: + id: from-3362 + uri: timer:template + parameters: + period: "1000" + steps: + - log: + id: log-6809 + message: ${body} +- route: + id: route-4321 + from: + id: from-3576 + uri: timer:template + parameters: + period: "1000" + steps: + - marshal: + id: marshal-4048 + - setHeader: + id: setHeader-3105 + expression: + simple: {} + - log: + id: log-2966 + message: ${body} diff --git a/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated2.yaml b/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated2.yaml new file mode 100644 index 000000000..90312694d --- /dev/null +++ b/packages/ui-tests/cypress/fixtures/flows/camelRoute/multiflowDnD-updated2.yaml @@ -0,0 +1,28 @@ +- route: + id: route-1234 + from: + id: from-3362 + uri: timer:template + parameters: + period: "1000" + steps: + - log: + id: log-6809 + message: ${body} +- route: + id: route-4321 + from: + id: from-3576 + uri: timer:template + parameters: + period: "1000" + steps: + - marshal: + id: marshal-4048 + - log: + id: log-2966 + message: ${body} + - setHeader: + id: setHeader-3105 + expression: + simple: {} diff --git a/packages/ui-tests/cypress/support/cypress.d.ts b/packages/ui-tests/cypress/support/cypress.d.ts index 3945d0ecd..743ac0df1 100644 --- a/packages/ui-tests/cypress/support/cypress.d.ts +++ b/packages/ui-tests/cypress/support/cypress.d.ts @@ -66,7 +66,7 @@ declare global { closeStepConfigurationTab(): Chainable>; closeCatalogModal(): Chainable>; removeNodeByName(inputName: string, nodeIndex?: number): Chainable>; - quickAppend(nodeIndex?: number): Chainable>; + quickAppendStep(path: string): Chainable>; selectDuplicateNode(inputName: string, nodeIndex?: number): Chainable>; selectMoveBeforeNode(inputName: string, nodeIndex?: number): Chainable>; selectMoveAfterNode(inputName: string, nodeIndex?: number): Chainable>; @@ -95,7 +95,8 @@ declare global { checkDarkMode(): Chainable>; switchCodeToXml(): Chainable>; switchCodeToYaml(): Chainable>; - DnD(sourceNode: string, targetNode: string): Chainable>; + DnDOnNode(sourceNode: string, targetNode: string): Chainable>; + DnDOnEdge(sourceNode: string, targetEdge: string): Chainable>; // nodeConfiguration interactWithConfigInputObject(inputName: string, value?: string): Chainable>; interactWithExpressionInputObject(inputName: string, value?: string, index?: number): Chainable>; diff --git a/packages/ui-tests/cypress/support/next-commands/design.ts b/packages/ui-tests/cypress/support/next-commands/design.ts index 6dab3af4f..9742fbf5a 100644 --- a/packages/ui-tests/cypress/support/next-commands/design.ts +++ b/packages/ui-tests/cypress/support/next-commands/design.ts @@ -39,9 +39,8 @@ Cypress.Commands.add('removeNodeByName', (nodeName: string, nodeIndex?: number) cy.wait(1000); }); -Cypress.Commands.add('quickAppend', (nodeIndex?: number) => { - nodeIndex = nodeIndex ?? 0; - cy.get('[data-testid="quick-append-step"]').eq(nodeIndex).click({ force: true }); +Cypress.Commands.add('quickAppendStep', (path: string) => { + cy.get(`[data-testid="placeholder-node__${path}"]`).click({ force: true }); }); Cypress.Commands.add('selectDuplicateNode', (nodeName: string, nodeIndex?: number) => { @@ -213,10 +212,18 @@ Cypress.Commands.add('checkLightMode', () => { cy.get('html').should('not.have.class', 'pf-v6-theme-dark'); }); -Cypress.Commands.add('DnD', (sourceNodeName: string, targetNodeName: string) => { +Cypress.Commands.add('DnDOnNode', (sourceNodeName: string, targetNodeName: string) => { const sourceNode = cy.get(`[data-testid="${sourceNodeName}"]`); const targetNode = cy.get(`[data-testid="${targetNodeName}"]`); sourceNode.realMouseDown({ button: 'left', position: 'center' }).realMouseMove(0, 0, { position: 'center' }); targetNode.realMouseMove(0, 0, { position: 'center' }).realMouseUp(); }); + +Cypress.Commands.add('DnDOnEdge', (sourceNodeName: string, targetEdgeName: string) => { + const sourceNode = cy.get(`[data-testid="${sourceNodeName}"]`); + const targetEdge = cy.get(`[data-id="${targetEdgeName}"]`); + + sourceNode.realMouseDown({ button: 'left', position: 'center' }).realMouseMove(0, 0, { position: 'center' }); + targetEdge.realMouseMove(0, 0, { position: 'center' }).realMouseUp(); +}); diff --git a/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.test.tsx b/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.test.tsx new file mode 100644 index 000000000..6d88f3dd9 --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.test.tsx @@ -0,0 +1,395 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { IVisualizationNode } from '../../../../models'; +import { useDeleteGroup } from '../../Custom/hooks/delete-group.hook'; +import { useDeleteStep } from '../../Custom/hooks/delete-step.hook'; +import { useDisableStep } from '../../Custom/hooks/disable-step.hook'; +import { useDuplicateStep } from '../../Custom/hooks/duplicate-step.hook'; +import { useEnableAllSteps } from '../../Custom/hooks/enable-all-steps.hook'; +import { useInsertStep } from '../../Custom/hooks/insert-step.hook'; +import { useMoveStep } from '../../Custom/hooks/move-step.hook'; +import { useReplaceStep } from '../../Custom/hooks/replace-step.hook'; +import { StepToolbar } from './StepToolbar'; + +// Mock all hooks +jest.mock('../../Custom/hooks/delete-group.hook'); +jest.mock('../../Custom/hooks/delete-step.hook'); +jest.mock('../../Custom/hooks/disable-step.hook'); +jest.mock('../../Custom/hooks/duplicate-step.hook'); +jest.mock('../../Custom/hooks/move-step.hook'); +jest.mock('../../Custom/hooks/enable-all-steps.hook'); +jest.mock('../../Custom/hooks/insert-step.hook'); +jest.mock('../../Custom/hooks/replace-step.hook'); + +const mockUseDeleteGroup = useDeleteGroup as jest.MockedFunction; +const mockUseDeleteStep = useDeleteStep as jest.MockedFunction; +const mockUseDisableStep = useDisableStep as jest.MockedFunction; +const mockUseDuplicateStep = useDuplicateStep as jest.MockedFunction; +const mockUseMoveStep = useMoveStep as jest.MockedFunction; +const mockUseEnableAllSteps = useEnableAllSteps as jest.MockedFunction; +const mockUseInsertStep = useInsertStep as jest.MockedFunction; +const mockUseReplaceStep = useReplaceStep as jest.MockedFunction; + +describe('StepToolbar', () => { + const mockGetNodeInteraction = jest.fn(); + const mockVizNode = { + getNodeInteraction: mockGetNodeInteraction, + } as unknown as IVisualizationNode; + + const defaultNodeInteraction = { + canHavePreviousStep: false, + canHaveNextStep: false, + canHaveChildren: false, + canHaveSpecialChildren: false, + canReplaceStep: false, + canRemoveStep: false, + canRemoveFlow: false, + canBeDisabled: false, + }; + + beforeEach(() => { + // Default hook implementations + mockUseDeleteGroup.mockReturnValue({ onDeleteGroup: jest.fn() }); + mockUseDeleteStep.mockReturnValue({ onDeleteStep: jest.fn() }); + mockUseDisableStep.mockReturnValue({ onToggleDisableNode: jest.fn(), isDisabled: false }); + mockUseDuplicateStep.mockReturnValue({ canDuplicate: false, onDuplicate: jest.fn() }); + mockUseMoveStep.mockReturnValue({ canBeMoved: false, onMoveStep: jest.fn() }); + mockUseEnableAllSteps.mockReturnValue({ areMultipleStepsDisabled: false, onEnableAllSteps: jest.fn() }); + mockUseInsertStep.mockReturnValue({ onInsertStep: jest.fn() }); + mockUseReplaceStep.mockReturnValue({ onReplaceNode: jest.fn() }); + + mockGetNodeInteraction.mockReturnValue(defaultNodeInteraction); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render the toolbar with correct data-testid', async () => { + await act(async () => { + render(); + }); + + expect(screen.getByTestId('test-toolbar')).toBeInTheDocument(); + expect(screen.getByTestId('test-toolbar')).toHaveClass('step-toolbar'); + }); + + it('should render no buttons when all interactions are disabled', async () => { + await act(async () => { + render(); + }); + + expect(screen.queryByTestId('step-toolbar-button-duplicate')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-add-special')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-disable')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-enable-all')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-replace')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-delete')).not.toBeInTheDocument(); + expect(screen.queryByTestId('step-toolbar-button-delete-group')).not.toBeInTheDocument(); + }); + }); + + describe('Duplicate button', () => { + it('should render duplicate button when canDuplicate is true and call onDuplicate when duplicate button is clicked', async () => { + const mockOnDuplicate = jest.fn(); + mockUseDuplicateStep.mockReturnValue({ canDuplicate: true, onDuplicate: mockOnDuplicate }); + + await act(async () => { + render(); + }); + + const duplicateButton = screen.getByTestId('step-toolbar-button-duplicate'); + expect(duplicateButton).toBeInTheDocument(); + expect(duplicateButton).toHaveAttribute('title', 'Duplicate'); + + act(() => { + fireEvent.click(duplicateButton); + }); + expect(mockOnDuplicate).toHaveBeenCalledTimes(1); + }); + }); + + describe('Move button', () => { + it('should render Move Before button when canBeMoved is true and call onMoveStep when Move button is clicked', async () => { + const mockOnMoveStep = jest.fn(); + mockUseMoveStep.mockReturnValue({ canBeMoved: true, onMoveStep: mockOnMoveStep }); + + await act(async () => { + render(); + }); + + const moveButton = screen.getByTestId('step-toolbar-button-move-before'); + expect(moveButton).toBeInTheDocument(); + expect(moveButton).toHaveAttribute('title', 'Move before'); + + act(() => { + fireEvent.click(moveButton); + }); + expect(mockOnMoveStep).toHaveBeenCalledTimes(1); + }); + + it('should render Move After button when canBeMoved is true and call onMoveStep when Move button is clicked', async () => { + const mockOnMoveStep = jest.fn(); + mockUseMoveStep.mockReturnValue({ canBeMoved: true, onMoveStep: mockOnMoveStep }); + + await act(async () => { + render(); + }); + + const moveButton = screen.getByTestId('step-toolbar-button-move-after'); + expect(moveButton).toBeInTheDocument(); + expect(moveButton).toHaveAttribute('title', 'Move after'); + + act(() => { + fireEvent.click(moveButton); + }); + expect(mockOnMoveStep).toHaveBeenCalledTimes(1); + }); + }); + + describe('Add special children button', () => { + it('should render add special button when canHaveSpecialChildren is true and call onInsertStep when add special button is clicked', async () => { + const mockOnInsertStep = jest.fn(); + mockUseInsertStep.mockReturnValue({ onInsertStep: mockOnInsertStep }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canHaveSpecialChildren: true, + }); + + await act(async () => { + render(); + }); + + const addSpecialButton = screen.getByTestId('step-toolbar-button-add-special'); + expect(addSpecialButton).toBeInTheDocument(); + expect(addSpecialButton).toHaveAttribute('title', 'Add branch'); + + act(() => { + fireEvent.click(addSpecialButton); + }); + expect(mockOnInsertStep).toHaveBeenCalledTimes(1); + }); + }); + + describe('Disable button', () => { + it('should show "Disable step" title when step is enabled', async () => { + mockUseDisableStep.mockReturnValue({ onToggleDisableNode: jest.fn(), isDisabled: false }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canBeDisabled: true, + }); + + await act(async () => { + render(); + }); + + const disableButton = screen.getByTestId('step-toolbar-button-disable'); + expect(disableButton).toHaveAttribute('title', 'Disable step'); + }); + + it('should show "Enable step" title when step is disabled', async () => { + mockUseDisableStep.mockReturnValue({ onToggleDisableNode: jest.fn(), isDisabled: true }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canBeDisabled: true, + }); + + await act(async () => { + render(); + }); + + const disableButton = screen.getByTestId('step-toolbar-button-disable'); + expect(disableButton).toHaveAttribute('title', 'Enable step'); + }); + + it('should call onToggleDisableNode when disable button is clicked', async () => { + const mockOnToggleDisableNode = jest.fn(); + mockUseDisableStep.mockReturnValue({ onToggleDisableNode: mockOnToggleDisableNode, isDisabled: false }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canBeDisabled: true, + }); + + await act(async () => { + render(); + }); + + const disableButton = screen.getByTestId('step-toolbar-button-disable'); + act(() => { + fireEvent.click(disableButton); + }); + expect(mockOnToggleDisableNode).toHaveBeenCalledTimes(1); + }); + }); + + describe('Enable all button', () => { + it('should render enable all button when areMultipleStepsDisabled is true and call onEnableAllSteps when enable all button is clicked', async () => { + const mockOnEnableAllSteps = jest.fn(); + mockUseEnableAllSteps.mockReturnValue({ areMultipleStepsDisabled: true, onEnableAllSteps: mockOnEnableAllSteps }); + + await act(async () => { + render(); + }); + + const enableAllButton = screen.getByTestId('step-toolbar-button-enable-all'); + expect(enableAllButton).toBeInTheDocument(); + expect(enableAllButton).toHaveAttribute('title', 'Enable all'); + + act(() => { + fireEvent.click(enableAllButton); + }); + expect(mockOnEnableAllSteps).toHaveBeenCalledTimes(1); + }); + }); + + describe('Replace button', () => { + it('should render replace button when canReplaceStep is true and call onReplaceNode when replace button is clicked', async () => { + const mockOnReplaceNode = jest.fn(); + mockUseReplaceStep.mockReturnValue({ onReplaceNode: mockOnReplaceNode }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canReplaceStep: true, + }); + + await act(async () => { + render(); + }); + + const replaceButton = screen.getByTestId('step-toolbar-button-replace'); + expect(replaceButton).toBeInTheDocument(); + expect(replaceButton).toHaveAttribute('title', 'Replace step'); + + act(() => { + fireEvent.click(replaceButton); + }); + expect(mockOnReplaceNode).toHaveBeenCalledTimes(1); + }); + }); + + describe('Collapse button', () => { + it('should show "Collapse step" title when not collapsed', async () => { + const mockOnCollapseToggle = jest.fn(); + + await act(async () => { + render(); + }); + + const collapseButton = screen.getByTestId('step-toolbar-button-collapse'); + expect(collapseButton).toHaveAttribute('title', 'Collapse step'); + }); + + it('should show "Expand step" title when collapsed', async () => { + const mockOnCollapseToggle = jest.fn(); + + await act(async () => { + render(); + }); + + const collapseButton = screen.getByTestId('step-toolbar-button-collapse'); + expect(collapseButton).toHaveAttribute('title', 'Expand step'); + }); + + it('should call onCollapseToggle when collapse button is clicked', async () => { + const mockOnCollapseToggle = jest.fn(); + + await act(async () => { + render(); + }); + + const collapseButton = screen.getByTestId('step-toolbar-button-collapse'); + act(() => { + fireEvent.click(collapseButton); + }); + expect(mockOnCollapseToggle).toHaveBeenCalledTimes(1); + }); + + it('should not render collapse button when onCollapseToggle is not provided', async () => { + await act(async () => { + render(); + }); + + expect(screen.queryByTestId('step-toolbar-button-collapse')).not.toBeInTheDocument(); + }); + }); + + describe('Delete step button', () => { + it('should render delete step button when canRemoveStep is true and call onDeleteStep when delete step button is clicked', async () => { + const mockOnDeleteStep = jest.fn(); + mockUseDeleteStep.mockReturnValue({ onDeleteStep: mockOnDeleteStep }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canRemoveStep: true, + }); + + await act(async () => { + render(); + }); + + const deleteButton = screen.getByTestId('step-toolbar-button-delete'); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).toHaveAttribute('title', 'Delete step'); + + act(() => { + fireEvent.click(deleteButton); + }); + expect(mockOnDeleteStep).toHaveBeenCalledTimes(1); + }); + }); + + describe('Delete group button', () => { + it('should render delete group button when canRemoveFlow is true and call onDeleteGroup when delete group button is clicked', async () => { + const mockOnDeleteGroup = jest.fn(); + mockUseDeleteGroup.mockReturnValue({ onDeleteGroup: mockOnDeleteGroup }); + mockGetNodeInteraction.mockReturnValue({ + ...defaultNodeInteraction, + canRemoveFlow: true, + }); + + await act(async () => { + render(); + }); + + const deleteGroupButton = screen.getByTestId('step-toolbar-button-delete-group'); + expect(deleteGroupButton).toBeInTheDocument(); + expect(deleteGroupButton).toHaveAttribute('title', 'Delete group'); + + act(() => { + fireEvent.click(deleteGroupButton); + }); + expect(mockOnDeleteGroup).toHaveBeenCalledTimes(1); + }); + }); + + describe('Multiple buttons rendering', () => { + it('should render all available buttons when all interactions are enabled', async () => { + const mockOnCollapseToggle = jest.fn(); + mockGetNodeInteraction.mockReturnValue({ + canHavePreviousStep: true, + canHaveNextStep: true, + canHaveChildren: true, + canHaveSpecialChildren: true, + canReplaceStep: true, + canRemoveStep: true, + canRemoveFlow: true, + canBeDisabled: true, + }); + mockUseDuplicateStep.mockReturnValue({ canDuplicate: true, onDuplicate: jest.fn() }); + mockUseDisableStep.mockReturnValue({ onToggleDisableNode: jest.fn(), isDisabled: false }); + mockUseEnableAllSteps.mockReturnValue({ areMultipleStepsDisabled: true, onEnableAllSteps: jest.fn() }); + + await act(async () => { + render(); + }); + + expect(screen.getByTestId('step-toolbar-button-duplicate')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-add-special')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-disable')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-enable-all')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-replace')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-collapse')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-delete')).toBeInTheDocument(); + expect(screen.getByTestId('step-toolbar-button-delete-group')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.tsx b/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.tsx index 59e0d8187..77c84cd72 100644 --- a/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.tsx +++ b/packages/ui/src/components/Visualization/Canvas/StepToolbar/StepToolbar.tsx @@ -2,6 +2,8 @@ import './StepToolbar.scss'; import { Button } from '@patternfly/react-core'; import { + AngleDoubleDownIcon, + AngleDoubleUpIcon, BanIcon, BlueprintIcon, CheckIcon, @@ -22,6 +24,7 @@ import { useDisableStep } from '../../Custom/hooks/disable-step.hook'; import { useDuplicateStep } from '../../Custom/hooks/duplicate-step.hook'; import { useEnableAllSteps } from '../../Custom/hooks/enable-all-steps.hook'; import { useInsertStep } from '../../Custom/hooks/insert-step.hook'; +import { useMoveStep } from '../../Custom/hooks/move-step.hook'; import { useReplaceStep } from '../../Custom/hooks/replace-step.hook'; interface IStepToolbar extends IDataTestID { @@ -48,6 +51,8 @@ export const StepToolbar: FunctionComponent = ({ const { onDeleteStep } = useDeleteStep(vizNode); const { onDeleteGroup } = useDeleteGroup(vizNode); const { canDuplicate, onDuplicate } = useDuplicateStep(vizNode); + const { canBeMoved: canMoveBefore, onMoveStep: onMoveBefore } = useMoveStep(vizNode, AddStepMode.PrependStep); + const { canBeMoved: canMoveAfter, onMoveStep: onMoveAfter } = useMoveStep(vizNode, AddStepMode.AppendStep); return (
@@ -65,6 +70,34 @@ export const StepToolbar: FunctionComponent = ({ /> )} + {canMoveBefore && ( +