diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 4a7b41a..7e31fc7 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -20,7 +20,7 @@ import { SelfReferencingEdge } from '@/components/edge/self-referencing-edge'; import { FieldEdge } from '@/components/edge/field-edge'; import { MarkerList } from '@/components/markers/marker-list'; import { ConnectionLine } from '@/components/line/connection-line'; -import { convertToExternalNode, convertToExternalNodes, convertToInternalNodes } from '@/utilities/convert-nodes'; +import { getExternalNode, convertToInternalNodes } from '@/utilities/convert-nodes'; import { convertToExternalEdge, convertToExternalEdges, convertToInternalEdges } from '@/utilities/convert-edges'; import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; @@ -66,6 +66,7 @@ export const Canvas = ({ onFieldNameChange, onFieldTypeChange, onFieldClick, + onFieldExpandToggle, onNodeContextMenu, onNodeDrag, onNodeDragStop, @@ -94,28 +95,28 @@ export const Canvas = ({ const _onNodeContextMenu = useCallback( (event: MouseEvent, node: InternalNode) => { - onNodeContextMenu?.(event, convertToExternalNode(node)); + onNodeContextMenu?.(event, getExternalNode(node)); }, [onNodeContextMenu], ); const _onNodeDrag = useCallback( (event: MouseEvent, node: InternalNode, nodes: InternalNode[]) => { - onNodeDrag?.(event, convertToExternalNode(node), convertToExternalNodes(nodes)); + onNodeDrag?.(event, getExternalNode(node), nodes.map(getExternalNode)); }, [onNodeDrag], ); const _onNodeDragStop = useCallback( (event: MouseEvent, node: InternalNode, nodes: InternalNode[]) => { - onNodeDragStop?.(event, convertToExternalNode(node), convertToExternalNodes(nodes)); + onNodeDragStop?.(event, getExternalNode(node), nodes.map(getExternalNode)); }, [onNodeDragStop], ); const _onSelectionDragStop = useCallback( (event: MouseEvent, nodes: InternalNode[]) => { - onSelectionDragStop?.(event, convertToExternalNodes(nodes)); + onSelectionDragStop?.(event, nodes.map(getExternalNode)); }, [onSelectionDragStop], ); @@ -129,21 +130,21 @@ export const Canvas = ({ const _onNodeClick = useCallback( (event: MouseEvent, node: InternalNode) => { - onNodeClick?.(event, convertToExternalNode(node)); + onNodeClick?.(event, getExternalNode(node)); }, [onNodeClick], ); const _onSelectionContextMenu = useCallback( (event: MouseEvent, nodes: InternalNode[]) => { - onSelectionContextMenu?.(event, convertToExternalNodes(nodes)); + onSelectionContextMenu?.(event, nodes.map(getExternalNode)); }, [onSelectionContextMenu], ); const _onSelectionChange = useCallback( ({ nodes, edges }: { nodes: InternalNode[]; edges: InternalEdge[] }) => { - onSelectionChange?.({ nodes: convertToExternalNodes(nodes), edges: convertToExternalEdges(edges) }); + onSelectionChange?.({ nodes: nodes.map(getExternalNode), edges: convertToExternalEdges(edges) }); }, [onSelectionChange], ); @@ -153,6 +154,7 @@ export const Canvas = ({ onFieldClick={onFieldClick} onAddFieldToNodeClick={onAddFieldToNodeClick} onNodeExpandToggle={onNodeExpandToggle} + onFieldExpandToggle={onFieldExpandToggle} onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick} onFieldNameChange={onFieldNameChange} onFieldTypeChange={onFieldTypeChange} diff --git a/src/components/diagram.stories.tsx b/src/components/diagram.stories.tsx index 4d1e034..44329c9 100644 --- a/src/components/diagram.stories.tsx +++ b/src/components/diagram.stories.tsx @@ -31,8 +31,12 @@ export const DiagramWithFieldToFieldEdges: Story = { title: 'MongoDB Diagram', isDarkMode: true, edges: [ - { ...EMPLOYEES_TO_ORDERS_EDGE, sourceFieldIndex: 0, targetFieldIndex: 1 }, - { ...EMPLOYEES_TO_EMPLOYEE_TERRITORIES_EDGE, sourceFieldIndex: 0, targetFieldIndex: 1 }, + { ...EMPLOYEES_TO_ORDERS_EDGE, sourceFieldId: ['address', 'city'], targetFieldId: ['SUPPLIER_ID'] }, + { + ...EMPLOYEES_TO_EMPLOYEE_TERRITORIES_EDGE, + sourceFieldId: ['employeeId'], + targetFieldId: ['employeeId'], + }, ], nodes: [ { ...EMPLOYEE_TERRITORIES_NODE, position: { x: 100, y: 100 } }, @@ -76,7 +80,13 @@ export const DiagramWithEditInteractions: Story = { args: { title: 'MongoDB Diagram', isDarkMode: true, - edges: [], + edges: [ + { + ...EMPLOYEES_TO_ORDERS_EDGE, + sourceFieldId: ['employeeId'], + targetFieldId: ['SUPPLIER_ID'], + }, + ], nodes: [ { ...ORDERS_NODE, diff --git a/src/components/edge/field-edge.test.tsx b/src/components/edge/field-edge.test.tsx index ef36ed5..ae60d95 100644 --- a/src/components/edge/field-edge.test.tsx +++ b/src/components/edge/field-edge.test.tsx @@ -4,6 +4,7 @@ import { ComponentProps } from 'react'; import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; import { render, screen } from '@/mocks/testing-utils'; import { FieldEdge } from '@/components/edge/field-edge'; +import { InternalNode } from '@/types/internal'; vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); @@ -19,9 +20,23 @@ function mockNodes(nodes: Node[]) { } describe('field-edge', () => { - const nodes = [ - { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } }, - { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }, + const nodes: InternalNode[] = [ + { + ...ORDERS_NODE, + data: { + title: ORDERS_NODE.title, + visibleFields: ORDERS_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: ORDERS_NODE, + }, + }, + { + ...EMPLOYEES_NODE, + data: { + title: EMPLOYEES_NODE.title, + visibleFields: EMPLOYEES_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: EMPLOYEES_NODE, + }, + }, ]; beforeEach(() => { @@ -44,13 +59,13 @@ describe('field-edge', () => { id={'orders-to-employees'} source={'orders'} target={'employees'} - data={{ sourceFieldIndex: 0, targetFieldIndex: 1 }} + data={{ sourceFieldId: ['ORDER_ID'], targetFieldId: ['employeeDetail'] }} {...props} />, ); }; - describe('With the nodes positioned above to each other', () => { + describe('With the nodes positioned next to each other', () => { it('Should render edge', () => { mockNodes([ { diff --git a/src/components/edge/field-edge.tsx b/src/components/edge/field-edge.tsx index 2b00939..aee72ca 100644 --- a/src/components/edge/field-edge.tsx +++ b/src/components/edge/field-edge.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react'; import { getFieldEdgeParams } from '@/utilities/get-edge-params'; import { InternalNode } from '@/types/internal'; import { Edge } from '@/components/edge/edge'; +import { FieldId } from '@/types/node'; export const FieldEdge = ({ id, @@ -12,11 +13,11 @@ export const FieldEdge = ({ markerEnd, markerStart, selected, - data: { sourceFieldIndex, targetFieldIndex }, + data: { sourceFieldId, targetFieldId }, }: EdgeProps & { data: { - sourceFieldIndex: number; - targetFieldIndex: number; + sourceFieldId: FieldId; + targetFieldId: FieldId; }; }) => { const nodes = useNodes(); @@ -32,8 +33,8 @@ export const FieldEdge = ({ const { sx, sy, tx, ty, sourcePos, targetPos } = getFieldEdgeParams( sourceNode, targetNode, - sourceFieldIndex, - targetFieldIndex, + sourceFieldId, + targetFieldId, ); const [path] = getSmoothStepPath({ diff --git a/src/components/edge/floating-edge.test.tsx b/src/components/edge/floating-edge.test.tsx index dc54135..d8802c0 100644 --- a/src/components/edge/floating-edge.test.tsx +++ b/src/components/edge/floating-edge.test.tsx @@ -5,6 +5,7 @@ import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; import { render, screen } from '@/mocks/testing-utils'; import { FloatingEdge } from '@/components/edge/floating-edge'; import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; +import { InternalNode } from '@/types/internal'; vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); @@ -20,9 +21,23 @@ function mockNodes(nodes: Node[]) { } describe('floating-edge', () => { - const nodes = [ - { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } }, - { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }, + const nodes: InternalNode[] = [ + { + ...ORDERS_NODE, + data: { + title: ORDERS_NODE.title, + visibleFields: ORDERS_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: ORDERS_NODE, + }, + }, + { + ...EMPLOYEES_NODE, + data: { + title: EMPLOYEES_NODE.title, + visibleFields: EMPLOYEES_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: EMPLOYEES_NODE, + }, + }, ]; beforeEach(() => { @@ -57,7 +72,7 @@ describe('floating-edge', () => { expect(path).toHaveAttribute('id', 'orders-to-employees'); expect(path).toHaveAttribute( 'd', - 'M263 189.5L263 209.5L 263,236Q 263,241 268,241L 358,241Q 363,241 363,246L363 272.5L363 292.5', + 'M263 189.5L263 209.5L 263,236Q 263,241 268,241L 331,241Q 336,241 336,246L336 272.5L336 292.5', ); }); }); diff --git a/src/components/edge/self-referencing-edge.test.tsx b/src/components/edge/self-referencing-edge.test.tsx index 9c5b047..e738dea 100644 --- a/src/components/edge/self-referencing-edge.test.tsx +++ b/src/components/edge/self-referencing-edge.test.tsx @@ -6,6 +6,7 @@ import { render, screen } from '@/mocks/testing-utils'; import { FloatingEdge } from '@/components/edge/floating-edge'; import { SelfReferencingEdge } from '@/components/edge/self-referencing-edge'; import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; +import { InternalNode } from '@/types/internal'; vi.mock('@xyflow/react', async () => { const actual = await vi.importActual('@xyflow/react'); @@ -21,7 +22,16 @@ function mockNodes(nodes: Node[]) { } describe('self-referencing-edge', () => { - const nodes = [{ ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }]; + const nodes: InternalNode[] = [ + { + ...EMPLOYEES_NODE, + data: { + title: EMPLOYEES_NODE.title, + visibleFields: EMPLOYEES_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: EMPLOYEES_NODE, + }, + }, + ]; beforeEach(() => { mockNodes(nodes); @@ -53,7 +63,7 @@ describe('self-referencing-edge', () => { renderComponent(); const path = screen.getByTestId('self-referencing-edge-employees-to-employees'); expect(path).toHaveAttribute('id', 'employees-to-employees'); - expect(path).toHaveAttribute('d', 'M422,292.5L422,262.5L584,262.5L584,351.5L551.5,351.5'); + expect(path).toHaveAttribute('d', 'M422,292.5L422,262.5L584,262.5L584,378.5L551.5,378.5'); }); }); diff --git a/src/components/field/field-content.tsx b/src/components/field/field-content.tsx index 6cf4647..d809eb8 100644 --- a/src/components/field/field-content.tsx +++ b/src/components/field/field-content.tsx @@ -1,10 +1,13 @@ import styled from '@emotion/styled'; import { fontWeights } from '@leafygreen-ui/tokens'; +import Icon from '@leafygreen-ui/icon'; +import { useTheme } from '@emotion/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ellipsisTruncation } from '@/styles/styles'; import { FieldDepth } from '@/components/field/field-depth'; import { FieldType } from '@/components/field/field-type'; +import { DiagramIconButton } from '@/components/buttons/diagram-icon-button'; import { FieldId, NodeField } from '@/types'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; @@ -26,14 +29,31 @@ interface FieldContentProps extends NodeField { id: FieldId; isEditable: boolean; isDisabled: boolean; + isExpandable?: boolean; nodeId: string; } -export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id, nodeId }: FieldContentProps) => { +export const FieldContent = ({ + isEditable, + isDisabled, + isExpandable, + depth = 0, + name, + type, + id, + nodeId, + expanded, +}: FieldContentProps) => { const [isEditing, setIsEditing] = useState(false); const fieldContentRef = useRef(null); + const theme = useTheme(); + + const { onChangeFieldName, onChangeFieldType, fieldTypes, onFieldExpandToggle } = useEditableDiagramInteractions(); + + const hasExpandFunctionality = !!onFieldExpandToggle; + const hasExpandButton = hasExpandFunctionality && isExpandable; + const placeholderCollapse = hasExpandFunctionality && !hasExpandButton; - const { onChangeFieldName, onChangeFieldType, fieldTypes } = useEditableDiagramInteractions(); const handleNameChange = useCallback( (newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName), [onChangeFieldName, id, nodeId], @@ -47,6 +67,16 @@ export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id setIsEditing(true); }, []); + const handleFieldExpandToggle = useCallback( + (event: React.MouseEvent) => { + if (!onFieldExpandToggle) return; + // Don't click on the field element. + event.stopPropagation(); + onFieldExpandToggle(event, nodeId, Array.isArray(id) ? id : [id], !expanded); + }, + [onFieldExpandToggle, nodeId, id, expanded], + ); + useEffect(() => { // When clicking outside of the field content while editing, stop editing. const container = fieldContentRef.current; @@ -98,7 +128,18 @@ export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id isEditing={isTypeEditable} isDisabled={isDisabled} onChange={handleTypeChange} + placeholderCollapse={placeholderCollapse} /> + {hasExpandButton && ( + + + + )} ); }; diff --git a/src/components/field/field-list.test.tsx b/src/components/field/field-list.test.tsx index 7b75185..304a911 100644 --- a/src/components/field/field-list.test.tsx +++ b/src/components/field/field-list.test.tsx @@ -8,17 +8,20 @@ const FieldWithEditableInteractions = ({ onAddFieldToObjectFieldClick, onFieldNameChange, onFieldClick, + onFieldExpandToggle, ...props }: Partial> & { onAddFieldToObjectFieldClick?: () => void; onFieldNameChange?: (newName: string) => void; onFieldClick?: () => void; + onFieldExpandToggle?: () => void; }) => { return ( @@ -32,8 +35,8 @@ describe('field-list', () => { , ); @@ -54,4 +57,23 @@ describe('field-list', () => { nodeId: 'coll', }); }); + + it('should ensure that items that are hasChildren have the toggle', () => { + const onFieldExpandToggle = vi.fn(); + render( + , + ); + expect(screen.queryByTestId('field-expand-toggle-coll-other')).not.toBeInTheDocument(); + expect(screen.getByTestId('field-expand-toggle-coll-parent')).toBeInTheDocument(); + expect(screen.queryByTestId('field-expand-toggle-coll-parent-child1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('field-expand-toggle-coll-parent-child2')).not.toBeInTheDocument(); + }); }); diff --git a/src/components/field/field-list.tsx b/src/components/field/field-list.tsx index 1efb48d..ea59f69 100644 --- a/src/components/field/field-list.tsx +++ b/src/components/field/field-list.tsx @@ -1,16 +1,16 @@ import { useMemo } from 'react'; import styled from '@emotion/styled'; -import { spacing } from '@leafygreen-ui/tokens'; import { Field } from '@/components/field/field'; -import { NodeField, NodeType } from '@/types'; +import { NodeType } from '@/types/node'; import { DEFAULT_PREVIEW_GROUP_AREA, getPreviewGroupArea, getPreviewId } from '@/utilities/get-preview-group-area'; import { DEFAULT_FIELD_PADDING } from '@/utilities/constants'; import { getSelectedFieldGroupHeight, getSelectedId } from '@/utilities/get-selected-field-group-height'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; +import { InternalNodeField } from '@/types/internal'; const NodeFieldWrapper = styled.div` - padding: ${DEFAULT_FIELD_PADDING}px ${spacing[400]}px; + padding: ${DEFAULT_FIELD_PADDING}px; font-size: 12px; `; @@ -18,7 +18,7 @@ interface Props { nodeType: NodeType; isHovering?: boolean; nodeId: string; - fields: NodeField[]; + fields: InternalNodeField[]; } export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => { @@ -30,6 +30,7 @@ export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => { const selectedGroupHeight = useMemo(() => { return isFieldSelectionEnabled ? getSelectedFieldGroupHeight(fields) : undefined; }, [fields, isFieldSelectionEnabled]); + return ( {fields.map(({ id, name, type: fieldType, ...rest }, i) => { diff --git a/src/components/field/field-type.tsx b/src/components/field/field-type.tsx index 650c3c9..62679ec 100644 --- a/src/components/field/field-type.tsx +++ b/src/components/field/field-type.tsx @@ -11,12 +11,12 @@ import { FieldTypeContent } from '@/components/field/field-type-content'; import { FieldId } from '@/types'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; -const FieldTypeWrapper = styled.div<{ color: string }>` +const FieldTypeWrapper = styled.div<{ color: string; placeholderCollapse?: boolean }>` color: ${props => props.color}; font-weight: normal; - padding-left:${spacing[100]}px; - padding-right ${spacing[50]}px; - flex: 0 0 ${spacing[200] * 10}px; + padding-left: ${spacing[100]}px; + padding-right: ${props => (props.placeholderCollapse ? spacing[600] : spacing[100])}px; + flex: 0 0 100px; display: flex; justify-content: flex-end; align-items: center; @@ -50,6 +50,7 @@ export function FieldType({ isEditing, isDisabled, onChange, + placeholderCollapse, }: { id: FieldId; nodeId: string; @@ -57,6 +58,7 @@ export function FieldType({ isEditing: boolean; isDisabled: boolean; onChange: (newType: string[]) => void; + placeholderCollapse?: boolean; }) { const internalTheme = useTheme(); const { theme } = useDarkMode(); @@ -86,6 +88,7 @@ export function FieldType({ } : undefined)} color={getSecondaryTextColor()} + placeholderCollapse={placeholderCollapse} > {/** * Rendering hidden select first so that whenever popover shows it, its relative diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 7b190a8..2b176e9 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -6,6 +6,12 @@ import { render, screen, waitFor } from '@/mocks/testing-utils'; import { Field as FieldComponent } from '@/components/field/field'; import { DEFAULT_PREVIEW_GROUP_AREA } from '@/utilities/get-preview-group-area'; import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; +import { + OnAddFieldToObjectFieldClickHandler, + OnFieldExpandHandler, + OnFieldNameChangeHandler, + OnFieldTypeChangeHandler, +} from '@/types'; const Field = (props: React.ComponentProps) => ( @@ -17,12 +23,14 @@ const FieldWithEditableInteractions = ({ onAddFieldToObjectFieldClick, onFieldNameChange, onFieldTypeChange, + onFieldExpandToggle, fieldTypes, ...fieldProps }: React.ComponentProps & { - onAddFieldToObjectFieldClick?: () => void; - onFieldNameChange?: (newName: string) => void; - onFieldTypeChange?: (nodeId: string, fieldPath: string[], newTypes: string[]) => void; + onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; + onFieldNameChange?: OnFieldNameChangeHandler; + onFieldTypeChange?: OnFieldTypeChangeHandler; + onFieldExpandToggle?: OnFieldExpandHandler; fieldTypes?: string[]; }) => { return ( @@ -30,6 +38,7 @@ const FieldWithEditableInteractions = ({ onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick} onFieldNameChange={onFieldNameChange} onFieldTypeChange={onFieldTypeChange} + onFieldExpandToggle={onFieldExpandToggle} fieldTypes={fieldTypes} > @@ -334,4 +343,85 @@ describe('field', () => { expect(screen.getByRole('img', { name: 'Link Icon' })).toHaveAttribute('color', palette.gray.dark1); }); }); + + describe('Expand/Collapse', () => { + describe('When the field has children', () => { + const hasChildrenProps = { + ...DEFAULT_PROPS, + onFieldExpandToggle: vi.fn(), + hasChildren: true, + }; + beforeEach(() => { + hasChildrenProps.onFieldExpandToggle.mockClear(); + }); + it('Shows collapse icon by default', async () => { + render(); + const toggle = screen.getByRole('button', { name: 'Collapse Field' }); + expect(toggle).toBeInTheDocument(); + await userEvent.click(toggle); + expect(hasChildrenProps.onFieldExpandToggle).toHaveBeenCalledWith( + expect.anything(), + hasChildrenProps.nodeId, + [hasChildrenProps.id as string], + false, + ); + }); + + it('Shows expand icon for a collapsed field', async () => { + render(); + const toggle = screen.getByRole('button', { name: 'Expand Field' }); + expect(toggle).toBeInTheDocument(); + await userEvent.click(toggle); + expect(hasChildrenProps.onFieldExpandToggle).toHaveBeenCalledWith( + expect.anything(), + hasChildrenProps.nodeId, + [hasChildrenProps.id as string], + true, + ); + }); + + it('Shows collapse icon for an expanded field', async () => { + render(); + const toggle = screen.getByRole('button', { name: 'Collapse Field' }); + expect(toggle).toBeInTheDocument(); + await userEvent.click(toggle); + expect(hasChildrenProps.onFieldExpandToggle).toHaveBeenCalledWith( + expect.anything(), + hasChildrenProps.nodeId, + [hasChildrenProps.id as string], + false, + ); + }); + }); + + describe('When the field is not hasChildren', () => { + it('Does not show the collapse/expand toggle', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'Collapse Field' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Field' })).not.toBeInTheDocument(); + }); + }); + + describe('When there is no method for field expand toggle', () => { + it('Does not show the collapse/expand toggle', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'Collapse Field' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Expand Field' })).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index a611b40..211d257 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { color, spacing as LGSpacing, spacing } from '@leafygreen-ui/tokens'; +import { color, spacing as LGSpacing } from '@leafygreen-ui/tokens'; import { palette } from '@leafygreen-ui/palette'; import Icon from '@leafygreen-ui/icon'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; @@ -14,15 +14,15 @@ import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-int import { FieldContent } from './field-content'; -const FIELD_BORDER_ANIMATED_PADDING = spacing[100]; -const FIELD_GLYPH_SPACING = spacing[400]; +const FIELD_BORDER_ANIMATED_PADDING = LGSpacing[100]; +const FIELD_GLYPH_SPACING = LGSpacing[400]; const GlyphToIcon: Record = { key: 'Key', link: 'Link', }; -const SELECTED_FIELD_BORDER_PADDING = spacing[100]; +const SELECTED_FIELD_BORDER_PADDING = LGSpacing[100]; const FieldWrapper = styled.div<{ color: string; @@ -36,12 +36,13 @@ const FieldWrapper = styled.div<{ width: auto; height: ${DEFAULT_FIELD_HEIGHT}px; color: ${props => props.color}; + padding-left: ${LGSpacing[200]}px; ${props => props.selectable && `&:hover { cursor: pointer; background-color: ${props.selectableHoverBackgroundColor}; - box-shadow: -${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}, ${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}; + box-shadow: -${LGSpacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}, ${LGSpacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}; }`} ${props => props.selected && @@ -54,7 +55,7 @@ const FieldWrapper = styled.div<{ position: absolute; outline: 2px solid ${palette.blue.base}; width: calc(100% + ${SELECTED_FIELD_BORDER_PADDING * 2}px); - border-radius: ${spacing[50]}px; + border-radius: ${LGSpacing[50]}px; height: ${props.selectedGroupHeight * DEFAULT_FIELD_HEIGHT}px; left: -${SELECTED_FIELD_BORDER_PADDING}px; top: 0px; @@ -98,7 +99,7 @@ const FieldRow = styled.div` `; const IconWrapper = styled(Icon)` - padding-right: ${spacing[100]}px; + padding-right: ${LGSpacing[100]}px; flex-shrink: 0; `; @@ -110,6 +111,7 @@ interface Props extends NodeField { isHovering?: boolean; previewGroupArea: PreviewGroupArea; selectedGroupHeight?: number; + hasChildren?: boolean; } export const Field = ({ @@ -128,6 +130,8 @@ export const Field = ({ spacing = 0, selectable = false, selected = false, + hasChildren = false, + expanded = true, editable = false, variant, }: Props) => { @@ -188,10 +192,12 @@ export const Field = ({ isDisabled={isDisabled} depth={depth} isEditable={selected && editable && !isDisabled} + isExpandable={hasChildren} name={name} type={type} id={id} nodeId={nodeId} + expanded={expanded} /> ); diff --git a/src/components/icons/chevron-collapse.tsx b/src/components/icons/chevron-collapse.tsx index 7c591e4..293f976 100644 --- a/src/components/icons/chevron-collapse.tsx +++ b/src/components/icons/chevron-collapse.tsx @@ -7,7 +7,7 @@ export const ChevronCollapse = ({ size = 14 }: { size?: number }) => { diff --git a/src/components/icons/chevron-expand.tsx b/src/components/icons/chevron-expand.tsx new file mode 100644 index 0000000..24e4d15 --- /dev/null +++ b/src/components/icons/chevron-expand.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@emotion/react'; + +export const ChevronExpand = ({ size = 14 }: { size?: number }) => { + const theme = useTheme(); + return ( + + + + ); +}; diff --git a/src/components/node/node.stories.tsx b/src/components/node/node.stories.tsx index b002d21..af58233 100644 --- a/src/components/node/node.stories.tsx +++ b/src/components/node/node.stories.tsx @@ -4,27 +4,33 @@ import { ReactFlowProvider } from '@xyflow/react'; import { InternalNode } from '@/types/internal'; import { Node } from '@/components/node/node'; import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; +import { NodeField, NodeProps } from '@/types'; +const fields = [ + { + name: 'customerId', + type: 'string', + hasChildren: false, + }, + { + name: 'companyName', + type: 'string', + hasChildren: false, + }, + { + name: 'phoneNumber', + type: 'number', + hasChildren: false, + }, +]; const INTERNAL_NODE: InternalNode = { id: 'orders', type: 'collection', position: { x: 100, y: 100 }, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - }, - { - name: 'companyName', - type: 'string', - }, - { - name: 'phoneNumber', - type: 'number', - }, - ], + visibleFields: fields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }; @@ -56,102 +62,122 @@ export const ConnectableType: Story = { args: { ...INTERNAL_NODE, type: 'connectable' }, }; +const fieldsWithGlyph: NodeField[] = [ + { + name: 'customerId', + type: 'string', + glyphs: ['key'], + }, + { + name: 'companyName', + type: 'string', + glyphs: ['link'], + }, +]; export const FieldsWithGlyph: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - glyphs: ['key'], - }, - { - name: 'companyName', - type: 'string', - glyphs: ['link'], - }, - ], + visibleFields: fieldsWithGlyph.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithMultipleGlyphs: NodeField[] = [ + { + name: 'customerId', + type: 'string', + glyphs: ['key', 'link'], + }, + { + name: 'companyName', + type: 'string', + glyphs: ['key', 'link'], + }, + { + name: 'addressId', + type: 'string', + }, +]; export const FieldsWithGlyphs: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - glyphs: ['key', 'link'], - }, - { - name: 'companyName', - type: 'string', - glyphs: ['key', 'link'], - }, - { - name: 'addressId', - type: 'string', - }, - ], + visibleFields: fieldsWithMultipleGlyphs.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithLongValues: NodeField[] = [ + { + name: 'customerId', + glyphs: ['key', 'link', 'link'], + type: 'someReallyLongStringRepresentation', + }, + { + name: 'oneReallyReallyReally', + type: 'string', + }, + { + name: 'anotherReallyLongName', + type: 'someReallyLongStringRepresentation', + }, +]; export const FieldsWithLongValues: Story = { args: { ...INTERNAL_NODE, data: { title: 'enterprise_tenant_finance_department_legacy_system_us_east_1_schema_2025_v15_monthly_billing_transactions_20250702145533', - fields: [ - { - name: 'customerId', - glyphs: ['key', 'link', 'link'], - type: 'someReallyLongStringRepresentation', - }, - { - name: 'oneReallyReallyReally', - type: 'string', - }, - { - name: 'anotherReallyLongName', - type: 'someReallyLongStringRepresentation', - }, - ], + visibleFields: fieldsWithLongValues.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const nestedFields: NodeField[] = [ + { + name: 'customerId', + type: 'string', + }, + { + name: 'detail', + type: '{}', + expanded: true, + }, + { + name: 'companyName', + type: '{}', + depth: 1, + expanded: false, + }, + { + name: 'acronym', + type: 'string', + depth: 2, + }, + { + name: 'fullName', + type: 'string', + depth: 2, + }, + { + name: 'phoneNumber', + type: 'number', + depth: 1, + }, +]; export const NestedFields: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - }, - { - name: 'detail', - type: '{}', - }, - { - name: 'companyName', - type: 'string', - depth: 1, - }, - { - name: 'phoneNumber', - type: 'number', - depth: 1, - }, - ], + visibleFields: nestedFields.map(field => ({ ...field, hasChildren: 'expanded' in field })), + externalNode: {} as unknown as NodeProps, }, }, }; @@ -161,14 +187,16 @@ export const NodeWithDefaultField: Story = { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ + visibleFields: [ { name: 'customerId', type: 'string', variant: 'default', glyphs: ['key'], + hasChildren: false, }, ], + externalNode: {} as unknown as NodeProps, }, }, }; @@ -179,13 +207,15 @@ export const DisabledNode: Story = { data: { disabled: true, title: 'orders', - fields: [ + visibleFields: [ { name: 'customerId', type: 'string', glyphs: ['key'], + hasChildren: false, }, ], + externalNode: {} as unknown as NodeProps, }, }, }; @@ -195,14 +225,16 @@ export const DisabledField: Story = { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ + visibleFields: [ { name: 'customerId', type: 'string', variant: 'disabled', glyphs: ['key'], + hasChildren: false, }, ], + externalNode: {} as unknown as NodeProps, }, }, }; @@ -212,36 +244,40 @@ export const DisabledWithHoverVariant: Story = { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ + visibleFields: [ { name: 'customerId', type: 'string', variant: 'disabled', hoverVariant: 'default', glyphs: ['key'], + hasChildren: false, }, ], + externalNode: {} as unknown as NodeProps, }, }, }; +const multipleTypesField: NodeField[] = [ + { + name: 'customerId', + type: ['string', 'number'], + variant: 'default', + glyphs: ['key'], + }, + { + name: 'customerId', + type: ['string', 'number', 'objectId', 'array', 'date', 'boolean', 'null', 'decimal', 'object', 'regex'], + }, +]; export const NodeWithMultipleTypesField: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: ['string', 'number'], - variant: 'default', - glyphs: ['key'], - }, - { - name: 'customerId', - type: ['string', 'number', 'objectId', 'array', 'date', 'boolean', 'null', 'decimal', 'object', 'regex'], - }, - ], + visibleFields: multipleTypesField.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; @@ -251,283 +287,310 @@ export const NodeWithPrimaryField: Story = { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ + visibleFields: [ { name: 'customerId', type: 'string', variant: 'primary', glyphs: ['key', 'link'], + hasChildren: false, }, ], + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithPreview: NodeField[] = [ + { + name: 'customerId', + type: 'string', + }, + { + name: 'companyName', + type: 'string', + variant: 'preview', + }, + { + name: 'phoneNumber', + type: 'number', + variant: 'preview', + }, + { + name: 'address', + type: 'string', + variant: 'preview', + }, +]; export const NodeWithPreviewFields: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - }, - { - name: 'companyName', - type: 'string', - variant: 'preview', - }, - { - name: 'phoneNumber', - type: 'number', - variant: 'preview', - }, - { - name: 'address', - type: 'string', - variant: 'preview', - }, - ], + visibleFields: fieldsWithPreview.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithPreviewGlyphs: NodeField[] = [ + { + name: '_id', + type: 'string', + variant: 'preview', + glyphs: ['key'], + }, + { + name: 'customerId', + type: 'string', + variant: 'preview', + glyphs: ['key', 'link'], + }, + { + name: 'companyName', + type: 'string', + variant: 'preview', + }, +]; export const NodeWithPreviewGlyphs: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: '_id', - type: 'string', - variant: 'preview', - glyphs: ['key'], - }, - { - name: 'customerId', - type: 'string', - variant: 'preview', - glyphs: ['key', 'link'], - }, - { - name: 'companyName', - type: 'string', - variant: 'preview', - }, - ], + visibleFields: fieldsWithPreviewGlyphs.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithSomePreviewGlyphs: NodeField[] = [ + { + name: 'customerId', + type: 'string', + glyphs: ['key', 'link'], + }, + { + name: 'companyName', + type: 'string', + variant: 'preview', + }, + { + name: 'address', + type: 'string', + variant: 'preview', + }, + { + name: 'fullName', + type: 'string', + variant: 'preview', + }, +]; export const NodeWithSomePreviewGlyphs: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'customerId', - type: 'string', - glyphs: ['key', 'link'], - }, - { - name: 'companyName', - type: 'string', - variant: 'preview', - }, - { - name: 'address', - type: 'string', - variant: 'preview', - }, - { - name: 'fullName', - type: 'string', - variant: 'preview', - }, - ], + visibleFields: fieldsWithSomePreviewGlyphs.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithNestedPreview: NodeField[] = [ + { + name: 'orderId', + type: 'string', + glyphs: ['key'], + }, + { + name: 'customer', + type: '{}', + variant: 'preview', + }, + { + name: 'customerId', + type: 'string', + depth: 1, + variant: 'preview', + }, + { + name: 'addresses', + type: '[]', + depth: 1, + variant: 'preview', + }, + { + name: 'addresses', + type: 'string', + depth: 2, + variant: 'preview', + }, + { + name: 'streetName', + type: 'string', + depth: 2, + variant: 'preview', + }, +]; export const NodeWithNestedPreviewFields: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'orderId', - type: 'string', - glyphs: ['key'], - }, - { - name: 'customer', - type: '{}', - variant: 'preview', - }, - { - name: 'customerId', - type: 'string', - depth: 1, - variant: 'preview', - }, - { - name: 'addresses', - type: '[]', - depth: 1, - variant: 'preview', - }, - { - name: 'addresses', - type: 'string', - depth: 2, - variant: 'preview', - }, - { - name: 'streetName', - type: 'string', - depth: 2, - variant: 'preview', - }, - ], + visibleFields: fieldsWithNestedPreview.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const deeplyNestedPreviewFields: NodeField[] = [ + { + name: 'orderId', + type: 'string', + glyphs: ['key'], + }, + { + name: 'customer', + type: '{}', + }, + { + name: 'customerId', + type: 'string', + depth: 1, + }, + { + name: 'addresses', + type: '[]', + depth: 1, + variant: 'preview', + }, + { + name: 'streetName', + type: 'string', + depth: 2, + variant: 'preview', + }, + { + name: 'postcode', + type: 'number', + depth: 2, + variant: 'preview', + }, + { + name: 'country', + type: 'string', + depth: 2, + variant: 'preview', + }, +]; export const NodeWithDeeplyNestedPreviewFields: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'orderId', - type: 'string', - glyphs: ['key'], - }, - { - name: 'customer', - type: '{}', - }, - { - name: 'customerId', - type: 'string', - depth: 1, - }, - { - name: 'addresses', - type: '[]', - depth: 1, - variant: 'preview', - }, - { - name: 'streetName', - type: 'string', - depth: 2, - variant: 'preview', - }, - { - name: 'postcode', - type: 'number', - depth: 2, - variant: 'preview', - }, - { - name: 'country', - type: 'string', - depth: 2, - variant: 'preview', - }, - ], + visibleFields: deeplyNestedPreviewFields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithDeeplyNestedPreviewEverywhere: NodeField[] = [ + { + name: 'orderId', + type: 'string', + glyphs: ['key'], + variant: 'preview', + }, + { + name: 'customer', + type: '{}', + }, + { + name: 'customerId', + type: 'string', + depth: 1, + }, + { + name: 'addresses', + type: '[]', + depth: 1, + }, + { + name: 'streetName', + type: 'string', + depth: 2, + variant: 'preview', + }, +]; export const NodeWithDeeplyNestedPreviewFieldsEverywhere: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: 'orderId', - type: 'string', - glyphs: ['key'], - variant: 'preview', - }, - { - name: 'customer', - type: '{}', - }, - { - name: 'customerId', - type: 'string', - depth: 1, - }, - { - name: 'addresses', - type: '[]', - depth: 1, - }, - { - name: 'streetName', - type: 'string', - depth: 2, - variant: 'preview', - }, - ], + visibleFields: fieldsWithDeeplyNestedPreviewEverywhere.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithSelected: NodeField[] = [ + { + name: '_id', + type: 'objectid', + glyphs: ['key'], + }, + { + name: 'customer', + type: '{}', + selected: true, + }, + { + name: 'customerId', + type: 'string', + depth: 1, + }, + { + name: 'addresses', + type: '[]', + depth: 1, + }, + { + name: 'streetName', + type: 'string', + depth: 2, + }, + { + name: 'source', + type: 'string', + }, + { + name: 'orderedAt', + type: 'date', + selected: true, + }, +]; export const NodeWithSelectedFields: Story = { args: { ...INTERNAL_NODE, data: { title: 'orders', - fields: [ - { - name: '_id', - type: 'objectid', - glyphs: ['key'], - }, - { - name: 'customer', - type: '{}', - selected: true, - }, - { - name: 'customerId', - type: 'string', - depth: 1, - }, - { - name: 'addresses', - type: '[]', - depth: 1, - }, - { - name: 'streetName', - type: 'string', - depth: 2, - }, - { - name: 'source', - type: 'string', - }, - { - name: 'orderedAt', - type: 'date', - selected: true, - }, - ], + visibleFields: fieldsWithSelected.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsWithWarning: NodeField[] = [ + { + name: '_id', + type: 'objectid', + glyphs: ['key'], + }, + { + name: 'customer', + type: '{}', + }, +]; export const NodeWithWarningIcon: Story = { args: { ...INTERNAL_NODE, @@ -537,21 +600,23 @@ export const NodeWithWarningIcon: Story = { type: 'warn', warnMessage: 'This is a warning message for the Orders node.', }, - fields: [ - { - name: '_id', - type: 'objectid', - glyphs: ['key'], - }, - { - name: 'customer', - type: '{}', - }, - ], + visibleFields: fieldsWithWarning.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; +const fieldsForLongTitleWarning: NodeField[] = [ + { + name: '_id', + type: 'objectid', + glyphs: ['key'], + }, + { + name: 'customer', + type: '{}', + }, +]; export const NodeWithLongTitleAndWarningIcon: Story = { args: { ...INTERNAL_NODE, @@ -561,17 +626,8 @@ export const NodeWithLongTitleAndWarningIcon: Story = { type: 'warn', warnMessage: 'This is a warning message for the Orders node.', }, - fields: [ - { - name: '_id', - type: 'objectid', - glyphs: ['key'], - }, - { - name: 'customer', - type: '{}', - }, - ], + visibleFields: fieldsForLongTitleWarning.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, }, }, }; diff --git a/src/components/node/node.test.tsx b/src/components/node/node.test.tsx index 738ba75..36003af 100644 --- a/src/components/node/node.test.tsx +++ b/src/components/node/node.test.tsx @@ -1,10 +1,11 @@ import { screen } from '@testing-library/react'; -import { NodeProps, useViewport } from '@xyflow/react'; +import { NodeProps as XyFlowNodeProps, useViewport } from '@xyflow/react'; import { userEvent } from '@testing-library/user-event'; import { palette } from '@leafygreen-ui/palette'; import { render } from '@/mocks/testing-utils'; import { InternalNode } from '@/types/internal'; +import { NodeProps } from '@/types/node'; import { Node as NodeComponent } from '@/components/node/node'; import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; @@ -33,10 +34,10 @@ vi.mock('@xyflow/react', async () => { }); describe('node', () => { - const DEFAULT_PROPS: NodeProps = { + const DEFAULT_PROPS: XyFlowNodeProps = { id: 'id', type: 'table', - data: { title: 'title', fields: [] }, + data: { title: 'title', visibleFields: [], externalNode: {} as unknown as NodeProps }, dragging: false, zIndex: 0, selectable: false, @@ -58,7 +59,11 @@ describe('node', () => { , ); expect(screen.getByRole('img', { name: 'Drag Icon' })).toBeInTheDocument(); @@ -72,7 +77,11 @@ describe('node', () => { , ); expect(screen.getByRole('img', { name: 'Drag Icon' })).toBeInTheDocument(); @@ -90,7 +99,11 @@ describe('node', () => { , ); expect(screen.queryByRole('img', { name: 'Drag Icon' })).not.toBeInTheDocument(); @@ -111,16 +124,66 @@ describe('node', () => { expect(onAddFieldToNodeClickMock).toHaveBeenCalled(); }); - it('Should show a clickable button to toggle expand collapse when onNodeExpandToggle is supplied', async () => { - const onNodeExpandToggleMock = vi.fn(); + describe('when onNodeExpandToggle is supplied', () => { + it('Should show a clickable button to collapse when there are no explicitly collapsed fields', async () => { + const onNodeExpandToggleMock = vi.fn(); + const expandedFields = [ + { + name: 'field1', + expanded: true, + hasChildren: true, + }, + { + // this field is not explicitly collapsed + name: 'field2', + hasChildren: true, + }, + ]; - 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(); + render( + , + ); + const button = screen.getByRole('button', { name: 'Collapse all' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Collapse all'); + expect(onNodeExpandToggleMock).not.toHaveBeenCalled(); + await userEvent.click(button); + expect(onNodeExpandToggleMock).toHaveBeenCalled(); + }); + + it('Should show a clickable button to expand when some fields are not expanded', async () => { + const onNodeExpandToggleMock = vi.fn(); + const variedFields = [ + { + name: 'field1', + expanded: true, + hasChildren: true, + }, + { + name: 'field2', + expanded: false, + hasChildren: true, + }, + ]; + + render( + , + ); + const button = screen.getByRole('button', { name: 'Expand all' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Expand all'); + expect(onNodeExpandToggleMock).not.toHaveBeenCalled(); + await userEvent.click(button); + expect(onNodeExpandToggleMock).toHaveBeenCalled(); + }); }); it('Should prioritise borderVariant over selected prop when setting the border', () => { diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index 406c466..78f42a5 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { fontFamilies, spacing } from '@leafygreen-ui/tokens'; import { useTheme } from '@emotion/react'; import Icon from '@leafygreen-ui/icon'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Tooltip } from '@leafygreen-ui/tooltip'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { palette } from '@leafygreen-ui/palette'; @@ -13,11 +13,12 @@ import { DEFAULT_NODE_HEADER_HEIGHT, ZOOM_THRESHOLD } from '@/utilities/constant import { InternalNode } from '@/types/internal'; import { PlusWithSquare } from '@/components/icons/plus-with-square'; import { ChevronCollapse } from '@/components/icons/chevron-collapse'; +import { ChevronExpand } from '@/components/icons/chevron-expand'; +import { DiagramIconButton } from '@/components/buttons/diagram-icon-button'; import { NodeBorder } from '@/components/node/node-border'; import { FieldList } from '@/components/field/field-list'; import { NodeType } from '@/types'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; -import { DiagramIconButton } from '@/components/buttons/diagram-icon-button'; const NodeZoomedOut = styled.div` display: flex; @@ -139,7 +140,7 @@ export const Node = ({ type, selected, isConnectable, - data: { title, fields, borderVariant, disabled, variant }, + data: { title, visibleFields: fields, borderVariant, disabled, variant }, }: NodeProps) => { const theme = useTheme(); const { darkMode } = useDarkMode(); @@ -157,11 +158,17 @@ export const Node = ({ [addFieldToNodeClickHandler, id], ); + const areSomeFieldsCollapsed = useMemo(() => { + return fields.some(field => { + return field.expanded === false; + }); + }, [fields]); + const handleNodeExpandToggle = useCallback( (event: React.MouseEvent) => { - onNodeExpandToggle?.(event, id); + onNodeExpandToggle?.(event, id, areSomeFieldsCollapsed); }, - [onNodeExpandToggle, id], + [onNodeExpandToggle, id, areSomeFieldsCollapsed], ); const getAccent = () => { @@ -216,6 +223,8 @@ export const Node = ({ setHovering(false); }; + const nodeExpandLabel = areSomeFieldsCollapsed ? 'Expand all' : 'Collapse all'; + return (
@@ -268,11 +277,11 @@ export const Node = ({ )} {onNodeExpandToggle && ( - + {areSomeFieldsCollapsed ? : } )} diff --git a/src/hooks/use-diagram.ts b/src/hooks/use-diagram.ts index f3c6229..a24cef5 100644 --- a/src/hooks/use-diagram.ts +++ b/src/hooks/use-diagram.ts @@ -1,6 +1,6 @@ import { useReactFlow } from '@xyflow/react'; -import { convertToExternalNode, convertToInternalNodes } from '@/utilities/convert-nodes'; +import { convertToInternalNodes, getExternalNode } from '@/utilities/convert-nodes'; import { InternalEdge, InternalNode } from '@/types/internal'; import { NodeProps, EdgeProps } from '@/types'; import { convertToExternalEdge, convertToInternalEdges } from '@/utilities/convert-edges'; @@ -14,12 +14,12 @@ export function useDiagram() { getNode: (id: string) => { const node = diagram.getNode(id); if (node) { - return convertToExternalNode(node); + return getExternalNode(node); } }, getNodes: () => { const nodes = diagram.getNodes(); - return nodes.map(convertToExternalNode); + return nodes.map(getExternalNode); }, addNodes: (payload: NodeProps[]) => { const data = convertToInternalNodes(payload); diff --git a/src/hooks/use-editable-diagram-interactions.tsx b/src/hooks/use-editable-diagram-interactions.tsx index ee73bab..1650b09 100644 --- a/src/hooks/use-editable-diagram-interactions.tsx +++ b/src/hooks/use-editable-diagram-interactions.tsx @@ -7,12 +7,14 @@ import { OnAddFieldToObjectFieldClickHandler, OnFieldNameChangeHandler, OnFieldTypeChangeHandler, + OnFieldExpandHandler, } from '@/types'; interface EditableDiagramInteractionsContextType { onClickField?: OnFieldClickHandler; onClickAddFieldToNode?: OnAddFieldToNodeClickHandler; onNodeExpandToggle?: OnNodeExpandHandler; + onFieldExpandToggle?: OnFieldExpandHandler; onClickAddFieldToObjectField?: OnAddFieldToObjectFieldClickHandler; onChangeFieldName?: OnFieldNameChangeHandler; onChangeFieldType?: OnFieldTypeChangeHandler; @@ -27,6 +29,7 @@ interface EditableDiagramInteractionsProviderProps { onFieldClick?: OnFieldClickHandler; onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; onNodeExpandToggle?: OnNodeExpandHandler; + onFieldExpandToggle?: OnFieldExpandHandler; onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; onFieldNameChange?: OnFieldNameChangeHandler; onFieldTypeChange?: OnFieldTypeChangeHandler; @@ -38,6 +41,7 @@ export const EditableDiagramInteractionsProvider: React.FC { const [nodes, setNodes] = useState([]); - const [expanded, setExpanded] = useState>(() => { - return Object.fromEntries( - nodes.map(node => { - return [node.id, true]; - }), - ); - }); - const hasInitialized = useRef(false); useEffect(() => { if (hasInitialized.current) { @@ -248,13 +240,34 @@ export const useEditableNodes = (initialNodes: NodeProps[]) => { ); }, []); - const onNodeExpandToggle = useCallback((_evt: ReactMouseEvent, nodeId: string) => { - setExpanded(state => { - return { - ...state, - [nodeId]: !state[nodeId], - }; - }); + const onNodeExpandToggle = useCallback((_evt: ReactMouseEvent, nodeId: string, expanded: boolean) => { + setNodes(nodes => + nodes.map(node => + node.id === nodeId + ? { + ...node, + fields: node.fields.map(field => { + return { ...field, expanded }; + }), + } + : node, + ), + ); + }, []); + + const onFieldExpandToggle = useCallback((_evt: ReactMouseEvent, nodeId: string, fieldId: FieldId) => { + setNodes(nodes => + nodes.map(node => + node.id === nodeId + ? { + ...node, + fields: node.fields.map(field => { + return field.id === fieldId ? { ...field, expanded: !field.expanded } : field; + }), + } + : node, + ), + ); }, []); const onNodeDragStop = useCallback((_event: ReactMouseEvent, node: NodeProps) => { @@ -271,28 +284,15 @@ export const useEditableNodes = (initialNodes: NodeProps[]) => { }); }, []); - 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, + nodes, onFieldClick, onAddFieldToNodeClick, onNodeExpandToggle, onAddFieldToObjectFieldClick, onFieldNameChange, onFieldTypeChange, + onFieldExpandToggle, fieldTypes, onNodeDragStop, }; diff --git a/src/types/component-props.ts b/src/types/component-props.ts index b98b150..37d2627 100644 --- a/src/types/component-props.ts +++ b/src/types/component-props.ts @@ -26,9 +26,21 @@ 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. + * Called when the button to expand / collapse all field is clicked in the node + * header. If any fields are currently collapsed, shouldExpand will be `true` */ -export type OnNodeExpandHandler = (event: ReactMouseEvent, nodeId: string) => void; +export type OnNodeExpandHandler = (event: ReactMouseEvent, nodeId: string, shouldExpand: boolean) => void; + +/** + * Called when the button to expand / collapse a single field is clicked + * header. + */ +export type OnFieldExpandHandler = ( + event: ReactMouseEvent, + nodeId: string, + fieldPath: string[], + shouldExpand: boolean, +) => void; /** * Called when the button to add a new field is clicked on an object type field in a node. @@ -199,6 +211,11 @@ export interface DiagramProps { */ onNodeExpandToggle?: OnNodeExpandHandler; + /** + * Callback when the user clicks the button to expand / collapse a single field in the node. + */ + onFieldExpandToggle?: OnFieldExpandHandler; + /** * Callback when the user clicks to add a new field to an object type field in a node. */ diff --git a/src/types/edge.ts b/src/types/edge.ts index 7ef99f2..c47ae9c 100644 --- a/src/types/edge.ts +++ b/src/types/edge.ts @@ -1,3 +1,5 @@ +import { FieldId } from './node'; + /** * Custom marker options for edge start/end. */ @@ -20,14 +22,14 @@ export interface EdgeProps { target: string; /** - * Index of the field in the source node this edge connects from (if applicable). + * Id of the field in the source node this edge connects from (if applicable). */ - sourceFieldIndex?: number; + sourceFieldId?: FieldId; /** - * Index of the field in the target node this edge connects to (if applicable). + * Id of the field in the target node this edge connects to (if applicable). */ - targetFieldIndex?: number; + targetFieldId?: FieldId; /** * Whether the edge should be hidden from view. diff --git a/src/types/internal.ts b/src/types/internal.ts index 138eefe..a3e9d55 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -1,14 +1,17 @@ import { Node as ReactFlowNode } from '@xyflow/react'; -import { NodeBorderVariant, NodeField, NodeVariant } from '@/types/node'; +import { FieldId, NodeBorderVariant, NodeField, NodeProps, NodeVariant } from '@/types/node'; import { EdgeProps } from '@/types/edge'; +export type InternalNodeField = NodeField & { hasChildren: boolean }; + export type NodeData = { title: string; disabled?: boolean; - fields: NodeField[]; + visibleFields: InternalNodeField[]; borderVariant?: NodeBorderVariant; variant?: NodeVariant; + externalNode: NodeProps; }; export type InternalNode = ReactFlowNode; @@ -18,7 +21,7 @@ export interface InternalEdge extends Omit { markerEnd: 'end-many', type: 'fieldEdge', data: { - sourceFieldIndex: 5, - targetFieldIndex: 2, + sourceFieldId: ['fieldA'], + targetFieldId: ['fieldB', 'childB'], }, }, ]; @@ -89,8 +89,8 @@ describe('convert-edges', () => { target: 'n3', markerStart: 'many', markerEnd: 'many', - sourceFieldIndex: 5, - targetFieldIndex: 2, + sourceFieldId: ['fieldA'], + targetFieldId: ['fieldB', 'childB'], }, ]); }); @@ -129,18 +129,18 @@ describe('convert-edges', () => { }); expect(result.type).toEqual('selfReferencingEdge'); }); - it('Should apply fieldEdge if the field indices are provided', () => { + it('Should apply fieldEdge if the field indexes are provided', () => { const result = convertToInternalEdge({ ...externalEdge, - sourceFieldIndex: 2, - targetFieldIndex: 4, + sourceFieldId: ['fieldA'], + targetFieldId: ['fieldB', 'childB'], }); expect(result.type).toEqual('fieldEdge'); - expect(result).not.toHaveProperty('sourceFieldIndex'); - expect(result).not.toHaveProperty('targetFieldIndex'); + expect(result).not.toHaveProperty('sourceFieldId'); + expect(result).not.toHaveProperty('targetFieldId'); expect(result.data).toEqual({ - sourceFieldIndex: 2, - targetFieldIndex: 4, + sourceFieldId: ['fieldA'], + targetFieldId: ['fieldB', 'childB'], }); }); }); diff --git a/src/utilities/convert-edges.ts b/src/utilities/convert-edges.ts index c0940bb..1e92155 100644 --- a/src/utilities/convert-edges.ts +++ b/src/utilities/convert-edges.ts @@ -8,8 +8,8 @@ export const convertToExternalEdge = (edge: InternalEdge): EdgeProps => { ...rest, markerStart: markerStart?.replace(/^start-/, '') as Marker, markerEnd: markerEnd?.replace(/^end-/, '') as Marker, - ...(data?.sourceFieldIndex !== undefined ? { sourceFieldIndex: data?.sourceFieldIndex } : {}), - ...(data?.targetFieldIndex !== undefined ? { targetFieldIndex: data?.targetFieldIndex } : {}), + ...(data?.sourceFieldId !== undefined ? { sourceFieldId: data?.sourceFieldId } : {}), + ...(data?.targetFieldId !== undefined ? { targetFieldId: data?.targetFieldId } : {}), }; }; @@ -18,7 +18,7 @@ export const convertToExternalEdges = (edges: InternalEdge[]): EdgeProps[] => { }; export const convertToInternalEdge = (edge: EdgeProps): InternalEdge => { - const { sourceFieldIndex, targetFieldIndex, ...edgeProps } = edge; + const { sourceFieldId, targetFieldId, ...edgeProps } = edge; return { ...edgeProps, markerStart: `start-${edge.markerStart}`, @@ -26,12 +26,12 @@ export const convertToInternalEdge = (edge: EdgeProps): InternalEdge => { type: edge.source === edge.target ? 'selfReferencingEdge' - : sourceFieldIndex !== undefined && targetFieldIndex !== undefined + : sourceFieldId !== undefined && targetFieldId !== undefined ? 'fieldEdge' : 'floatingEdge', data: { - ...(sourceFieldIndex !== undefined ? { sourceFieldIndex } : {}), - ...(targetFieldIndex !== undefined ? { targetFieldIndex } : {}), + ...(sourceFieldId !== undefined ? { sourceFieldId } : {}), + ...(targetFieldId !== undefined ? { targetFieldId } : {}), }, }; }; diff --git a/src/utilities/convert-nodes.test.ts b/src/utilities/convert-nodes.test.ts index c031565..66f1de0 100644 --- a/src/utilities/convert-nodes.test.ts +++ b/src/utilities/convert-nodes.test.ts @@ -1,132 +1,38 @@ import { InternalNode } from '@/types/internal'; import { NodeType } from '@/types'; -import { - convertToExternalNode, - convertToExternalNodes, - convertToInternalNode, - convertToInternalNodes, -} from '@/utilities/convert-nodes'; +import { convertToInternalNode, convertToInternalNodes, getExternalNode } from '@/utilities/convert-nodes'; import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; describe('convert-nodes', () => { - describe('convertToExternalNode', () => { - it('Should convert nodes', () => { - const internalNode: InternalNode = { - id: 'node-1', - type: 'collection', - position: { x: 100, y: 200 }, - data: { - title: 'some-title', - fields: [], - }, - }; - - const result = convertToExternalNode(internalNode); - expect(result).toEqual({ + describe('getExternalNode', () => { + it('Should retrieve the original node', () => { + const externalNode = { id: 'node-1', type: 'collection' as NodeType, position: { x: 100, y: 200 }, title: 'some-title', fields: [], - }); - }); - it('Should convert to external node when variant=default', () => { - const internalNode: InternalNode = { - id: 'node-1', - type: 'collection', - position: { x: 100, y: 200 }, - data: { - title: 'some-title', - fields: [], - variant: { - type: 'default', - }, - }, }; - - const result = convertToExternalNode(internalNode); - expect(result).toEqual({ - id: 'node-1', - type: 'collection' as NodeType, - position: { x: 100, y: 200 }, - title: 'some-title', - fields: [], - variant: { - type: 'default', - }, - }); - }); - it('Should convert to external node when variant=warn', () => { const internalNode: InternalNode = { id: 'node-1', type: 'collection', position: { x: 100, y: 200 }, data: { title: 'some-title', - fields: [], - variant: { - type: 'warn', - warnMessage: 'This is a warning', - }, + visibleFields: [], + externalNode, }, }; - const result = convertToExternalNode(internalNode); - expect(result).toEqual({ - id: 'node-1', - type: 'collection' as NodeType, - position: { x: 100, y: 200 }, - title: 'some-title', - fields: [], - variant: { - type: 'warn', - warnMessage: 'This is a warning', - }, - }); - }); - }); - - describe('convertToExternalNodes', () => { - it('should convert an array of internal nodes', () => { - const internalNodes: InternalNode[] = [ - { - id: 'n1', - type: 'collection', - position: { x: 0, y: 0 }, - data: { title: 'Node 1', fields: [] }, - }, - { - id: 'n2', - type: 'table', - position: { x: 10, y: 10 }, - data: { title: 'Node 2', fields: [] }, - }, - ]; - - const result = convertToExternalNodes(internalNodes); - expect(result).toEqual([ - { - id: 'n1', - type: 'collection', - title: 'Node 1', - position: { x: 0, y: 0 }, - fields: [], - }, - { - id: 'n2', - type: 'table', - title: 'Node 2', - position: { x: 10, y: 10 }, - fields: [], - }, - ]); + const result = getExternalNode(internalNode); + expect(result).toEqual(externalNode); }); }); describe('convertToInternalNode', () => { it('Should convert node props to internal node', () => { - const node = { + const externalNode = { id: 'node-1', type: 'table' as const, position: { x: 100, y: 200 }, @@ -134,7 +40,7 @@ describe('convert-nodes', () => { fields: [], }; - const result = convertToInternalNode(node); + const result = convertToInternalNode(externalNode); expect(result).toEqual({ id: 'node-1', type: 'table', @@ -142,14 +48,15 @@ describe('convert-nodes', () => { connectable: false, data: { title: 'some-title', - fields: [], + visibleFields: [], borderVariant: undefined, disabled: undefined, + externalNode, }, }); }); it('Should be connectable', () => { - const node = { + const externalNode = { id: 'node-1', type: 'table' as const, position: { x: 100, y: 200 }, @@ -157,7 +64,7 @@ describe('convert-nodes', () => { fields: [], connectable: true, }; - const result = convertToInternalNode(node); + const result = convertToInternalNode(externalNode); expect(result).toEqual({ id: 'node-1', type: 'table', @@ -165,14 +72,15 @@ describe('convert-nodes', () => { connectable: true, data: { title: 'some-title', - fields: [], + visibleFields: [], borderVariant: undefined, disabled: undefined, + externalNode, }, }); }); it('Should be selectable', () => { - const node = { + const externalNode = { id: 'node-1', type: 'table' as const, position: { x: 100, y: 200 }, @@ -180,7 +88,7 @@ describe('convert-nodes', () => { fields: [], selectable: true, }; - const result = convertToInternalNode(node); + const result = convertToInternalNode(externalNode); expect(result).toEqual({ id: 'node-1', type: 'table', @@ -189,14 +97,15 @@ describe('convert-nodes', () => { selectable: true, data: { title: 'some-title', - fields: [], + visibleFields: [], borderVariant: undefined, disabled: undefined, + externalNode, }, }); }); it('Should be handle node variant=default', () => { - const node = { + const externalNode = { id: 'node-1', type: 'table' as const, position: { x: 100, y: 200 }, @@ -207,7 +116,7 @@ describe('convert-nodes', () => { type: 'default' as const, }, }; - const result = convertToInternalNode(node); + const result = convertToInternalNode(externalNode); expect(result).toEqual({ id: 'node-1', type: 'table', @@ -216,17 +125,18 @@ describe('convert-nodes', () => { selectable: true, data: { title: 'some-title', - fields: [], + visibleFields: [], borderVariant: undefined, disabled: undefined, variant: { type: 'default', }, + externalNode, }, }); }); it('Should be handle node variant=warn', () => { - const node = { + const externalNode = { id: 'node-1', type: 'table' as const, position: { x: 100, y: 200 }, @@ -238,7 +148,7 @@ describe('convert-nodes', () => { warnMessage: 'This is a warning', }, }; - const result = convertToInternalNode(node); + const result = convertToInternalNode(externalNode); expect(result).toEqual({ id: 'node-1', type: 'table', @@ -247,13 +157,53 @@ describe('convert-nodes', () => { selectable: true, data: { title: 'some-title', - fields: [], + visibleFields: [], borderVariant: undefined, disabled: undefined, variant: { type: 'warn', warnMessage: 'This is a warning', }, + externalNode, + }, + }); + }); + it('Should resolve expansion and assign expandability', () => { + const fields = [ + { id: ['expandedParent'], name: 'expandedParent', expanded: true }, + { id: ['expandedParent', 'child1'], name: 'visibleChild1', depth: 1 }, + { id: ['expandedParent', 'child2'], name: 'visibleChild2', depth: 1 }, + { id: ['collapsedParent'], name: 'collapsedParent', expanded: false }, + { id: ['collapsedParent', 'child1'], name: 'invisibleChild1', depth: 1 }, + { id: ['collapsedParent', 'child2'], name: 'invisibleChild2', depth: 1 }, + { id: ['other'], name: 'other' }, + ]; + const externalNode = { + id: 'node-1', + type: 'table' as const, + position: { x: 100, y: 200 }, + title: 'some-title', + fields, + }; + + const result = convertToInternalNode(externalNode); + expect(result).toEqual({ + id: 'node-1', + type: 'table', + position: { x: 100, y: 200 }, + connectable: false, + data: { + title: 'some-title', + visibleFields: [ + { id: ['expandedParent'], name: 'expandedParent', expanded: true, hasChildren: true }, + { id: ['expandedParent', 'child1'], name: 'visibleChild1', depth: 1, hasChildren: false }, + { id: ['expandedParent', 'child2'], name: 'visibleChild2', depth: 1, hasChildren: false }, + { id: ['collapsedParent'], name: 'collapsedParent', expanded: false, hasChildren: true }, + { id: ['other'], name: 'other', hasChildren: false }, + ], + borderVariant: undefined, + disabled: undefined, + externalNode, }, }); }); @@ -261,7 +211,7 @@ describe('convert-nodes', () => { describe('convertToInternalNodes', () => { it('Should convert node props to internal node', () => { - const internalNodes = convertToInternalNodes([ + const externalNodes = [ { ...ORDERS_NODE, measured: { @@ -271,7 +221,8 @@ describe('convert-nodes', () => { disabled: true, }, EMPLOYEES_NODE, - ]); + ]; + const internalNodes = convertToInternalNodes(externalNodes); expect(internalNodes).toEqual([ { id: 'orders', @@ -287,10 +238,11 @@ describe('convert-nodes', () => { }, data: { disabled: true, - fields: [ - { name: 'ORDER_ID', type: 'varchar', glyphs: ['key'] }, - { name: 'SUPPLIER_ID', type: 'varchar', glyphs: ['link'] }, + visibleFields: [ + { name: 'ORDER_ID', type: 'varchar', glyphs: ['key'], hasChildren: false, id: ['ORDER_ID'] }, + { name: 'SUPPLIER_ID', type: 'varchar', glyphs: ['link'], hasChildren: false, id: ['SUPPLIER_ID'] }, ], + externalNode: externalNodes[0], title: 'orders', }, }, @@ -303,13 +255,29 @@ describe('convert-nodes', () => { }, connectable: false, data: { - fields: [ - { name: 'employeeId', type: 'objectId', glyphs: ['key'] }, - { name: 'employeeDetail', type: 'object' }, - { name: 'firstName', type: 'string', depth: 1 }, - { name: 'lastName', type: 'string', depth: 1 }, + visibleFields: [ + { + name: 'employeeId', + type: 'objectIdButMuchLonger', + glyphs: ['key'], + hasChildren: false, + id: ['employeeId'], + }, + { name: 'employeeDetail', type: 'object', hasChildren: true, id: ['employeeDetail'] }, + { name: 'firstName', type: 'string', depth: 1, hasChildren: false, id: ['employeeDetail', 'firstName'] }, + { name: 'lastName', type: 'string', depth: 1, hasChildren: false, id: ['employeeDetail', 'lastName'] }, + { name: 'address', type: 'object', hasChildren: true, id: ['address'] }, + { + name: 'street', + type: 'string', + depth: 1, + hasChildren: false, + id: ['address', 'street'], + }, + { name: 'city', type: 'string', depth: 1, hasChildren: false, id: ['address', 'city'] }, ], title: 'employees', + externalNode: externalNodes[1], }, }, ]); diff --git a/src/utilities/convert-nodes.ts b/src/utilities/convert-nodes.ts index 7abc510..155846c 100644 --- a/src/utilities/convert-nodes.ts +++ b/src/utilities/convert-nodes.ts @@ -1,18 +1,40 @@ -import { InternalNode } from '@/types/internal'; -import { NodeProps, NodeType } from '@/types'; +import { InternalNode, InternalNodeField } from '@/types/internal'; +import { NodeField, NodeProps } from '@/types'; -export const convertToExternalNode = (node: InternalNode): NodeProps => { - const { data, ...rest } = node; - return { - ...rest, - ...data, - type: node.type as NodeType, - }; -}; +export const getExternalNode = (node: InternalNode): NodeProps => node.data.externalNode; -export const convertToExternalNodes = (nodes: InternalNode[]): NodeProps[] => { - return nodes.map(node => convertToExternalNode(node)); -}; +function hasChildren(field: NodeField, index: number, fields: NodeField[]): boolean { + const fieldDepth = field.depth ?? 0; + const nextField = fields.length > index + 1 ? fields[index + 1] : null; + if (!nextField) return false; + return nextField.depth !== undefined && nextField.depth > fieldDepth; +} + +// Filter out all the fields that are children of explicitly collapsed fields. +// We get fields as a flattened list with the depth indicaing the nesting +// level, so everything that is deeper than the collapsed field (child) will +// be filtered out as a child until we run into another element with the same +// depth (a sibling) +// We also annotate each field with whether it is expandable (has children) +// This is more reliable than checking the type of the field, since the object could be hidden in arrays, or simply have no children +function getFieldsWithExpandStatus(fields: NodeField[]): InternalNodeField[] { + const visibleFields: InternalNodeField[] = []; + let currentDepth = 0; + let skipChildren = false; + fields.forEach((field, index) => { + const fieldDepth = field.depth ?? 0; + if (skipChildren && fieldDepth > currentDepth) { + return; + } + currentDepth = fieldDepth; + skipChildren = field.expanded === false; + visibleFields.push({ + ...field, + hasChildren: hasChildren(field, index, fields), + }); + }); + return visibleFields; +} export const convertToInternalNode = (node: NodeProps): InternalNode => { const { title, fields, borderVariant, disabled, connectable, variant, ...rest } = node; @@ -22,9 +44,10 @@ export const convertToInternalNode = (node: NodeProps): InternalNode => { data: { title, disabled, - fields, + visibleFields: getFieldsWithExpandStatus(fields), borderVariant, variant, + externalNode: node, }, }; }; diff --git a/src/utilities/get-edge-params.test.ts b/src/utilities/get-edge-params.test.ts index e77ab6b..75f0e3c 100644 --- a/src/utilities/get-edge-params.test.ts +++ b/src/utilities/get-edge-params.test.ts @@ -1,20 +1,35 @@ import { getEdgeParams } from '@/utilities/get-edge-params'; import { EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/datasets/nodes'; import { DEFAULT_FIELD_HEIGHT, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; +import { NodeProps } from '@/types'; describe('get-edge-params', () => { describe('Without measured heights', () => { it('Should get parameters', () => { const result = getEdgeParams( - { ...ORDERS_NODE, data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields } }, - { ...EMPLOYEES_NODE, data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields } }, + { + ...ORDERS_NODE, + data: { + title: ORDERS_NODE.title, + visibleFields: ORDERS_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, + }, + }, + { + ...EMPLOYEES_NODE, + data: { + title: EMPLOYEES_NODE.title, + visibleFields: EMPLOYEES_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, + }, + }, ); expect(result).toEqual({ sourcePos: 'bottom', sx: 263, sy: 189.5, targetPos: 'top', - tx: 363, + tx: 336, ty: 292.5, }); }); @@ -25,7 +40,11 @@ describe('get-edge-params', () => { const result = getEdgeParams( { ...ORDERS_NODE, - data: { title: ORDERS_NODE.title, fields: ORDERS_NODE.fields }, + data: { + title: ORDERS_NODE.title, + visibleFields: ORDERS_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, + }, measured: { width: DEFAULT_NODE_WIDTH, height: DEFAULT_FIELD_HEIGHT * 2, @@ -33,7 +52,11 @@ describe('get-edge-params', () => { }, { ...EMPLOYEES_NODE, - data: { title: EMPLOYEES_NODE.title, fields: EMPLOYEES_NODE.fields }, + data: { + title: EMPLOYEES_NODE.title, + visibleFields: EMPLOYEES_NODE.fields.map(field => ({ ...field, hasChildren: false })), + externalNode: {} as unknown as NodeProps, + }, measured: { width: DEFAULT_NODE_WIDTH, height: DEFAULT_FIELD_HEIGHT * 4, diff --git a/src/utilities/get-edge-params.ts b/src/utilities/get-edge-params.ts index 463d294..bb6a54c 100644 --- a/src/utilities/get-edge-params.ts +++ b/src/utilities/get-edge-params.ts @@ -1,6 +1,7 @@ import { Position, XYPosition } from '@xyflow/react'; import { InternalNode } from '@/types/internal'; +import { FieldId, NodeField } from '@/types/node'; import { DEFAULT_FIELD_HEIGHT, DEFAULT_MARKER_SIZE, DEFAULT_NODE_WIDTH } from '@/utilities/constants'; import { getFieldYPosition, getNodeHeight, getNodeWidth } from './node-dimensions'; @@ -37,18 +38,28 @@ const getNodeIntersection = (intersectionNode: InternalNode, targetNode: Interna return { x, y }; }; +const getVerticalIntersectionAtField = (nodeHeight: number, fields: NodeField[], fieldId: FieldId) => { + if (!nodeHeight) return 0; + const fieldIndex = fields.findIndex(({ id }) => JSON.stringify(id) === JSON.stringify(fieldId)); + if (fieldIndex === -1) { + // field not found, return center of node + return nodeHeight / 2; + } + return getFieldYPosition(fieldIndex) + DEFAULT_FIELD_HEIGHT / 2; +}; + /** * Returns the coordinates where the edge should connect to a specific field * (on the left or right side of the node) * * @param intersectionNode The source node * @param targetNode The target node - * @param intersectionFieldIndex The index of the field on the source node + * @param intersectionFieldId The id of the field on the source node */ -const getNodeIntersectionAtField = ( +export const getNodeIntersectionAtField = ( intersectionNode: InternalNode, targetNode: InternalNode, - intersectionFieldIndex: number, + intersectionFieldId: FieldId, ): XYPosition => { const intersectionNodeWidth = getNodeWidth(intersectionNode); const intersectionNodeHeight = getNodeHeight(intersectionNode); @@ -65,7 +76,11 @@ const getNodeIntersectionAtField = ( : 0; // vertical intersection is calculated based on the field index - const h = intersectionNodeHeight ? getFieldYPosition(intersectionFieldIndex) + DEFAULT_FIELD_HEIGHT / 2 : 0; + const h = getVerticalIntersectionAtField( + intersectionNodeHeight, + intersectionNode.data.visibleFields, + intersectionFieldId, + ); // the final position is added to the node position const x = intersectionNode.position.x + w; @@ -164,11 +179,11 @@ export const getEdgeParams = (source: InternalNode, target: InternalNode) => { export const getFieldEdgeParams = ( source: InternalNode, target: InternalNode, - sourceFieldIndex: number, - targetFieldIndex: number, + sourceFieldId: FieldId, + targetFieldId: FieldId, ) => { - const sourceIntersectionPoint = getNodeIntersectionAtField(source, target, sourceFieldIndex); - const targetIntersectionPoint = getNodeIntersectionAtField(target, source, targetFieldIndex); + const sourceIntersectionPoint = getNodeIntersectionAtField(source, target, sourceFieldId); + const targetIntersectionPoint = getNodeIntersectionAtField(target, source, targetFieldId); const sourcePos = getEdgePosition(source, sourceIntersectionPoint); const targetPos = getEdgePosition(target, targetIntersectionPoint); diff --git a/src/utilities/node-dimensions.ts b/src/utilities/node-dimensions.ts index d1db5b2..ec7334d 100644 --- a/src/utilities/node-dimensions.ts +++ b/src/utilities/node-dimensions.ts @@ -22,8 +22,8 @@ export const getNodeHeight = < } if ('data' in node) { let internalNode = node as InternalNode; - if (internalNode.data?.fields && Array.isArray(internalNode.data.fields)) { - fieldCount = internalNode.data.fields.length; + if (internalNode.data?.visibleFields && Array.isArray(internalNode.data.visibleFields)) { + fieldCount = internalNode.data.visibleFields.length; } } const calculatedHeight = getFieldYPosition(fieldCount) + DEFAULT_FIELD_PADDING;