diff --git a/packages/ui/src/components/Visualization/Canvas/Canvas.scss b/packages/ui/src/components/Visualization/Canvas/Canvas.scss index 0a14e61c2..424251c34 100644 --- a/packages/ui/src/components/Visualization/Canvas/Canvas.scss +++ b/packages/ui/src/components/Visualization/Canvas/Canvas.scss @@ -4,3 +4,13 @@ border: none; } } + +.canvas-empty-state { + position: absolute; + height: 100%; + width: 100%; +} + +.hidden { + visibility: hidden; +} diff --git a/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx b/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx index 989d77519..09cd7a49c 100644 --- a/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Canvas.test.tsx @@ -1,31 +1,52 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { VisualizationProvider } from '@patternfly/react-topology'; +import { act, fireEvent, render, RenderResult, screen, waitFor } from '@testing-library/react'; import { CamelRouteResource, KameletResource } from '../../../models/camel'; import { CamelRouteVisualEntity } from '../../../models/visualization/flows'; +import { ActionConfirmationModalContextProvider } from '../../../providers'; import { CatalogModalContext } from '../../../providers/catalog-modal.provider'; import { VisibleFLowsContextResult } from '../../../providers/visible-flows.provider'; import { TestProvidersWrapper } from '../../../stubs'; import { camelRouteJson } from '../../../stubs/camel-route'; import { kameletJson } from '../../../stubs/kamelet-route'; import { Canvas } from './Canvas'; -import { ActionConfirmationModalContextProvider } from '../../../providers'; +import { ControllerService } from './controller.service'; describe('Canvas', () => { const entity = new CamelRouteVisualEntity(camelRouteJson); const entity2 = { ...entity, id: 'route-9999' } as CamelRouteVisualEntity; + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('should render correctly', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const { Provider } = TestProvidersWrapper({ visibleFlowsContext: { visibleFlows: { ['route-8888']: true } } as unknown as VisibleFLowsContextResult, }); - const result = render( - - - , - ); - await waitFor(async () => expect(result.container.querySelector('#fit-to-screen')).toBeInTheDocument()); - expect(result.container).toMatchSnapshot(); + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + await waitFor(async () => expect(screen.getByText('Reset View')).toBeInTheDocument()); + expect(result?.asFragment()).toMatchSnapshot(); }); it('should render correctly with more routes ', async () => { @@ -34,14 +55,25 @@ describe('Canvas', () => { visibleFlows: { ['route-8888']: true, ['route-9999']: false }, } as unknown as VisibleFLowsContextResult, }); - const result = render( - - - , - ); - await waitFor(async () => expect(result.container.querySelector('#fit-to-screen')).toBeInTheDocument()); - expect(result.container).toMatchSnapshot(); + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + await waitFor(async () => expect(screen.getByText('Reset View')).toBeInTheDocument()); + expect(result?.asFragment()).toMatchSnapshot(); }); it('should be able to delete the routes', async () => { @@ -55,16 +87,31 @@ describe('Canvas', () => { visibleFlows: { ['route-8888']: true }, } as unknown as VisibleFLowsContextResult, }); - const wrapper = render( - - - - - , - ); + + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + const route = result?.getByText('route-8888'); + if (!route) { + fail('Route not found'); + } // Right click anywhere on the container label - const route = wrapper.getByText('route-8888'); await act(async () => { fireEvent.contextMenu(route); }); @@ -102,17 +149,30 @@ describe('Canvas', () => { } as unknown as VisibleFLowsContextResult, }); - const wrapper = render( - - - - - , - ); + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + const kamelet = result?.getByText('user-source'); + if (!kamelet) { + fail('Kamelet not found'); + } // Right click anywhere on the container label - const kamelet = wrapper.getByText('user-source'); - // const route = document.querySelectorAll('.pf-topology__group'); await act(async () => { fireEvent.contextMenu(kamelet); }); @@ -137,22 +197,58 @@ describe('Canvas', () => { expect(removeSpy).toHaveBeenCalled(); }); - it('should render the Catalog button if `CatalogModalContext` is provided', async () => { - const { Provider } = TestProvidersWrapper({ - visibleFlowsContext: { visibleFlows: { ['route-8888']: true } } as unknown as VisibleFLowsContextResult, + describe('Catalog button', () => { + it('should be present if `CatalogModalContext` is provided', async () => { + const { Provider } = TestProvidersWrapper({ + visibleFlowsContext: { visibleFlows: { ['route-8888']: true } } as unknown as VisibleFLowsContextResult, + }); + + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + await waitFor(async () => expect(screen.getByText('Open Catalog')).toBeInTheDocument()); + expect(result?.asFragment()).toMatchSnapshot(); + }); + + it('should NOT be present if `CatalogModalContext` is NOT provided', async () => { + const { Provider } = TestProvidersWrapper({ + visibleFlowsContext: { visibleFlows: { ['route-8888']: true } } as unknown as VisibleFLowsContextResult, + }); + + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + await waitFor(async () => expect(screen.queryByText('Open Catalog')).not.toBeInTheDocument()); + expect(result?.asFragment()).toMatchSnapshot(); }); - const result = render( - - - - - , - ); - - await waitFor(async () => - expect(result.container.querySelector('#topology-control-bar-catalog-button')).toBeInTheDocument(), - ); - expect(result.container).toMatchSnapshot(); }); describe('Empty state', () => { @@ -160,28 +256,49 @@ describe('Canvas', () => { const { Provider } = TestProvidersWrapper({ visibleFlowsContext: { visibleFlows: {} } as unknown as VisibleFLowsContextResult, }); - const result = render( - - - , - ); - await waitFor(async () => expect(result.getByTestId('visualization-empty-state')).toBeInTheDocument()); - expect(result.container).toMatchSnapshot(); + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); + + await waitFor(async () => expect(screen.getByTestId('visualization-empty-state')).toBeInTheDocument()); + expect(result?.asFragment()).toMatchSnapshot(); }); it('should render empty state when there is no visible flows', async () => { const { Provider } = TestProvidersWrapper({ visibleFlowsContext: { visibleFlows: { ['route-8888']: false } } as unknown as VisibleFLowsContextResult, }); - const result = render( - - - , - ); + let result: RenderResult | undefined; + + await act(async () => { + result = render( + + + + + , + ); + }); + + await act(async () => { + await jest.runAllTimersAsync(); + }); - await waitFor(async () => expect(result.getByTestId('visualization-empty-state')).toBeInTheDocument()); - expect(result.container).toMatchSnapshot(); + await waitFor(async () => expect(screen.getByTestId('visualization-empty-state')).toBeInTheDocument()); + expect(result?.container).toMatchSnapshot(); }); }); }); diff --git a/packages/ui/src/components/Visualization/Canvas/Canvas.tsx b/packages/ui/src/components/Visualization/Canvas/Canvas.tsx index b390a409b..b8cfbf22e 100644 --- a/packages/ui/src/components/Visualization/Canvas/Canvas.tsx +++ b/packages/ui/src/components/Visualization/Canvas/Canvas.tsx @@ -1,8 +1,6 @@ import { Icon } from '@patternfly/react-core'; import { CatalogIcon } from '@patternfly/react-icons'; import { - GRAPH_LAYOUT_END_EVENT, - GraphLayoutEndEventListener, Model, SELECTION_EVENT, SelectionEventListener, @@ -16,6 +14,7 @@ import { useEventListener, useVisualizationController, } from '@patternfly/react-topology'; +import clsx from 'clsx'; import { FunctionComponent, PropsWithChildren, @@ -41,12 +40,13 @@ import { CanvasEdge, CanvasNode, LayoutType } from './canvas.models'; import { FlowService } from './flow.service'; interface CanvasProps { - contextToolbar?: ReactNode; entities: BaseVisualCamelEntity[]; + contextToolbar?: ReactNode; } export const Canvas: FunctionComponent> = ({ entities, contextToolbar }) => { /** State for @patternfly/react-topology */ + const [initialized, setInitialized] = useState(false); const [selectedIds, setSelectedIds] = useState([]); const [selectedNode, setSelectedNode] = useState(undefined); const [activeLayout, setActiveLayout] = useLocalStorage(LocalStorageKeys.CanvasLayout, CanvasDefaults.DEFAULT_LAYOUT); @@ -89,28 +89,22 @@ export const Canvas: FunctionComponent> = ({ enti }, }; - console.log('[RENDER] Canvas - Draw graph', nodes); controller.fromModel(model, true); - }, [activeLayout, controller, entities, visibleFlows]); - - useEventListener(SELECTION_EVENT, (ids) => { - setSelectedIds(ids); - }); - useEventListener(GRAPH_LAYOUT_END_EVENT, ({ graph }) => { - console.log('[RENDER] Canvas - Graph layout end'); - setTimeout( - action(() => { - graph.fit(80); - }), - 0, - ); - }); + setInitialized(true); + }, [controller, entities, visibleFlows]); + + const handleSelection = useCallback((selectedIds: string[]) => { + setSelectedIds(selectedIds); + }, []); + useEventListener(SELECTION_EVENT, handleSelection); /** Set select node and pan it into view */ useEffect(() => { let resizeTimeout: number | undefined; if (!selectedIds[0]) { + setSelectedNode(undefined); + } else { const selectedNode = controller.getNodeById(selectedIds[0]); if (selectedNode) { setSelectedNode(selectedNode as unknown as CanvasNode); @@ -122,69 +116,65 @@ export const Canvas: FunctionComponent> = ({ enti 500, ) as unknown as number; } + return () => { + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + }; } - return () => { - if (resizeTimeout) { - clearTimeout(resizeTimeout); - } - }; }, [selectedIds, controller]); const controlButtons = useMemo(() => { - const customButtons: TopologyControlButton[] = catalogModalContext - ? [ - { - id: 'topology-control-bar-h_layout-button', - icon: ( - - - - ), - tooltip: 'Horizontal Layout', - callback: action(() => { - setActiveLayout(LayoutType.DagreHorizontal); - controller.getGraph().setLayout(LayoutType.DagreHorizontal); - controller.getGraph().reset(); - controller.getGraph().layout(); - }), - }, - { - id: 'topology-control-bar-v_layout-button', - icon: ( - - - - ), - tooltip: 'Vertical Layout', - callback: action(() => { - setActiveLayout(LayoutType.DagreVertical); - controller.getGraph().setLayout(LayoutType.DagreVertical); - controller.getGraph().reset(); - controller.getGraph().layout(); - }), - }, - { - id: 'topology-control-bar-catalog-button', - icon: , - tooltip: 'Open Catalog', - callback: action(() => { - catalogModalContext.setIsModalOpen(true); - }), - }, - ] - : []; + const customButtons: TopologyControlButton[] = [ + { + id: 'topology-control-bar-h_layout-button', + icon: ( + + + + ), + tooltip: 'Horizontal Layout', + callback: action(() => { + setActiveLayout(LayoutType.DagreHorizontal); + controller.getGraph().setLayout(LayoutType.DagreHorizontal); + controller.getGraph().layout(); + }), + }, + { + id: 'topology-control-bar-v_layout-button', + icon: ( + + + + ), + tooltip: 'Vertical Layout', + callback: action(() => { + setActiveLayout(LayoutType.DagreVertical); + controller.getGraph().setLayout(LayoutType.DagreVertical); + controller.getGraph().layout(); + }), + }, + ]; + if (catalogModalContext) { + customButtons.push({ + id: 'topology-control-bar-catalog-button', + icon: , + tooltip: 'Open Catalog', + callback: action(() => { + catalogModalContext.setIsModalOpen(true); + }), + }); + } return createTopologyControlButtons({ ...defaultControlButtonsOptions, + fitToScreen: false, zoomInCallback: action(() => { controller.getGraph().scaleBy(4 / 3); }), zoomOutCallback: action(() => { controller.getGraph().scaleBy(3 / 4); }), - fitToScreenCallback: action(() => { - controller.getGraph().fit(80); - }), resetViewCallback: action(() => { controller.getGraph().reset(); controller.getGraph().layout(); @@ -203,6 +193,7 @@ export const Canvas: FunctionComponent> = ({ enti return ( > = ({ enti contextToolbar={contextToolbar} controlBar={} > - {shouldShowEmptyState ? ( - - ) : ( - + + + {shouldShowEmptyState && ( + )} ); diff --git a/packages/ui/src/components/Visualization/Canvas/CanvasController.tsx b/packages/ui/src/components/Visualization/Canvas/CanvasController.tsx deleted file mode 100644 index 6d01f7f59..000000000 --- a/packages/ui/src/components/Visualization/Canvas/CanvasController.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { VisualizationProvider } from '@patternfly/react-topology'; -import { FunctionComponent, PropsWithChildren, ReactNode, useMemo } from 'react'; -import { BaseVisualCamelEntity } from '../../../models/visualization/base-visual-entity'; -import { Canvas } from './Canvas'; -import './Canvas.scss'; -import { ControllerService } from './controller.service'; - -interface CanvasProps { - contextToolbar?: ReactNode; - entities: BaseVisualCamelEntity[]; -} - -export const CanvasController: FunctionComponent> = ({ entities }) => { - const controller = useMemo(() => ControllerService.createController(), []); - - return ( - - - - ); -}; diff --git a/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap b/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap index dc3856713..426317993 100644 --- a/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap +++ b/packages/ui/src/components/Visualization/Canvas/__snapshots__/Canvas.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Canvas Empty state should render empty state when there is no visible flows 1`] = ` -
+exports[`Canvas Catalog button should NOT be present if \`CatalogModalContext\` is NOT provided 1`] = ` +
@@ -24,445 +24,574 @@ exports[`Canvas Empty state should render empty state when there is no visible f class="pf-topology-content" >
-
-
-
+ -
+ -
- -
-
-

-

- There are no visible routes -

-

-
-
-
-

- You can toggle the visibility of a route by using Routes list -

-
- -
-
-
- -
-
-
-
-
-
- -
-
-
+
+ +
+ + route-8888 + +
+ +
+
+ +
+
+
+ + + + -
- +
+
+ +
+
+
+ + + + + + + +
- Zoom Out - - -
-
-
+
+ +
+ + otherwise + +
+ +
+
+ +
+
+
+ + + + + + -
- -
-
-
+ + + + + -
-
+ +
+
+
+
+
+
+
-
-
-
- - -
-
-
- - - - - -`; - -exports[`Canvas Empty state should render empty state when there is no visual entity 1`] = ` -
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-

-

- There are no routes defined -

-

-
-
-
-

- You can create a new route using the New button -

-
- -
-
-
-
- -
-
-
-
-
- Zoom In + Zoom Out
@@ -506,10 +635,10 @@ exports[`Canvas Empty state should render empty state when there is no visual en
@@ -542,29 +671,27 @@ exports[`Canvas Empty state should render empty state when there is no visual en
@@ -578,29 +705,27 @@ exports[`Canvas Empty state should render empty state when there is no visual en
@@ -631,11 +756,11 @@ exports[`Canvas Empty state should render empty state when there is no visual en
-
+ `; -exports[`Canvas should render correctly 1`] = ` -
+exports[`Canvas Catalog button should be present if \`CatalogModalContext\` is provided 1`] = ` +
@@ -678,7 +803,7 @@ exports[`Canvas should render correctly 1`] = ` />
- - - + + +
+
+
+ +
+ + otherwise + +
+ +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+ +`; + +exports[`Canvas Empty state should render empty state when there is no visible flows 1`] = ` +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + +
+
+
+
+
+
+
+ +
+
+

+

+ There are no visible routes +

+

+
+
+
+

+ You can toggle the visibility of a route by using Routes list +

+
+ +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ -
-
- -
-
-
- - - - - - + + + + Horizontal Layout + + +
+
+
- - - - - - - - - - + + + + + + Vertical Layout + + +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+`; + +exports[`Canvas Empty state should render empty state when there is no visual entity 1`] = ` + +
+
+
+
+
+
+
+
+ + + + + + + + + + + +
+
+
+
+
+
+
- - - +
+
- - - - - - - - - - + There are no routes defined +

+ +
+
+
+

+ You can create a new route using the New button +

+
+ +
+
+
+
+
- Fit to Screen + Reset View
@@ -1305,29 +2242,61 @@ exports[`Canvas should render correctly 1`] = ` +
+
+
+
+
@@ -1358,11 +2327,11 @@ exports[`Canvas should render correctly 1`] = `
-
+ `; -exports[`Canvas should render correctly with more routes 1`] = ` -
+exports[`Canvas should render correctly 1`] = ` +
@@ -1405,7 +2374,7 @@ exports[`Canvas should render correctly with more routes 1`] = ` />
@@ -1884,7 +2853,7 @@ exports[`Canvas should render correctly with more routes 1`] = ` data-id="direct-1234" data-kind="node" data-type="node" - transform="translate(0, 0)" + transform="translate(402.5, 770)" /> @@ -1900,10 +2869,10 @@ exports[`Canvas should render correctly with more routes 1`] = ` >
- Fit to Screen + Reset View
@@ -2032,29 +3001,61 @@ exports[`Canvas should render correctly with more routes 1`] = ` +
+
+
+
+
@@ -2085,11 +3086,11 @@ exports[`Canvas should render correctly with more routes 1`] = `
-
+
`; -exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is provided 1`] = ` -
+exports[`Canvas should render correctly with more routes 1`] = ` +
@@ -2132,7 +3133,7 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p />
@@ -2611,7 +3612,7 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p data-id="direct-1234" data-kind="node" data-type="node" - transform="translate(0, 0)" + transform="translate(402.5, 770)" /> @@ -2627,10 +3628,10 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p >
- - - Fit to Screen - - -
-
-
-
-
-
-
- -
-
@@ -2916,5 +3845,5 @@ exports[`Canvas should render the Catalog button if \`CatalogModalContext\` is p
-
+
`; diff --git a/packages/ui/src/components/Visualization/Canvas/controller.service.test.ts b/packages/ui/src/components/Visualization/Canvas/controller.service.test.ts index d9e056011..823b1c5b7 100644 --- a/packages/ui/src/components/Visualization/Canvas/controller.service.test.ts +++ b/packages/ui/src/components/Visualization/Canvas/controller.service.test.ts @@ -28,6 +28,20 @@ describe('ControllerService', () => { expect(baselineElementFactorySpy).toHaveBeenCalledWith(ControllerService.baselineElementFactory); }); + it('should generate an empty graph when creating a controller to force computing dimensions right away', () => { + const fromModelSpy = jest.spyOn(Visualization.prototype, 'fromModel'); + + const controller = ControllerService.createController(); + + expect(controller).toBeInstanceOf(Visualization); + expect(fromModelSpy).toHaveBeenCalledWith({ + graph: { + id: 'g1', + type: 'graph', + }, + }); + }); + describe('baselineComponentFactory', () => { it('should return the correct component for a group', () => { const component = ControllerService.baselineComponentFactory({} as ModelKind, 'group'); diff --git a/packages/ui/src/components/Visualization/Canvas/controller.service.ts b/packages/ui/src/components/Visualization/Canvas/controller.service.ts index dfd921d9b..2f5089a1a 100644 --- a/packages/ui/src/components/Visualization/Canvas/controller.service.ts +++ b/packages/ui/src/components/Visualization/Canvas/controller.service.ts @@ -28,6 +28,13 @@ export class ControllerService { newController.registerLayoutFactory(this.baselineLayoutFactory); newController.registerComponentFactory(this.baselineComponentFactory); newController.registerElementFactory(this.baselineElementFactory); + newController.setFitToScreenOnLayout(true, 80); + newController.fromModel({ + graph: { + id: 'g1', + type: 'graph', + }, + }); return newController; } diff --git a/packages/ui/src/components/Visualization/ContextToolbar/ContextToolbar.scss b/packages/ui/src/components/Visualization/ContextToolbar/ContextToolbar.scss index 4067c9520..d166ea8b1 100644 --- a/packages/ui/src/components/Visualization/ContextToolbar/ContextToolbar.scss +++ b/packages/ui/src/components/Visualization/ContextToolbar/ContextToolbar.scss @@ -27,3 +27,8 @@ --pf-v5-c-toolbar__content--PaddingRight: 0; --pf-v5-c-toolbar--RowGap: 0; } + +// Override for the default patternfly class +.pf-v5-u-m-sm { + margin: var(--pf-v5-global--spacer--sm); +} diff --git a/packages/ui/src/components/Visualization/ContextToolbar/Flows/FlowsMenu.scss b/packages/ui/src/components/Visualization/ContextToolbar/Flows/FlowsMenu.scss index 7e4c11f36..60652333f 100644 --- a/packages/ui/src/components/Visualization/ContextToolbar/Flows/FlowsMenu.scss +++ b/packages/ui/src/components/Visualization/ContextToolbar/Flows/FlowsMenu.scss @@ -1,7 +1,8 @@ .flows-menu-display { display: inline-grid; - max-width: 200px; + max-width: 300px; margin: 0 8px; + text-align: left; } .flows-menu-truncate { diff --git a/packages/ui/src/components/Visualization/EmptyState/VisualizationEmptyState.tsx b/packages/ui/src/components/Visualization/EmptyState/VisualizationEmptyState.tsx index cca4d3604..b1ea48411 100644 --- a/packages/ui/src/components/Visualization/EmptyState/VisualizationEmptyState.tsx +++ b/packages/ui/src/components/Visualization/EmptyState/VisualizationEmptyState.tsx @@ -18,13 +18,14 @@ const EyeSlashIcon: FunctionComponent = (props) => = (props) => { const hasRoutes = useMemo(() => props.entitiesNumber > 0, [props.entitiesNumber]); return ( - + > = (props) => { - const lastUpdate = useMemo(() => Date.now(), [props.entities]); + const controller = useMemo(() => ControllerService.createController(), []); return ( -
- }> + +
- } entities={props.entities} /> + }> + } entities={props.entities} /> + - -
+
+ ); };