diff --git a/package.json b/package.json index 908ba9c..34de3a7 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.6.1", "@types/d3-path": "^3", "@types/jest": "30.0.0", "@types/node": "22.15.29", diff --git a/src/components/buttons/diagram-icon-button.tsx b/src/components/buttons/diagram-icon-button.tsx new file mode 100644 index 0000000..558ab57 --- /dev/null +++ b/src/components/buttons/diagram-icon-button.tsx @@ -0,0 +1,82 @@ +import { ButtonHTMLAttributes } from 'react'; +import styled from '@emotion/styled'; +import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; +import { palette } from '@leafygreen-ui/palette'; + +const StyledDiagramIconButton = styled.button` + background: none; + border: none; + outline: none; + padding: ${spacing[100]}px; + margin: 0; + margin-left: ${spacing[100]}px; + cursor: pointer; + color: inherit; + display: flex; + position: relative; + color: ${props => props.theme.node.fieldIconButton}; + + &::before { + content: ''; + transition: ${transitionDuration.default}ms all ease-in-out; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + border-radius: 100%; + transform: scale(0.8); + } + + &:active::before, + &:hover::before, + &:focus::before, + &[data-hover='true']::before, + &[data-focus='true']::before { + transform: scale(1); + } + + &:active, + &:hover, + &[data-hover='true'], + &:focus-visible, + &[data-focus='true'] { + color: ${palette.black}; + + &::before { + background-color: ${props => props.theme.node.fieldIconButtonHoverBackground}; + } + } + + // Focus ring styles. + &::after { + position: absolute; + content: ''; + pointer-events: none; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: ${spacing[100]}px; + box-shadow: 0 0 0 0 transparent; + transition: box-shadow 0.16s ease-in; + z-index: 1; + } + &:focus-visible { + &::after { + box-shadow: 0 0 0 3px ${palette.blue.light1} !important; + transition-timing-function: ease-out; + } + } +`; + +// Use a custom button component instead of LeafyGreen's IconButton +// to allow us to have a smaller focus ring and icon size without overwriting internal styles. +export const DiagramIconButton = ({ + children, + ...props +}: ButtonHTMLAttributes & { + children?: React.ReactNode; +}) => { + return {children}; +}; diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index 87e608b..a3cbeda 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -21,7 +21,7 @@ import { MarkerList } from '@/components/markers/marker-list'; import { ConnectionLine } from '@/components/line/connection-line'; import { convertToExternalNode, convertToExternalNodes, convertToInternalNodes } from '@/utilities/convert-nodes'; import { convertToExternalEdge, convertToExternalEdges, convertToInternalEdges } from '@/utilities/convert-edges'; -import { FieldSelectionProvider } from '@/hooks/use-field-selection'; +import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; const MAX_ZOOM = 3; const MIN_ZOOM = 0.1; @@ -57,6 +57,8 @@ export const Canvas = ({ edges: externalEdges, onConnect, id, + onAddFieldToNodeClick, + onAddFieldToObjectFieldClick, onFieldClick, onNodeContextMenu, onNodeDrag, @@ -141,7 +143,11 @@ export const Canvas = ({ ); return ( - + - + ); }; diff --git a/src/components/diagram.stories.tsx b/src/components/diagram.stories.tsx index 11c6028..921fcc3 100644 --- a/src/components/diagram.stories.tsx +++ b/src/components/diagram.stories.tsx @@ -5,7 +5,7 @@ import { EMPLOYEE_TERRITORIES_NODE, EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/ import { EMPLOYEES_TO_EMPLOYEES_EDGE, ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges'; import { DiagramStressTestDecorator } from '@/mocks/decorators/diagram-stress-test.decorator'; import { DiagramConnectableDecorator } from '@/mocks/decorators/diagram-connectable.decorator'; -import { DiagramSelectableFieldsDecorator } from '@/mocks/decorators/diagram-selectable-fields.decorator'; +import { DiagramEditableInteractionsDecorator } from '@/mocks/decorators/diagram-editable-interactions.decorator'; const diagram: Meta = { title: 'Diagram', @@ -51,8 +51,8 @@ function idFromDepthAccumulator(name: string, depth?: number) { lastDepth = depth ?? 0; return [...idAccumulator]; } -export const DiagramWithSelectableFields: Story = { - decorators: [DiagramSelectableFieldsDecorator], +export const DiagramWithEditInteractions: Story = { + decorators: [DiagramEditableInteractionsDecorator], args: { title: 'MongoDB Diagram', isDarkMode: true, diff --git a/src/components/field/field-list.tsx b/src/components/field/field-list.tsx index ccfa569..666cec2 100644 --- a/src/components/field/field-list.tsx +++ b/src/components/field/field-list.tsx @@ -6,8 +6,8 @@ import { Field } from '@/components/field/field'; import { NodeField, NodeType } from '@/types'; import { DEFAULT_PREVIEW_GROUP_AREA, getPreviewGroupArea, getPreviewId } from '@/utilities/get-preview-group-area'; import { DEFAULT_FIELD_PADDING } from '@/utilities/constants'; -import { useFieldSelection } from '@/hooks/use-field-selection'; import { getSelectedFieldGroupHeight, getSelectedId } from '@/utilities/get-selected-field-group-height'; +import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; const NodeFieldWrapper = styled.div` padding: ${DEFAULT_FIELD_PADDING}px ${spacing[400]}px; @@ -22,7 +22,8 @@ interface Props { } export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => { - const { enabled: isFieldSelectionEnabled } = useFieldSelection(); + const { onClickField } = useEditableDiagramInteractions(); + const isFieldSelectionEnabled = !!onClickField; const spacing = Math.max(0, ...fields.map(field => field.glyphs?.length || 0)); const previewGroupArea = useMemo(() => getPreviewGroupArea(fields), [fields]); diff --git a/src/components/field/field-type-content.tsx b/src/components/field/field-type-content.tsx new file mode 100644 index 0000000..d80fec8 --- /dev/null +++ b/src/components/field/field-type-content.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import styled from '@emotion/styled'; + +import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; +import { PlusWithSquare } from '@/components/icons/plus-with-square'; +import { DiagramIconButton } from '@/components/buttons/diagram-icon-button'; + +const ObjectTypeContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + line-height: 20px; +`; + +export const FieldTypeContent = ({ + type, + nodeId, + id, +}: { + id: string | string[]; + nodeId: string; + type: React.ReactNode; +}) => { + const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions(); + + const onClickAddFieldToObject = useMemo( + () => + _onClickAddFieldToObjectField + ? (event: React.MouseEvent) => { + // Don't click on the field element. + event.stopPropagation(); + _onClickAddFieldToObjectField(event, nodeId, Array.isArray(id) ? id : [id]); + } + : undefined, + [_onClickAddFieldToObjectField, nodeId, id], + ); + + if (type === 'object') { + return ( + + {'{}'} + {onClickAddFieldToObject && ( + + + + )} + + ); + } + + if (type === 'array') { + return '[]'; + } + + return <>{type}; +}; diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 8d4cf9c..6011a8a 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -1,17 +1,31 @@ import { palette } from '@leafygreen-ui/palette'; import { ComponentProps } from 'react'; +import { userEvent } from '@testing-library/user-event'; import { render, screen } from '@/mocks/testing-utils'; import { Field as FieldComponent } from '@/components/field/field'; import { DEFAULT_PREVIEW_GROUP_AREA } from '@/utilities/get-preview-group-area'; -import { FieldSelectionProvider } from '@/hooks/use-field-selection'; +import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; const Field = (props: React.ComponentProps) => ( - + - + ); +const FieldWithEditableInteractions = ({ + onAddFieldToObjectFieldClick, + ...fieldProps +}: React.ComponentProps & { + onAddFieldToObjectFieldClick?: () => void; +}) => { + return ( + + + + ); +}; + describe('field', () => { const DEFAULT_PROPS: ComponentProps = { nodeType: 'collection', @@ -30,6 +44,52 @@ describe('field', () => { expect(screen.getByRole('img', { name: 'Key Icon' })).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Link Icon' })).toBeInTheDocument(); }); + it('Should not have a button to add a field on an object type', () => { + render(); + expect(screen.getByText('ordersId')).toBeInTheDocument(); + expect(screen.getByText('{}')).toBeInTheDocument(); + const button = screen.queryByRole('button'); + expect(button).not.toBeInTheDocument(); + }); + describe('With editable interactions supplied', () => { + it('Should have a button to add a field on an object type', async () => { + const onAddFieldToObjectFieldClickMock = vi.fn(); + + render( + , + ); + expect(screen.getByText('ordersId')).toBeInTheDocument(); + expect(screen.getByText('{}')).toBeInTheDocument(); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('data-testid', 'object-field-type-pineapple-ordersId'); + expect(button).toHaveAttribute('title', 'Add Field'); + expect(onAddFieldToObjectFieldClickMock).not.toHaveBeenCalled(); + await userEvent.click(button); + expect(onAddFieldToObjectFieldClickMock).toHaveBeenCalled(); + }); + + it('Should not have a button to add a field with non-object types', () => { + render(); + expect(screen.getByText('ordersId')).toBeInTheDocument(); + expect(screen.getByText('objectId')).toBeInTheDocument(); + const button = screen.queryByRole('button'); + expect(button).not.toBeInTheDocument(); + }); + }); + describe('With specific types', () => { + it('shows [] with "array"', () => { + render(); + expect(screen.getByText('[]')).toBeInTheDocument(); + expect(screen.queryByText('array')).not.toBeInTheDocument(); + }); + }); + describe('With glyphs', () => { it('With disabled', () => { render(); diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index 594fe70..ec07894 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -4,14 +4,15 @@ import { palette } from '@leafygreen-ui/palette'; import Icon from '@leafygreen-ui/icon'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { useTheme } from '@emotion/react'; -import { MouseEvent as ReactMouseEvent, useMemo } from 'react'; +import { useMemo } from 'react'; import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles'; import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants'; import { FieldDepth } from '@/components/field/field-depth'; +import { FieldTypeContent } from '@/components/field/field-type-content'; import { NodeField, NodeGlyph, NodeType } from '@/types'; import { PreviewGroupArea } from '@/utilities/get-preview-group-area'; -import { useFieldSelection } from '@/hooks/use-field-selection'; +import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; const FIELD_BORDER_ANIMATED_PADDING = spacing[100]; const FIELD_GLYPH_SPACING = spacing[400]; @@ -145,7 +146,6 @@ export const Field = ({ selectedGroupHeight = 0, previewGroupArea, glyphSize = LGSpacing[300], - renderName, spacing = 0, selectable = false, selected = false, @@ -153,7 +153,7 @@ export const Field = ({ }: Props) => { const { theme } = useDarkMode(); - const { fieldProps } = useFieldSelection(); + const { onClickField } = useEditableDiagramInteractions(); const internalTheme = useTheme(); @@ -167,14 +167,14 @@ export const Field = ({ * Create the field selection props when the field is selectable. */ const fieldSelectionProps = useMemo(() => { - return selectable && fieldProps + return selectable && !!onClickField ? { 'data-testid': `selectable-field-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`, selectable: true, - onClick: (event: ReactMouseEvent) => fieldProps.onClick(event, { id, nodeId }), + onClick: (event: React.MouseEvent) => onClickField(event, { id, nodeId }), } : undefined; - }, [fieldProps, selectable, id, nodeId]); + }, [onClickField, selectable, id, nodeId]); const getTextColor = () => { if (isDisabled) { @@ -215,9 +215,11 @@ export const Field = ({ <> - {renderName || name} + {name} - {type} + + + ); diff --git a/src/components/icons/plus-with-square.tsx b/src/components/icons/plus-with-square.tsx new file mode 100644 index 0000000..5f15085 --- /dev/null +++ b/src/components/icons/plus-with-square.tsx @@ -0,0 +1,14 @@ +import { useTheme } from '@emotion/react'; + +export const PlusWithSquare: React.FunctionComponent = () => { + const theme = useTheme(); + + return ( + + + + ); +}; diff --git a/src/components/node/node.stories.tsx b/src/components/node/node.stories.tsx index 2aa0fa1..91a1ccb 100644 --- a/src/components/node/node.stories.tsx +++ b/src/components/node/node.stories.tsx @@ -3,7 +3,7 @@ import { ReactFlowProvider } from '@xyflow/react'; import { InternalNode } from '@/types/internal'; import { Node } from '@/components/node/node'; -import { FieldSelectionProvider } from '@/hooks/use-field-selection'; +import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; const INTERNAL_NODE: InternalNode = { id: 'orders', @@ -34,11 +34,11 @@ const nodeStory: Meta = { decorators: [ Story => ( - +
-
+
), ], @@ -246,33 +246,6 @@ export const NodeWithCustomTypeField: Story = { }, }; -export const NodeWithCustomFieldNameRender: Story = { - args: { - ...INTERNAL_NODE, - data: { - title: 'orders', - fields: [ - { - name: 'customerId', - renderName: ( -
- e.preventDefault()} - defaultValue="Custom name" - style={{ marginLeft: 5, marginRight: 5, width: '90px' }} - /> -
- ), - type: 'string', - variant: 'default', - glyphs: ['key'], - }, - ], - }, - }, -}; - export const NodeWithPrimaryField: Story = { args: { ...INTERNAL_NODE, @@ -510,23 +483,6 @@ export const NodeWithDeeplyNestedPreviewFieldsEverywhere: Story = { }, }; -export const NodeWithAction: Story = { - args: { - ...INTERNAL_NODE, - data: { - title: 'orders', - fields: [ - { - name: 'orderId', - type: 'string', - glyphs: ['key'], - }, - ], - actions: , - }, - }, -}; - export const NodeWithSelectedFields: Story = { args: { ...INTERNAL_NODE, diff --git a/src/components/node/node.test.tsx b/src/components/node/node.test.tsx index f00c78b..f849541 100644 --- a/src/components/node/node.test.tsx +++ b/src/components/node/node.test.tsx @@ -1,15 +1,21 @@ import { screen } from '@testing-library/react'; import { NodeProps, useViewport } from '@xyflow/react'; +import { userEvent } from '@testing-library/user-event'; import { render } from '@/mocks/testing-utils'; import { InternalNode } from '@/types/internal'; import { Node as NodeComponent } from '@/components/node/node'; -import { FieldSelectionProvider } from '@/hooks/use-field-selection'; +import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; -const Node = (props: React.ComponentProps) => ( - +const Node = ({ + onAddFieldToNodeClick, + ...props +}: React.ComponentProps & { + onAddFieldToNodeClick?: () => void; +}) => ( + - + ); vi.mock('@xyflow/react', async () => { @@ -67,6 +73,8 @@ describe('node', () => { expect(screen.getByText('employees')).toBeInTheDocument(); expect(screen.getByText('employeeId')).toBeInTheDocument(); expect(screen.getByText('string')).toBeInTheDocument(); + const button = screen.queryByRole('button', { name: 'Add Field' }); + expect(button).not.toBeInTheDocument(); }); it('Should show contextual zoom', () => { @@ -84,4 +92,16 @@ describe('node', () => { expect(screen.queryByText('employeeId')).not.toBeVisible(); expect(screen.queryByText('string')).not.toBeVisible(); }); + + it('Should show a clickable button to add a field when add field is supplied', async () => { + const onAddFieldToNodeClickMock = vi.fn(); + + render(); + const button = screen.getByRole('button', { name: 'Add Field' }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('title', 'Add Field'); + expect(onAddFieldToNodeClickMock).not.toHaveBeenCalled(); + await userEvent.click(button); + expect(onAddFieldToNodeClickMock).toHaveBeenCalled(); + }); }); diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index 459e2cf..fde7e18 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -3,13 +3,16 @@ import styled from '@emotion/styled'; import { fontFamilies, spacing } from '@leafygreen-ui/tokens'; import { useTheme } from '@emotion/react'; import Icon from '@leafygreen-ui/icon'; -import { useState } from 'react'; +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 { 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; @@ -99,18 +102,33 @@ const NodeWithFields = styled.div<{ visibility: string }>` visibility: ${props => props.visibility}; `; +const AddNewFieldIconButtonButton = styled(DiagramIconButton)` + margin-left: auto; + margin-right: ${spacing[200]}px; +`; + export const Node = ({ id, type, selected, isConnectable, - data: { actions, title, fields, borderVariant, disabled }, + data: { title, fields, borderVariant, disabled }, }: NodeProps) => { const theme = useTheme(); const { zoom } = useViewport(); const [isHovering, setHovering] = useState(false); + const { onClickAddFieldToNode: addFieldToNodeClickHandler } = useEditableDiagramInteractions(); + + const onClickAddFieldToNode = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + addFieldToNodeClickHandler?.(event, id); + }, + [addFieldToNodeClickHandler, id], + ); + const getAccent = () => { if (disabled && !isHovering) { return theme.node.disabledAccent; @@ -185,7 +203,11 @@ export const Node = ({ {title} - {actions} + {addFieldToNodeClickHandler && ( + + + + )} diff --git a/src/hooks/use-editable-diagram-interactions.tsx b/src/hooks/use-editable-diagram-interactions.tsx new file mode 100644 index 0000000..3761d9f --- /dev/null +++ b/src/hooks/use-editable-diagram-interactions.tsx @@ -0,0 +1,59 @@ +import React, { createContext, useContext, useMemo, ReactNode } from 'react'; + +import { OnFieldClickHandler, OnAddFieldToNodeClickHandler, OnAddFieldToObjectFieldClickHandler } from '@/types'; + +interface EditableDiagramInteractionsContextType { + onClickField?: OnFieldClickHandler; + onClickAddFieldToNode?: OnAddFieldToNodeClickHandler; + onClickAddFieldToObjectField?: OnAddFieldToObjectFieldClickHandler; +} + +const EditableDiagramInteractionsContext = createContext(undefined); + +interface EditableDiagramInteractionsProviderProps { + children: ReactNode; + onFieldClick?: OnFieldClickHandler; + onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; + onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; +} + +export const EditableDiagramInteractionsProvider: React.FC = ({ + children, + onFieldClick, + onAddFieldToNodeClick, + onAddFieldToObjectFieldClick, +}) => { + const value: EditableDiagramInteractionsContextType = useMemo(() => { + return { + ...(onFieldClick + ? { + onClickField: onFieldClick, + } + : undefined), + ...(onAddFieldToNodeClick + ? { + onClickAddFieldToNode: onAddFieldToNodeClick, + } + : undefined), + ...(onAddFieldToObjectFieldClick + ? { + onClickAddFieldToObjectField: onAddFieldToObjectFieldClick, + } + : undefined), + }; + }, [onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick]); + + return ( + {children} + ); +}; + +export const useEditableDiagramInteractions = (): EditableDiagramInteractionsContextType => { + const context = useContext(EditableDiagramInteractionsContext); + + if (context === undefined) { + throw new Error('useEditableDiagramInteractions must be used within a EditableDiagramInteractionsProvider'); + } + + return context; +}; diff --git a/src/hooks/use-field-selection.tsx b/src/hooks/use-field-selection.tsx deleted file mode 100644 index f766b45..0000000 --- a/src/hooks/use-field-selection.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { createContext, useContext, useMemo, ReactNode } from 'react'; - -import { OnFieldClickHandler } from '@/types'; - -interface FieldSelectionContextType { - enabled: boolean; - fieldProps: - | { - onClick: OnFieldClickHandler; - } - | undefined; -} - -const FieldSelectionContext = createContext(undefined); - -interface FieldSelectionProviderProps { - children: ReactNode; - onFieldClick?: OnFieldClickHandler; -} - -export const FieldSelectionProvider: React.FC = ({ children, onFieldClick }) => { - const value: FieldSelectionContextType = useMemo( - () => ({ - enabled: !!true, - fieldProps: onFieldClick - ? { - onClick: onFieldClick, - } - : undefined, - }), - [onFieldClick], - ); - - return {children}; -}; - -export const useFieldSelection = (): FieldSelectionContextType => { - const context = useContext(FieldSelectionContext); - - if (context === undefined) { - throw new Error('useFieldSelection must be used within a FieldSelectionProvider'); - } - - return context; -}; diff --git a/src/mocks/datasets/nodes.tsx b/src/mocks/datasets/nodes.tsx index ac57f17..d0bc908 100644 --- a/src/mocks/datasets/nodes.tsx +++ b/src/mocks/datasets/nodes.tsx @@ -24,7 +24,7 @@ export const EMPLOYEES_NODE: NodeProps = { title: 'employees', fields: [ { name: 'employeeId', type: 'objectId', glyphs: ['key'] }, - { name: 'employeeDetail', type: '{}' }, + { name: 'employeeDetail', type: 'object' }, { name: 'firstName', type: 'string', depth: 1 }, { name: 'lastName', type: 'string', depth: 1 }, ], diff --git a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx new file mode 100644 index 0000000..2442272 --- /dev/null +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -0,0 +1,120 @@ +import { useCallback, useState, MouseEvent as ReactMouseEvent } from 'react'; +import { Decorator } from '@storybook/react'; + +import { DiagramProps, FieldId, NodeField, NodeProps } from '@/types'; + +function stringArrayCompare(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + if (a === b) return true; + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +const newField = (parentFieldPath?: string[]) => { + const name = `newField${Math.floor(Math.random() * 100_000)}`; + + return { + name, + type: 'string', + selectable: true, + depth: parentFieldPath ? parentFieldPath.length : 0, + id: parentFieldPath ? [...parentFieldPath, name] : [name], + }; +}; + +function addFieldToNode(existingFields: NodeField[], parentFieldPath: string[]) { + if (parentFieldPath.length === 0) { + return [...existingFields, newField()]; + } + + const fields = [...existingFields]; + + const indexToAddFieldTo = fields.findIndex((field: NodeField) => { + if (typeof field.id !== 'object') { + throw new Error('Invalid field to add to'); + } + + return stringArrayCompare(field.id, parentFieldPath); + }); + + if (indexToAddFieldTo === -1) { + throw new Error('Field to add to not found'); + } + + fields.splice(indexToAddFieldTo + 1, 0, newField(parentFieldPath)); + + return fields; +} + +export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { + const [nodes, setNodes] = useState(context.args.nodes); + + const onFieldClick = useCallback( + ( + event: ReactMouseEvent, + params: { + nodeId: string; + id: FieldId; + }, + ) => { + setNodes(nodes => + nodes.map(node => ({ + ...node, + fields: node.fields.map(field => ({ + ...field, + selected: + params.nodeId === node.id && + !!field.id && + typeof field.id !== 'string' && + typeof params.id !== 'string' && + stringArrayCompare(params.id, field.id), + })), + })), + ); + }, + [], + ); + + const onAddFieldToNodeClick = useCallback((event: ReactMouseEvent, nodeId: string) => { + setNodes(nodes => + nodes.map(node => + node.id !== nodeId + ? node + : { + ...node, + fields: [...node.fields, newField()], + }, + ), + ); + }, []); + + const onAddFieldToObjectFieldClick = useCallback( + (event: ReactMouseEvent, nodeId: string, parentFieldPath: string[]) => { + setNodes(nodes => + nodes.map(node => + node.id === nodeId + ? { + ...node, + fields: addFieldToNode(node.fields, parentFieldPath), + } + : node, + ), + ); + }, + [], + ); + + return Story({ + ...context, + args: { + ...context.args, + nodes, + onFieldClick, + onAddFieldToNodeClick, + onAddFieldToObjectFieldClick, + }, + }); +}; diff --git a/src/mocks/decorators/diagram-selectable-fields.decorator.tsx b/src/mocks/decorators/diagram-selectable-fields.decorator.tsx deleted file mode 100644 index 4d4e0bf..0000000 --- a/src/mocks/decorators/diagram-selectable-fields.decorator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useState, MouseEvent as ReactMouseEvent } from 'react'; -import { Decorator } from '@storybook/react'; - -import { DiagramProps, FieldId, NodeProps } from '@/types'; - -function stringArrayCompare(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - if (a === b) return true; - - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -export const DiagramSelectableFieldsDecorator: Decorator = (Story, context) => { - const [nodes, setNodes] = useState(context.args.nodes); - - const onFieldClick = useCallback( - ( - event: ReactMouseEvent, - params: { - nodeId: string; - id: FieldId; - }, - ) => { - setNodes( - nodes.map(node => ({ - ...node, - fields: node.fields.map(field => ({ - ...field, - selected: - params.nodeId === node.id && - !!field.id && - typeof field.id !== 'string' && - typeof params.id !== 'string' && - stringArrayCompare(params.id, field.id), - })), - })), - ); - }, - [nodes], - ); - - return Story({ - ...context, - args: { - ...context.args, - nodes, - onFieldClick, - }, - }); -}; diff --git a/src/styles/theme-dark.ts b/src/styles/theme-dark.ts index 04a63b5..1ab2946 100644 --- a/src/styles/theme-dark.ts +++ b/src/styles/theme-dark.ts @@ -1,5 +1,6 @@ import { Theme } from '@emotion/react'; import { palette } from '@leafygreen-ui/palette'; +import { color } from '@leafygreen-ui/tokens'; import { hexToRgb } from '@/styles/utils'; @@ -31,5 +32,7 @@ export const DARK_THEME: Theme = { disabledHeader: palette.gray.dark3, disabledColor: palette.gray.dark1, headerIcon: palette.gray.light2, + fieldIconButton: color.dark.icon.primary.default, + fieldIconButtonHoverBackground: hexToRgb(palette.gray.light2, 0.1), }, }; diff --git a/src/styles/theme-light.ts b/src/styles/theme-light.ts index 472c30d..3e3b1cd 100644 --- a/src/styles/theme-light.ts +++ b/src/styles/theme-light.ts @@ -1,5 +1,6 @@ import { Theme } from '@emotion/react'; import { palette } from '@leafygreen-ui/palette'; +import { color } from '@leafygreen-ui/tokens'; import { hexToRgb } from '@/styles/utils'; @@ -29,5 +30,7 @@ export const LIGHT_THEME: Theme = { disabledHeader: palette.gray.light3, disabledColor: palette.gray.light1, headerIcon: palette.gray.dark1, + fieldIconButton: color.light.icon.primary.default, + fieldIconButtonHoverBackground: hexToRgb(palette.gray.dark2, 0.1), }, }; diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 3726c70..1ddf885 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -27,6 +27,8 @@ declare module '@emotion/react' { disabledHeader: string; disabledColor: string; headerIcon: string; + fieldIconButton: string; + fieldIconButtonHoverBackground: string; }; } } diff --git a/src/types/component-props.ts b/src/types/component-props.ts index 0d320d6..a5c1377 100644 --- a/src/types/component-props.ts +++ b/src/types/component-props.ts @@ -20,6 +20,16 @@ export type OnFieldClickHandler = ( }, ) => void; +/** + * Called when the button to add a new field is clicked on a node. + */ +export type OnAddFieldToNodeClickHandler = (event: ReactMouseEvent, nodeId: string) => void; + +/** + * Called when the button to add a new field is clicked on an object type field in a node. + */ +export type OnAddFieldToObjectFieldClickHandler = (event: ReactMouseEvent, nodeId: string, fieldPath: string[]) => void; + /** * Called when the canvas (pane) is clicked. */ @@ -164,6 +174,16 @@ export interface DiagramProps { */ onFieldClick?: OnFieldClickHandler; + /** + * Callback when the user clicks the button to add a new field to a node. + */ + onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; + + /** + * Callback when the user clicks to add a new field to an object type field in a node. + */ + onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; + /** * Whether the diagram should pan when dragging elements. */ diff --git a/src/types/internal.ts b/src/types/internal.ts index a68de55..3113ea2 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -4,7 +4,6 @@ import { NodeBorderVariant, NodeField } from '@/types/node'; import { EdgeProps } from '@/types/edge'; export type NodeData = { - actions?: React.ReactNode; title: string; disabled?: boolean; fields: NodeField[]; diff --git a/src/types/node.ts b/src/types/node.ts index 7cba025..4233ad7 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -143,11 +143,6 @@ export interface NodeField { */ name: string; - /** - * Optional custom rendering for the field name. If not provided, `name` will be used. - */ - renderName?: React.ReactNode; - /** * Unique identifier for the field. Passed in field click events. * Defaults to `name` when not supplied. diff --git a/src/utilities/convert-nodes.test.ts b/src/utilities/convert-nodes.test.ts index 1564667..0c9c7fc 100644 --- a/src/utilities/convert-nodes.test.ts +++ b/src/utilities/convert-nodes.test.ts @@ -74,7 +74,6 @@ describe('convert-nodes', () => { it('Should convert node props to internal node', () => { const node = { id: 'node-1', - actions: 'pineapple', type: 'table' as const, position: { x: 100, y: 200 }, title: 'some-title', @@ -88,7 +87,6 @@ describe('convert-nodes', () => { position: { x: 100, y: 200 }, connectable: false, data: { - actions: 'pineapple', title: 'some-title', fields: [], borderVariant: undefined, @@ -191,7 +189,7 @@ describe('convert-nodes', () => { data: { fields: [ { name: 'employeeId', type: 'objectId', glyphs: ['key'] }, - { name: 'employeeDetail', type: '{}' }, + { name: 'employeeDetail', type: 'object' }, { name: 'firstName', type: 'string', depth: 1 }, { name: 'lastName', type: 'string', depth: 1 }, ], diff --git a/src/utilities/convert-nodes.ts b/src/utilities/convert-nodes.ts index 8c481d3..23bda7b 100644 --- a/src/utilities/convert-nodes.ts +++ b/src/utilities/convert-nodes.ts @@ -15,12 +15,11 @@ export const convertToExternalNodes = (nodes: InternalNode[]): NodeProps[] => { }; export const convertToInternalNode = (node: NodeProps): InternalNode => { - const { actions, title, fields, borderVariant, disabled, connectable, ...rest } = node; + const { title, fields, borderVariant, disabled, connectable, ...rest } = node; return { ...rest, connectable: connectable ?? false, data: { - actions, title, disabled, fields, diff --git a/yarn.lock b/yarn.lock index a895a11..0f74007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1051,16 +1051,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/emotion@npm:^5.0.2": - version: 5.0.2 - resolution: "@leafygreen-ui/emotion@npm:5.0.2" - dependencies: - "@emotion/css": "npm:^11.1.3" - "@emotion/server": "npm:^11.4.0" - checksum: 10c0/e70f3a89086a655952fef7bdb80593a0ed808b45b88b337b7a36a9313e60aa846e137a9bfa037fcff96512662e24051fc1c8630969f38b7e69c0559ac354aff1 - languageName: node - linkType: hard - "@leafygreen-ui/hooks@npm:^9.1.1": version: 9.1.1 resolution: "@leafygreen-ui/hooks@npm:9.1.1" @@ -1104,17 +1094,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/lib@npm:^15.3.0": - version: 15.3.0 - resolution: "@leafygreen-ui/lib@npm:15.3.0" - dependencies: - lodash: "npm:^4.17.21" - peerDependencies: - react: ^17.0.0 || ^18.0.0 - checksum: 10c0/f8b9f37ecfbcc652c8503183c30f6c5a317798482c67a310af32b9cd799590f53d24992508ff6be4dcc18a4fbb686c65b8ee23ad9e586304d6ef0d55761f01c8 - languageName: node - linkType: hard - "@leafygreen-ui/palette@npm:^5.0.0": version: 5.0.0 resolution: "@leafygreen-ui/palette@npm:5.0.0" @@ -1122,13 +1101,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/palette@npm:^5.0.2": - version: 5.0.2 - resolution: "@leafygreen-ui/palette@npm:5.0.2" - checksum: 10c0/4ba4ad32098bb7b6b790b982450c8859c867667534e5ebc332bb06c146b136e1a00e4343954fab8a9e0b6e94809876634006937dc18d56ab94af1f909938ba4e - languageName: node - linkType: hard - "@leafygreen-ui/polymorphic@npm:^3.0.3": version: 3.0.3 resolution: "@leafygreen-ui/polymorphic@npm:3.0.3" @@ -1140,14 +1112,14 @@ __metadata: linkType: hard "@leafygreen-ui/tokens@npm:^3.1.2, @leafygreen-ui/tokens@npm:^3.2.0, @leafygreen-ui/tokens@npm:^3.2.1": - version: 3.2.4 - resolution: "@leafygreen-ui/tokens@npm:3.2.4" + version: 3.2.1 + resolution: "@leafygreen-ui/tokens@npm:3.2.1" dependencies: - "@leafygreen-ui/emotion": "npm:^5.0.2" - "@leafygreen-ui/lib": "npm:^15.3.0" - "@leafygreen-ui/palette": "npm:^5.0.2" + "@leafygreen-ui/emotion": "npm:^5.0.0" + "@leafygreen-ui/lib": "npm:^15.2.0" + "@leafygreen-ui/palette": "npm:^5.0.0" polished: "npm:^4.2.2" - checksum: 10c0/bf3e063004ed7671771d11da9196ba9025f38548086025c431f47e9033595bdc880d50d36d2fd63ac0dd453976fccedbdf1527a99a58094965591b9f099b6071 + checksum: 10c0/5719a0c8ad60d886437407a303444c6d0925287a75140d35ad86c428ff6a612ddfea77bd4f7d181ea2cc74b873547ec3921a98855ab753fbe7150257e8d789bc languageName: node linkType: hard @@ -1241,6 +1213,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^12.1.5" "@testing-library/react-hooks": "npm:^8.0.1" + "@testing-library/user-event": "npm:^14.6.1" "@types/d3-path": "npm:^3" "@types/jest": "npm:30.0.0" "@types/node": "npm:22.15.29"