From 0a777b311fd066b745c7948e0be9a8ef1ceff3c6 Mon Sep 17 00:00:00 2001 From: tplevko Date: Thu, 25 Jul 2024 11:43:52 +0200 Subject: [PATCH] fix(1216): Expressions should be shown similar to data formats in the config panel --- .../sidepanelConfig/propertiesFilter.cy.ts | 10 +- .../specialCamelRoutes/onException.cy.ts | 30 ++-- .../expressionStepConfig.cy.ts | 23 +-- .../setHeadersStepConfig.cy.ts | 12 +- .../ui-tests/cypress/support/cypress.d.ts | 11 +- .../next-commands/nodeConfiguration.ts | 61 +++---- .../ExpressionAwareNestField.test.tsx | 49 ++--- .../expression/ExpressionAwareNestField.tsx | 85 +-------- .../Form/expression/ExpressionEditor.scss | 5 +- .../Form/expression/ExpressionEditor.test.tsx | 156 +++++++++------- .../Form/expression/ExpressionEditor.tsx | 84 ++++++--- .../Form/expression/ExpressionField.test.tsx | 58 +----- .../Form/expression/ExpressionField.tsx | 62 +------ .../expression/ExpressionModalLauncher.scss | 3 - .../ExpressionModalLauncher.test.tsx | 170 ------------------ .../expression/ExpressionModalLauncher.tsx | 107 ----------- .../expression/expression.service.test.ts | 2 +- .../Form/expression/expression.service.ts | 4 + .../StepExpressionEditor.test.tsx | 30 +--- .../stepExpression/StepExpressionEditor.tsx | 158 +++++++++------- .../Canvas/CanvasFormTabs.test.tsx | 23 --- 21 files changed, 347 insertions(+), 796 deletions(-) delete mode 100644 packages/ui/src/components/Form/expression/ExpressionModalLauncher.scss delete mode 100644 packages/ui/src/components/Form/expression/ExpressionModalLauncher.test.tsx delete mode 100644 packages/ui/src/components/Form/expression/ExpressionModalLauncher.tsx diff --git a/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts b/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts index 2a5dc9f85..a3677daa7 100644 --- a/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts @@ -16,11 +16,12 @@ describe('Tests for side panel step filtering', () => { cy.get(`textarea[name="description"]`).should('exist'); cy.get(`input[name="name"]`).should('exist'); cy.get(`input[name="disabled"]`).should('exist'); - cy.get(`input[data-testid="expression-preview-input"]`).should('exist'); + cy.get(`.expression-metadata-editor`).should('exist'); + cy.get('.pf-v5-c-card__header-toggle').click(); // filter fields cy.filterFields('name'); - cy.get(`input[data-testid="expression-preview-input"]`).should('exist'); + cy.get(`.expression-metadata-editor`).should('exist'); cy.get(`input[name="name"]`).should('exist'); cy.get(`input[name="id"]`).should('not.exist'); cy.get(`textarea[name="description"]`).should('not.exist'); @@ -40,11 +41,12 @@ describe('Tests for side panel step filtering', () => { cy.get(`textarea[name="description"]`).should('exist'); cy.get(`input[name="name"]`).should('exist'); cy.get(`input[name="disabled"]`).should('exist'); - cy.get(`input[data-testid="expression-preview-input"]`).should('exist'); + cy.get(`.expression-metadata-editor`).should('exist'); + cy.get('.pf-v5-c-card__header-toggle').click(); // filter fields cy.filterFields('DISABLED'); - cy.get(`input[data-testid="expression-preview-input"]`).should('exist'); + cy.get(`.expression-metadata-editor`).should('exist'); cy.get(`input[name="disabled"]`).should('exist'); cy.get(`input[name="name"]`).should('not.exist'); cy.get(`input[name="id"]`).should('not.exist'); diff --git a/packages/ui-tests/cypress/e2e/designer/specialCamelRoutes/onException.cy.ts b/packages/ui-tests/cypress/e2e/designer/specialCamelRoutes/onException.cy.ts index b8be954de..cb544bf2c 100644 --- a/packages/ui-tests/cypress/e2e/designer/specialCamelRoutes/onException.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/specialCamelRoutes/onException.cy.ts @@ -61,22 +61,22 @@ describe('Test for root on exception container', () => { cy.get(`input[name="redeliveryPolicy.redeliveryDelay"]`).clear().type('2000'); cy.get(`input[name="redeliveryPolicy.retryAttemptedLogInterval"]`).clear().type('2'); cy.get(`input[name="redeliveryPolicyRef"]`).clear().type('testRedeliveryPolicyRef'); - cy.openExpressionModal('retryWhile'); - cy.selectExpression('Constant'); - cy.interactWithExpressionInputObject('expression', `retryWhile.constant`); - cy.interactWithExpressionInputObject('id', 'retryWhile.constantExpressionId'); - cy.confirmExpressionModal(); - cy.openExpressionModal('handled'); - cy.selectExpression('Constant'); - cy.interactWithExpressionInputObject('expression', `handled.constant`); - cy.interactWithExpressionInputObject('id', 'handled.constantExpressionId'); - cy.confirmExpressionModal(); - cy.openExpressionModal('continued'); - cy.selectExpression('Constant'); - cy.interactWithExpressionInputObject('expression', `continued.constant`); - cy.interactWithExpressionInputObject('id', 'continued.constantExpressionId'); - cy.confirmExpressionModal(); + cy.get('[data-fieldname="retryWhile"]').within(() => { + cy.selectExpression('Constant'); + cy.interactWithExpressionInputObject('expression', `retryWhile.constant`); + cy.interactWithExpressionInputObject('id', 'retryWhile.constantExpressionId'); + }); + cy.get('[data-fieldname="handled"]').within(() => { + cy.selectExpression('Constant'); + cy.interactWithExpressionInputObject('expression', `handled.constant`); + cy.interactWithExpressionInputObject('id', 'handled.constantExpressionId'); + }); + cy.get('[data-fieldname="continued"]').within(() => { + cy.selectExpression('Constant'); + cy.interactWithExpressionInputObject('expression', `continued.constant`); + cy.interactWithExpressionInputObject('id', 'continued.constantExpressionId'); + }); cy.openSourceCode(); cy.checkCodeSpanLine('description: testDescription'); diff --git a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts index d43c0e306..c08c7f163 100644 --- a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/expressionStepConfig.cy.ts @@ -8,12 +8,10 @@ describe('Tests for sidebar expression configuration', () => { cy.openDesignPage(); // Configure setHeader expression cy.openStepConfigurationTab('setHeader'); - cy.openExpressionModalBtn(); cy.selectExpression('Simple'); cy.interactWithExpressionInputObject('expression', `{{}{{}header.baz}}`); cy.interactWithExpressionInputObject('id', 'simpleExpressionId'); cy.addExpressionResultType('java.lang.String'); - cy.confirmExpressionModal(); // CHECK they are reflected in the code editor cy.openSourceCode(); cy.checkCodeSpanLine('expression: "{{header.baz}}"', 1); @@ -27,12 +25,10 @@ describe('Tests for sidebar expression configuration', () => { cy.openDesignPage(); cy.openStepConfigurationTab('setHeader'); - cy.openExpressionModalBtn(); cy.selectExpression('JQ'); cy.interactWithConfigInputObject('expression', '.id'); cy.addExpressionResultType('java.lang.String'); cy.interactWithConfigInputObject('trim'); - cy.confirmExpressionModal(); cy.selectAppendNode('setHeader'); cy.chooseFromCatalog('processor', 'setHeader'); @@ -40,29 +36,23 @@ describe('Tests for sidebar expression configuration', () => { cy.checkNodeExist('setHeader', 2); cy.openStepConfigurationTab('setHeader', 1); - cy.openExpressionModalBtn(); cy.selectExpression('JQ'); cy.interactWithConfigInputObject('expression', '.name'); cy.addExpressionResultType('java.lang.String'); cy.interactWithConfigInputObject('trim'); - cy.confirmExpressionModal(); cy.openStepConfigurationTab('setHeader', 0); // Check the configured fields didn't disappear from the first node - cy.openExpressionModalBtn(); cy.checkConfigCheckboxObject('trim', true); cy.checkExpressionResultType('java.lang.String'); cy.checkConfigInputObject('expression', '.id'); - cy.cancelExpressionModal(); // Check the configured fields didn't disappear from the second node cy.openStepConfigurationTab('setHeader', 0); - cy.openExpressionModalBtn(); cy.checkConfigCheckboxObject('trim', true); cy.addExpressionResultType('java.lang.String'); cy.checkConfigInputObject('expression', '.name'); - cy.cancelExpressionModal(); // CHECK they are reflected in the code editor cy.openSourceCode(); @@ -76,18 +66,11 @@ describe('Tests for sidebar expression configuration', () => { cy.openDesignPage(); // Configure setHeader expression cy.openStepConfigurationTab('setHeader'); - cy.openExpressionModalBtn(); cy.selectExpression('Simple'); cy.interactWithExpressionInputObject('expression', `{{}{{}header.baz}}`); - cy.get('[data-ouia-component-id="ExpressionModal"]').within(() => { - cy.get('textarea[name="expression"]').should('have.value', '{{header.baz}}'); - }); + cy.get('textarea[name="expression"]').should('have.value', '{{header.baz}}'); cy.selectExpression('Constant'); - cy.get('[data-ouia-component-id="ExpressionModal"]').within(() => { - cy.get('textarea[name="expression"]').should('not.have.value', '{{header.baz}}'); - }); - - cy.confirmExpressionModal(); + cy.get('textarea[name="expression"]').should('not.have.value', '{{header.baz}}'); // CHECK they are reflected in the code editor cy.openSourceCode(); @@ -100,12 +83,10 @@ describe('Tests for sidebar expression configuration', () => { cy.openDesignPage(); // Configure setBody expression cy.openStepConfigurationTab('setBody'); - cy.openExpressionModalBtn(); cy.selectExpression('Simple'); cy.interactWithExpressionInputObject('expression', `{{}{{}body.baz}}`); cy.interactWithExpressionInputObject('id', 'simpleExpressionId'); cy.addExpressionResultType('java.lang.String'); - cy.confirmExpressionModal(); // CHECK they are reflected in the code editor cy.openSourceCode(); diff --git a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/setHeadersStepConfig.cy.ts b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/setHeadersStepConfig.cy.ts index 91a047c74..159e5e6a7 100644 --- a/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/setHeadersStepConfig.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/specialStepConfiguration/setHeadersStepConfig.cy.ts @@ -12,22 +12,18 @@ describe('Tests for sidebar setHeaders step configuration', () => { cy.openStepConfigurationTab('setHeaders'); cy.get('[data-testid="list-add-field"]').click(); - cy.get('[data-testid="launch-expression-modal-btn"]').should('be.visible').click(); cy.selectExpression('Simple'); cy.interactWithExpressionInputObject('expression', `{{}random(1,100)}`); cy.interactWithExpressionInputObject('id', 'simpleExpressionId'); cy.addExpressionResultType('java.lang.String'); - cy.confirmExpressionModal(); cy.get('[data-testid="list-add-field"]').click(); - cy.get('[data-testid="launch-expression-modal-btn"]').eq(1).should('be.visible').click(); - cy.selectExpression('Constant'); - cy.interactWithExpressionInputObject('expression', `constant`); - cy.interactWithExpressionInputObject('id', 'constantExpressionId'); - cy.addExpressionResultType('java.lang.String'); - cy.confirmExpressionModal(); + cy.selectExpression('Constant', 1); + cy.interactWithExpressionInputObject('expression', `constant`, 1); + cy.interactWithExpressionInputObject('id', 'constantExpressionId', 1); + cy.addExpressionResultType('java.lang.String', 1); cy.openSourceCode(); const headers = [ diff --git a/packages/ui-tests/cypress/support/cypress.d.ts b/packages/ui-tests/cypress/support/cypress.d.ts index e145a9a12..d5c49a65b 100644 --- a/packages/ui-tests/cypress/support/cypress.d.ts +++ b/packages/ui-tests/cypress/support/cypress.d.ts @@ -1,3 +1,4 @@ +import { Index } from '@kaoto/camel-catalog/types'; // Augment the Cypress namespace to include type definitions for // your custom command. // Alternatively, can be defined in cypress/support/component.d.ts @@ -53,18 +54,14 @@ declare global { checkCatalogVersion(version: string): Chainable>; chooseFromCatalog(nodeType: string, name: string): Chainable>; // nodeConfiguration - interactWithExpressionInputObject(inputName: string, value?: string): Chainable>; - addExpressionResultType(value: string): Chainable>; + interactWithExpressionInputObject(inputName: string, value?: string, index?: number): Chainable>; + addExpressionResultType(value: string, index?: number): Chainable>; checkExpressionResultType(value: string): Chainable>; interactWithConfigInputObject(inputName: string, value?: string): Chainable>; interactWithDataformatInputObject(inputName: string, value?: string): Chainable>; checkConfigCheckboxObject(inputName: string, value: boolean): Chainable>; checkConfigInputObject(inputName: string, value: string): Chainable>; - openExpressionModalBtn(): Chainable>; - openExpressionModal(expression: string): Chainable>; - selectExpression(expression: string): Chainable>; - confirmExpressionModal(): Chainable>; - cancelExpressionModal(): Chainable>; + selectExpression(expression: string, index?: number): Chainable>; selectInTypeaheadField(inputGroup: string, value: string): Chainable>; configureBeanReference(inputName: string, value: string): Chainable>; configureNewBeanReference(inputName: string): Chainable>; diff --git a/packages/ui-tests/cypress/support/next-commands/nodeConfiguration.ts b/packages/ui-tests/cypress/support/next-commands/nodeConfiguration.ts index bf3a90506..9068f64e8 100644 --- a/packages/ui-tests/cypress/support/next-commands/nodeConfiguration.ts +++ b/packages/ui-tests/cypress/support/next-commands/nodeConfiguration.ts @@ -1,16 +1,24 @@ -Cypress.Commands.add('interactWithExpressionInputObject', (inputName: string, value?: string) => { - cy.get('[data-ouia-component-id="ExpressionModal"]').within(() => { - cy.interactWithConfigInputObject(inputName, value); - }); +Cypress.Commands.add('interactWithExpressionInputObject', (inputName: string, value?: string, index?: number) => { + index = index ?? 0; + cy.get('.expression-metadata-editor-card') + .eq(index) + .parent() + .within(() => { + cy.interactWithConfigInputObject(inputName, value); + }); }); -Cypress.Commands.add('addExpressionResultType', (value: string) => { - cy.get('[data-ouia-component-id="ExpressionModal"]').within(() => { - cy.get('[data-fieldname="resultType"]').within(() => { - cy.get(`input.pf-v5-c-text-input-group__text-input`).clear(); - cy.get(`input.pf-v5-c-text-input-group__text-input`).type(value).type('{enter}'); +Cypress.Commands.add('addExpressionResultType', (value: string, index?: number) => { + index = index ?? 0; + cy.get('.expression-metadata-editor-card') + .eq(index) + .parent() + .within(() => { + cy.get('[data-fieldname="resultType"]').within(() => { + cy.get(`input.pf-v5-c-text-input-group__text-input`).clear(); + cy.get(`input.pf-v5-c-text-input-group__text-input`).type(value).type('{enter}'); + }); }); - }); }); Cypress.Commands.add('checkExpressionResultType', (value: string) => { @@ -43,35 +51,24 @@ Cypress.Commands.add('checkConfigInputObject', (inputName: string, value: string cy.get(`input[name="${inputName}"], textarea[name="${inputName}"]`).should('have.value', value); }); -Cypress.Commands.add('openExpressionModalBtn', () => { - cy.get('[data-testid="launch-expression-modal-btn"]').scrollIntoView().should('be.visible').click(); -}); - -Cypress.Commands.add('openExpressionModal', (expression: string) => { - cy.get(`[data-fieldname="${expression}"]`) +Cypress.Commands.add('selectExpression', (expression: string, index?: number) => { + index = index ?? 0; + cy.get('[data-testid="expression-config-card"]') + .eq(index) .scrollIntoView() + .should('be.visible') .within(() => { - cy.openExpressionModalBtn(); + cy.get('div.pf-m-typeahead') + .eq(0) + .should('be.visible') + .within(() => { + cy.get('button.pf-v5-c-menu-toggle__button').click(); + }); }); -}); - -Cypress.Commands.add('selectExpression', (expression: string) => { - cy.get('div[data-ouia-component-id="ExpressionModal"] button.pf-v5-c-menu-toggle__button') - .eq(0) - .should('be.visible') - .click(); const regex = new RegExp(`^${expression}$`); cy.get('span.pf-v5-c-menu__item-text').contains(regex).should('exist').scrollIntoView().click(); }); -Cypress.Commands.add('confirmExpressionModal', () => { - cy.get('[data-testid="confirm-expression-modal-btn"]').should('be.visible').click(); -}); - -Cypress.Commands.add('cancelExpressionModal', () => { - cy.get('[data-testid="cancel-expression-modal-btn"]').should('be.visible').click(); -}); - Cypress.Commands.add('selectInTypeaheadField', (inputGroup: string, value: string) => { cy.get(`[data-fieldname="${inputGroup}"]`).within(() => { cy.get('button.pf-v5-c-menu-toggle__button').click(); diff --git a/packages/ui/src/components/Form/expression/ExpressionAwareNestField.test.tsx b/packages/ui/src/components/Form/expression/ExpressionAwareNestField.test.tsx index ba6208ff3..6e40ffc43 100644 --- a/packages/ui/src/components/Form/expression/ExpressionAwareNestField.test.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionAwareNestField.test.tsx @@ -38,7 +38,7 @@ describe('ExpressionAwareNestField', () => { CamelCatalogService.setCatalogKey(CatalogKind.Language, catalogsMap.languageCatalog); }); - it('should render with a modal closed, open by click, then close by cancel button', () => { + it('should render', async () => { render( @@ -46,31 +46,21 @@ describe('ExpressionAwareNestField', () => { , ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - expect(link).toBeInTheDocument(); - expect(screen.queryByRole('dialog')).toBeNull(); - act(() => { - fireEvent.click(link); - }); - expect(screen.queryByRole('dialog')).toBeInTheDocument(); - const cancelBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Cancel'); - expect(cancelBtn).toHaveLength(1); - act(() => { - fireEvent.click(cancelBtn[0]); + const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(buttons[0]); }); - expect(screen.queryByRole('dialog')).toBeNull(); + const json = screen.getByTestId('expression-dropdownitem-datasonnet'); + fireEvent.click(json.getElementsByTagName('button')[0]); + const form = screen.getByTestId('metadata-editor-form-expression'); + expect(form.innerHTML).toContain('Output Media Type'); }); it('should render with parameters filled with passed in model, emit onChange with apply button', () => { const mockOnChange = jest.fn(); const fieldProperties = { - value: { simple: { expression: '${body}', resultType: 'string' } }, + value: { expression: { simple: { expression: '${body}', resultType: 'string' } } }, onChange: mockOnChange, - field: { - type: 'object', - title: 'expression field title', - $comment: 'expression', - }, }; render( @@ -79,25 +69,12 @@ describe('ExpressionAwareNestField', () => { , ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - act(() => { - fireEvent.click(link); - }); - - const expressionInput = screen - .getAllByRole('textbox') - .filter((textbox) => textbox.getAttribute('name') === 'expression'); - expect(expressionInput).toHaveLength(1); - expect(expressionInput[0].textContent).toEqual('${body}'); - act(() => { - fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - }); - - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); - expect(applyBtn).toHaveLength(1); + const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); + expect(idInput).toHaveLength(1); + expect(idInput[0].getAttribute('value')).toEqual(''); expect(mockOnChange.mock.calls).toHaveLength(0); act(() => { - fireEvent.click(applyBtn[0]); + fireEvent.input(idInput[0], { target: { value: 'foo' } }); }); expect(mockOnChange.mock.calls).toHaveLength(1); }); diff --git a/packages/ui/src/components/Form/expression/ExpressionAwareNestField.tsx b/packages/ui/src/components/Form/expression/ExpressionAwareNestField.tsx index beb52aaa2..7ff428e0e 100644 --- a/packages/ui/src/components/Form/expression/ExpressionAwareNestField.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionAwareNestField.tsx @@ -17,76 +17,15 @@ * under the License. */ -import { connectField, filterDOMProps, HTMLFieldProps } from 'uniforms'; +import { AutoField } from '@kaoto-next/uniforms-patternfly'; import { Card, CardBody } from '@patternfly/react-core'; -import { AutoField, wrapField } from '@kaoto-next/uniforms-patternfly'; -import { ExpressionModalLauncher } from './ExpressionModalLauncher'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ICamelLanguageDefinition } from '../../../models'; -import { ExpressionService } from './expression.service'; -import { getSerializedModel } from '../../../utils'; +import { HTMLFieldProps, connectField, filterDOMProps } from 'uniforms'; +import { ExpressionEditor } from './ExpressionEditor'; export type NestFieldProps = HTMLFieldProps; export const ExpressionAwareNestField = connectField( - ({ - children, - error, - errorMessage, - fields, - itemProps, - label, - name, - showInlineError, - disabled, - ...props - }: NestFieldProps) => { - const languageCatalogMap = useMemo(() => { - return ExpressionService.getLanguageMap(); - }, []); - const [preparedLanguage, setPreparedLanguage] = useState(); - const [preparedModel, setPreparedModel] = useState | undefined>({}); - - const resetModel = useCallback(() => { - const { language, model: expressionModel } = ExpressionService.parseStepExpressionModel( - languageCatalogMap, - props.value as Record, - ); - setPreparedLanguage(language); - setPreparedModel(expressionModel); - }, [languageCatalogMap, props.value]); - - useEffect(() => { - resetModel(); - }, [resetModel]); - - const handleChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (languageName: string, model: any) => { - const language = ExpressionService.getDefinitionFromModelName(languageCatalogMap, languageName); - setPreparedLanguage(language); - setPreparedModel(getSerializedModel(model)); - }, - [languageCatalogMap], - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleConfirm = useCallback(() => { - if (preparedLanguage && preparedModel) { - ExpressionService.setStepExpressionModel( - languageCatalogMap, - props.value as Record, - preparedLanguage?.model.name, - preparedModel, - ); - props.onChange(props.value); - } - }, [languageCatalogMap, preparedLanguage, preparedModel, props]); - - const handleCancel = useCallback(() => { - resetModel(); - }, [resetModel]); - + ({ children, fields, itemProps, label, name, disabled, ...props }: NestFieldProps) => { return ( @@ -95,18 +34,10 @@ export const ExpressionAwareNestField = connectField( {label} )} - {wrapField( - { id: 'expression-wrapper', ...itemProps }, - , - )} + } + onChangeExpressionModel={props.onChange} + /> {children || fields?.map((field) => )} diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.scss b/packages/ui/src/components/Form/expression/ExpressionEditor.scss index b8f03aff9..180d57f4f 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.scss +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.scss @@ -1,7 +1,6 @@ .expression-metadata-editor { - #typeahead-select { - max-height: 500px; - overflow-y: auto; + .expression-metadata-editor-card { + margin-bottom: 24px; } form { diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx b/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx index 5df803eb7..315221d4f 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.test.tsx @@ -1,21 +1,22 @@ import catalogLibrary from '@kaoto/camel-catalog/index.json'; import { CatalogLibrary } from '@kaoto/camel-catalog/types'; import { act, fireEvent, render, screen } from '@testing-library/react'; +import { SchemaService } from '..'; import { CamelCatalogService, CatalogKind, ICamelLanguageDefinition } from '../../../models'; import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; -import { SchemaService } from '../schema.service'; +import { MetadataEditor } from '../../MetadataEditor'; import { ExpressionEditor } from './ExpressionEditor'; -import { ExpressionService } from './expression.service'; describe('ExpressionEditor', () => { const onChangeMock = jest.fn(); - /* eslint-disable @typescript-eslint/no-explicit-any */ - let languageCatalog: Record; - beforeAll(async () => { + + beforeEach(async () => { + jest.clearAllMocks(); + const catalogsMap = await getFirstCatalogMap(catalogLibrary as CatalogLibrary); languageCatalog = catalogsMap.languageCatalog; - CamelCatalogService.setCatalogKey(CatalogKind.Language, languageCatalog); + CamelCatalogService.setCatalogKey(CatalogKind.Language, catalogsMap.languageCatalog); onChangeMock.mockClear(); }); @@ -27,25 +28,27 @@ describe('ExpressionEditor', () => { expect(dropdown).toHaveLength(1); }); + it('should render', async () => { + const expressionModel = {}; + + render(); + const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(buttons[0]); + }); + const json = screen.getByTestId('expression-dropdownitem-datasonnet'); + fireEvent.click(json.getElementsByTagName('button')[0]); + const form = screen.getByTestId('metadata-editor-form-expression'); + expect(form.innerHTML).toContain('Output Media Type'); + }); + it('render model parameter and emit onChange when something is changed', () => { - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'jq', - ); - const model = { expression: '.field3', resultType: 'string' }; - render( - , - ); - const dropdown = screen.getAllByTestId('typeahead-select-input').filter((input) => input.innerHTML.includes('JQ')); - expect(dropdown).toHaveLength(1); - const resultTypeInput = screen - .getAllByTestId('create-typeahead-select-input') - .filter((input) => input.innerHTML.includes('string')); - expect(resultTypeInput).toHaveLength(1); + const model = { + expression: { + jq: {}, + }, + }; + render(); const sourceInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Source'); expect(sourceInput).toHaveLength(1); expect(sourceInput[0].getAttribute('value')).toEqual(''); @@ -54,56 +57,87 @@ describe('ExpressionEditor', () => { fireEvent.input(sourceInput[0], { target: { value: 'foo' } }); }); expect(onChangeMock.mock.calls).toHaveLength(1); - expect(onChangeMock.mock.calls[0][1]).toEqual({ expression: '.field3', resultType: 'string', source: 'foo' }); + expect(onChangeMock.mock.calls[0][0]).toEqual({ expression: { jq: { source: 'foo' } } }); + }); + it('find bean method with a word bean', async () => { + render(); + const inputElement = screen.getAllByRole('combobox')[0]; + await act(async () => { + fireEvent.change(inputElement, { target: { value: 'b' } }); + }); + const dropdownItems = screen.getAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems).toHaveLength(6); + await act(async () => { + fireEvent.change(inputElement, { target: { value: 'bean' } }); + }); + const dropdownItems2 = screen.getAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems2).toHaveLength(1); }); - it('clear the input value in case the clear button is clicked', async () => { - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'jq', - ); - render( - , - ); + it('should filter candidates with a text input', async () => { + const expressionModel = {}; + + render(); + const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(buttons[0]); + }); + + let dropdownItems = screen.queryAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems.length).toBeGreaterThan(25); const inputElement = screen.getAllByRole('combobox')[0]; await act(async () => { - fireEvent.change(inputElement, { target: { value: 'JQ' } }); + fireEvent.change(inputElement, { target: { value: 'simple' } }); }); - expect(inputElement).toHaveValue('JQ'); + dropdownItems = screen.getAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems).toHaveLength(2); + }); + + it('should clear filter and close the dropdown with close button', async () => { + const expressionModel = {}; + + render(); + const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(buttons[0]); + }); + let inputElement = screen.getAllByRole('combobox')[0]; + await act(async () => { + fireEvent.change(inputElement, { target: { value: 'simple' } }); + }); + let dropdownItems = screen.getAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems).toHaveLength(2); const clearButton = screen.getByLabelText('Clear input value'); await act(async () => { fireEvent.click(clearButton); }); + dropdownItems = screen.getAllByTestId(/expression-dropdownitem-.*/); + expect(dropdownItems.length).toBeGreaterThan(25); + inputElement = screen.getAllByRole('combobox')[0]; expect(inputElement).toHaveValue(''); }); - it('find bean method with a word bean', async () => { - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'method', - ); - render( - , - ); - const inputElement = screen.getAllByRole('combobox')[0]; - await act(async () => { - fireEvent.change(inputElement, { target: { value: 'b' } }); + it('should render for all expressions without an error', () => { + Object.entries(languageCatalog).forEach(([name, expression]) => { + try { + if (name === 'default') return; + expect(expression).toBeDefined(); + /* eslint-disable @typescript-eslint/no-explicit-any */ + const schema = (expression as any).propertiesSchema; + render( + {}} + />, + ); + } catch (e) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + throw new Error(`Error rendering ${name} expression: ${(e as any).message}`); + } }); - const dropdownItems = screen.getAllByTestId(/expression-dropdownitem-.*/); - expect(dropdownItems).toHaveLength(6); - await act(async () => { - fireEvent.change(inputElement, { target: { value: 'bean' } }); - }); - const dropdownItems2 = screen.getAllByTestId(/expression-dropdownitem-.*/); - expect(dropdownItems2).toHaveLength(1); }); }); diff --git a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx index c0d142169..7c777a839 100644 --- a/packages/ui/src/components/Form/expression/ExpressionEditor.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionEditor.tsx @@ -1,24 +1,36 @@ -import { SelectOptionProps } from '@patternfly/react-core'; -import { FunctionComponent, useCallback, useMemo, useState } from 'react'; -import { ICamelLanguageDefinition } from '../../../models'; +import { + Card, + CardBody, + CardExpandableContent, + CardHeader, + CardTitle, + SelectOptionProps, +} from '@patternfly/react-core'; +import { FunctionComponent, useCallback, useContext, useMemo, useState } from 'react'; +import { EntitiesContext } from '../../../providers'; +import { getSerializedModel } from '../../../utils'; +import { TypeaheadEditor } from '../customField/TypeaheadEditor'; import './ExpressionEditor.scss'; import { ExpressionService } from './expression.service'; -import { TypeaheadEditor } from '../customField/TypeaheadEditor'; interface ExpressionEditorProps { - language?: ICamelLanguageDefinition; expressionModel: Record; - onChangeExpressionModel: (languageName: string, model: Record) => void; + onChangeExpressionModel: (model: Record) => void; } export const ExpressionEditor: FunctionComponent = ({ - language, expressionModel, onChangeExpressionModel, }) => { - const languageCatalogMap: SelectOptionProps[] = useMemo(() => { - const languageCatalog = Object.values(ExpressionService.getLanguageMap()); - return languageCatalog!.map((option) => { + const entitiesContext = useContext(EntitiesContext); + const [isExpanded, setIsExpanded] = useState(true); + + const languageCatalogMap = useMemo(() => { + return ExpressionService.getLanguageMap(); + }, []); + + const initialExpressionOptions: SelectOptionProps[] = useMemo(() => { + return Object.values(languageCatalogMap).map((option) => { return { value: option.model.name, children: option.model.title, @@ -26,7 +38,12 @@ export const ExpressionEditor: FunctionComponent = ({ description: option.model.description, }; }); - }, []); + }, [languageCatalogMap]); + + const { language, model: languageModel } = ExpressionService.parseStepExpressionModel( + languageCatalogMap, + expressionModel, + ); const languageOption = language && { name: language!.model.name, @@ -37,7 +54,10 @@ export const ExpressionEditor: FunctionComponent = ({ ); const languageSchema = useMemo(() => { - return language && ExpressionService.getLanguageSchema(ExpressionService.setStepExpressionResultType(language)); + if (!language) { + return undefined; + } + return ExpressionService.getLanguageSchema(ExpressionService.setStepExpressionResultType(language)); }, [language]); const handleOnChange = useCallback( @@ -46,21 +66,37 @@ export const ExpressionEditor: FunctionComponent = ({ newlanguageModel: Record, ) => { setSelectedLanguageOption(selectedLanguageOption); - onChangeExpressionModel(selectedLanguageOption ? selectedLanguageOption!.name : '', newlanguageModel); + ExpressionService.setStepExpressionModel( + languageCatalogMap, + expressionModel, + selectedLanguageOption ? selectedLanguageOption!.name : '', + getSerializedModel(newlanguageModel), + ); + onChangeExpressionModel(expressionModel); + entitiesContext?.updateSourceCodeFromEntities(); }, - [languageCatalogMap], + [languageCatalogMap, expressionModel, entitiesContext], ); return ( -
- -
+ <> + + setIsExpanded(!isExpanded)}> + Expression + + + + + + + + ); }; diff --git a/packages/ui/src/components/Form/expression/ExpressionField.test.tsx b/packages/ui/src/components/Form/expression/ExpressionField.test.tsx index 2a0d24313..9ede54125 100644 --- a/packages/ui/src/components/Form/expression/ExpressionField.test.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionField.test.tsx @@ -33,40 +33,11 @@ beforeAll(async () => { mockOnChange.mockClear(); }); - describe('ExpressionField', () => { - it('should render with a modal closed, open by click, then close by cancel button', () => { - render( - - - - - , - ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - expect(link).toBeInTheDocument(); - expect(screen.queryByRole('dialog')).toBeNull(); - act(() => { - fireEvent.click(link); - }); - expect(screen.queryByRole('dialog')).toBeInTheDocument(); - const cancelBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Cancel'); - expect(cancelBtn).toHaveLength(1); - act(() => { - fireEvent.click(cancelBtn[0]); - }); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('should render with parameters filled with passed in model, emit onChange with apply button', () => { + it('should render', async () => { const fieldProperties = { value: { simple: { expression: '${body}', resultType: 'string' } }, onChange: mockOnChange, - field: { - type: 'object', - title: 'expression field title', - $comment: 'expression', - }, }; render( @@ -75,26 +46,13 @@ describe('ExpressionField', () => { , ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - act(() => { - fireEvent.click(link); - }); - - const expressionInput = screen - .getAllByRole('textbox') - .filter((textbox) => textbox.getAttribute('name') === 'expression'); - expect(expressionInput).toHaveLength(1); - expect(expressionInput[0].textContent).toEqual('${body}'); - act(() => { - fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - }); - - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); - expect(applyBtn).toHaveLength(1); - expect(mockOnChange.mock.calls).toHaveLength(0); - act(() => { - fireEvent.click(applyBtn[0]); + const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(buttons[0]); }); - expect(mockOnChange.mock.calls).toHaveLength(1); + const json = screen.getByTestId('expression-dropdownitem-datasonnet'); + fireEvent.click(json.getElementsByTagName('button')[0]); + const form = screen.getByTestId('metadata-editor-form-expression'); + expect(form.innerHTML).toContain('Output Media Type'); }); }); diff --git a/packages/ui/src/components/Form/expression/ExpressionField.tsx b/packages/ui/src/components/Form/expression/ExpressionField.tsx index 86e78b57a..d1fbc5f6c 100644 --- a/packages/ui/src/components/Form/expression/ExpressionField.tsx +++ b/packages/ui/src/components/Form/expression/ExpressionField.tsx @@ -1,76 +1,18 @@ import { wrapField } from '@kaoto-next/uniforms-patternfly'; -import { useCallback, useEffect, useMemo, useState } from 'react'; import { HTMLFieldProps, connectField } from 'uniforms'; -import { ICamelLanguageDefinition } from '../../../models'; -import { ExpressionModalLauncher } from './ExpressionModalLauncher'; -import { ExpressionService } from './expression.service'; -import { getSerializedModel } from '../../../utils'; +import { ExpressionEditor } from './ExpressionEditor'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ExpressionFieldProps = HTMLFieldProps; const ExpressionFieldComponent = (props: ExpressionFieldProps) => { - const languageCatalogMap = useMemo(() => { - return ExpressionService.getLanguageMap(); - }, []); - const [preparedLanguage, setPreparedLanguage] = useState(); - const [preparedModel, setPreparedModel] = useState>({}); - - const resetModel = useCallback(() => { - const { language, model: expressionModel } = ExpressionService.parsePropertyExpressionModel( - languageCatalogMap, - props.value, - ); - setPreparedLanguage(language); - setPreparedModel(expressionModel); - }, [languageCatalogMap, props.value]); - - useEffect(() => { - resetModel(); - }, [resetModel]); - - const onChange = useCallback( - (languageName: string, model: Record) => { - const language = ExpressionService.getDefinitionFromModelName(languageCatalogMap, languageName); - setPreparedLanguage(language); - setPreparedModel(getSerializedModel(model)); - }, - [languageCatalogMap], - ); - - const handleConfirm = useCallback(() => { - if (preparedLanguage && preparedModel) { - ExpressionService.setPropertyExpressionModel( - languageCatalogMap, - props.value, - preparedLanguage?.model.name, - preparedModel, - ); - props.onChange(props.value); - } - }, [languageCatalogMap, preparedLanguage, preparedModel, props]); - - const handleCancel = useCallback(() => { - resetModel(); - }, [resetModel]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const title = (props.field as any).title; const description = title ? `Configure expression for "${title}" parameter` : 'Configure expression'; return wrapField( { ...props, description: description }, - , + , ); }; export const ExpressionField = connectField(ExpressionFieldComponent); diff --git a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.scss b/packages/ui/src/components/Form/expression/ExpressionModalLauncher.scss deleted file mode 100644 index 8dadf8d62..000000000 --- a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.scss +++ /dev/null @@ -1,3 +0,0 @@ -.expression-field { - margin: 24px 0; -} diff --git a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.test.tsx b/packages/ui/src/components/Form/expression/ExpressionModalLauncher.test.tsx deleted file mode 100644 index 293169371..000000000 --- a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import catalogLibrary from '@kaoto/camel-catalog/index.json'; -import { CatalogLibrary } from '@kaoto/camel-catalog/types'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { CamelCatalogService, CatalogKind, ICamelLanguageDefinition } from '../../../models'; -import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; -import { ExpressionModalLauncher } from './ExpressionModalLauncher'; -import { ExpressionService } from './expression.service'; - -describe('ExpressionModalLauncher', () => { - let languageCatalog: Record; - beforeAll(async () => { - const catalogsMap = await getFirstCatalogMap(catalogLibrary as CatalogLibrary); - languageCatalog = catalogsMap.languageCatalog; - CamelCatalogService.setCatalogKey(CatalogKind.Language, languageCatalog); - }); - - it('should render', () => { - render( - , - ); - }); - - it('should render with a modal closed, open by click, then close by cancel button', () => { - render( - , - ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - expect(link).toBeInTheDocument(); - expect(screen.queryByRole('dialog')).toBeNull(); - act(() => { - fireEvent.click(link); - }); - expect(screen.queryByRole('dialog')).toBeInTheDocument(); - const cancelBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Cancel'); - expect(cancelBtn).toHaveLength(1); - act(() => { - fireEvent.click(cancelBtn[0]); - }); - expect(screen.queryByRole('dialog')).toBeNull(); - }); - - it('should render with parameters filled with passed in model, emit onChange with apply button', () => { - const model = { expression: '${body}', resultType: 'string' }; - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'simple', - ); - const mockOnChange = jest.fn(); - const mockOnConfirm = jest.fn(); - const mockOnCancel = jest.fn(); - render( - , - ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - act(() => { - fireEvent.click(link); - }); - - const expressionInput = screen - .getAllByRole('textbox') - .filter((textbox) => textbox.getAttribute('name') === 'expression'); - expect(expressionInput).toHaveLength(1); - expect(expressionInput[0].textContent).toEqual('${body}'); - expect(mockOnChange.mock.calls).toHaveLength(0); - expect(mockOnConfirm.mock.calls).toHaveLength(0); - act(() => { - fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - }); - expect(mockOnChange.mock.calls).toHaveLength(1); - expect(mockOnConfirm.mock.calls).toHaveLength(0); - - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); - expect(applyBtn).toHaveLength(1); - act(() => { - fireEvent.click(applyBtn[0]); - }); - expect(mockOnChange.mock.calls).toHaveLength(1); - expect(mockOnConfirm.mock.calls).toHaveLength(1); - }); - - it('should render expression preview for bean method', () => { - const model = { ref: '#myBean', method: 'doSomething' }; - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'method', - ); - const mockOnChange = jest.fn(); - const mockOnConfirm = jest.fn(); - const mockOnCancel = jest.fn(); - render( - , - ); - const previewInput = screen.getByTestId('expression-preview-input'); - expect(previewInput.getAttribute('value')).toEqual('Bean Method: #myBean.doSomething()'); - }); - - it('should render expression preview for tokenize', () => { - const model = { token: ',' }; - const language = ExpressionService.getDefinitionFromModelName( - languageCatalog as unknown as Record, - 'tokenize', - ); - const mockOnChange = jest.fn(); - const mockOnConfirm = jest.fn(); - const mockOnCancel = jest.fn(); - render( - , - ); - const previewInput = screen.getByTestId('expression-preview-input'); - expect(previewInput.getAttribute('value')).toEqual('Tokenize: (token=[,])'); - }); -}); - -it('should close the modal when the close button is clicked', () => { - render( - , - ); - const link = screen.getByRole('button', { name: 'Configure Expression' }); - act(() => { - fireEvent.click(link); - }); - const closeButton = screen.getByLabelText('Close'); - act(() => { - fireEvent.click(closeButton); - }); - expect(screen.queryByRole('dialog')).toBeNull(); -}); diff --git a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.tsx b/packages/ui/src/components/Form/expression/ExpressionModalLauncher.tsx deleted file mode 100644 index f25f5ac31..000000000 --- a/packages/ui/src/components/Form/expression/ExpressionModalLauncher.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Button, InputGroup, InputGroupItem, Modal, TextInput, ModalVariant } from '@patternfly/react-core'; -import { PencilAltIcon } from '@patternfly/react-icons'; -import { useMemo, useState } from 'react'; -import { ICamelLanguageDefinition } from '../../../models'; -import { ExpressionEditor } from './ExpressionEditor'; -import './ExpressionModalLauncher.scss'; - -export type ExpressionModalLauncherProps = { - name: string; - title?: string; - language?: ICamelLanguageDefinition; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - model: any; - description?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange: (languageName: string, model: any) => void; - onConfirm: () => void; - onCancel: () => void; -}; - -export const ExpressionModalLauncher = ({ - name, - title, - language, - model, - description, - onChange, - onConfirm, - onCancel, -}: ExpressionModalLauncherProps) => { - const [isModalOpen, setIsModalOpen] = useState(false); - - const handleOnConfirm = () => { - setIsModalOpen(false); - onConfirm(); - }; - - const handleOnCancel = () => { - setIsModalOpen(false); - onCancel(); - }; - - const expressionLabel = useMemo(() => { - if (!language) return ''; - if (language.model.name === 'method') { - let expression = model.ref || model.beanType || ''; - if (expression) expression += '.'; - if (model.method) expression += model.method + '()'; - return 'Bean Method: ' + expression; - } - if (language.model.name === 'tokenize') { - return model.token ? `Tokenize: (token=[${model.token}])` : 'Tokenize'; - } - return model?.expression ? language.model.name + ': ' + model.expression : language.model.name; - }, [language, model?.beanType, model?.expression, model?.method, model?.ref, model?.token]); - - return ( - <> - - - - - - , - , - ]} - ouiaId="ExpressionModal" - > - - - - ); -}; diff --git a/packages/ui/src/components/Form/expression/expression.service.test.ts b/packages/ui/src/components/Form/expression/expression.service.test.ts index c459d5f0e..4e830484a 100644 --- a/packages/ui/src/components/Form/expression/expression.service.test.ts +++ b/packages/ui/src/components/Form/expression/expression.service.test.ts @@ -122,7 +122,7 @@ describe('ExpressionService', () => { it('should not write if empty', () => { const parentModel: any = {}; ExpressionService.setStepExpressionModel(languageMap, parentModel, '', {}); - expect(parentModel.expression.simple).toBeUndefined(); + expect(parentModel.expression).toBeUndefined(); }); }); diff --git a/packages/ui/src/components/Form/expression/expression.service.ts b/packages/ui/src/components/Form/expression/expression.service.ts index d305066f5..1873eeb27 100644 --- a/packages/ui/src/components/Form/expression/expression.service.ts +++ b/packages/ui/src/components/Form/expression/expression.service.ts @@ -136,6 +136,10 @@ export class ExpressionService { Object.values(languageCatalogMap).forEach((language) => { delete parentModel[language.model.name]; }); + if (!languageModelName || !languageCatalogMap[languageModelName]) { + delete parentModel.expression; + return; + } parentModel.expression = {}; (parentModel.expression as Record)[languageModelName] = newExpressionModel; } diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx index 46df1d159..20dfe0735 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx @@ -5,11 +5,10 @@ import { CatalogKind, ICamelLanguageDefinition, KaotoSchemaDefinition } from '.. import { IVisualizationNode, VisualComponentSchema } from '../../../models/visualization/base-visual-entity'; import { CamelCatalogService } from '../../../models/visualization/flows'; import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; -import { MetadataEditor } from '../../MetadataEditor'; +import { FormTabsModes } from '../../Visualization/Canvas'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { SchemaService } from '../schema.service'; import { StepExpressionEditor } from './StepExpressionEditor'; -import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; describe('StepExpressionEditor', () => { let mockNode: CanvasNode; @@ -54,10 +53,6 @@ describe('StepExpressionEditor', () => { it('should render', async () => { render(); - const launcherButton = screen.getAllByRole('button', { name: 'Configure Expression' }); - await act(async () => { - fireEvent.click(launcherButton[0]); - }); const dropdown = screen .getAllByTestId('typeahead-select-input') .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); @@ -69,27 +64,4 @@ describe('StepExpressionEditor', () => { const form = screen.getByTestId('metadata-editor-form-expression'); expect(form.innerHTML).toContain('Suppress Exceptions'); }); - - it('should render for all languages without an error', () => { - Object.entries(languageCatalog).forEach(([name, language]) => { - try { - if (name === 'default') return; - expect(language).toBeDefined(); - /* eslint-disable @typescript-eslint/no-explicit-any */ - const schema = (language as any).propertiesSchema; - render( - {}} - />, - ); - } catch (e) { - /* eslint-disable @typescript-eslint/no-explicit-any */ - throw new Error(`Error rendering ${name} language: ${(e as any).message}`); - } - }); - }); }); diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx index 51880f185..8a28abef7 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx @@ -1,12 +1,18 @@ -import { wrapField } from '@kaoto-next/uniforms-patternfly'; -import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { ICamelLanguageDefinition } from '../../../models'; +import { + Card, + CardBody, + CardExpandableContent, + CardHeader, + CardTitle, + SelectOptionProps, +} from '@patternfly/react-core'; +import { FunctionComponent, useCallback, useContext, useMemo, useState } from 'react'; import { EntitiesContext } from '../../../providers'; +import { getSerializedModel, getUserUpdatedPropertiesSchema, isDefined } from '../../../utils'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; -import { ExpressionService } from '..//expression/expression.service'; -import { ExpressionModalLauncher } from '../expression/ExpressionModalLauncher'; -import { getSerializedModel, isDefined } from '../../../utils'; -import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; +import { TypeaheadEditor } from '../customField/TypeaheadEditor'; +import { ExpressionService } from '../expression/expression.service'; +import { FormTabsModes } from '../../Visualization/Canvas'; interface StepExpressionEditorProps { selectedNode: CanvasNode; @@ -15,82 +21,104 @@ interface StepExpressionEditorProps { export const StepExpressionEditor: FunctionComponent = (props) => { const entitiesContext = useContext(EntitiesContext); + const [isExpanded, setIsExpanded] = useState(true); const languageCatalogMap = useMemo(() => { return ExpressionService.getLanguageMap(); }, []); - const [preparedLanguage, setPreparedLanguage] = useState(); - const [preparedModel, setPreparedModel] = useState | undefined>({}); - - const resetModel = useCallback(() => { - const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); - if (visualComponentSchema) { - if (!visualComponentSchema.definition) { - visualComponentSchema.definition = {}; - } + const visualComponentSchema = props.selectedNode.data?.vizNode?.getComponentSchema(); + if (visualComponentSchema) { + if (!visualComponentSchema.definition) { + visualComponentSchema.definition = {}; } - const { language, model: expressionModel } = ExpressionService.parseStepExpressionModel( - languageCatalogMap, - visualComponentSchema?.definition, - ); - setPreparedLanguage(language); - setPreparedModel(expressionModel); - }, [languageCatalogMap, props.selectedNode.data?.vizNode]); + } - useEffect(() => { - resetModel(); - }, [resetModel]); + const initialExpressionOptions: SelectOptionProps[] = useMemo(() => { + return Object.values(languageCatalogMap).map((option) => { + return { + value: option.model.name, + children: option.model.title, + className: option.model.name, + description: option.model.description, + }; + }); + }, [languageCatalogMap]); - const handleOnChange = useCallback( - (selectedLanguage: string, newExpressionModel: Record) => { - const language = ExpressionService.getDefinitionFromModelName(languageCatalogMap, selectedLanguage); - setPreparedLanguage(language); - setPreparedModel(getSerializedModel(newExpressionModel)); - }, - [languageCatalogMap], + const { language, model: languageModel } = ExpressionService.parseStepExpressionModel( + languageCatalogMap, + visualComponentSchema?.definition, + ); + + const languageOption = language && { + name: language!.model.name, + title: language!.model.title, + }; + const [selectedLanguageOption, setSelectedLanguageOption] = useState<{ name: string; title: string } | undefined>( + languageOption, ); - const handleConfirm = useCallback(() => { - const model = props.selectedNode.data?.vizNode?.getComponentSchema()?.definition || {}; - if (preparedLanguage && preparedModel) { - ExpressionService.setStepExpressionModel(languageCatalogMap, model, preparedLanguage.model.name, preparedModel); - } else { - ExpressionService.deleteStepExpressionModel(model); + const languageSchema = useMemo(() => { + if (!language) { + return undefined; } - props.selectedNode.data?.vizNode?.updateModel(model); - entitiesContext?.updateSourceCodeFromEntities(); - }, [entitiesContext, languageCatalogMap, preparedLanguage, preparedModel, props.selectedNode.data?.vizNode]); + return ExpressionService.getLanguageSchema(ExpressionService.setStepExpressionResultType(language)); + }, [language]); - const handleCancel = useCallback(() => { - resetModel(); - }, [resetModel]); - const title = props.selectedNode.label; - const description = title ? `Configure expression for "${title}" parameter` : 'Configure expression'; + const processedSchema = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return languageSchema; + return { + ...languageSchema, + properties: getUserUpdatedPropertiesSchema(languageSchema?.properties ?? {}, languageModel ?? {}), + }; + }, [props.formMode, language]); + + const handleOnChange = useCallback( + ( + selectedLanguageOption: { name: string; title: string } | undefined, + newlanguageModel: Record, + ) => { + const model = props.selectedNode.data?.vizNode?.getComponentSchema()?.definition; + if (!model) return; + + setSelectedLanguageOption(selectedLanguageOption); + ExpressionService.setStepExpressionModel( + languageCatalogMap, + model, + selectedLanguageOption ? selectedLanguageOption!.name : '', + getSerializedModel(newlanguageModel), + ); + props.selectedNode.data?.vizNode?.updateModel(model); + entitiesContext?.updateSourceCodeFromEntities(); + }, + [languageCatalogMap, props.selectedNode.data?.vizNode?.getComponentSchema()?.definition, entitiesContext], + ); const showEditor = useMemo(() => { if (props.formMode === FormTabsModes.ALL_FIELDS) return true; - return props.formMode === FormTabsModes.USER_MODIFIED && isDefined(preparedLanguage); + return props.formMode === FormTabsModes.USER_MODIFIED && isDefined(selectedLanguageOption); }, [props.formMode]); if (!showEditor) return null; return ( - languageCatalogMap && ( -
- {wrapField( - { ...props, label: 'Expression', id: 'expression-wrapper', description: description }, - , - )} -
- ) +
+ + setIsExpanded(!isExpanded)}> + Expression + + + + + + + +
); }; diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx index f928e6f09..acfa0848d 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx @@ -138,17 +138,10 @@ describe('CanvasFormTabs', () => { fireEvent.click(modifiedTab); }); - expect(screen.queryByTestId('launch-expression-modal-btn')).toBeNull(); - act(() => { fireEvent.click(defaultTab); }); - const launchExpressionDefaultTab = screen.getByTestId('launch-expression-modal-btn'); - - act(() => { - fireEvent.click(launchExpressionDefaultTab); - }); const button = screen .getAllByTestId('typeahead-select-input') .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); @@ -162,17 +155,13 @@ describe('CanvasFormTabs', () => { const expressionInput = screen .getAllByRole('textbox') .filter((textbox) => textbox.getAttribute('name') === 'expression'); - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); act(() => { fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - fireEvent.click(applyBtn[0]); }); act(() => { fireEvent.click(modifiedTab); }); - - expect(screen.getByTestId('launch-expression-modal-btn')).toBeInTheDocument(); }); it('dataformat field', async () => { @@ -387,10 +376,6 @@ describe('CanvasFormTabs', () => { , ); - const launchExpression = screen.getByTestId('launch-expression-modal-btn'); - act(() => { - fireEvent.click(launchExpression); - }); const button = screen .getAllByTestId('typeahead-select-input') .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); @@ -404,10 +389,8 @@ describe('CanvasFormTabs', () => { const expressionInput = screen .getAllByRole('textbox') .filter((textbox) => textbox.getAttribute('name') === 'expression'); - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); act(() => { fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - fireEvent.click(applyBtn[0]); }); /* eslint-disable @typescript-eslint/no-explicit-any */ expect((camelRoute.from.steps[0].setHeader!.expression as any).simple.expression).toEqual('${header.foo}'); @@ -460,10 +443,6 @@ describe('CanvasFormTabs', () => { expect(camelRoute.from.steps[0].setHeader!.expression).toBeUndefined(); expect(camelRoute.from.steps[0].setHeader!.name).toEqual('bar'); - const launchExpression = screen.getByTestId('launch-expression-modal-btn'); - act(() => { - fireEvent.click(launchExpression); - }); const button = screen .getAllByTestId('typeahead-select-input') .filter((input) => input.innerHTML.includes(SchemaService.DROPDOWN_PLACEHOLDER)); @@ -477,10 +456,8 @@ describe('CanvasFormTabs', () => { const expressionInput = screen .getAllByRole('textbox') .filter((textbox) => textbox.getAttribute('name') === 'expression'); - const applyBtn = screen.getAllByRole('button').filter((button) => button.textContent === 'Apply'); act(() => { fireEvent.input(expressionInput[0], { target: { value: '${header.foo}' } }); - fireEvent.click(applyBtn[0]); }); /* eslint-disable @typescript-eslint/no-explicit-any */ expect((camelRoute.from.steps[0].setHeader!.expression as any).simple.expression).toEqual('${header.foo}');