From 8b4e6d4f8f344e5fdad3f12349ed7f038d92f10c Mon Sep 17 00:00:00 2001 From: "Ricardo M." Date: Thu, 26 Sep 2024 17:45:00 +0200 Subject: [PATCH] fix(PropertiesField): Reduce padding and start with expandable open Currently, the `PropertiesField` component overflows from the containing `Form`. This commit: * Reduces its padding * Changes the containing `Td` so it can limit the overflow * Extracts the `PropertiesFieldEmpty` state component * Add a few missing tests fix: https://github.com/KaotoIO/kaoto/issues/1473 --- .../sidepanelConfig/propertiesFilter.cy.ts | 2 +- .../stories/canvas/CanvasSideBar.stories.tsx | 14 +- .../components/Form/CustomAutoFields.test.tsx | 40 + .../src/components/Form/CustomAutoFields.tsx | 4 +- .../src/components/Form/NoFieldFound.test.tsx | 44 + .../ui/src/components/Form/NoFieldFound.tsx | 13 +- .../components/Form/OneOf/OneOfField.test.tsx | 29 +- .../CustomAutoFields.test.tsx.snap | 1582 +++++++++++++++++ .../Form/properties/AddPropertyButtons.tsx | 39 +- .../Form/properties/PropertiesField.scss | 19 + .../Form/properties/PropertiesField.tsx | 87 +- .../properties/PropertiesFieldEmptyState.tsx | 30 + .../Form/properties/PropertyRow.scss | 7 - .../Form/properties/PropertyRow.tsx | 52 +- .../Form/properties/PropetiesField.test.tsx | 42 + .../properties/properties-field.models.ts | 11 + .../Canvas/CanvasSideBar.test.tsx | 15 +- .../Canvas/Form/CanvasForm.test.tsx | 16 +- .../Canvas/Form/CanvasFormBody.tsx | 2 +- .../Canvas/Form/CanvasFormHeader.test.tsx | 7 +- .../Canvas/Form/CanvasFormHeader.tsx | 30 +- .../canvas-form-tabs.provider.test.tsx | 10 +- .../providers/canvas-form-tabs.provider.tsx | 10 +- packages/ui/src/stubs/TestUniformsWrapper.tsx | 30 + .../ui/src/stubs/timer.component.schema.ts | 133 ++ .../ui/src/utils/get-field-groups.test.ts | 135 +- packages/ui/src/utils/index.ts | 1 + packages/ui/src/utils/join-path.test.ts | 28 + packages/ui/src/utils/join-path.ts | 7 + 29 files changed, 2140 insertions(+), 299 deletions(-) create mode 100644 packages/ui/src/components/Form/CustomAutoFields.test.tsx create mode 100644 packages/ui/src/components/Form/NoFieldFound.test.tsx create mode 100644 packages/ui/src/components/Form/__snapshots__/CustomAutoFields.test.tsx.snap create mode 100644 packages/ui/src/components/Form/properties/PropertiesField.scss create mode 100644 packages/ui/src/components/Form/properties/PropertiesFieldEmptyState.tsx delete mode 100644 packages/ui/src/components/Form/properties/PropertyRow.scss create mode 100644 packages/ui/src/components/Form/properties/PropetiesField.test.tsx create mode 100644 packages/ui/src/components/Form/properties/properties-field.models.ts create mode 100644 packages/ui/src/stubs/TestUniformsWrapper.tsx create mode 100644 packages/ui/src/stubs/timer.component.schema.ts create mode 100644 packages/ui/src/utils/join-path.test.ts create mode 100644 packages/ui/src/utils/join-path.ts 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 b9faf750a..32ba8c828 100644 --- a/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts +++ b/packages/ui-tests/cypress/e2e/designer/sidepanelConfig/propertiesFilter.cy.ts @@ -113,7 +113,7 @@ describe('Tests for side panel step filtering', () => { cy.selectFormTab('Required'); - cy.get('.pf-v5-c-alert__title').should('contain', 'No Required Field Found'); + cy.get('.pf-v5-c-alert__title').should('contain', 'No Required fields found'); }); it('Side panel to retain user specified fields filter', () => { diff --git a/packages/ui-tests/stories/canvas/CanvasSideBar.stories.tsx b/packages/ui-tests/stories/canvas/CanvasSideBar.stories.tsx index 9e456590e..0df0a1807 100644 --- a/packages/ui-tests/stories/canvas/CanvasSideBar.stories.tsx +++ b/packages/ui-tests/stories/canvas/CanvasSideBar.stories.tsx @@ -1,5 +1,6 @@ import { CamelRouteVisualEntity, + CanvasFormTabsContext, CanvasNode, CanvasSideBar, IVisualizationNode, @@ -99,9 +100,16 @@ const Template: StoryFn = (args) => { const [isModalOpen, setIsModalOpen] = useState(false); const handleClose = () => setIsModalOpen(!isModalOpen); return ( - - - + {}, + }} + > + + + + ); }; diff --git a/packages/ui/src/components/Form/CustomAutoFields.test.tsx b/packages/ui/src/components/Form/CustomAutoFields.test.tsx new file mode 100644 index 000000000..0aa5f113b --- /dev/null +++ b/packages/ui/src/components/Form/CustomAutoFields.test.tsx @@ -0,0 +1,40 @@ +import { act, render } from '@testing-library/react'; +import { KaotoSchemaDefinition } from '../../models'; +import { CanvasFormTabsProvider } from '../../providers'; +import { UniformsWrapper } from '../../stubs/TestUniformsWrapper'; +import { TimerComponentSchema } from '../../stubs/timer.component.schema'; +import { CustomAutoFields } from './CustomAutoFields'; + +describe('CustomAutoFields', () => { + it('renders `AutoFields` for common fields', () => { + let wrapper: ReturnType | undefined; + + act(() => { + wrapper = render( + + + + + , + ); + }); + + expect(wrapper?.asFragment()).toMatchSnapshot(); + }); + + it('render `NoFieldFound` when there are no fields', () => { + let wrapper: ReturnType | undefined; + + act(() => { + wrapper = render( + + + + + , + ); + }); + + expect(wrapper?.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/components/Form/CustomAutoFields.tsx b/packages/ui/src/components/Form/CustomAutoFields.tsx index c4a9cfae1..3626033e8 100644 --- a/packages/ui/src/components/Form/CustomAutoFields.tsx +++ b/packages/ui/src/components/Form/CustomAutoFields.tsx @@ -27,7 +27,7 @@ export function CustomAutoFields({ const { schema } = useForm(); const rootField = schema.getField(''); const { filteredFieldText, isGroupExpanded } = useContext(FilteredFieldContext); - const { selectedTab } = useContext(CanvasFormTabsContext); + const canvasFormTabsContext = useContext(CanvasFormTabsContext); /** Special handling for oneOf schemas */ if (Array.isArray((rootField as KaotoSchemaDefinition['schema']).oneOf)) { @@ -45,7 +45,7 @@ export function CustomAutoFields({ const propertiesArray = getFieldGroups(actualFieldsSchema); if ( - selectedTab !== 'All' && + canvasFormTabsContext?.selectedTab !== 'All' && propertiesArray.common.length === 0 && Object.keys(propertiesArray.groups).length === 0 ) { diff --git a/packages/ui/src/components/Form/NoFieldFound.test.tsx b/packages/ui/src/components/Form/NoFieldFound.test.tsx new file mode 100644 index 000000000..6b5ce1eda --- /dev/null +++ b/packages/ui/src/components/Form/NoFieldFound.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { CanvasFormTabsContext, CanvasFormTabsContextResult } from '../../providers'; +import { NoFieldFound } from './NoFieldFound'; + +describe('NoFieldFound Component', () => { + it('should render null if canvasFormTabsContext is not provided', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render the alert with the correct tab name', () => { + const mockContextValue: CanvasFormTabsContextResult = { + selectedTab: 'Required', + onTabChange: jest.fn(), + }; + + render( + + + , + ); + + expect(screen.getByTestId('no-field-found')).toBeInTheDocument(); + expect(screen.getByText('No Required fields found')).toBeInTheDocument(); + }); + + it('should call onTabChange when the button is clicked', () => { + const mockContextValue: CanvasFormTabsContextResult = { + selectedTab: 'Required', + onTabChange: jest.fn(), + }; + + render( + + + , + ); + + const button = screen.getByRole('button', { name: /All/i }); + fireEvent.click(button); + + expect(mockContextValue.onTabChange).toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/Form/NoFieldFound.tsx b/packages/ui/src/components/Form/NoFieldFound.tsx index 1c4667cbb..ce11a2755 100644 --- a/packages/ui/src/components/Form/NoFieldFound.tsx +++ b/packages/ui/src/components/Form/NoFieldFound.tsx @@ -3,13 +3,18 @@ import { FunctionComponent, useContext } from 'react'; import { CanvasFormTabsContext } from '../../providers'; export const NoFieldFound: FunctionComponent = () => { - const { selectedTab, onTabChange } = useContext(CanvasFormTabsContext); + const canvasFormTabsContext = useContext(CanvasFormTabsContext); + + if (!canvasFormTabsContext) { + return null; + } + return ( - + - + No field found matching this criteria. Please switch to the{' '} - {' '} tab. diff --git a/packages/ui/src/components/Form/OneOf/OneOfField.test.tsx b/packages/ui/src/components/Form/OneOf/OneOfField.test.tsx index 19a368c50..8333936e5 100644 --- a/packages/ui/src/components/Form/OneOf/OneOfField.test.tsx +++ b/packages/ui/src/components/Form/OneOf/OneOfField.test.tsx @@ -1,8 +1,6 @@ -import { AutoForm } from '@kaoto-next/uniforms-patternfly'; import { act, fireEvent, render } from '@testing-library/react'; -import { FunctionComponent, PropsWithChildren, useEffect, useRef, useState } from 'react'; import { KaotoSchemaDefinition } from '../../../models/kaoto-schema'; -import { SchemaBridgeContext } from '../../../providers/schema-bridge.provider'; +import { UniformsWrapper } from '../../../stubs/TestUniformsWrapper'; import { SchemaService } from '../schema.service'; import { OneOfField } from './OneOfField'; @@ -88,28 +86,3 @@ describe('OneOfField', () => { expect(nameField).toHaveValue(''); }); }); - -const UniformsWrapper: FunctionComponent< - PropsWithChildren<{ - model: Record; - schema: KaotoSchemaDefinition['schema']; - }> -> = (props) => { - const schemaBridge = new SchemaService().getSchemaBridge(props.schema); - const divRef = useRef(null); - const [, setLastRenderTimestamp] = useState(-1); - - useEffect(() => { - /** Force re-render to update the divRef */ - setLastRenderTimestamp(Date.now()); - }, []); - - return ( - - - {props.children} - -
- - ); -}; diff --git a/packages/ui/src/components/Form/__snapshots__/CustomAutoFields.test.tsx.snap b/packages/ui/src/components/Form/__snapshots__/CustomAutoFields.test.tsx.snap new file mode 100644 index 000000000..0af9aadef --- /dev/null +++ b/packages/ui/src/components/Form/__snapshots__/CustomAutoFields.test.tsx.snap @@ -0,0 +1,1582 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomAutoFields render \`NoFieldFound\` when there are no fields 1`] = ` + +
+
+
+
+
+ +
+

+ + Info alert: + + No Required fields found +

+
+ No field found matching this criteria. Please switch to the + + tab. +
+
+
+
+
+
+ +`; + +exports[`CustomAutoFields renders \`AutoFields\` for common fields 1`] = ` + +
+
+
+
+ + +
+ +
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+
+ + +
+ +
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+
+
+ +
+ + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+ +
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+
+ + +
+ +
+
+
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +`; diff --git a/packages/ui/src/components/Form/properties/AddPropertyButtons.tsx b/packages/ui/src/components/Form/properties/AddPropertyButtons.tsx index 2ce7eae68..45c95bb91 100644 --- a/packages/ui/src/components/Form/properties/AddPropertyButtons.tsx +++ b/packages/ui/src/components/Form/properties/AddPropertyButtons.tsx @@ -2,10 +2,11 @@ import { Button, Split, SplitItem, Tooltip } from '@patternfly/react-core'; import { FolderPlusIcon, PlusCircleIcon } from '@patternfly/react-icons'; type AddPropertyPopoverProps = { - showLabel?: boolean; path: string[]; - disabled?: boolean; createPlaceholder: (isObject: boolean) => void; + canAddObjectProperties?: boolean; + showLabel?: boolean; + disabled?: boolean; }; /** @@ -14,10 +15,11 @@ type AddPropertyPopoverProps = { * @constructor */ export function AddPropertyButtons({ - showLabel = false, path, - disabled = false, createPlaceholder, + canAddObjectProperties = true, + showLabel = false, + disabled = false, }: AddPropertyPopoverProps) { return ( @@ -34,19 +36,22 @@ export function AddPropertyButtons({ - - - - - + + {canAddObjectProperties && ( + + + + + + )} ); } diff --git a/packages/ui/src/components/Form/properties/PropertiesField.scss b/packages/ui/src/components/Form/properties/PropertiesField.scss new file mode 100644 index 000000000..b7c22cbc8 --- /dev/null +++ b/packages/ui/src/components/Form/properties/PropertiesField.scss @@ -0,0 +1,19 @@ +$root-element: 'properties-field'; + +.#{$root-element} { + .#{$root-element} &__head, + .#{$root-element} &__body { + .#{$root-element}__row__value { + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + --pf-v5-c-table--cell--PaddingLeft: 0 !important; + } + + .#{$root-element}__row__action[data-column-type='action'] { + --pf-v5-c-table--cell--PaddingRight: 0 !important; + } + } +} diff --git a/packages/ui/src/components/Form/properties/PropertiesField.tsx b/packages/ui/src/components/Form/properties/PropertiesField.tsx index 8dffa360f..02bf51260 100644 --- a/packages/ui/src/components/Form/properties/PropertiesField.tsx +++ b/packages/ui/src/components/Form/properties/PropertiesField.tsx @@ -1,40 +1,35 @@ import { wrapField } from '@kaoto-next/uniforms-patternfly'; -import { Badge, EmptyState, EmptyStateBody, ExpandableSection, Stack, StackItem } from '@patternfly/react-core'; +import { Badge, ExpandableSection, Stack, StackItem } from '@patternfly/react-core'; import { Table, TableVariant, Tbody, Td, TdProps, Th, Thead, Tr } from '@patternfly/react-table'; -import { ReactNode, useState } from 'react'; -import { HTMLFieldProps, connectField } from 'uniforms'; +import { ReactNode, useContext, useMemo, useState } from 'react'; +import { connectField } from 'uniforms'; +import { CanvasFormTabsContext } from '../../../providers'; +import { getJoinPath, isDefined } from '../../../utils'; import { AddPropertyButtons } from './AddPropertyButtons'; +import { IPropertiesField, PlaceholderState } from './properties-field.models'; +import './PropertiesField.scss'; +import { PropertiesFieldEmptyState } from './PropertiesFieldEmptyState'; import { PropertyRow } from './PropertyRow'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type PropertiesFieldProps = HTMLFieldProps; - /** * The uniforms custom field for editing generic properties where it has type "object" in the schema, * but it doesn't have "properties" declared. * @param props * @constructor */ -const PropertiesFieldComponent = (props: PropertiesFieldProps) => { - const [isFieldExpanded, setFieldExpanded] = useState(false); +export const PropertiesField = connectField((props: IPropertiesField) => { + const propertiesModel = props.value ? { ...props.value } : {}; + const [isFieldExpanded, setFieldExpanded] = useState(Object.keys(propertiesModel).length > 0); const [expandedNodes, setExpandedNodes] = useState([]); const [placeholderState, setPlaceholderState] = useState(null); - const propertiesModel = props.value ? { ...props.value } : {}; - - type PlaceholderState = { - isObject: boolean; - parentNodeId: string; - }; + const canvasFormTabsContext = useContext(CanvasFormTabsContext); + const canAddObjectProperties = useMemo(() => !isDefined(canvasFormTabsContext), [canvasFormTabsContext]); function handleModelChange() { setPlaceholderState(null); props.onChange(propertiesModel, props.name); } - function getNodeId(path: string[]) { - return path.join('-'); - } - function handleCreatePlaceHolder(state: PlaceholderState) { setPlaceholderState({ ...state }); if (state.parentNodeId && state.parentNodeId.length > 0) { @@ -68,7 +63,7 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => { }, }; - return placeholderState && placeholderState.parentNodeId === getNodeId(parentPath) + return placeholderState && placeholderState.parentNodeId === getJoinPath(parentPath) ? [ { const nodeValue = node[1]; const path = parentPath.slice(); path.push(nodeName); - const nodeId = getNodeId(path); + const nodeId = getJoinPath(path); const isExpanded = expandedNodes.includes(nodeId); const childRows = @@ -127,7 +122,7 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => { return [ { createPlaceholder={(isObject) => { handleCreatePlaceHolder({ isObject: isObject, - parentNodeId: getNodeId(path), + parentNodeId: getJoinPath(path), }); }} />, @@ -165,8 +160,15 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => { > - - +
+ - - + + {Object.keys(propertiesModel).length > 0 || placeholderState ? renderRows(Object.entries(propertiesModel), propertiesModel) : !props.disabled && ( - )} @@ -217,6 +218,4 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => { , ); -}; - -export const PropertiesField = connectField(PropertiesFieldComponent); +}); diff --git a/packages/ui/src/components/Form/properties/PropertiesFieldEmptyState.tsx b/packages/ui/src/components/Form/properties/PropertiesFieldEmptyState.tsx new file mode 100644 index 000000000..41f2d11ed --- /dev/null +++ b/packages/ui/src/components/Form/properties/PropertiesFieldEmptyState.tsx @@ -0,0 +1,30 @@ +import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; +import { FunctionComponent } from 'react'; +import { AddPropertyButtons } from './AddPropertyButtons'; + +interface IPropertiesFieldEmptyState { + name: string; + disabled: boolean; + canAddObjectProperties: boolean; + createPlaceholder: (isObject: boolean) => void; +} + +export const PropertiesFieldEmptyState: FunctionComponent = ({ + name, + disabled, + canAddObjectProperties, + createPlaceholder, +}) => { + return ( + + No {name} + + + ); +}; diff --git a/packages/ui/src/components/Form/properties/PropertyRow.scss b/packages/ui/src/components/Form/properties/PropertyRow.scss deleted file mode 100644 index 7251824aa..000000000 --- a/packages/ui/src/components/Form/properties/PropertyRow.scss +++ /dev/null @@ -1,7 +0,0 @@ -/* stylelint-disable-next-line selector-class-pattern */ -td.property-row__value { - max-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/packages/ui/src/components/Form/properties/PropertyRow.tsx b/packages/ui/src/components/Form/properties/PropertyRow.tsx index b6ad1c90d..18d31e271 100644 --- a/packages/ui/src/components/Form/properties/PropertyRow.tsx +++ b/packages/ui/src/components/Form/properties/PropertyRow.tsx @@ -2,7 +2,6 @@ import { Button, HelperText, HelperTextItem, Split, SplitItem, TextInput, Toolti import { CheckIcon, PencilAltIcon, TimesIcon, TrashIcon } from '@patternfly/react-icons'; import { Td, TdProps, TreeRowWrapper } from '@patternfly/react-table'; import { FormEvent, useState } from 'react'; -import './PropertyRow.scss'; import { AddPropertyButtons } from './AddPropertyButtons'; type PropertyRowProps = { @@ -97,7 +96,12 @@ export function PropertyRow({ return ( - - -
NAME @@ -174,10 +176,11 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => { VALUE + handleCreatePlaceHolder({ isObject, @@ -188,26 +191,24 @@ const PropertiesFieldComponent = (props: PropertiesFieldProps) => {
- - No {props.name} - - handleCreatePlaceHolder({ - isObject: isObject, - parentNodeId: '', - }) - } - /> - + + + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: '', + }) + } + />
+ {isEditing ? ( <> ) : ( -
{nodeName}
+ <>{nodeName} )}
- {(() => { - if (isObject && !isEditing) { - return ; - } else if (!isObject && isEditing) { - return ( - event.stopPropagation()} - onChange={(event, value) => handleUserInputValue(value, event)} - /> - ); - } else if (!isObject && !isEditing) { - return {nodeValue}; - } else { - return <>; - } - })()} + + + {isObject && !isEditing && } + + {!isObject && isEditing && ( + event.stopPropagation()} + onChange={(event, value) => handleUserInputValue(value, event)} + /> + )} + + {!isObject && !isEditing && <>{nodeValue}} + + {isEditing ? [ diff --git a/packages/ui/src/components/Form/properties/PropetiesField.test.tsx b/packages/ui/src/components/Form/properties/PropetiesField.test.tsx new file mode 100644 index 000000000..36632d4be --- /dev/null +++ b/packages/ui/src/components/Form/properties/PropetiesField.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@testing-library/react'; +import { KaotoSchemaDefinition } from '../../../models'; +import { UniformsWrapper } from '../../../stubs/TestUniformsWrapper'; +import { PropertiesField } from './PropertiesField'; + +describe('PropertiesField Component', () => { + const parametersSchema: KaotoSchemaDefinition['schema'] = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + parameters: { + title: 'Parameters', + group: 'consumer', + description: 'List of Tool parameters in the form of parameter.=', + type: 'object', + deprecated: false, + }, + }, + }; + + it('renders without crashing', () => { + const wrapper = render( + + + , + ); + + const propertiesFieldElement = wrapper.getByTestId('expandable-section-parameters'); + expect(propertiesFieldElement).toBeInTheDocument(); + }); + + it('displays the correct label', () => { + const label = 'Test Label'; + const wrapper = render( + + + , + ); + const labelElement = wrapper.getByText(label); + expect(labelElement).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Form/properties/properties-field.models.ts b/packages/ui/src/components/Form/properties/properties-field.models.ts new file mode 100644 index 000000000..b28a5b13d --- /dev/null +++ b/packages/ui/src/components/Form/properties/properties-field.models.ts @@ -0,0 +1,11 @@ +import { HTMLFieldProps } from 'uniforms'; + +export interface PlaceholderState { + isObject: boolean; + parentNodeId: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface IPropertiesField extends HTMLFieldProps { + [key: string]: unknown; +} diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasSideBar.test.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasSideBar.test.tsx index f2cb9f78d..105c3f3f6 100644 --- a/packages/ui/src/components/Visualization/Canvas/CanvasSideBar.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/CanvasSideBar.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render } from '@testing-library/react'; import { FunctionComponent, PropsWithChildren } from 'react'; import { CamelRouteResource } from '../../../models/camel'; import { EntityType } from '../../../models/camel/entities'; +import { CanvasFormTabsProvider } from '../../../providers'; import { TestProvidersWrapper } from '../../../stubs/TestProvidersWrapper'; import { CanvasNode } from './canvas.models'; import { CanvasSideBar } from './CanvasSideBar'; @@ -20,7 +21,11 @@ describe('CanvasSideBar', () => { }); it('does not render anything if there is no selectedNode', () => { - const wrapper = render( {}} />); + const wrapper = render( + + {}} /> + , + ); expect(wrapper.container).toBeEmptyDOMElement(); }); @@ -28,7 +33,9 @@ describe('CanvasSideBar', () => { it('displays selected node information', () => { const wrapper = render( - {}} /> + + {}} /> + , ); @@ -40,7 +47,9 @@ describe('CanvasSideBar', () => { const wrapper = render( - + + + , ); diff --git a/packages/ui/src/components/Visualization/Canvas/Form/CanvasForm.test.tsx b/packages/ui/src/components/Visualization/Canvas/Form/CanvasForm.test.tsx index e62dd706a..caa94f0ec 100644 --- a/packages/ui/src/components/Visualization/Canvas/Form/CanvasForm.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Form/CanvasForm.test.tsx @@ -65,7 +65,9 @@ describe('CanvasForm', () => { const { container } = render( - + + + , ); @@ -94,7 +96,9 @@ describe('CanvasForm', () => { const { container } = render( - + + + , ); @@ -128,7 +132,9 @@ describe('CanvasForm', () => { const { container } = render( - + + + , ); @@ -255,7 +261,9 @@ describe('CanvasForm', () => { render( - + + + , ); diff --git a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormBody.tsx b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormBody.tsx index 6c67e0dcc..d8f7b9b94 100644 --- a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormBody.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormBody.tsx @@ -16,7 +16,7 @@ interface CanvasFormTabsProps { export const CanvasFormBody: FunctionComponent = (props) => { const entitiesContext = useContext(EntitiesContext); - const { selectedTab } = useContext(CanvasFormTabsContext); + const { selectedTab } = useContext(CanvasFormTabsContext) ?? { selectedTab: 'Required' }; const divRef = useRef(null); const formRef = useRef(null); const omitFields = useRef(props.selectedNode.data?.vizNode?.getOmitFormFields() || []); diff --git a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.test.tsx b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.test.tsx index 515fef87b..5610e7ee1 100644 --- a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.test.tsx @@ -1,9 +1,14 @@ import { render } from '@testing-library/react'; +import { CanvasFormTabsProvider } from '../../../../providers'; import { CanvasFormHeader } from './CanvasFormHeader'; describe('CanvasFormHeader', () => { it('renders correctly', () => { - const { asFragment } = render(); + const { asFragment } = render( + + + , + ); expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.tsx b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.tsx index 642d2f4cd..c65165189 100644 --- a/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Form/CanvasFormHeader.tsx @@ -24,7 +24,7 @@ interface CanvasFormHeaderProps { export const CanvasFormHeader: FunctionComponent = (props) => { const { filteredFieldText, onFilterChange } = useContext(FilteredFieldContext); - const { selectedTab, onTabChange } = useContext(CanvasFormTabsContext); + const canvasFormTabsContext = useContext(CanvasFormTabsContext); return ( <> @@ -40,19 +40,21 @@ export const CanvasFormHeader: FunctionComponent = (props - - {Object.entries(FormTabsModes).map(([mode, tooltip]) => ( - - - - ))} - + {canvasFormTabsContext && ( + + {Object.entries(FormTabsModes).map(([mode, tooltip]) => ( + + + + ))} + + )} { it('should provide default selectedTab value', () => { const TestComponent: FunctionComponent = () => { - const { selectedTab } = useContext(CanvasFormTabsContext); + const { selectedTab } = useContext(CanvasFormTabsContext)!; return
{selectedTab}
; }; @@ -21,17 +21,17 @@ describe('CanvasFormTabsProvider', () => { it('should update selectedTab on tab change', () => { const TestComponent: FunctionComponent = () => { - const { selectedTab, onTabChange } = useContext(CanvasFormTabsContext); + const { selectedTab, onTabChange } = useContext(CanvasFormTabsContext)!; return (
{selectedTab}
- - -
diff --git a/packages/ui/src/providers/canvas-form-tabs.provider.tsx b/packages/ui/src/providers/canvas-form-tabs.provider.tsx index 03a2cbe2e..172a68088 100644 --- a/packages/ui/src/providers/canvas-form-tabs.provider.tsx +++ b/packages/ui/src/providers/canvas-form-tabs.provider.tsx @@ -4,15 +4,9 @@ import { FormTabsModes } from '../components/Visualization/Canvas/Form/canvasfor export interface CanvasFormTabsContextResult { selectedTab: keyof typeof FormTabsModes; - onTabChange: ( - event: MouseEvent | React.MouseEvent | React.KeyboardEvent, - _isSelected: boolean, - ) => void; + onTabChange: (event: MouseEvent | React.MouseEvent | React.KeyboardEvent) => void; } -export const CanvasFormTabsContext = createContext({ - selectedTab: 'Required', - onTabChange: () => {}, -}); +export const CanvasFormTabsContext = createContext(undefined); /** * Used for fetching and injecting the selected tab information from the canvas form diff --git a/packages/ui/src/stubs/TestUniformsWrapper.tsx b/packages/ui/src/stubs/TestUniformsWrapper.tsx new file mode 100644 index 000000000..cfdd23ce9 --- /dev/null +++ b/packages/ui/src/stubs/TestUniformsWrapper.tsx @@ -0,0 +1,30 @@ +import { AutoForm } from '@kaoto-next/uniforms-patternfly'; +import { FunctionComponent, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import { SchemaService } from '../components/Form/schema.service'; +import { KaotoSchemaDefinition } from '../models/kaoto-schema'; +import { SchemaBridgeContext } from '../providers/schema-bridge.provider'; + +export const UniformsWrapper: FunctionComponent< + PropsWithChildren<{ + model: Record; + schema: KaotoSchemaDefinition['schema']; + }> +> = (props) => { + const schemaBridge = new SchemaService().getSchemaBridge(props.schema); + const divRef = useRef(null); + const [, setLastRenderTimestamp] = useState(-1); + + useEffect(() => { + /** Force re-render to update the divRef */ + setLastRenderTimestamp(Date.now()); + }, []); + + return ( + + + {props.children} + +
+ + ); +}; diff --git a/packages/ui/src/stubs/timer.component.schema.ts b/packages/ui/src/stubs/timer.component.schema.ts new file mode 100644 index 000000000..36fdc6146 --- /dev/null +++ b/packages/ui/src/stubs/timer.component.schema.ts @@ -0,0 +1,133 @@ +export const TimerComponentSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + timerName: { + title: 'Timer Name', + group: 'consumer', + description: 'The name of the timer', + type: 'string', + deprecated: false, + }, + delay: { + title: 'Delay', + group: 'consumer', + description: + 'The number of milliseconds to wait before the first event is generated. Should not be used in conjunction with the time option. The default value is 1000.', + format: 'duration', + type: 'string', + deprecated: false, + default: '1000', + }, + fixedRate: { + title: 'Fixed Rate', + group: 'consumer', + description: 'Events take place at approximately regular intervals, separated by the specified period.', + type: 'boolean', + deprecated: false, + default: false, + }, + includeMetadata: { + title: 'Include Metadata', + group: 'consumer', + description: 'Whether to include metadata in the exchange such as fired time, timer name, timer count etc.', + type: 'boolean', + deprecated: false, + default: false, + }, + period: { + title: 'Period', + group: 'consumer', + description: 'Generate periodic events every period. Must be zero or positive value. The default value is 1000.', + format: 'duration', + type: 'string', + deprecated: false, + default: '1000', + }, + repeatCount: { + title: 'Repeat Count', + group: 'consumer', + description: + 'Specifies a maximum limit for the number of fires. Therefore, if you set it to 1, the timer will only fire once. If you set it to 5, it will only fire five times. A value of zero or negative means fire forever.', + type: 'integer', + deprecated: false, + }, + bridgeErrorHandler: { + title: 'Bridge Error Handler', + group: 'consumer (advanced)', + description: + 'Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored.', + type: 'boolean', + deprecated: false, + default: false, + }, + exceptionHandler: { + title: 'Exception Handler', + group: 'consumer (advanced)', + description: + 'To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored.', + type: 'string', + deprecated: false, + $comment: 'class:org.apache.camel.spi.ExceptionHandler', + }, + exchangePattern: { + title: 'Exchange Pattern', + group: 'consumer (advanced)', + description: 'Sets the exchange pattern when the consumer creates an exchange.', + type: 'string', + deprecated: false, + enum: ['InOnly', 'InOut'], + }, + daemon: { + title: 'Daemon', + group: 'advanced', + description: + 'Specifies whether the thread associated with the timer endpoint runs as a daemon. The default value is true.', + type: 'boolean', + deprecated: false, + default: true, + }, + pattern: { + title: 'Pattern', + group: 'advanced', + description: 'Allows you to specify a custom Date pattern to use for setting the time option using URI syntax.', + type: 'string', + deprecated: false, + }, + synchronous: { + title: 'Synchronous', + group: 'advanced', + description: 'Sets whether synchronous processing should be strictly used', + type: 'boolean', + deprecated: false, + default: false, + }, + time: { + title: 'Time', + group: 'advanced', + description: + "A java.util.Date the first event should be generated. If using the URI, the pattern expected is: yyyy-MM-dd HH:mm:ss or yyyy-MM-dd'T'HH:mm:ss.", + type: 'string', + deprecated: false, + }, + timer: { + title: 'Timer', + group: 'advanced', + description: 'To use a custom Timer', + type: 'string', + deprecated: false, + $comment: 'class:java.util.Timer', + }, + runLoggingLevel: { + title: 'Run Logging Level', + group: 'scheduler', + description: + 'The consumer logs a start/complete log line when it polls. This option allows you to configure the logging level for that.', + type: 'string', + deprecated: false, + default: 'TRACE', + enum: ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'OFF'], + }, + }, + required: ['timerName'], +}; diff --git a/packages/ui/src/utils/get-field-groups.test.ts b/packages/ui/src/utils/get-field-groups.test.ts index 6b4564832..5e2a978c3 100644 --- a/packages/ui/src/utils/get-field-groups.test.ts +++ b/packages/ui/src/utils/get-field-groups.test.ts @@ -1,3 +1,4 @@ +import { TimerComponentSchema } from '../stubs/timer.component.schema'; import { getFieldGroups } from './get-field-groups'; describe('useFieldGroups', () => { @@ -81,135 +82,7 @@ describe('useFieldGroups', () => { expect(propertiesArray).toEqual(expectedOutputValue); }); - it('should get a object with common array and groups object containing differnt groups array', () => { - inputValue = { - timerName: { - title: 'Timer Name', - group: 'consumer', - description: 'The name of the timer', - type: 'string', - deprecated: false, - }, - delay: { - title: 'Delay', - group: 'consumer', - description: 'Delay before first event is triggered.', - format: 'duration', - type: 'string', - deprecated: false, - default: '1000', - }, - fixedRate: { - title: 'Fixed Rate', - group: 'consumer', - description: 'Events take place at approximately regular intervals, separated by the specified period.', - type: 'boolean', - deprecated: false, - default: false, - }, - includeMetadata: { - title: 'Include Metadata', - group: 'consumer', - description: 'Whether to include metadata in the exchange such as fired time, timer name, timer count etc.', - type: 'boolean', - deprecated: false, - default: false, - }, - period: { - title: 'Period', - group: 'consumer', - description: 'If greater than 0, generate periodic events every period.', - format: 'duration', - type: 'string', - deprecated: false, - default: '1000', - }, - repeatCount: { - title: 'Repeat Count', - group: 'consumer', - description: - 'Specifies a maximum limit of number of fires. So if you set it to 1, the timer will only fire once. If you set it to 5, it will only fire five times. A value of zero or negative means fire forever.', - type: 'integer', - deprecated: false, - }, - bridgeErrorHandler: { - title: 'Bridge Error Handler', - group: 'consumer (advanced)', - description: - 'Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored.', - type: 'boolean', - deprecated: false, - default: false, - }, - exceptionHandler: { - title: 'Exception Handler', - group: 'consumer (advanced)', - description: - 'To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored.', - type: 'string', - deprecated: false, - $comment: 'class:org.apache.camel.spi.ExceptionHandler', - }, - exchangePattern: { - title: 'Exchange Pattern', - group: 'consumer (advanced)', - description: 'Sets the exchange pattern when the consumer creates an exchange.', - type: 'string', - deprecated: false, - enum: ['InOnly', 'InOut'], - }, - daemon: { - title: 'Daemon', - group: 'advanced', - description: - 'Specifies whether or not the thread associated with the timer endpoint runs as a daemon. The default value is true.', - type: 'boolean', - deprecated: false, - default: true, - }, - pattern: { - title: 'Pattern', - group: 'advanced', - description: 'Allows you to specify a custom Date pattern to use for setting the time option using URI syntax.', - type: 'string', - deprecated: false, - }, - synchronous: { - title: 'Synchronous', - group: 'advanced', - description: 'Sets whether synchronous processing should be strictly used', - type: 'boolean', - deprecated: false, - default: false, - }, - time: { - title: 'Time', - group: 'advanced', - description: - "A java.util.Date the first event should be generated. If using the URI, the pattern expected is: yyyy-MM-dd HH:mm:ss or yyyy-MM-dd'T'HH:mm:ss.", - type: 'string', - deprecated: false, - }, - timer: { - title: 'Timer', - group: 'advanced', - description: 'To use a custom Timer', - type: 'string', - deprecated: false, - $comment: 'class:java.util.Timer', - }, - runLoggingLevel: { - title: 'Run Logging Level', - group: 'scheduler', - description: - 'The consumer logs a start/complete log line when it polls. This option allows you to configure the logging level for that.', - type: 'string', - deprecated: false, - default: 'TRACE', - enum: ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'OFF'], - }, - }; - + it('should get a object with common array and groups object containing different groups array', () => { const expectedOutputValue = { common: ['timerName', 'delay', 'fixedRate', 'includeMetadata', 'period', 'repeatCount'], groups: { @@ -218,11 +91,11 @@ describe('useFieldGroups', () => { scheduler: ['runLoggingLevel'], }, }; - const propertiesArray = getFieldGroups(inputValue); + const propertiesArray = getFieldGroups(TimerComponentSchema.properties); expect(propertiesArray).toEqual(expectedOutputValue); }); - it('should get a object with empty common array and emplty groups object', () => { + it('should get a object with empty common array and empty groups object', () => { inputValue = {}; const expectedOutputValue = { diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index eba9dd4eb..77a7d70fd 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -7,6 +7,7 @@ export * from './get-value'; export * from './init-visible-flows'; export * from './is-defined'; export * from './is-enum-type'; +export * from './join-path'; export * from './node-icon-resolver'; export * from './resolve-ref-if-needed'; export * from './set-value'; diff --git a/packages/ui/src/utils/join-path.test.ts b/packages/ui/src/utils/join-path.test.ts new file mode 100644 index 000000000..44e31ad94 --- /dev/null +++ b/packages/ui/src/utils/join-path.test.ts @@ -0,0 +1,28 @@ +import { getJoinPath } from './join-path'; + +describe('getJoinPath', () => { + it('should return a joined string with hyphens for a valid array of strings', () => { + const result = getJoinPath(['home', 'user', 'documents']); + expect(result).toBe('home-user-documents'); + }); + + it('should return an empty string if the array is empty', () => { + const result = getJoinPath([]); + expect(result).toBe(''); + }); + + it('should handle an array with one element correctly', () => { + const result = getJoinPath(['single']); + expect(result).toBe('single'); + }); + + it('should handle an array with multiple elements correctly', () => { + const result = getJoinPath(['a', 'b', 'c']); + expect(result).toBe('a-b-c'); + }); + + it('should handle an array with empty strings correctly', () => { + const result = getJoinPath(['a', '', 'c']); + expect(result).toBe('a--c'); + }); +}); diff --git a/packages/ui/src/utils/join-path.ts b/packages/ui/src/utils/join-path.ts new file mode 100644 index 000000000..e2e5bfaad --- /dev/null +++ b/packages/ui/src/utils/join-path.ts @@ -0,0 +1,7 @@ +export const getJoinPath = (path: string[]) => { + if (!Array.isArray(path)) { + return ''; + } + + return path.join('-'); +};