From 71e64c471451884b3750f0a7273698b7ca7e3fa4 Mon Sep 17 00:00:00 2001 From: Shivam Gupta Date: Wed, 17 Jul 2024 20:03:30 +0530 Subject: [PATCH] feat(form): Introduce a 'User Modified' from section --- .../DataformatEditor.stories.tsx | 3 +- .../ExpressionEditor.stories.tsx | 2 + .../Form/dataFormat/DataFormatEditor.test.tsx | 55 +- .../Form/dataFormat/DataFormatEditor.tsx | 21 +- .../loadBalancer/LoadBalancerEditor.test.tsx | 55 +- .../Form/loadBalancer/LoadBalancerEditor.tsx | 22 +- .../StepExpressionEditor.test.tsx | 9 +- .../stepExpression/StepExpressionEditor.tsx | 11 +- .../Visualization/Canvas/CanvasForm.test.tsx | 352 +-------- .../Visualization/Canvas/CanvasForm.tsx | 69 +- ...tsx => CanvasFormTabs.exhaustive.test.tsx} | 2 +- .../Visualization/Canvas/CanvasFormTabs.scss | 3 + .../Canvas/CanvasFormTabs.test.tsx | 702 ++++++++++++++++++ .../Visualization/Canvas/CanvasFormTabs.tsx | 124 ++++ .../__snapshots__/CanvasForm.test.tsx.snap | 38 + .../Canvas/canvasformtabs.modes.ts | 4 + .../components/Visualization/Canvas/index.ts | 1 + ...get-user-updated-properties-schema.test.ts | 300 ++++++++ .../get-user-updated-properties-schema.ts | 47 ++ packages/ui/src/utils/index.ts | 1 + 20 files changed, 1389 insertions(+), 432 deletions(-) rename packages/ui/src/components/Visualization/Canvas/{CanvasForm.exhaustive.test.tsx => CanvasFormTabs.exhaustive.test.tsx} (99%) create mode 100644 packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.scss create mode 100644 packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx create mode 100644 packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.tsx create mode 100644 packages/ui/src/components/Visualization/Canvas/canvasformtabs.modes.ts create mode 100644 packages/ui/src/utils/get-user-updated-properties-schema.test.ts create mode 100644 packages/ui/src/utils/get-user-updated-properties-schema.ts diff --git a/packages/ui-tests/stories/metadataEditor/DataformatEditor.stories.tsx b/packages/ui-tests/stories/metadataEditor/DataformatEditor.stories.tsx index 977681314..b5782e6ea 100644 --- a/packages/ui-tests/stories/metadataEditor/DataformatEditor.stories.tsx +++ b/packages/ui-tests/stories/metadataEditor/DataformatEditor.stories.tsx @@ -1,4 +1,4 @@ -import { DataFormatEditor, MetadataEditor } from '@kaoto/kaoto'; +import { DataFormatEditor, MetadataEditor, FormTabsModes } from '@kaoto/kaoto'; import { CanvasNode, CatalogLoaderProvider, @@ -63,4 +63,5 @@ const Template: StoryFn = (args: CanvasNode) => { export const Default = Template.bind({}); Default.args = { selectedNode: mockNode, + formMode: FormTabsModes.ALL_FIELDS, }; diff --git a/packages/ui-tests/stories/metadataEditor/ExpressionEditor.stories.tsx b/packages/ui-tests/stories/metadataEditor/ExpressionEditor.stories.tsx index 12067e232..73ad5a030 100644 --- a/packages/ui-tests/stories/metadataEditor/ExpressionEditor.stories.tsx +++ b/packages/ui-tests/stories/metadataEditor/ExpressionEditor.stories.tsx @@ -9,6 +9,7 @@ import { SchemasLoaderProvider, StepExpressionEditor, VisualComponentSchema, + FormTabsModes, } from '@kaoto/kaoto/testing'; import { Meta, StoryFn } from '@storybook/react'; @@ -63,4 +64,5 @@ const Template: StoryFn = (args: CanvasNode) => { export const Default = Template.bind({}); Default.args = { selectedNode: mockNode, + formMode: FormTabsModes.ALL_FIELDS, }; diff --git a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.test.tsx b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.test.tsx index 95a84bfe3..36857a1eb 100644 --- a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.test.tsx +++ b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.test.tsx @@ -8,6 +8,7 @@ import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; import { MetadataEditor } from '../../MetadataEditor'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { DataFormatEditor } from './DataFormatEditor'; +import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; describe('DataFormatEditor', () => { let mockNode: CanvasNode; @@ -45,8 +46,56 @@ describe('DataFormatEditor', () => { }; }); + it('should not render', () => { + render(); + const buttons = screen.queryAllByRole('button', { name: 'Typeahead menu toggle' }); + expect(buttons).toHaveLength(0); + }); + + it('should render with only the user updated fields', () => { + const visualComponentSchema: VisualComponentSchema = { + title: 'My Node', + schema: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + } as unknown as KaotoSchemaDefinition['schema'], + definition: { + name: 'my node', + asn1: { + id: 'test', + }, + }, + }; + + mockNode = { + id: '1', + type: 'node', + data: { + vizNode: { + getComponentSchema: () => visualComponentSchema, + updateModel: (_value: unknown) => {}, + } as IVisualizationNode, + }, + }; + render(); + const buttons = screen.queryAllByRole('button', { name: 'Typeahead menu toggle' }); + expect(buttons).toHaveLength(1); + + const inputElement = screen.getAllByRole('combobox')[0]; + expect(inputElement).toHaveValue('ASN.1 File'); + + const inputIdModifiedTabElement = screen + .queryAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Id'); + expect(inputIdModifiedTabElement).toHaveLength(1); + }); + it('should render', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); @@ -58,7 +107,7 @@ describe('DataFormatEditor', () => { }); it('should filter candidates with a text input', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); @@ -74,7 +123,7 @@ describe('DataFormatEditor', () => { }); it('should clear filter and close the dropdown with close button', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); diff --git a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx index afa26a3d1..b6840bd61 100644 --- a/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx +++ b/packages/ui/src/components/Form/dataFormat/DataFormatEditor.tsx @@ -12,10 +12,12 @@ import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import './DataFormatEditor.scss'; import { DataFormatService } from './dataformat.service'; import { TypeaheadEditor } from '../customField/TypeaheadEditor'; -import { getSerializedModel } from '../../../utils'; +import { getSerializedModel, getUserUpdatedPropertiesSchema, isDefined } from '../../../utils'; +import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; interface DataFormatEditorProps { selectedNode: CanvasNode; + formMode: FormTabsModes; } export const DataFormatEditor: FunctionComponent = (props) => { @@ -58,6 +60,14 @@ export const DataFormatEditor: FunctionComponent = (props return DataFormatService.getDataFormatSchema(dataFormat); }, [dataFormat]); + const processedSchema = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return dataFormatSchema; + return { + ...dataFormatSchema, + properties: getUserUpdatedPropertiesSchema(dataFormatSchema?.properties ?? {}, dataFormatModel ?? {}), + }; + }, [props.formMode, dataFormat]); + const handleOnChange = useCallback( ( selectedDataFormatOption: { name: string; title: string } | undefined, @@ -78,6 +88,13 @@ export const DataFormatEditor: FunctionComponent = (props [entitiesContext, dataFormatCatalogMap, props.selectedNode.data?.vizNode], ); + const showEditor = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return true; + return props.formMode === FormTabsModes.USER_MODIFIED && isDefined(selectedDataFormatOption); + }, [props.formMode]); + + if (!showEditor) return null; + return (
@@ -91,7 +108,7 @@ export const DataFormatEditor: FunctionComponent = (props title="dataformat" selected={selectedDataFormatOption} selectedModel={dataFormatModel} - selectedSchema={dataFormatSchema} + selectedSchema={processedSchema} selectionOnChange={handleOnChange} /> diff --git a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.test.tsx b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.test.tsx index 0f353dc22..23e15523f 100644 --- a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.test.tsx +++ b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.test.tsx @@ -8,6 +8,7 @@ import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; import { MetadataEditor } from '../../MetadataEditor'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { LoadBalancerEditor } from './LoadBalancerEditor'; +import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; describe('LoadBalancerEditor', () => { let mockNode: CanvasNode; @@ -46,8 +47,56 @@ describe('LoadBalancerEditor', () => { }; }); + it('should not render', () => { + render(); + const buttons = screen.queryAllByRole('button', { name: 'Typeahead menu toggle' }); + expect(buttons).toHaveLength(0); + }); + + it('should render with only the user updated fields', () => { + const visualComponentSchema: VisualComponentSchema = { + title: 'My Node', + schema: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + } as unknown as KaotoSchemaDefinition['schema'], + definition: { + name: 'my node', + failoverLoadBalancer: { + id: 'test', + }, + }, + }; + + mockNode = { + id: '1', + type: 'node', + data: { + vizNode: { + getComponentSchema: () => visualComponentSchema, + updateModel: (_value: unknown) => {}, + } as IVisualizationNode, + }, + }; + render(); + const buttons = screen.queryAllByRole('button', { name: 'Typeahead menu toggle' }); + expect(buttons).toHaveLength(1); + + const inputElement = screen.getAllByRole('combobox')[0]; + expect(inputElement).toHaveValue('Failover Load Balancer'); + + const inputIdModifiedTabElement = screen + .queryAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Id'); + expect(inputIdModifiedTabElement).toHaveLength(1); + }); + it('should render', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); @@ -61,7 +110,7 @@ describe('LoadBalancerEditor', () => { }); it('should filter candidates with a text input', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); @@ -77,7 +126,7 @@ describe('LoadBalancerEditor', () => { }); it('should clear filter and close the dropdown with close button', async () => { - render(); + render(); const buttons = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); await act(async () => { fireEvent.click(buttons[0]); diff --git a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx index 4ac586903..5d37322e6 100644 --- a/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx +++ b/packages/ui/src/components/Form/loadBalancer/LoadBalancerEditor.tsx @@ -12,10 +12,12 @@ import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { LoadBalancerService } from './loadbalancer.service'; import './LoadBalancerEditor.scss'; import { TypeaheadEditor } from '../customField/TypeaheadEditor'; -import { getSerializedModel } from '../../../utils'; +import { getSerializedModel, getUserUpdatedPropertiesSchema, isDefined } from '../../../utils'; +import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; interface LoadBalancerEditorProps { selectedNode: CanvasNode; + formMode: FormTabsModes; } export const LoadBalancerEditor: FunctionComponent = (props) => { @@ -46,6 +48,7 @@ export const LoadBalancerEditor: FunctionComponent = (p loadBalancerCatalogMap, visualComponentSchema?.definition, ); + const loadBalancerOption = loadBalancer && { name: loadBalancer!.model.name, title: loadBalancer!.model.title, @@ -58,6 +61,14 @@ export const LoadBalancerEditor: FunctionComponent = (p return LoadBalancerService.getLoadBalancerSchema(loadBalancer); }, [loadBalancer]); + const processedSchema = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return loadBalancerSchema; + return { + ...loadBalancerSchema, + properties: getUserUpdatedPropertiesSchema(loadBalancerSchema?.properties ?? {}, loadBalancerModel ?? {}), + }; + }, [props.formMode, loadBalancer]); + const handleOnChange = useCallback( ( selectedLoadBalancerOption: { name: string; title: string } | undefined, @@ -78,6 +89,13 @@ export const LoadBalancerEditor: FunctionComponent = (p [entitiesContext, loadBalancerCatalogMap, props.selectedNode.data?.vizNode], ); + const showEditor = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return true; + return props.formMode === FormTabsModes.USER_MODIFIED && isDefined(selectedLoadBalancerOption); + }, [props.formMode]); + + if (!showEditor) return null; + return (
@@ -91,7 +109,7 @@ export const LoadBalancerEditor: FunctionComponent = (p title="loadbalancer" selected={selectedLoadBalancerOption} selectedModel={loadBalancerModel} - selectedSchema={loadBalancerSchema} + selectedSchema={processedSchema} selectionOnChange={handleOnChange} /> diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx index dd83a15ae..46df1d159 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.test.tsx @@ -9,6 +9,7 @@ import { MetadataEditor } from '../../MetadataEditor'; 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; @@ -45,8 +46,14 @@ describe('StepExpressionEditor', () => { }; }); + it('should not render', () => { + render(); + const launcherButton = screen.queryAllByRole('button', { name: 'Configure Expression' }); + expect(launcherButton).toHaveLength(0); + }); + it('should render', async () => { - render(); + render(); const launcherButton = screen.getAllByRole('button', { name: 'Configure Expression' }); await act(async () => { fireEvent.click(launcherButton[0]); diff --git a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx index 0586933d4..51880f185 100644 --- a/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx +++ b/packages/ui/src/components/Form/stepExpression/StepExpressionEditor.tsx @@ -5,10 +5,12 @@ import { EntitiesContext } from '../../../providers'; import { CanvasNode } from '../../Visualization/Canvas/canvas.models'; import { ExpressionService } from '..//expression/expression.service'; import { ExpressionModalLauncher } from '../expression/ExpressionModalLauncher'; -import { getSerializedModel } from '../../../utils'; +import { getSerializedModel, isDefined } from '../../../utils'; +import { FormTabsModes } from '../../Visualization/Canvas/canvasformtabs.modes'; interface StepExpressionEditorProps { selectedNode: CanvasNode; + formMode: FormTabsModes; } export const StepExpressionEditor: FunctionComponent = (props) => { @@ -65,6 +67,13 @@ export const StepExpressionEditor: FunctionComponent const title = props.selectedNode.label; const description = title ? `Configure expression for "${title}" parameter` : 'Configure expression'; + const showEditor = useMemo(() => { + if (props.formMode === FormTabsModes.ALL_FIELDS) return true; + return props.formMode === FormTabsModes.USER_MODIFIED && isDefined(preparedLanguage); + }, [props.formMode]); + + if (!showEditor) return null; + return ( languageCatalogMap && (
diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx index 20f36e30d..9c7bf7d28 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.test.tsx @@ -1,5 +1,5 @@ import catalogLibrary from '@kaoto/camel-catalog/index.json'; -import { CatalogLibrary, RouteDefinition } from '@kaoto/camel-catalog/types'; +import { CatalogLibrary } from '@kaoto/camel-catalog/types'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { CamelCatalogService, @@ -17,7 +17,6 @@ import { VisibleFlowsContext, VisibleFlowsProvider } from '../../../providers'; import { EntitiesContext, EntitiesProvider } from '../../../providers/entities.provider'; import { camelRouteJson, kameletJson } from '../../../stubs'; import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; -import { SchemaService } from '../../Form'; import { CanvasForm } from './CanvasForm'; import { CanvasNode } from './canvas.models'; import { CanvasService } from './canvas.service'; @@ -266,353 +265,4 @@ describe('CanvasForm', () => { expect(kameletVisualEntity.id).toEqual(newName); expect(dispatchSpy).toHaveBeenCalledWith({ type: 'renameFlow', flowId, newName }); }); - - describe('should persists changes from both expression editor and main form', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - it('expression => main form', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - setHeader: { - name: 'foo', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: setHeaderNode, - }, - }; - - render( - - - - - , - ); - 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)); - act(() => { - fireEvent.click(button[0]); - }); - const simple = screen.getByTestId('expression-dropdownitem-simple'); - act(() => { - fireEvent.click(simple.getElementsByTagName('button')[0]); - }); - 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}'); - expect(camelRoute.from.steps[0].setHeader!.name).toEqual('foo'); - - const filtered = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Name'); - act(() => { - fireEvent.input(filtered[0], { target: { value: 'bar' } }); - }); - /* eslint-disable @typescript-eslint/no-explicit-any */ - expect((camelRoute.from.steps[0].setHeader!.expression as any).simple.expression).toEqual('${header.foo}'); - expect(camelRoute.from.steps[0].setHeader!.name).toEqual('bar'); - }); - - it('main form => expression', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - setHeader: { - name: 'foo', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: setHeaderNode, - }, - }; - - render( - - - - - , - ); - const filtered = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Name'); - act(() => { - fireEvent.input(filtered[0], { target: { value: 'bar' } }); - }); - 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)); - act(() => { - fireEvent.click(button[0]); - }); - const simple = screen.getByTestId('expression-dropdownitem-simple'); - act(() => { - fireEvent.click(simple.getElementsByTagName('button')[0]); - }); - 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}'); - expect(camelRoute.from.steps[0].setHeader!.name).toEqual('bar'); - }); - }); - - describe('should persists changes from both dataformat editor and main form', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - it('dataformat => main form', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - marshal: { - id: 'ms', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const marshalNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: marshalNode, - }, - }; - - render( - - - - - , - ); - const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); - await act(async () => { - fireEvent.click(button[0]); - }); - const avro = screen.getByTestId('dataformat-dropdownitem-avro'); - await act(async () => { - fireEvent.click(avro.getElementsByTagName('button')[0]); - }); - expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); - expect(camelRoute.from.steps[0].marshal!.id).toEqual('ms'); - - const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); - await act(async () => { - fireEvent.input(idInput[1], { target: { value: 'modified' } }); - }); - expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); - expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); - }); - - it('main form => dataformat', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - marshal: { - id: 'ms', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const marshalNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: marshalNode, - }, - }; - - render( - - - - - , - ); - const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); - await act(async () => { - fireEvent.input(idInput[0], { target: { value: 'modified' } }); - }); - expect(camelRoute.from.steps[0].marshal!.avro).toBeUndefined(); - expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); - - const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); - await act(async () => { - fireEvent.click(button[0]); - }); - const avro = screen.getByTestId('dataformat-dropdownitem-avro'); - await act(async () => { - fireEvent.click(avro.getElementsByTagName('button')[0]); - }); - expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); - expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); - }); - }); - - describe('should persists changes from both loadbalancer editor and main form', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - it('loadbalancer => main form', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - loadBalance: { - id: 'lb', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: loadBalanceNode, - }, - }; - - render( - - - - - , - ); - const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); - await act(async () => { - fireEvent.click(button[0]); - }); - const avro = screen.getByTestId('loadbalancer-dropdownitem-weightedLoadBalancer'); - await act(async () => { - fireEvent.click(avro.getElementsByTagName('button')[0]); - }); - expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); - expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('lb'); - - const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); - await act(async () => { - fireEvent.input(idInput[1], { target: { value: 'modified' } }); - }); - expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); - expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); - }); - - it('main form => loadbalancer', async () => { - const camelRoute = { - from: { - uri: 'timer:tutorial', - steps: [ - { - loadBalance: { - id: 'lb', - }, - }, - ], - }, - } as RouteDefinition; - const entity = new CamelRouteVisualEntity(camelRoute); - const rootNode: IVisualizationNode = entity.toVizNode(); - const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; - const selectedNode = { - id: '1', - type: 'node', - data: { - vizNode: loadBalanceNode, - }, - }; - - render( - - - - - , - ); - const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); - await act(async () => { - fireEvent.input(idInput[0], { target: { value: 'modified' } }); - }); - expect(camelRoute.from.steps[0].loadBalance!.weighted).toBeUndefined(); - expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); - - const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); - await act(async () => { - fireEvent.click(button[0]); - }); - const avro = screen.getByTestId('loadbalancer-dropdownitem-weightedLoadBalancer'); - await act(async () => { - fireEvent.click(avro.getElementsByTagName('button')[0]); - }); - expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); - expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); - }); - }); }); diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx index 64a17cf27..b674007ea 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasForm.tsx @@ -1,18 +1,11 @@ import { Card, CardBody, CardHeader, SearchInput } from '@patternfly/react-core'; import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { VisibleFlowsContext, FilteredFieldContext } from '../../../providers'; -import { EntitiesContext } from '../../../providers/entities.provider'; -import { SchemaBridgeProvider } from '../../../providers/schema-bridge.provider'; -import { isDefined, setValue } from '../../../utils'; import { ErrorBoundary } from '../../ErrorBoundary'; -import { CustomAutoForm, CustomAutoFormRef } from '../../Form/CustomAutoForm'; -import { DataFormatEditor } from '../../Form/dataFormat/DataFormatEditor'; -import { LoadBalancerEditor } from '../../Form/loadBalancer/LoadBalancerEditor'; -import { StepExpressionEditor } from '../../Form/stepExpression/StepExpressionEditor'; -import { UnknownNode } from '../Custom/UnknownNode'; import './CanvasForm.scss'; import { CanvasFormHeader } from './Form/CanvasFormHeader'; import { CanvasNode } from './canvas.models'; +import { CanvasFormTabs } from './CanvasFormTabs'; interface CanvasFormProps { selectedNode: CanvasNode; @@ -21,12 +14,8 @@ interface CanvasFormProps { export const CanvasForm: FunctionComponent = (props) => { const { visualFlowsApi } = useContext(VisibleFlowsContext)!; - const entitiesContext = useContext(EntitiesContext); const { filteredFieldText, onFilterChange } = useContext(FilteredFieldContext); - const formRef = useRef(null); - const divRef = useRef(null); const flowIdRef = useRef(undefined); - const omitFields = useRef(props.selectedNode.data?.vizNode?.getBaseEntity()?.getOmitFormFields() || []); const visualComponentSchema = useMemo(() => { const answer = props.selectedNode.data?.vizNode?.getComponentSchema(); @@ -36,7 +25,6 @@ export const CanvasForm: FunctionComponent = (props) => { } return answer; }, [props.selectedNode.data?.vizNode]); - const model = visualComponentSchema?.definition; const title = visualComponentSchema?.title; /** Store the flow's initial Id */ @@ -44,41 +32,6 @@ export const CanvasForm: FunctionComponent = (props) => { flowIdRef.current = props.selectedNode.data?.vizNode?.getBaseEntity()?.getId(); }, []); - useEffect(() => { - formRef.current?.form.reset(); - }, [props.selectedNode.data?.vizNode]); - - const handleOnChangeIndividualProp = useCallback( - (path: string, value: unknown) => { - if (!props.selectedNode.data?.vizNode) { - return; - } - - let updatedValue = value; - if (typeof value === 'string' && value.trim() === '') { - updatedValue = undefined; - } - - const newModel = props.selectedNode.data.vizNode.getComponentSchema()?.definition || {}; - setValue(newModel, path, updatedValue); - props.selectedNode.data.vizNode.updateModel(newModel); - entitiesContext?.updateSourceCodeFromEntities(); - }, - [entitiesContext, props.selectedNode.data?.vizNode], - ); - - const stepFeatures = useMemo(() => { - const comment = visualComponentSchema?.schema?.['$comment'] ?? ''; - const isExpressionAwareStep = comment.includes('expression'); - const isDataFormatAwareStep = comment.includes('dataformat'); - const isLoadBalanceAwareStep = comment.includes('loadbalance'); - const isUnknownComponent = - !isDefined(visualComponentSchema) || - !isDefined(visualComponentSchema.schema) || - Object.keys(visualComponentSchema.schema).length === 0; - return { isExpressionAwareStep, isDataFormatAwareStep, isLoadBalanceAwareStep, isUnknownComponent }; - }, [visualComponentSchema]); - const onClose = useCallback(() => { props.onClose?.(); const newId = props.selectedNode.data?.vizNode?.getBaseEntity()?.getId(); @@ -108,25 +61,7 @@ export const CanvasForm: FunctionComponent = (props) => { - {stepFeatures.isUnknownComponent ? ( - - ) : ( - - {stepFeatures.isExpressionAwareStep && } - {stepFeatures.isDataFormatAwareStep && } - {stepFeatures.isLoadBalanceAwareStep && } - -
- - )} + diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasForm.exhaustive.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.exhaustive.test.tsx similarity index 99% rename from packages/ui/src/components/Visualization/Canvas/CanvasForm.exhaustive.test.tsx rename to packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.exhaustive.test.tsx index f4bf6c76e..ce31c0351 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasForm.exhaustive.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.exhaustive.test.tsx @@ -14,7 +14,7 @@ import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; import { SchemaService } from '../../Form'; import { CustomAutoFieldDetector } from '../../Form/CustomAutoField'; -describe('CanvasForm', () => { +describe('CanvasFormTabs', () => { let componentCatalogMap: Record; let patternCatalogMap: Record; let kameletCatalogMap: Record; diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.scss b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.scss new file mode 100644 index 000000000..82d3dfe6f --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.scss @@ -0,0 +1,3 @@ +.form-tabs { + margin-bottom: 15px; +} diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx new file mode 100644 index 000000000..f928e6f09 --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.test.tsx @@ -0,0 +1,702 @@ +import catalogLibrary from '@kaoto/camel-catalog/index.json'; +import { CatalogLibrary, RouteDefinition } from '@kaoto/camel-catalog/types'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + CamelCatalogService, + CamelRouteVisualEntity, + CatalogKind, + ICamelComponentDefinition, + ICamelProcessorDefinition, + IKameletDefinition, +} from '../../../models'; +import { IVisualizationNode } from '../../../models/visualization/base-visual-entity'; +import { VisibleFlowsProvider } from '../../../providers'; +import { EntitiesContext, EntitiesProvider } from '../../../providers/entities.provider'; +import { camelRouteJson } from '../../../stubs'; +import { getFirstCatalogMap } from '../../../stubs/test-load-catalog'; +import { SchemaService } from '../../Form'; +import { CanvasNode } from './canvas.models'; +import { CanvasService } from './canvas.service'; +import { CanvasFormTabs } from './CanvasFormTabs'; + +describe('CanvasFormTabs', () => { + let camelRouteVisualEntity: CamelRouteVisualEntity; + let selectedNode: CanvasNode; + let componentCatalogMap: Record; + let patternCatalogMap: Record; + let kameletCatalogMap: Record; + + beforeAll(async () => { + const catalogsMap = await getFirstCatalogMap(catalogLibrary as CatalogLibrary); + componentCatalogMap = catalogsMap.componentCatalogMap; + patternCatalogMap = catalogsMap.patternCatalogMap; + kameletCatalogMap = catalogsMap.kameletsCatalogMap; + + CamelCatalogService.setCatalogKey(CatalogKind.Component, componentCatalogMap); + CamelCatalogService.setCatalogKey(CatalogKind.Pattern, patternCatalogMap); + CamelCatalogService.setCatalogKey(CatalogKind.Kamelet, kameletCatalogMap); + CamelCatalogService.setCatalogKey(CatalogKind.Processor, catalogsMap.modelCatalogMap); + CamelCatalogService.setCatalogKey(CatalogKind.Language, catalogsMap.languageCatalog); + CamelCatalogService.setCatalogKey(CatalogKind.Dataformat, catalogsMap.dataformatCatalog); + CamelCatalogService.setCatalogKey(CatalogKind.Loadbalancer, catalogsMap.loadbalancerCatalog); + CamelCatalogService.setCatalogKey(CatalogKind.Entity, catalogsMap.entitiesCatalog); + }); + + beforeEach(() => { + camelRouteVisualEntity = new CamelRouteVisualEntity(camelRouteJson); + const { nodes } = CanvasService.getFlowDiagram(camelRouteVisualEntity.toVizNode()); + selectedNode = nodes[0]; // timer + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('should show the User-updated field under the modified tab', () => { + it('normal text field', async () => { + render( + + + + + , + ); + + const defaultTab = screen.getByRole('button', { name: 'All Fields' }); + const modifiedTab = screen.getByRole('button', { name: 'User Modified' }); + + expect(defaultTab).toBeInTheDocument(); + expect(modifiedTab).toBeInTheDocument(); + + act(() => { + fireEvent.click(modifiedTab); + }); + + const inputVariableReceiveModifiedTabElement = screen + .queryAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Variable Receive'); + expect(inputVariableReceiveModifiedTabElement).toHaveLength(0); + + act(() => { + fireEvent.click(defaultTab); + }); + + await act(async () => { + const inputVariableReceiveDefaultTabElement = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Variable Receive'); + fireEvent.change(inputVariableReceiveDefaultTabElement[0], { target: { value: 'test' } }); + fireEvent.blur(inputVariableReceiveDefaultTabElement[0]); + }); + + act(() => { + fireEvent.click(modifiedTab); + }); + + const inputVariableReceiveModifiedTabElementNew = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Variable Receive'); + expect(inputVariableReceiveModifiedTabElementNew).toHaveLength(1); + }); + + it('expression field', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + setHeader: { + name: 'foo', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: setHeaderNode, + }, + }; + + render( + + + + + , + ); + + const defaultTab = screen.getByRole('button', { name: 'All Fields' }); + const modifiedTab = screen.getByRole('button', { name: 'User Modified' }); + + act(() => { + 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)); + act(() => { + fireEvent.click(button[0]); + }); + const simple = screen.getByTestId('expression-dropdownitem-simple'); + act(() => { + fireEvent.click(simple.getElementsByTagName('button')[0]); + }); + 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 () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + marshal: { + id: 'ms', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const marshalNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: marshalNode, + }, + }; + + render( + + + + + , + ); + + const defaultTab = screen.getByRole('button', { name: 'All Fields' }); + const modifiedTab = screen.getByRole('button', { name: 'User Modified' }); + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeNull(); + + act(() => { + fireEvent.click(defaultTab); + }); + + const dataformatDefaultTabButton = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(dataformatDefaultTabButton[0]); + }); + const asn1 = screen.getByTestId('dataformat-dropdownitem-asn1'); + await act(async () => { + fireEvent.click(asn1.getElementsByTagName('button')[0]); + }); + + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeInTheDocument(); + + const inputUnmarshalTypeModifiedTabElement = screen + .queryAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Unmarshal Type'); + expect(inputUnmarshalTypeModifiedTabElement).toHaveLength(0); + + act(() => { + fireEvent.click(defaultTab); + }); + + await act(async () => { + const inputUnmarshalTypeDefaultTabElement = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Unmarshal Type'); + fireEvent.change(inputUnmarshalTypeDefaultTabElement[0], { target: { value: 'test' } }); + fireEvent.blur(inputUnmarshalTypeDefaultTabElement[0]); + }); + + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeInTheDocument(); + + const inputUnmarshalTypeModifiedTabElementNew = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Unmarshal Type'); + expect(inputUnmarshalTypeModifiedTabElementNew).toHaveLength(1); + }); + + it('loadbalancer field', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + loadBalance: { + id: 'lb', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: loadBalanceNode, + }, + }; + + render( + + + + + , + ); + const defaultTab = screen.getByRole('button', { name: 'All Fields' }); + const modifiedTab = screen.getByRole('button', { name: 'User Modified' }); + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeNull(); + + act(() => { + fireEvent.click(defaultTab); + }); + + const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(button[0]); + }); + const weightedLoadBalancer = screen.getByTestId('loadbalancer-dropdownitem-weightedLoadBalancer'); + await act(async () => { + fireEvent.click(weightedLoadBalancer.getElementsByTagName('button')[0]); + }); + + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeInTheDocument(); + + const inputDistributionRatioModifiedTabElement = screen + .queryAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Distribution Ratio'); + expect(inputDistributionRatioModifiedTabElement).toHaveLength(0); + + act(() => { + fireEvent.click(defaultTab); + }); + + await act(async () => { + const inputDistributionRatioDefaultTabElement = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Distribution Ratio'); + fireEvent.change(inputDistributionRatioDefaultTabElement[0], { target: { value: 'test' } }); + fireEvent.blur(inputDistributionRatioDefaultTabElement[0]); + }); + + act(() => { + fireEvent.click(modifiedTab); + }); + + expect(screen.queryByRole('button', { name: 'Typeahead menu toggle' })).toBeInTheDocument(); + + const inputDistributionRatioModifiedTabElementNew = screen + .getAllByRole('textbox') + .filter((textbox) => textbox.getAttribute('label') === 'Distribution Ratio'); + expect(inputDistributionRatioModifiedTabElementNew).toHaveLength(1); + }); + }); + + describe('should persists changes from both expression editor and main form', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('expression => main form', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + setHeader: { + name: 'foo', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: setHeaderNode, + }, + }; + + render( + + + + + , + ); + 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)); + act(() => { + fireEvent.click(button[0]); + }); + const simple = screen.getByTestId('expression-dropdownitem-simple'); + act(() => { + fireEvent.click(simple.getElementsByTagName('button')[0]); + }); + 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}'); + expect(camelRoute.from.steps[0].setHeader!.name).toEqual('foo'); + + const filtered = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Name'); + act(() => { + fireEvent.input(filtered[0], { target: { value: 'bar' } }); + }); + /* eslint-disable @typescript-eslint/no-explicit-any */ + expect((camelRoute.from.steps[0].setHeader!.expression as any).simple.expression).toEqual('${header.foo}'); + expect(camelRoute.from.steps[0].setHeader!.name).toEqual('bar'); + }); + + it('main form => expression', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + setHeader: { + name: 'foo', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const setHeaderNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: setHeaderNode, + }, + }; + + render( + + + + + , + ); + const filtered = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Name'); + act(() => { + fireEvent.input(filtered[0], { target: { value: 'bar' } }); + }); + 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)); + act(() => { + fireEvent.click(button[0]); + }); + const simple = screen.getByTestId('expression-dropdownitem-simple'); + act(() => { + fireEvent.click(simple.getElementsByTagName('button')[0]); + }); + 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}'); + expect(camelRoute.from.steps[0].setHeader!.name).toEqual('bar'); + }); + }); + + describe('should persists changes from both dataformat editor and main form', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('dataformat => main form', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + marshal: { + id: 'ms', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const marshalNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: marshalNode, + }, + }; + + render( + + + + + , + ); + const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(button[0]); + }); + const avro = screen.getByTestId('dataformat-dropdownitem-avro'); + await act(async () => { + fireEvent.click(avro.getElementsByTagName('button')[0]); + }); + expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); + expect(camelRoute.from.steps[0].marshal!.id).toEqual('ms'); + + const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); + await act(async () => { + fireEvent.input(idInput[1], { target: { value: 'modified' } }); + }); + expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); + expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); + }); + + it('main form => dataformat', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + marshal: { + id: 'ms', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const marshalNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: marshalNode, + }, + }; + + render( + + + + + , + ); + const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); + await act(async () => { + fireEvent.input(idInput[0], { target: { value: 'modified' } }); + }); + expect(camelRoute.from.steps[0].marshal!.avro).toBeUndefined(); + expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); + + const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(button[0]); + }); + const avro = screen.getByTestId('dataformat-dropdownitem-avro'); + await act(async () => { + fireEvent.click(avro.getElementsByTagName('button')[0]); + }); + expect(camelRoute.from.steps[0].marshal!.avro).toBeDefined(); + expect(camelRoute.from.steps[0].marshal!.id).toEqual('modified'); + }); + }); + + describe('should persists changes from both loadbalancer editor and main form', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('loadbalancer => main form', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + loadBalance: { + id: 'lb', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: loadBalanceNode, + }, + }; + + render( + + + + + , + ); + const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(button[0]); + }); + const weightedLoadBalancer = screen.getByTestId('loadbalancer-dropdownitem-weightedLoadBalancer'); + await act(async () => { + fireEvent.click(weightedLoadBalancer.getElementsByTagName('button')[0]); + }); + expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); + expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('lb'); + + const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); + await act(async () => { + fireEvent.input(idInput[1], { target: { value: 'modified' } }); + }); + expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); + expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); + }); + + it('main form => loadbalancer', async () => { + const camelRoute = { + from: { + uri: 'timer:tutorial', + steps: [ + { + loadBalance: { + id: 'lb', + }, + }, + ], + }, + } as RouteDefinition; + const entity = new CamelRouteVisualEntity(camelRoute); + const rootNode: IVisualizationNode = entity.toVizNode(); + const loadBalanceNode = rootNode.getChildren()![0].getChildren()![0]; + const selectedNode = { + id: '1', + type: 'node', + data: { + vizNode: loadBalanceNode, + }, + }; + + render( + + + + + , + ); + const idInput = screen.getAllByRole('textbox').filter((textbox) => textbox.getAttribute('label') === 'Id'); + await act(async () => { + fireEvent.input(idInput[0], { target: { value: 'modified' } }); + }); + expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeUndefined(); + expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); + + const button = screen.getAllByRole('button', { name: 'Typeahead menu toggle' }); + await act(async () => { + fireEvent.click(button[0]); + }); + const weighted = screen.getByTestId('loadbalancer-dropdownitem-weightedLoadBalancer'); + await act(async () => { + fireEvent.click(weighted.getElementsByTagName('button')[0]); + }); + expect(camelRoute.from.steps[0].loadBalance!.weightedLoadBalancer).toBeDefined(); + expect(camelRoute.from.steps[0].loadBalance!.id).toEqual('modified'); + }); + }); +}); diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.tsx new file mode 100644 index 000000000..d7d743b45 --- /dev/null +++ b/packages/ui/src/components/Visualization/Canvas/CanvasFormTabs.tsx @@ -0,0 +1,124 @@ +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core'; +import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { EntitiesContext } from '../../../providers/entities.provider'; +import { SchemaBridgeProvider } from '../../../providers/schema-bridge.provider'; +import { getUserUpdatedPropertiesSchema, isDefined, setValue } from '../../../utils'; +import { CustomAutoForm, CustomAutoFormRef } from '../../Form/CustomAutoForm'; +import { DataFormatEditor } from '../../Form/dataFormat/DataFormatEditor'; +import { LoadBalancerEditor } from '../../Form/loadBalancer/LoadBalancerEditor'; +import { StepExpressionEditor } from '../../Form/stepExpression/StepExpressionEditor'; +import { UnknownNode } from '../Custom/UnknownNode'; +import './CanvasFormTabs.scss'; +import { CanvasNode } from './canvas.models'; +import { FormTabsModes } from './canvasformtabs.modes'; + +interface CanvasFormTabsProps { + selectedNode: CanvasNode; +} + +export const CanvasFormTabs: FunctionComponent = (props) => { + const entitiesContext = useContext(EntitiesContext); + const divRef = useRef(null); + const formRef = useRef(null); + const omitFields = useRef(props.selectedNode.data?.vizNode?.getBaseEntity()?.getOmitFormFields() || []); + const [selectedTab, setSelectedTab] = useState(FormTabsModes.ALL_FIELDS); + + const visualComponentSchema = useMemo(() => { + const answer = props.selectedNode.data?.vizNode?.getComponentSchema(); + // Overriding parameters with an empty object When the parameters property is mistakenly set to null + if (answer?.definition?.parameters === null) { + answer!.definition.parameters = {}; + } + return answer; + }, [props.selectedNode.data?.vizNode, selectedTab]); + const model = visualComponentSchema?.definition; + let processedSchema = visualComponentSchema?.schema; + if (selectedTab === FormTabsModes.USER_MODIFIED) { + processedSchema = { + ...visualComponentSchema?.schema, + properties: getUserUpdatedPropertiesSchema(visualComponentSchema?.schema.properties ?? {}, model), + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleItemClick = (event: any, _isSelected: boolean) => { + const id = event.currentTarget.id; + setSelectedTab(id); + }; + + useEffect(() => { + formRef.current?.form.reset(); + }, [props.selectedNode.data?.vizNode, selectedTab]); + + const stepFeatures = useMemo(() => { + const comment = visualComponentSchema?.schema?.['$comment'] ?? ''; + const isExpressionAwareStep = comment.includes('expression'); + const isDataFormatAwareStep = comment.includes('dataformat'); + const isLoadBalanceAwareStep = comment.includes('loadbalance'); + const isUnknownComponent = + !isDefined(visualComponentSchema) || + !isDefined(visualComponentSchema.schema) || + Object.keys(visualComponentSchema.schema).length === 0; + return { isExpressionAwareStep, isDataFormatAwareStep, isLoadBalanceAwareStep, isUnknownComponent }; + }, [visualComponentSchema]); + + const handleOnChangeIndividualProp = useCallback( + (path: string, value: unknown) => { + if (!props.selectedNode.data?.vizNode) { + return; + } + + let updatedValue = value; + if (typeof value === 'string' && value.trim() === '') { + updatedValue = undefined; + } + + const newModel = props.selectedNode.data.vizNode.getComponentSchema()?.definition || {}; + setValue(newModel, path, updatedValue); + props.selectedNode.data.vizNode.updateModel(newModel); + entitiesContext?.updateSourceCodeFromEntities(); + }, + [entitiesContext, props.selectedNode.data?.vizNode], + ); + + return ( + <> + {stepFeatures.isUnknownComponent ? ( + + ) : ( + + + {Object.values(FormTabsModes).map((mode) => ( + + ))} + + {stepFeatures.isExpressionAwareStep && ( + + )} + {stepFeatures.isDataFormatAwareStep && ( + + )} + {stepFeatures.isLoadBalanceAwareStep && ( + + )} + +
+ + )} + + ); +}; diff --git a/packages/ui/src/components/Visualization/Canvas/__snapshots__/CanvasForm.test.tsx.snap b/packages/ui/src/components/Visualization/Canvas/__snapshots__/CanvasForm.test.tsx.snap index 0cc30f65b..5c93e506a 100644 --- a/packages/ui/src/components/Visualization/Canvas/__snapshots__/CanvasForm.test.tsx.snap +++ b/packages/ui/src/components/Visualization/Canvas/__snapshots__/CanvasForm.test.tsx.snap @@ -105,6 +105,44 @@ exports[`CanvasForm should render 1`] = `
+
+
+ +
+
+ +
+
{ + const schema = { + type: 'object', + properties: { + id: { + title: 'Id', + type: 'string', + }, + description: { + title: 'Description', + type: 'string', + }, + uri: { + title: 'Uri', + type: 'string', + }, + variableReceive: { + title: 'Variable Receive', + type: 'string', + }, + parameters: { + type: 'object', + title: 'Endpoint Properties', + description: 'Endpoint properties description', + properties: { + timerName: { + title: 'Timer Name', + type: 'string', + }, + delay: { + title: 'Delay', + type: 'string', + default: '1000', + }, + fixedRate: { + title: 'Fixed Rate', + type: 'boolean', + default: false, + }, + includeMetadata: { + title: 'Include Metadata', + type: 'boolean', + default: false, + }, + period: { + title: 'Period', + type: 'string', + default: '1000', + }, + repeatCount: { + title: 'Repeat Count', + type: 'integer', + }, + exceptionHandler: { + title: 'Exception Handler', + type: 'string', + $comment: 'class:org.apache.camel.spi.ExceptionHandler', + }, + exchangePattern: { + title: 'Exchange Pattern', + type: 'string', + enum: ['InOnly', 'InOut'], + }, + synchronous: { + title: 'Synchronous', + type: 'boolean', + default: false, + }, + timer: { + title: 'Timer', + type: 'string', + $comment: 'class:java.util.Timer', + }, + runLoggingLevel: { + title: 'Run Logging Level', + type: 'string', + default: 'TRACE', + enum: ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'OFF'], + }, + }, + required: ['timerName'], + }, + kameletProperties: { + title: 'Properties', + type: 'array', + description: 'Configure properties on the Kamelet', + items: { + type: 'object', + properties: { + name: { + title: 'Property name', + description: 'Name of the property', + type: 'string', + }, + title: { + title: 'Title', + description: 'Display name of the property', + type: 'string', + }, + description: { + title: 'Description', + description: 'Simple text description of the property', + type: 'string', + }, + type: { + title: 'Property type', + description: 'Set the expected type for this property', + type: 'string', + enum: ['string', 'number', 'boolean'], + default: 'string', + }, + default: { + title: 'Default', + description: 'Default value for the property', + type: 'string', + }, + 'x-descriptors': { + title: 'X-descriptors', + description: 'Specific aids for the visual tools', + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['name', 'type'], + }, + }, + labels: { + additionalProperties: { + default: '', + type: 'string', + }, + title: 'Additional Labels', + description: + 'Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels', + type: 'object', + }, + }, + } as unknown as KaotoSchemaDefinition['schema']; + + const inputModel: Record = { + id: 'from-2860', + uri: 'test', + variableReceive: 'test', + parameters: { + period: '1000', + timerName: 'template', + exceptionHandler: '#test', + exchangePattern: 'InOnly', + fixedRate: true, + runLoggingLevel: 'OFF', + repeatCount: '2', + time: undefined, + timer: '#testbean', + }, + kameletProperties: [ + { + name: 'period', + title: 'Period', + description: 'The time interval between two events', + type: 'integer', + default: 5000, + }, + ], + labels: { + test: 'test', + }, + steps: [ + { + log: { + id: 'log-2942', + message: 'template message', + }, + }, + ], + }; + + const expectedSchema = { + id: { + title: 'Id', + type: 'string', + }, + uri: { + title: 'Uri', + type: 'string', + }, + parameters: { + type: 'object', + title: 'Endpoint Properties', + description: 'Endpoint properties description', + properties: { + timerName: { + title: 'Timer Name', + type: 'string', + }, + fixedRate: { + title: 'Fixed Rate', + type: 'boolean', + default: false, + }, + repeatCount: { + title: 'Repeat Count', + type: 'integer', + }, + exceptionHandler: { + title: 'Exception Handler', + type: 'string', + $comment: 'class:org.apache.camel.spi.ExceptionHandler', + }, + exchangePattern: { + title: 'Exchange Pattern', + type: 'string', + enum: ['InOnly', 'InOut'], + }, + timer: { + title: 'Timer', + type: 'string', + $comment: 'class:java.util.Timer', + }, + runLoggingLevel: { + title: 'Run Logging Level', + type: 'string', + default: 'TRACE', + enum: ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'OFF'], + }, + }, + required: ['timerName'], + }, + kameletProperties: { + title: 'Properties', + type: 'array', + description: 'Configure properties on the Kamelet', + items: { + type: 'object', + properties: { + name: { + title: 'Property name', + description: 'Name of the property', + type: 'string', + }, + title: { + title: 'Title', + description: 'Display name of the property', + type: 'string', + }, + description: { + title: 'Description', + description: 'Simple text description of the property', + type: 'string', + }, + type: { + title: 'Property type', + description: 'Set the expected type for this property', + type: 'string', + enum: ['string', 'number', 'boolean'], + default: 'string', + }, + default: { + title: 'Default', + description: 'Default value for the property', + type: 'string', + }, + 'x-descriptors': { + title: 'X-descriptors', + description: 'Specific aids for the visual tools', + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['name', 'type'], + }, + }, + labels: { + additionalProperties: { + default: '', + type: 'string', + }, + title: 'Additional Labels', + description: + 'Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels', + type: 'object', + }, + }; + + it('should return only the properties which are user Modified', () => { + const procesedSchema = getUserUpdatedPropertiesSchema(schema.properties!, inputModel); + expect(procesedSchema).toMatchObject(expectedSchema); + }); + + it('should return {}', () => { + const procesedSchema = getUserUpdatedPropertiesSchema(schema.properties!, {}); + expect(procesedSchema).toMatchObject({}); + }); +}); diff --git a/packages/ui/src/utils/get-user-updated-properties-schema.ts b/packages/ui/src/utils/get-user-updated-properties-schema.ts new file mode 100644 index 000000000..901f3b7cb --- /dev/null +++ b/packages/ui/src/utils/get-user-updated-properties-schema.ts @@ -0,0 +1,47 @@ +import { KaotoSchemaDefinition } from '../models'; +import { isDefined } from './is-defined'; + +export function getUserUpdatedPropertiesSchema( + schemaProperties: KaotoSchemaDefinition['schema'], + inputModel: Record, +): KaotoSchemaDefinition['schema'] { + if (!isDefined(schemaProperties) || !isDefined(inputModel)) return {}; + + const nonDefaultFormSchema = Object.entries(schemaProperties).reduce( + (acc, [property, definition]) => { + if (property in inputModel && isDefined(inputModel[property])) { + if ( + definition['type'] === 'string' || + definition['type'] === 'boolean' || + definition['type'] === 'integer' || + definition['type'] === 'number' + ) { + if ('default' in definition) { + if (!(definition['default'] == inputModel[property])) { + acc[property] = definition; + } + } else { + acc[property] = definition; + } + } else if (definition['type'] === 'object' && Object.keys(inputModel[property] as object).length > 0) { + if ('properties' in definition) { + const subSchema = getUserUpdatedPropertiesSchema( + definition['properties'], + inputModel[property] as Record, + ); + acc[property] = { ...definition, properties: subSchema }; + } else { + acc[property] = definition; + } + } else if (definition['type'] === 'array' && (inputModel[property] as unknown[]).length > 0) { + acc[property] = definition; + } + } + + return acc; + }, + {} as KaotoSchemaDefinition['schema'], + ); + + return nonDefaultFormSchema; +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 3937fc85e..44fb2af7f 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -14,4 +14,5 @@ export * from './update-kamelet-from-custom-schema'; export * from './pipe-custom-schema'; export * from './get-field-groups'; export * from './get-serialized-model'; +export * from './get-user-updated-properties-schema'; export * from './weight-schema-properties';