From f5f14094e8e234656e862a9f930216afca5c920e Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 11 Sep 2025 09:42:54 -0400 Subject: [PATCH 1/9] feat/COMPASS-9792: Add renderName for custom field name rendering --- src/components/field/field.tsx | 4 +++- src/components/node/node.stories.tsx | 27 +++++++++++++++++++++++++++ src/components/node/node.tsx | 4 +++- src/types/node.ts | 5 +++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index 8f9ce3d..594fe70 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -145,6 +145,7 @@ export const Field = ({ selectedGroupHeight = 0, previewGroupArea, glyphSize = LGSpacing[300], + renderName, spacing = 0, selectable = false, selected = false, @@ -168,6 +169,7 @@ export const Field = ({ const fieldSelectionProps = useMemo(() => { return selectable && fieldProps ? { + 'data-testid': `selectable-field-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`, selectable: true, onClick: (event: ReactMouseEvent) => fieldProps.onClick(event, { id, nodeId }), } @@ -213,7 +215,7 @@ export const Field = ({ <> - {name} + {renderName || name} {type} diff --git a/src/components/node/node.stories.tsx b/src/components/node/node.stories.tsx index 85a34da..2aa0fa1 100644 --- a/src/components/node/node.stories.tsx +++ b/src/components/node/node.stories.tsx @@ -246,6 +246,33 @@ 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, diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index 5111fea..459e2cf 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -66,7 +66,8 @@ const NodeHeader = styled.div<{ background?: string }>` line-height: 20px; font-weight: bold; min-height: ${DEFAULT_NODE_HEADER_HEIGHT}px; - padding: 0px ${spacing[400]}px 0px ${spacing[200]}px; + padding: 0px; + padding-left: ${spacing[200]}px; background: ${props => props.background}; `; @@ -79,6 +80,7 @@ const NodeHeaderIcon = styled.div` export const NodeHeaderTitle = styled.div` overflow-wrap: break-word; min-width: 0; + margin-right: ${spacing[200]}px; `; const NodeHandle = styled(Handle)<{ ['z-index']?: number }>` diff --git a/src/types/node.ts b/src/types/node.ts index 4233ad7..7cba025 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -143,6 +143,11 @@ 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. From 4e068ac190f3a36ab8dc24bc271bc3f99c65da89 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 19 Sep 2025 19:16:54 -0400 Subject: [PATCH 2/9] refactor: add editable node interactions, remove generic node actions --- package.json | 1 + src/components/canvas/canvas.tsx | 12 ++- src/components/diagram.stories.tsx | 6 +- src/components/field/field-list.tsx | 5 +- src/components/field/field.tsx | 10 +- src/components/icons/plus-with-square.tsx | 17 +++ src/components/node/node.stories.tsx | 17 --- src/components/node/node.tsx | 33 +++++- .../use-editable-diagram-interactions.tsx | 59 +++++++++++ ...agram-editable-interactions.decorator.tsx} | 27 ++++- src/types/component-props.ts | 20 ++++ src/types/internal.ts | 1 - yarn.lock | 100 ++++++++++++++++++ 13 files changed, 272 insertions(+), 36 deletions(-) create mode 100644 src/components/icons/plus-with-square.tsx create mode 100644 src/hooks/use-editable-diagram-interactions.tsx rename src/mocks/decorators/{diagram-selectable-fields.decorator.tsx => diagram-editable-interactions.decorator.tsx} (66%) diff --git a/package.json b/package.json index 4711d72..2c4679d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@leafygreen-ui/icon": "^14.3.0", + "@leafygreen-ui/icon-button": "^16.0.2", "@leafygreen-ui/leafygreen-provider": "^5.0.2", "@leafygreen-ui/palette": "^5.0.0", "@leafygreen-ui/tokens": "^3.2.1", 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.tsx b/src/components/field/field.tsx index 594fe70..ef138fb 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -11,7 +11,7 @@ import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constan import { FieldDepth } from '@/components/field/field-depth'; 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]; @@ -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: ReactMouseEvent) => onClickField(event, { id, nodeId }), } : undefined; - }, [fieldProps, selectable, id, nodeId]); + }, [onClickField, selectable, id, nodeId]); const getTextColor = () => { if (isDisabled) { diff --git a/src/components/icons/plus-with-square.tsx b/src/components/icons/plus-with-square.tsx new file mode 100644 index 0000000..7f0157a --- /dev/null +++ b/src/components/icons/plus-with-square.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { useTheme } from '@emotion/react'; + +export const PlusWithSquare: React.FunctionComponent = () => { + const theme = useTheme(); + + const strokeColor = useMemo(() => theme.node.headerIcon, [theme]); + + return ( + + + + ); +}; diff --git a/src/components/node/node.stories.tsx b/src/components/node/node.stories.tsx index 2aa0fa1..18eecc6 100644 --- a/src/components/node/node.stories.tsx +++ b/src/components/node/node.stories.tsx @@ -510,23 +510,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.tsx b/src/components/node/node.tsx index 459e2cf..878b7cd 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 IconButton from '@leafygreen-ui/icon-button'; +import { useMemo, 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'; const NodeZoomedOut = styled.div` display: flex; @@ -99,18 +102,44 @@ const NodeWithFields = styled.div<{ visibility: string }>` visibility: ${props => props.visibility}; `; +const AddNewFieldIconButtonButton = styled(IconButton)` + margin-left: auto; + margin-right: ${spacing[100]}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 } = useEditableDiagramInteractions(); + + const actions = useMemo(() => { + if (!onClickAddFieldToNode) { + return; + } + + return ( + ) => { + event.stopPropagation(); + onClickAddFieldToNode(event, id); + }} + title="Add Field" + > + + + ); + }, [onClickAddFieldToNode, id]); + const getAccent = () => { if (disabled && !isHovering) { return theme.node.disabledAccent; 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/mocks/decorators/diagram-selectable-fields.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx similarity index 66% rename from src/mocks/decorators/diagram-selectable-fields.decorator.tsx rename to src/mocks/decorators/diagram-editable-interactions.decorator.tsx index 4d4e0bf..1e6921c 100644 --- a/src/mocks/decorators/diagram-selectable-fields.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -13,7 +13,14 @@ function stringArrayCompare(a: string[], b: string[]): boolean { return true; } -export const DiagramSelectableFieldsDecorator: Decorator = (Story, context) => { +function newField() { + return { + name: `new-field-${Math.floor(Math.random() * 100_000)}`, + type: 'string', + }; +} + +export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { const [nodes, setNodes] = useState(context.args.nodes); const onFieldClick = useCallback( @@ -24,7 +31,7 @@ export const DiagramSelectableFieldsDecorator: Decorator = (Story, id: FieldId; }, ) => { - setNodes( + setNodes(nodes => nodes.map(node => ({ ...node, fields: node.fields.map(field => ({ @@ -39,15 +46,29 @@ export const DiagramSelectableFieldsDecorator: Decorator = (Story, })), ); }, - [nodes], + [], ); + const onAddFieldToNodeClick = useCallback((event: ReactMouseEvent, nodeId: string) => { + setNodes(nodes => + nodes.map(node => + node.id !== nodeId + ? node + : { + ...node, + fields: [...node.fields, newField()], + }, + ), + ); + }, []); + return Story({ ...context, args: { ...context.args, nodes, onFieldClick, + onAddFieldToNodeClick, }, }); }; diff --git a/src/types/component-props.ts b/src/types/component-props.ts index 0d320d6..a38dd53 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) => 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/yarn.lock b/yarn.lock index c2fc6ed..611af4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1115,6 +1115,27 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/a11y@npm:^2.0.7": + version: 2.0.7 + resolution: "@leafygreen-ui/a11y@npm:2.0.7" + dependencies: + "@leafygreen-ui/emotion": "npm:^4.1.1" + "@leafygreen-ui/hooks": "npm:^8.4.1" + "@leafygreen-ui/lib": "npm:^14.2.0" + checksum: 10c0/dfa1e05e0d4814d4be4d4796da8850ea0ad197d569ec1a38eaa0f23fb8e2e270996f902e02283fd04cc3020b12dee10755c239ccddf7f0dc43a4a38801100a81 + languageName: node + linkType: hard + +"@leafygreen-ui/emotion@npm:^4.1.1": + version: 4.1.1 + resolution: "@leafygreen-ui/emotion@npm:4.1.1" + dependencies: + "@emotion/css": "npm:^11.1.3" + "@emotion/server": "npm:^11.4.0" + checksum: 10c0/e580785982c28e7b91141ee6531764eb7c01e8d3f9613d1efec78e28390f2629fe1b8b532e92bca47a9569bcf6ba037d72951bc4f89335c56b4f412ce0bfb2b8 + languageName: node + linkType: hard + "@leafygreen-ui/emotion@npm:^5.0.0": version: 5.0.0 resolution: "@leafygreen-ui/emotion@npm:5.0.0" @@ -1125,6 +1146,16 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/hooks@npm:^8.4.1": + version: 8.4.1 + resolution: "@leafygreen-ui/hooks@npm:8.4.1" + dependencies: + "@leafygreen-ui/lib": "npm:^14.2.0" + lodash: "npm:^4.17.21" + checksum: 10c0/e038b85f74c2226a5f8ae00c102470b66ebe3db4befe9eb57d31852659f4f8ce19c3646bb5e82995106a337cc54fa39904065a2f5f4d5a2f5f2a895f7ccfdbdf + languageName: node + linkType: hard + "@leafygreen-ui/hooks@npm:^9.1.1": version: 9.1.1 resolution: "@leafygreen-ui/hooks@npm:9.1.1" @@ -1136,6 +1167,34 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/icon-button@npm:^16.0.2": + version: 16.0.12 + resolution: "@leafygreen-ui/icon-button@npm:16.0.12" + dependencies: + "@leafygreen-ui/a11y": "npm:^2.0.7" + "@leafygreen-ui/emotion": "npm:^4.1.1" + "@leafygreen-ui/icon": "npm:^13.4.0" + "@leafygreen-ui/lib": "npm:^14.2.0" + "@leafygreen-ui/palette": "npm:^4.1.4" + "@leafygreen-ui/polymorphic": "npm:^2.0.9" + "@leafygreen-ui/tokens": "npm:^2.12.2" + polished: "npm:^4.2.2" + peerDependencies: + "@leafygreen-ui/leafygreen-provider": ^4.0.7 + checksum: 10c0/52fd17f5567242a90ddca97897b6a9590589ebb79391fc669974dc92b97ff6419e0ab9e8609f09f9ed5f3c75492b5d61585149b6ff56dddc0c3b916c08c94f6a + languageName: node + linkType: hard + +"@leafygreen-ui/icon@npm:^13.4.0": + version: 13.4.0 + resolution: "@leafygreen-ui/icon@npm:13.4.0" + dependencies: + "@leafygreen-ui/emotion": "npm:^4.1.1" + lodash: "npm:^4.17.21" + checksum: 10c0/77921266df0a8f58383976e0a2f8dfb225b7e26661737aa83d755218d589e41ede370a090994acd7b5956f91ca5125cb00f46375284579350f8e4d9e110e45c2 + languageName: node + linkType: hard + "@leafygreen-ui/icon@npm:^14.1.0, @leafygreen-ui/icon@npm:^14.3.0": version: 14.3.0 resolution: "@leafygreen-ui/icon@npm:14.3.0" @@ -1157,6 +1216,17 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/lib@npm:^14.2.0": + version: 14.2.0 + resolution: "@leafygreen-ui/lib@npm:14.2.0" + dependencies: + lodash: "npm:^4.17.21" + peerDependencies: + react: ^17.0.0 || ^18.0.0 + checksum: 10c0/7622c730c689cbf250cf2348682d164a463b969aeea001d082ef06990fa5f419c1595a2c52a33686e8ad5561d9d23f7b8ef772a0bfd6c41c70595b32cee7c1e3 + languageName: node + linkType: hard + "@leafygreen-ui/lib@npm:^15.2.0": version: 15.2.0 resolution: "@leafygreen-ui/lib@npm:15.2.0" @@ -1168,6 +1238,13 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/palette@npm:^4.1.4": + version: 4.1.4 + resolution: "@leafygreen-ui/palette@npm:4.1.4" + checksum: 10c0/ba532e31cb0d18886d69e08a9b005653e42d878e67b9e19b7434f30b36891b3a504309910a3786893d394396070a68ddca3553e95fb47ca0f755dc59959cfd43 + languageName: node + linkType: hard + "@leafygreen-ui/palette@npm:^5.0.0": version: 5.0.0 resolution: "@leafygreen-ui/palette@npm:5.0.0" @@ -1175,6 +1252,16 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/polymorphic@npm:^2.0.9": + version: 2.0.9 + resolution: "@leafygreen-ui/polymorphic@npm:2.0.9" + dependencies: + "@leafygreen-ui/lib": "npm:^14.2.0" + lodash: "npm:^4.17.21" + checksum: 10c0/a67ef96e04f4c0589ce30ac7a3bcd2c9724a0b32d5327974a742109d648faa0947272ab316f5900ddcdda9c04e0deef52ddd8d5a956b359c0d6107dc325c4b45 + languageName: node + linkType: hard + "@leafygreen-ui/polymorphic@npm:^3.0.3": version: 3.0.3 resolution: "@leafygreen-ui/polymorphic@npm:3.0.3" @@ -1185,6 +1272,18 @@ __metadata: languageName: node linkType: hard +"@leafygreen-ui/tokens@npm:^2.12.2": + version: 2.12.2 + resolution: "@leafygreen-ui/tokens@npm:2.12.2" + dependencies: + "@leafygreen-ui/emotion": "npm:^4.1.1" + "@leafygreen-ui/lib": "npm:^14.2.0" + "@leafygreen-ui/palette": "npm:^4.1.4" + polished: "npm:^4.2.2" + checksum: 10c0/8797ec573903c2a6925f82c2780a66955e7d79e2009b7ec79a179fecacbadce59c18bae06375951a5aaf2d4ec45807b2d52da8b041dbca266acd6a5dce8ad6b1 + languageName: node + 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.1 resolution: "@leafygreen-ui/tokens@npm:3.2.1" @@ -1276,6 +1375,7 @@ __metadata: "@eslint/eslintrc": "npm:^3.3.0" "@eslint/js": "npm:^9.22.0" "@leafygreen-ui/icon": "npm:^14.3.0" + "@leafygreen-ui/icon-button": "npm:^16.0.2" "@leafygreen-ui/leafygreen-provider": "npm:^5.0.2" "@leafygreen-ui/palette": "npm:^5.0.0" "@leafygreen-ui/tokens": "npm:^3.2.1" From 4e6d3c81e486239e21961cf78671c03fc71d73ab Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Mon, 22 Sep 2025 14:28:57 -0400 Subject: [PATCH 3/9] refactor: add handler and button to add fields to objects --- src/components/field/field.test.tsx | 38 +++++- src/components/field/field.tsx | 40 ++++++- src/components/field/object-field-type.tsx | 113 ++++++++++++++++++ src/components/node/node.stories.tsx | 33 +---- src/components/node/node.test.tsx | 6 +- src/hooks/use-field-selection.tsx | 45 ------- src/mocks/datasets/nodes.tsx | 2 +- ...iagram-editable-interactions.decorator.tsx | 53 +++++++- src/styles/theme-dark.ts | 3 + src/styles/theme-light.ts | 3 + src/styles/theme.ts | 3 + src/types/component-props.ts | 2 +- src/types/node.ts | 5 - src/utilities/convert-nodes.test.ts | 4 +- src/utilities/convert-nodes.ts | 3 +- 15 files changed, 254 insertions(+), 99 deletions(-) create mode 100644 src/components/field/object-field-type.tsx delete mode 100644 src/hooks/use-field-selection.tsx diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 8d4cf9c..a27095b 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -4,12 +4,26 @@ import { ComponentProps } from 'react'; 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 noop = () => { + /* no operation */ +}; + +const FieldWithEditableInteractions = (props: React.ComponentProps) => ( + + + ); describe('field', () => { @@ -30,6 +44,24 @@ 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('object')).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', () => { + 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'); + }); + }); describe('With glyphs', () => { it('With disabled', () => { render(); diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index ef138fb..1f0db18 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -9,6 +9,7 @@ import { MouseEvent as ReactMouseEvent, 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 { ObjectFieldType } from '@/components/field/object-field-type'; import { NodeField, NodeGlyph, NodeType } from '@/types'; import { PreviewGroupArea } from '@/utilities/get-preview-group-area'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; @@ -132,6 +133,38 @@ interface Props extends NodeField { selectedGroupHeight?: number; } +function FieldTypeContent({ + type, + nodeId, + id, +}: { + id: string | string[]; +} & Pick) { + const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions(); + + const onClickAddFieldToObject = useMemo( + () => + _onClickAddFieldToObjectField && Array.isArray(id) + ? (event: React.MouseEvent) => { + event.stopPropagation(); + _onClickAddFieldToObjectField(event, nodeId, id); + } + : undefined, + [_onClickAddFieldToObjectField, nodeId, id], + ); + + if (type === 'object' && !!onClickAddFieldToObject) { + return ( + + ); + } + + return <>{type}; +} + export const Field = ({ hoverVariant, isHovering = false, @@ -145,7 +178,6 @@ export const Field = ({ selectedGroupHeight = 0, previewGroupArea, glyphSize = LGSpacing[300], - renderName, spacing = 0, selectable = false, selected = false, @@ -215,9 +247,11 @@ export const Field = ({ <> - {renderName || name} + {name} - {type} + + + ); diff --git a/src/components/field/object-field-type.tsx b/src/components/field/object-field-type.tsx new file mode 100644 index 0000000..ea4f8a3 --- /dev/null +++ b/src/components/field/object-field-type.tsx @@ -0,0 +1,113 @@ +import { useCallback } from 'react'; +import styled from '@emotion/styled'; +import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; +import { palette } from '@leafygreen-ui/palette'; + +import { PlusWithSquare } from '@/components/icons/plus-with-square'; + +const ObjectTypeContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + line-height: 20px; +`; + +const AddNestedFieldIconButton = 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: ${props => props.theme.node.fieldIconButtonHover}; + + &::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; + } + } +`; + +type ObjectFieldTypeProps = { + onClickAddFieldToObject: (event: React.MouseEvent) => void; + ['data-testid']: string; +}; + +export const ObjectFieldType = ({ + 'data-testid': dataTestId, + onClickAddFieldToObject: _onClickAddFieldToObject, +}: ObjectFieldTypeProps) => { + const onClickAddFieldToObject = useCallback( + (event: React.MouseEvent) => { + // Don't click on the field element. + event.stopPropagation(); + _onClickAddFieldToObject(event); + }, + [_onClickAddFieldToObject], + ); + + return ( + + {'{}'} + + + + + ); +}; diff --git a/src/components/node/node.stories.tsx b/src/components/node/node.stories.tsx index 18eecc6..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, diff --git a/src/components/node/node.test.tsx b/src/components/node/node.test.tsx index f00c78b..5837c05 100644 --- a/src/components/node/node.test.tsx +++ b/src/components/node/node.test.tsx @@ -4,12 +4,12 @@ import { NodeProps, useViewport } from '@xyflow/react'; 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) => ( - + - + ); vi.mock('@xyflow/react', async () => { 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 index 1e6921c..47c2204 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, MouseEvent as ReactMouseEvent } from 'react'; import { Decorator } from '@storybook/react'; -import { DiagramProps, FieldId, NodeProps } from '@/types'; +import { DiagramProps, FieldId, NodeField, NodeProps } from '@/types'; function stringArrayCompare(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; @@ -13,11 +13,41 @@ function stringArrayCompare(a: string[], b: string[]): boolean { return true; } -function newField() { +const newField = (parentFieldPath?: string[]) => { + const name = `newField${Math.floor(Math.random() * 100_000)}`; + return { - name: `new-field-${Math.floor(Math.random() * 100_000)}`, + name, type: 'string', + selectable: true, + depth: parentFieldPath ? parentFieldPath.length : 0, + id: parentFieldPath ? [...parentFieldPath, name] : [name], }; +}; + +// Note: This currently only adds fields to one or no level, not nested levels. +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 === 0) { + throw new Error('Field to add to not found'); + } + + fields.splice(indexToAddFieldTo + 1, 0, newField(parentFieldPath)); + + return fields; } export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { @@ -62,6 +92,22 @@ export const DiagramEditableInteractionsDecorator: Decorator = (St ); }, []); + 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: { @@ -69,6 +115,7 @@ export const DiagramEditableInteractionsDecorator: Decorator = (St nodes, onFieldClick, onAddFieldToNodeClick, + onAddFieldToObjectFieldClick, }, }); }; diff --git a/src/styles/theme-dark.ts b/src/styles/theme-dark.ts index 04a63b5..d725316 100644 --- a/src/styles/theme-dark.ts +++ b/src/styles/theme-dark.ts @@ -31,5 +31,8 @@ export const DARK_THEME: Theme = { disabledHeader: palette.gray.dark3, disabledColor: palette.gray.dark1, headerIcon: palette.gray.light2, + fieldIconButton: palette.gray.light1, + fieldIconButtonHover: palette.black, + fieldIconButtonHoverBackground: hexToRgb(palette.gray.light2, 0.1), }, }; diff --git a/src/styles/theme-light.ts b/src/styles/theme-light.ts index 472c30d..b62614e 100644 --- a/src/styles/theme-light.ts +++ b/src/styles/theme-light.ts @@ -29,5 +29,8 @@ export const LIGHT_THEME: Theme = { disabledHeader: palette.gray.light3, disabledColor: palette.gray.light1, headerIcon: palette.gray.dark1, + fieldIconButton: palette.gray.dark1, + fieldIconButtonHover: palette.black, + fieldIconButtonHoverBackground: hexToRgb(palette.gray.dark2, 0.1), }, }; diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 3726c70..5307b4d 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -27,6 +27,9 @@ declare module '@emotion/react' { disabledHeader: string; disabledColor: string; headerIcon: string; + fieldIconButton: string; + fieldIconButtonHover: string; + fieldIconButtonHoverBackground: string; }; } } diff --git a/src/types/component-props.ts b/src/types/component-props.ts index a38dd53..a5c1377 100644 --- a/src/types/component-props.ts +++ b/src/types/component-props.ts @@ -28,7 +28,7 @@ export type OnAddFieldToNodeClickHandler = (event: ReactMouseEvent, nodeId: stri /** * 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) => void; +export type OnAddFieldToObjectFieldClickHandler = (event: ReactMouseEvent, nodeId: string, fieldPath: string[]) => void; /** * Called when the canvas (pane) is clicked. 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, From 725d591aaec571eb521d3c4df850cecd89b472e5 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Mon, 22 Sep 2025 14:45:49 -0400 Subject: [PATCH 4/9] fixup: update comment and index --- .../decorators/diagram-editable-interactions.decorator.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx index 47c2204..2442272 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -25,7 +25,6 @@ const newField = (parentFieldPath?: string[]) => { }; }; -// Note: This currently only adds fields to one or no level, not nested levels. function addFieldToNode(existingFields: NodeField[], parentFieldPath: string[]) { if (parentFieldPath.length === 0) { return [...existingFields, newField()]; @@ -41,7 +40,7 @@ function addFieldToNode(existingFields: NodeField[], parentFieldPath: string[]) return stringArrayCompare(field.id, parentFieldPath); }); - if (indexToAddFieldTo === 0) { + if (indexToAddFieldTo === -1) { throw new Error('Field to add to not found'); } From 16fa8a8f6df8b1ab738a68314920ebd9d5f5eab5 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Tue, 23 Sep 2025 11:14:17 -0400 Subject: [PATCH 5/9] fixup: review comments, cleanup, add tests --- package.json | 1 + src/components/field/field-type-content.tsx | 38 ++++++++++++++++ src/components/field/field.test.tsx | 48 +++++++++++++++------ src/components/field/field.tsx | 38 ++-------------- src/components/field/object-field-type.tsx | 2 +- src/components/icons/plus-with-square.tsx | 5 +-- src/components/node/node.test.tsx | 24 ++++++++++- src/components/node/node.tsx | 35 ++++++--------- src/styles/theme-dark.ts | 4 +- src/styles/theme-light.ts | 4 +- src/styles/theme.ts | 1 - yarn.lock | 1 + 12 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 src/components/field/field-type-content.tsx diff --git a/package.json b/package.json index eec4d54..49388a5 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,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/field/field-type-content.tsx b/src/components/field/field-type-content.tsx new file mode 100644 index 0000000..65eb3e1 --- /dev/null +++ b/src/components/field/field-type-content.tsx @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { ObjectFieldType } from '@/components/field/object-field-type'; +import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; + +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) => { + event.stopPropagation(); + _onClickAddFieldToObjectField(event, nodeId, Array.isArray(id) ? id : [id]); + } + : undefined, + [_onClickAddFieldToObjectField, nodeId, id], + ); + + if (type === 'object' && !!onClickAddFieldToObject) { + return ( + + ); + } + + return <>{type}; +}; diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index a27095b..1310673 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -1,5 +1,6 @@ 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'; @@ -12,20 +13,19 @@ const Field = (props: React.ComponentProps) => ( ); -const noop = () => { - /* no operation */ +const FieldWithEditableInteractions = ({ + onAddFieldToObjectFieldClick, + ...fieldProps +}: React.ComponentProps & { + onAddFieldToObjectFieldClick?: () => void; +}) => { + return ( + + + + ); }; -const FieldWithEditableInteractions = (props: React.ComponentProps) => ( - - - -); - describe('field', () => { const DEFAULT_PROPS: ComponentProps = { nodeType: 'collection', @@ -52,14 +52,34 @@ describe('field', () => { expect(button).not.toBeInTheDocument(); }); describe('With editable interactions supplied', () => { - it('Should have a button to add a field on an object type', () => { - render(); + 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 glyphs', () => { diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index 1f0db18..ec07894 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -4,12 +4,12 @@ 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 { ObjectFieldType } from '@/components/field/object-field-type'; +import { FieldTypeContent } from '@/components/field/field-type-content'; import { NodeField, NodeGlyph, NodeType } from '@/types'; import { PreviewGroupArea } from '@/utilities/get-preview-group-area'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; @@ -133,38 +133,6 @@ interface Props extends NodeField { selectedGroupHeight?: number; } -function FieldTypeContent({ - type, - nodeId, - id, -}: { - id: string | string[]; -} & Pick) { - const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions(); - - const onClickAddFieldToObject = useMemo( - () => - _onClickAddFieldToObjectField && Array.isArray(id) - ? (event: React.MouseEvent) => { - event.stopPropagation(); - _onClickAddFieldToObjectField(event, nodeId, id); - } - : undefined, - [_onClickAddFieldToObjectField, nodeId, id], - ); - - if (type === 'object' && !!onClickAddFieldToObject) { - return ( - - ); - } - - return <>{type}; -} - export const Field = ({ hoverVariant, isHovering = false, @@ -203,7 +171,7 @@ export const Field = ({ ? { 'data-testid': `selectable-field-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`, selectable: true, - onClick: (event: ReactMouseEvent) => onClickField(event, { id, nodeId }), + onClick: (event: React.MouseEvent) => onClickField(event, { id, nodeId }), } : undefined; }, [onClickField, selectable, id, nodeId]); diff --git a/src/components/field/object-field-type.tsx b/src/components/field/object-field-type.tsx index ea4f8a3..c88a978 100644 --- a/src/components/field/object-field-type.tsx +++ b/src/components/field/object-field-type.tsx @@ -50,7 +50,7 @@ const AddNestedFieldIconButton = styled.button` &[data-hover='true'], &:focus-visible, &[data-focus='true'] { - color: ${props => props.theme.node.fieldIconButtonHover}; + color: ${palette.black}; &::before { background-color: ${props => props.theme.node.fieldIconButtonHoverBackground}; diff --git a/src/components/icons/plus-with-square.tsx b/src/components/icons/plus-with-square.tsx index 7f0157a..5f15085 100644 --- a/src/components/icons/plus-with-square.tsx +++ b/src/components/icons/plus-with-square.tsx @@ -1,16 +1,13 @@ -import { useMemo } from 'react'; import { useTheme } from '@emotion/react'; export const PlusWithSquare: React.FunctionComponent = () => { const theme = useTheme(); - const strokeColor = useMemo(() => theme.node.headerIcon, [theme]); - return ( ); diff --git a/src/components/node/node.test.tsx b/src/components/node/node.test.tsx index 5837c05..f849541 100644 --- a/src/components/node/node.test.tsx +++ b/src/components/node/node.test.tsx @@ -1,13 +1,19 @@ 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 { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions'; -const Node = (props: React.ComponentProps) => ( - +const Node = ({ + onAddFieldToNodeClick, + ...props +}: React.ComponentProps & { + onAddFieldToNodeClick?: () => void; +}) => ( + ); @@ -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 878b7cd..b59d34a 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -4,7 +4,7 @@ import { fontFamilies, spacing } from '@leafygreen-ui/tokens'; import { useTheme } from '@emotion/react'; import Icon from '@leafygreen-ui/icon'; import IconButton from '@leafygreen-ui/icon-button'; -import { useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import { DEFAULT_NODE_HEADER_HEIGHT, ZOOM_THRESHOLD } from '@/utilities/constants'; import { InternalNode } from '@/types/internal'; @@ -119,26 +119,15 @@ export const Node = ({ const [isHovering, setHovering] = useState(false); - const { onClickAddFieldToNode } = useEditableDiagramInteractions(); + const { onClickAddFieldToNode: addFieldToNodeClickHandler } = useEditableDiagramInteractions(); - const actions = useMemo(() => { - if (!onClickAddFieldToNode) { - return; - } - - return ( - ) => { - event.stopPropagation(); - onClickAddFieldToNode(event, id); - }} - title="Add Field" - > - - - ); - }, [onClickAddFieldToNode, id]); + const onClickAddFieldToNode = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + addFieldToNodeClickHandler?.(event, id); + }, + [addFieldToNodeClickHandler, id], + ); const getAccent = () => { if (disabled && !isHovering) { @@ -214,7 +203,11 @@ export const Node = ({ {title} - {actions} + {addFieldToNodeClickHandler && ( + + + + )} diff --git a/src/styles/theme-dark.ts b/src/styles/theme-dark.ts index d725316..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,8 +32,7 @@ export const DARK_THEME: Theme = { disabledHeader: palette.gray.dark3, disabledColor: palette.gray.dark1, headerIcon: palette.gray.light2, - fieldIconButton: palette.gray.light1, - fieldIconButtonHover: palette.black, + 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 b62614e..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,8 +30,7 @@ export const LIGHT_THEME: Theme = { disabledHeader: palette.gray.light3, disabledColor: palette.gray.light1, headerIcon: palette.gray.dark1, - fieldIconButton: palette.gray.dark1, - fieldIconButtonHover: palette.black, + 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 5307b4d..1ddf885 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -28,7 +28,6 @@ declare module '@emotion/react' { disabledColor: string; headerIcon: string; fieldIconButton: string; - fieldIconButtonHover: string; fieldIconButtonHoverBackground: string; }; } diff --git a/yarn.lock b/yarn.lock index 85920fc..fedc9e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1387,6 +1387,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" From d8595a8e6ccc40012e08f23173f0ddb94f2933da Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Tue, 23 Sep 2025 13:14:25 -0400 Subject: [PATCH 6/9] fixup: remove object field type file, use field type content for it --- src/components/field/field-type-content.tsx | 95 +++++++++++++++- src/components/field/object-field-type.tsx | 113 -------------------- 2 files changed, 90 insertions(+), 118 deletions(-) delete mode 100644 src/components/field/object-field-type.tsx diff --git a/src/components/field/field-type-content.tsx b/src/components/field/field-type-content.tsx index 65eb3e1..69cc59e 100644 --- a/src/components/field/field-type-content.tsx +++ b/src/components/field/field-type-content.tsx @@ -1,7 +1,84 @@ import { useMemo } from 'react'; +import styled from '@emotion/styled'; +import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; +import { palette } from '@leafygreen-ui/palette'; -import { ObjectFieldType } from '@/components/field/object-field-type'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; +import { PlusWithSquare } from '@/components/icons/plus-with-square'; + +const ObjectTypeContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + line-height: 20px; +`; + +const AddNestedFieldIconButton = 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; + } + } +`; export const FieldTypeContent = ({ type, @@ -18,6 +95,7 @@ export const FieldTypeContent = ({ () => _onClickAddFieldToObjectField ? (event: React.MouseEvent) => { + // Don't click on the field element. event.stopPropagation(); _onClickAddFieldToObjectField(event, nodeId, Array.isArray(id) ? id : [id]); } @@ -27,10 +105,17 @@ export const FieldTypeContent = ({ if (type === 'object' && !!onClickAddFieldToObject) { return ( - + + {'{}'} + + + + ); } diff --git a/src/components/field/object-field-type.tsx b/src/components/field/object-field-type.tsx deleted file mode 100644 index c88a978..0000000 --- a/src/components/field/object-field-type.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback } from 'react'; -import styled from '@emotion/styled'; -import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; -import { palette } from '@leafygreen-ui/palette'; - -import { PlusWithSquare } from '@/components/icons/plus-with-square'; - -const ObjectTypeContainer = styled.div` - display: flex; - justify-content: flex-end; - align-items: center; - line-height: 20px; -`; - -const AddNestedFieldIconButton = 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; - } - } -`; - -type ObjectFieldTypeProps = { - onClickAddFieldToObject: (event: React.MouseEvent) => void; - ['data-testid']: string; -}; - -export const ObjectFieldType = ({ - 'data-testid': dataTestId, - onClickAddFieldToObject: _onClickAddFieldToObject, -}: ObjectFieldTypeProps) => { - const onClickAddFieldToObject = useCallback( - (event: React.MouseEvent) => { - // Don't click on the field element. - event.stopPropagation(); - _onClickAddFieldToObject(event); - }, - [_onClickAddFieldToObject], - ); - - return ( - - {'{}'} - - - - - ); -}; From be02b217f85b9be1ad878d4f79e0a15e25952ccd Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Wed, 24 Sep 2025 13:52:05 -0400 Subject: [PATCH 7/9] fixup: add array type --- src/components/field/field-type-content.tsx | 4 ++++ src/components/field/field.test.tsx | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/field/field-type-content.tsx b/src/components/field/field-type-content.tsx index 69cc59e..aeb1b4f 100644 --- a/src/components/field/field-type-content.tsx +++ b/src/components/field/field-type-content.tsx @@ -119,5 +119,9 @@ export const FieldTypeContent = ({ ); } + if (type === 'array') { + return '[]'; + } + return <>{type}; }; diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 1310673..9416578 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -82,6 +82,14 @@ describe('field', () => { 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(); From 2e2404da263c064f84a39bb9a3828a5c02ed5594 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Wed, 24 Sep 2025 23:24:18 -0400 Subject: [PATCH 8/9] fixup: remove icon-button from LG, use ours and share --- package.json | 1 - .../buttons/diagram-icon-button.tsx | 82 ++++++++++++++ src/components/field/field-type-content.tsx | 74 +------------ src/components/node/node.tsx | 6 +- yarn.lock | 100 ------------------ 5 files changed, 88 insertions(+), 175 deletions(-) create mode 100644 src/components/buttons/diagram-icon-button.tsx diff --git a/package.json b/package.json index 49388a5..0e71aeb 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@leafygreen-ui/icon": "^14.3.0", - "@leafygreen-ui/icon-button": "^16.0.2", "@leafygreen-ui/leafygreen-provider": "^5.0.2", "@leafygreen-ui/palette": "^5.0.0", "@leafygreen-ui/tokens": "^3.2.1", 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/field/field-type-content.tsx b/src/components/field/field-type-content.tsx index aeb1b4f..1e28fdc 100644 --- a/src/components/field/field-type-content.tsx +++ b/src/components/field/field-type-content.tsx @@ -1,10 +1,9 @@ import { useMemo } from 'react'; import styled from '@emotion/styled'; -import { spacing, transitionDuration } from '@leafygreen-ui/tokens'; -import { palette } from '@leafygreen-ui/palette'; 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; @@ -13,73 +12,6 @@ const ObjectTypeContainer = styled.div` line-height: 20px; `; -const AddNestedFieldIconButton = 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; - } - } -`; - export const FieldTypeContent = ({ type, nodeId, @@ -107,14 +39,14 @@ export const FieldTypeContent = ({ return ( {'{}'} - - + ); } diff --git a/src/components/node/node.tsx b/src/components/node/node.tsx index b59d34a..fde7e18 100644 --- a/src/components/node/node.tsx +++ b/src/components/node/node.tsx @@ -3,7 +3,6 @@ import styled from '@emotion/styled'; import { fontFamilies, spacing } from '@leafygreen-ui/tokens'; import { useTheme } from '@emotion/react'; import Icon from '@leafygreen-ui/icon'; -import IconButton from '@leafygreen-ui/icon-button'; import { useCallback, useState } from 'react'; import { DEFAULT_NODE_HEADER_HEIGHT, ZOOM_THRESHOLD } from '@/utilities/constants'; @@ -13,6 +12,7 @@ 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; @@ -102,9 +102,9 @@ const NodeWithFields = styled.div<{ visibility: string }>` visibility: ${props => props.visibility}; `; -const AddNewFieldIconButtonButton = styled(IconButton)` +const AddNewFieldIconButtonButton = styled(DiagramIconButton)` margin-left: auto; - margin-right: ${spacing[100]}px; + margin-right: ${spacing[200]}px; `; export const Node = ({ diff --git a/yarn.lock b/yarn.lock index fedc9e5..4ff88f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1115,27 +1115,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/a11y@npm:^2.0.7": - version: 2.0.7 - resolution: "@leafygreen-ui/a11y@npm:2.0.7" - dependencies: - "@leafygreen-ui/emotion": "npm:^4.1.1" - "@leafygreen-ui/hooks": "npm:^8.4.1" - "@leafygreen-ui/lib": "npm:^14.2.0" - checksum: 10c0/dfa1e05e0d4814d4be4d4796da8850ea0ad197d569ec1a38eaa0f23fb8e2e270996f902e02283fd04cc3020b12dee10755c239ccddf7f0dc43a4a38801100a81 - languageName: node - linkType: hard - -"@leafygreen-ui/emotion@npm:^4.1.1": - version: 4.1.1 - resolution: "@leafygreen-ui/emotion@npm:4.1.1" - dependencies: - "@emotion/css": "npm:^11.1.3" - "@emotion/server": "npm:^11.4.0" - checksum: 10c0/e580785982c28e7b91141ee6531764eb7c01e8d3f9613d1efec78e28390f2629fe1b8b532e92bca47a9569bcf6ba037d72951bc4f89335c56b4f412ce0bfb2b8 - languageName: node - linkType: hard - "@leafygreen-ui/emotion@npm:^5.0.0": version: 5.0.0 resolution: "@leafygreen-ui/emotion@npm:5.0.0" @@ -1146,16 +1125,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/hooks@npm:^8.4.1": - version: 8.4.1 - resolution: "@leafygreen-ui/hooks@npm:8.4.1" - dependencies: - "@leafygreen-ui/lib": "npm:^14.2.0" - lodash: "npm:^4.17.21" - checksum: 10c0/e038b85f74c2226a5f8ae00c102470b66ebe3db4befe9eb57d31852659f4f8ce19c3646bb5e82995106a337cc54fa39904065a2f5f4d5a2f5f2a895f7ccfdbdf - languageName: node - linkType: hard - "@leafygreen-ui/hooks@npm:^9.1.1": version: 9.1.1 resolution: "@leafygreen-ui/hooks@npm:9.1.1" @@ -1167,34 +1136,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/icon-button@npm:^16.0.2": - version: 16.0.12 - resolution: "@leafygreen-ui/icon-button@npm:16.0.12" - dependencies: - "@leafygreen-ui/a11y": "npm:^2.0.7" - "@leafygreen-ui/emotion": "npm:^4.1.1" - "@leafygreen-ui/icon": "npm:^13.4.0" - "@leafygreen-ui/lib": "npm:^14.2.0" - "@leafygreen-ui/palette": "npm:^4.1.4" - "@leafygreen-ui/polymorphic": "npm:^2.0.9" - "@leafygreen-ui/tokens": "npm:^2.12.2" - polished: "npm:^4.2.2" - peerDependencies: - "@leafygreen-ui/leafygreen-provider": ^4.0.7 - checksum: 10c0/52fd17f5567242a90ddca97897b6a9590589ebb79391fc669974dc92b97ff6419e0ab9e8609f09f9ed5f3c75492b5d61585149b6ff56dddc0c3b916c08c94f6a - languageName: node - linkType: hard - -"@leafygreen-ui/icon@npm:^13.4.0": - version: 13.4.0 - resolution: "@leafygreen-ui/icon@npm:13.4.0" - dependencies: - "@leafygreen-ui/emotion": "npm:^4.1.1" - lodash: "npm:^4.17.21" - checksum: 10c0/77921266df0a8f58383976e0a2f8dfb225b7e26661737aa83d755218d589e41ede370a090994acd7b5956f91ca5125cb00f46375284579350f8e4d9e110e45c2 - languageName: node - linkType: hard - "@leafygreen-ui/icon@npm:^14.1.0, @leafygreen-ui/icon@npm:^14.3.0": version: 14.3.0 resolution: "@leafygreen-ui/icon@npm:14.3.0" @@ -1216,17 +1157,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/lib@npm:^14.2.0": - version: 14.2.0 - resolution: "@leafygreen-ui/lib@npm:14.2.0" - dependencies: - lodash: "npm:^4.17.21" - peerDependencies: - react: ^17.0.0 || ^18.0.0 - checksum: 10c0/7622c730c689cbf250cf2348682d164a463b969aeea001d082ef06990fa5f419c1595a2c52a33686e8ad5561d9d23f7b8ef772a0bfd6c41c70595b32cee7c1e3 - languageName: node - linkType: hard - "@leafygreen-ui/lib@npm:^15.2.0": version: 15.2.0 resolution: "@leafygreen-ui/lib@npm:15.2.0" @@ -1238,13 +1168,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/palette@npm:^4.1.4": - version: 4.1.4 - resolution: "@leafygreen-ui/palette@npm:4.1.4" - checksum: 10c0/ba532e31cb0d18886d69e08a9b005653e42d878e67b9e19b7434f30b36891b3a504309910a3786893d394396070a68ddca3553e95fb47ca0f755dc59959cfd43 - languageName: node - linkType: hard - "@leafygreen-ui/palette@npm:^5.0.0": version: 5.0.0 resolution: "@leafygreen-ui/palette@npm:5.0.0" @@ -1252,16 +1175,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/polymorphic@npm:^2.0.9": - version: 2.0.9 - resolution: "@leafygreen-ui/polymorphic@npm:2.0.9" - dependencies: - "@leafygreen-ui/lib": "npm:^14.2.0" - lodash: "npm:^4.17.21" - checksum: 10c0/a67ef96e04f4c0589ce30ac7a3bcd2c9724a0b32d5327974a742109d648faa0947272ab316f5900ddcdda9c04e0deef52ddd8d5a956b359c0d6107dc325c4b45 - languageName: node - linkType: hard - "@leafygreen-ui/polymorphic@npm:^3.0.3": version: 3.0.3 resolution: "@leafygreen-ui/polymorphic@npm:3.0.3" @@ -1272,18 +1185,6 @@ __metadata: languageName: node linkType: hard -"@leafygreen-ui/tokens@npm:^2.12.2": - version: 2.12.2 - resolution: "@leafygreen-ui/tokens@npm:2.12.2" - dependencies: - "@leafygreen-ui/emotion": "npm:^4.1.1" - "@leafygreen-ui/lib": "npm:^14.2.0" - "@leafygreen-ui/palette": "npm:^4.1.4" - polished: "npm:^4.2.2" - checksum: 10c0/8797ec573903c2a6925f82c2780a66955e7d79e2009b7ec79a179fecacbadce59c18bae06375951a5aaf2d4ec45807b2d52da8b041dbca266acd6a5dce8ad6b1 - languageName: node - 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.1 resolution: "@leafygreen-ui/tokens@npm:3.2.1" @@ -1375,7 +1276,6 @@ __metadata: "@eslint/eslintrc": "npm:^3.3.0" "@eslint/js": "npm:^9.22.0" "@leafygreen-ui/icon": "npm:^14.3.0" - "@leafygreen-ui/icon-button": "npm:^16.0.2" "@leafygreen-ui/leafygreen-provider": "npm:^5.0.2" "@leafygreen-ui/palette": "npm:^5.0.0" "@leafygreen-ui/tokens": "npm:^3.2.1" From c423dd5954d83b6355097800f234992318a5f3ee Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 25 Sep 2025 10:11:32 -0400 Subject: [PATCH 9/9] fixup: move object render change if --- src/components/field/field-type-content.tsx | 20 +++++++++++--------- src/components/field/field.test.tsx | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/field/field-type-content.tsx b/src/components/field/field-type-content.tsx index 1e28fdc..d80fec8 100644 --- a/src/components/field/field-type-content.tsx +++ b/src/components/field/field-type-content.tsx @@ -35,18 +35,20 @@ export const FieldTypeContent = ({ [_onClickAddFieldToObjectField, nodeId, id], ); - if (type === 'object' && !!onClickAddFieldToObject) { + if (type === 'object') { return ( {'{}'} - - - + {onClickAddFieldToObject && ( + + + + )} ); } diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 9416578..6011a8a 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -47,7 +47,7 @@ describe('field', () => { it('Should not have a button to add a field on an object type', () => { render(); expect(screen.getByText('ordersId')).toBeInTheDocument(); - expect(screen.getByText('object')).toBeInTheDocument(); + expect(screen.getByText('{}')).toBeInTheDocument(); const button = screen.queryByRole('button'); expect(button).not.toBeInTheDocument(); });