From 3b9d5d5829e5470e00730ffe41f68a62c8793712 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Tue, 14 Oct 2025 14:41:25 +0200 Subject: [PATCH 1/3] fix/COMPASS-9798 sync field name to prop (#153) --- src/components/field/field-name-content.tsx | 4 ++++ src/components/field/field.test.tsx | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/components/field/field-name-content.tsx b/src/components/field/field-name-content.tsx index 62be542..05d7094 100644 --- a/src/components/field/field-name-content.tsx +++ b/src/components/field/field-name-content.tsx @@ -32,6 +32,10 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) const [value, setValue] = useState(name); const textInputRef = useRef(null); + useEffect(() => { + setValue(name); + }, [name]); + const handleSubmit = useCallback(() => { setIsEditing(false); onChange?.(value); diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 33f6a7c..3f376da 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -141,6 +141,17 @@ describe('field', () => { await userEvent.dblClick(fieldName); expect(screen.queryByDisplayValue('ordersId')).not.toBeUndefined(); }); + + it('Should sync with prop changes', async () => { + const originalName = 'originalName'; + const newName = 'newName'; + const { rerender } = render( + , + ); + expect(screen.getByText(originalName)).toBeInTheDocument(); + await rerender(); + expect(screen.getByText(newName)).toBeInTheDocument(); + }); }); describe('With specific types', () => { From d33fe5e04a4b4423366791c2194116f1701019cc Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Wed, 15 Oct 2025 10:00:37 +0200 Subject: [PATCH 2/3] feat/COMPASS-9504 add expand collapse toggle to the node title (#152) * feat/COMPASS-9504 add expand collapse toggle to the node title * Pass nodeId in the callback * add storybook story --- .../buttons/diagram-icon-button.tsx | 1 - src/components/canvas/canvas.tsx | 2 + src/components/icons/chevron-collapse.tsx | 16 ++++++++ src/components/node/node.test.tsx | 19 ++++++++- src/components/node/node.tsx | 38 +++++++++++++---- .../use-editable-diagram-interactions.tsx | 11 ++++- ...iagram-editable-interactions.decorator.tsx | 41 ++++++++++++++++++- src/types/component-props.ts | 10 +++++ 8 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 src/components/icons/chevron-collapse.tsx diff --git a/src/components/buttons/diagram-icon-button.tsx b/src/components/buttons/diagram-icon-button.tsx index 558ab57..b1769cf 100644 --- a/src/components/buttons/diagram-icon-button.tsx +++ b/src/components/buttons/diagram-icon-button.tsx @@ -9,7 +9,6 @@ const StyledDiagramIconButton = styled.button` outline: none; padding: ${spacing[100]}px; margin: 0; - margin-left: ${spacing[100]}px; cursor: pointer; color: inherit; display: flex; diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 2eb3bf4..b8b8070 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -58,6 +58,7 @@ export const Canvas = ({ onConnect, id, onAddFieldToNodeClick, + onNodeExpandToggle, onAddFieldToObjectFieldClick, onFieldNameChange, onFieldClick, @@ -147,6 +148,7 @@ export const Canvas = ({ diff --git a/src/components/icons/chevron-collapse.tsx b/src/components/icons/chevron-collapse.tsx new file mode 100644 index 0000000..7c591e4 --- /dev/null +++ b/src/components/icons/chevron-collapse.tsx @@ -0,0 +1,16 @@ +import { useTheme } from '@emotion/react'; + +export const ChevronCollapse = ({ size = 14 }: { size?: number }) => { + const theme = useTheme(); + return ( + + + + ); +}; diff --git a/src/components/node/node.test.tsx b/src/components/node/node.test.tsx index 6bf5c36..738ba75 100644 --- a/src/components/node/node.test.tsx +++ b/src/components/node/node.test.tsx @@ -10,11 +10,16 @@ import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagra const Node = ({ onAddFieldToNodeClick, + onNodeExpandToggle, ...props }: React.ComponentProps & { onAddFieldToNodeClick?: () => void; + onNodeExpandToggle?: () => void; }) => ( - + ); @@ -106,6 +111,18 @@ describe('node', () => { expect(onAddFieldToNodeClickMock).toHaveBeenCalled(); }); + it('Should show a clickable button to toggle expand collapse when onNodeExpandToggle is supplied', async () => { + const onNodeExpandToggleMock = vi.fn(); + + render(); + const button = screen.getByRole('button', { name: 'Toggle Expand / Collapse Fields' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Toggle Expand / Collapse Fields'); + expect(onNodeExpandToggleMock).not.toHaveBeenCalled(); + await userEvent.click(button); + expect(onNodeExpandToggleMock).toHaveBeenCalled(); + }); + it('Should prioritise borderVariant over selected prop when setting the border', () => { render( , diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index 847dd14..805a610 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -8,6 +8,7 @@ import { useCallback, useState } from 'react'; import { DEFAULT_NODE_HEADER_HEIGHT, ZOOM_THRESHOLD } from '@/utilities/constants'; import { InternalNode } from '@/types/internal'; import { PlusWithSquare } from '@/components/icons/plus-with-square'; +import { ChevronCollapse } from '@/components/icons/chevron-collapse'; import { NodeBorder } from '@/components/node/node-border'; import { FieldList } from '@/components/field/field-list'; import { NodeType } from '@/types'; @@ -102,9 +103,14 @@ const NodeWithFields = styled.div<{ visibility: string }>` visibility: ${props => props.visibility}; `; -const AddNewFieldIconButtonButton = styled(DiagramIconButton)` +const TitleControlsContainer = styled.div` margin-left: auto; margin-right: ${spacing[200]}px; + display: flex; + gap: ${spacing[50]}px; + & > * { + flex: 0 0 auto; + } `; export const Node = ({ @@ -119,7 +125,7 @@ export const Node = ({ const [isHovering, setHovering] = useState(false); - const { onClickAddFieldToNode: addFieldToNodeClickHandler } = useEditableDiagramInteractions(); + const { onClickAddFieldToNode: addFieldToNodeClickHandler, onNodeExpandToggle } = useEditableDiagramInteractions(); const onClickAddFieldToNode = useCallback( (event: React.MouseEvent) => { @@ -129,6 +135,13 @@ export const Node = ({ [addFieldToNodeClickHandler, id], ); + const handleNodeExpandToggle = useCallback( + (event: React.MouseEvent) => { + onNodeExpandToggle?.(event, id); + }, + [onNodeExpandToggle, id], + ); + const getAccent = () => { if (disabled && !isHovering) { return theme.node.disabledAccent; @@ -210,11 +223,22 @@ export const Node = ({ {title} - {addFieldToNodeClickHandler && ( - - - - )} + + {addFieldToNodeClickHandler && ( + + + + )} + {onNodeExpandToggle && ( + + + + )} + diff --git a/src/hooks/use-editable-diagram-interactions.tsx b/src/hooks/use-editable-diagram-interactions.tsx index 4da8b3e..061f9a3 100644 --- a/src/hooks/use-editable-diagram-interactions.tsx +++ b/src/hooks/use-editable-diagram-interactions.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, useMemo, ReactNode } from 'react'; import { OnFieldClickHandler, OnAddFieldToNodeClickHandler, + OnNodeExpandHandler, OnAddFieldToObjectFieldClickHandler, OnFieldNameChangeHandler, } from '@/types'; @@ -10,6 +11,7 @@ import { interface EditableDiagramInteractionsContextType { onClickField?: OnFieldClickHandler; onClickAddFieldToNode?: OnAddFieldToNodeClickHandler; + onNodeExpandToggle?: OnNodeExpandHandler; onClickAddFieldToObjectField?: OnAddFieldToObjectFieldClickHandler; onChangeFieldName?: OnFieldNameChangeHandler; } @@ -20,6 +22,7 @@ interface EditableDiagramInteractionsProviderProps { children: ReactNode; onFieldClick?: OnFieldClickHandler; onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; + onNodeExpandToggle?: OnNodeExpandHandler; onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; onFieldNameChange?: OnFieldNameChangeHandler; } @@ -28,6 +31,7 @@ export const EditableDiagramInteractionsProvider: React.FC { @@ -43,6 +47,11 @@ export const EditableDiagramInteractionsProvider: React.FC{children} diff --git a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx index c967dfe..493a826 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, MouseEvent as ReactMouseEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, MouseEvent as ReactMouseEvent, useMemo } from 'react'; import { Decorator } from '@storybook/react'; import { DiagramProps, FieldId, NodeField, NodeProps } from '@/types'; @@ -88,6 +88,13 @@ function editableNodesFromNodes(nodes: NodeProps[]): NodeProps[] { export const useEditableNodes = (initialNodes: NodeProps[]) => { const [nodes, setNodes] = useState([]); + const [expanded, setExpanded] = useState>(() => { + return Object.fromEntries( + nodes.map(node => { + return [node.id, true]; + }), + ); + }); const hasInitialized = useRef(false); useEffect(() => { @@ -185,7 +192,37 @@ export const useEditableNodes = (initialNodes: NodeProps[]) => { ); }, []); - return { nodes, onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, onFieldNameChange }; + const onNodeExpandToggle = useCallback((_evt: ReactMouseEvent, nodeId: string) => { + setExpanded(state => { + return { + ...state, + [nodeId]: !state[nodeId], + }; + }); + }, []); + + const _nodes = useMemo(() => { + return nodes.map(node => { + if (expanded[node.id]) { + return node; + } + return { + ...node, + fields: node.fields.filter(field => { + return !field.depth || field.depth === 0; + }), + }; + }); + }, [nodes, expanded]); + + return { + nodes: _nodes, + onFieldClick, + onAddFieldToNodeClick, + onNodeExpandToggle, + onAddFieldToObjectFieldClick, + onFieldNameChange, + }; }; export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { diff --git a/src/types/component-props.ts b/src/types/component-props.ts index a9fb74a..1350657 100644 --- a/src/types/component-props.ts +++ b/src/types/component-props.ts @@ -25,6 +25,11 @@ export type OnFieldClickHandler = ( */ export type OnAddFieldToNodeClickHandler = (event: ReactMouseEvent, nodeId: string) => void; +/** + * Called when the button to expand / collapse all field is clicked in the node header. + */ +export type OnNodeExpandHandler = (event: ReactMouseEvent, nodeId: string) => void; + /** * Called when the button to add a new field is clicked on an object type field in a node. */ @@ -184,6 +189,11 @@ export interface DiagramProps { */ onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; + /** + * Callback when the user clicks the button to expand / collapse all fields in the node. + */ + onNodeExpandToggle?: OnNodeExpandHandler; + /** * Callback when the user clicks to add a new field to an object type field in a node. */ From 2b5a180b474776f187f27b441d5eafb65efb7184 Mon Sep 17 00:00:00 2001 From: Sergey Petushkov Date: Wed, 15 Oct 2025 10:25:49 +0200 Subject: [PATCH 3/3] fix/COMPASS-9956 use more stable key for field component rendering (#154) * fix/COMPASS-9956 use more stable key for field component rendering * better key and add tests * Add comment in test --- src/components/diagram.test.tsx | 56 +++++++++++++++++++++++++++++ src/components/field/field-list.tsx | 31 ++++++++-------- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/components/diagram.test.tsx b/src/components/diagram.test.tsx index c919fbe..0a03183 100644 --- a/src/components/diagram.test.tsx +++ b/src/components/diagram.test.tsx @@ -4,6 +4,7 @@ import { ReactFlowProvider } from '@xyflow/react'; import { Diagram } from '@/components/diagram'; import { EMPLOYEES_NODE } from '@/mocks/datasets/nodes'; import { EMPLOYEES_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges'; +import { NodeProps } from '@/types'; describe('Diagram', () => { it('Should render diagram', () => { @@ -19,4 +20,59 @@ describe('Diagram', () => { expect(screen.getByTestId('rf__minimap')).toBeInTheDocument(); expect(screen.getByTestId('rf__node-employees')).toBeInTheDocument(); }); + + it('Should correctly add / remove fields in the node on update', () => { + const nodeWithFields: NodeProps = { + id: 'node-1', + title: 'Node 1', + position: { x: 0, y: 0 }, + type: 'collection', + fields: [{ name: 'field-a' }, { name: 'field-b' }, { name: 'field-c' }], + }; + + // Render all fields first + const { rerender } = render( + + + , + ); + + expect(screen.getByText('field-a')).toBeInTheDocument(); + expect(screen.getByText('field-b')).toBeInTheDocument(); + expect(screen.getByText('field-c')).toBeInTheDocument(); + + // Add a field in the middle of the list + const nodeWithFieldAdded = { + ...nodeWithFields, + fields: [nodeWithFields.fields[0], { name: 'field-after-a' }, nodeWithFields.fields[1], nodeWithFields.fields[2]], + }; + + rerender( + + + , + ); + + expect(screen.getByText('field-a')).toBeInTheDocument(); + expect(screen.getByText('field-after-a')).toBeInTheDocument(); + expect(screen.getByText('field-b')).toBeInTheDocument(); + expect(screen.getByText('field-c')).toBeInTheDocument(); + + // Remove the field from the middle of the list + const nodeWithFieldRemoved = { + ...nodeWithFields, + fields: [nodeWithFields.fields[0], nodeWithFields.fields[2]], + }; + + rerender( + + + , + ); + + expect(screen.getByText('field-a')).toBeInTheDocument(); + expect(screen.getByText('field-c')).toBeInTheDocument(); + expect(() => screen.getByText('field-after-a')).toThrow(); + expect(() => screen.getByText('field-b')).toThrow(); + }); }); diff --git a/src/components/field/field-list.tsx b/src/components/field/field-list.tsx index 666cec2..8c29cfa 100644 --- a/src/components/field/field-list.tsx +++ b/src/components/field/field-list.tsx @@ -32,20 +32,23 @@ export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => { }, [fields, isFieldSelectionEnabled]); return ( - {fields.map(({ name, type: fieldType, ...rest }, i) => ( - - ))} + {fields.map(({ id, name, type: fieldType, ...rest }, i) => { + const key = id ? (Array.isArray(id) ? id.join('#') : id) : `${name}-${i}`; + return ( + + ); + })} ); };