Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +57,8 @@ export const Canvas = ({
edges: externalEdges,
onConnect,
id,
onAddFieldToNodeClick,
onAddFieldToObjectFieldClick,
onFieldClick,
onNodeContextMenu,
onNodeDrag,
Expand Down Expand Up @@ -141,7 +143,11 @@ export const Canvas = ({
);

return (
<FieldSelectionProvider onFieldClick={onFieldClick}>
<EditableDiagramInteractionsProvider
onFieldClick={onFieldClick}
onAddFieldToNodeClick={onAddFieldToNodeClick}
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
>
<ReactFlowWrapper>
<ReactFlow
id={id}
Expand Down Expand Up @@ -177,6 +183,6 @@ export const Canvas = ({
<MiniMap />
</ReactFlow>
</ReactFlowWrapper>
</FieldSelectionProvider>
</EditableDiagramInteractionsProvider>
);
};
6 changes: 3 additions & 3 deletions src/components/diagram.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Diagram> = {
title: 'Diagram',
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/components/field/field-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]);
Expand Down
38 changes: 35 additions & 3 deletions src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof FieldComponent>) => (
<FieldSelectionProvider>
<EditableDiagramInteractionsProvider>
<FieldComponent {...props} />
</FieldSelectionProvider>
</EditableDiagramInteractionsProvider>
);

const noop = () => {
/* no operation */
};

const FieldWithEditableInteractions = (props: React.ComponentProps<typeof FieldComponent>) => (
<EditableDiagramInteractionsProvider
onFieldClick={noop}
onAddFieldToNodeClick={noop}
onAddFieldToObjectFieldClick={noop}
>
<FieldComponent {...props} />
</EditableDiagramInteractionsProvider>
);

describe('field', () => {
Expand All @@ -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(<Field {...DEFAULT_PROPS} type={'object'} id={['ordersId']} />);
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(<FieldWithEditableInteractions {...DEFAULT_PROPS} type={'object'} id={['ordersId']} />);
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(<Field {...DEFAULT_PROPS} variant={'disabled'} />);
Expand Down
50 changes: 42 additions & 8 deletions src/components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ 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 { 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];
Expand Down Expand Up @@ -132,6 +133,38 @@ interface Props extends NodeField {
selectedGroupHeight?: number;
}

function FieldTypeContent({
type,
nodeId,
id,
}: {
id: string | string[];
} & Pick<Props, 'type' | 'nodeId'>) {
const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions();

const onClickAddFieldToObject = useMemo(
() =>
_onClickAddFieldToObjectField && Array.isArray(id)
? (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
_onClickAddFieldToObjectField(event, nodeId, id);
}
: undefined,
[_onClickAddFieldToObjectField, nodeId, id],
);

if (type === 'object' && !!onClickAddFieldToObject) {
return (
<ObjectFieldType
data-testid={`object-field-type-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
onClickAddFieldToObject={onClickAddFieldToObject}
/>
);
}

return <>{type}</>;
}

export const Field = ({
hoverVariant,
isHovering = false,
Expand All @@ -145,15 +178,14 @@ export const Field = ({
selectedGroupHeight = 0,
previewGroupArea,
glyphSize = LGSpacing[300],
renderName,
spacing = 0,
selectable = false,
selected = false,
variant,
}: Props) => {
const { theme } = useDarkMode();

const { fieldProps } = useFieldSelection();
const { onClickField } = useEditableDiagramInteractions();

const internalTheme = useTheme();

Expand All @@ -167,14 +199,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) {
Expand Down Expand Up @@ -215,9 +247,11 @@ export const Field = ({
<>
<FieldName>
<FieldDepth depth={depth} />
<InnerFieldName>{renderName || name}</InnerFieldName>
<InnerFieldName>{name}</InnerFieldName>
</FieldName>
<FieldType color={getSecondaryTextColor()}>{type}</FieldType>
<FieldType color={getSecondaryTextColor()}>
<FieldTypeContent type={type} nodeId={nodeId} id={id} />
</FieldType>
</>
);

Expand Down
113 changes: 113 additions & 0 deletions src/components/field/object-field-type.tsx
Original file line number Diff line number Diff line change
@@ -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`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to style a button rather than using the IconButton that LG provides?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LeafyGreen one was too large in terms of both size and focus state. I was thinking passing a lot of custom overriding styles would end up in a more broken state down the line if internals in LeafyGreen change and we upgrade.
https://www.mongodb.design/component/icon-button/live-example

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much would we have to override other than the style? I understand the risk with depending on LG, but this way it's completely detached from the other plus btn which should look pretty much the same. Maybe we could ask LG to support the size we need and have a follow up to include it here, or if we say diagram buttons are a special case, then let's define an IconButton here in the lib and use it for both

Copy link
Member Author

@Anemy Anemy Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only styles we're overriding. https://github.com/mongodb/leafygreen-ui/blob/main/packages/icon-button/src/IconButton/IconButton.styles.tsx

Defined a custom one that we're now using in both places. Removed icon-button from LG as we aren't using it anymore.

I'll bring it up in their slack channel. The diagram is pretty custom styles that they aren't covering though so I'm not sure it's something they'd want to add. IIRC we do have some custom IconButton styles in Compass though and some are smaller so that could be another case.

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<HTMLButtonElement>) => void;
['data-testid']: string;
};

export const ObjectFieldType = ({
'data-testid': dataTestId,
onClickAddFieldToObject: _onClickAddFieldToObject,
}: ObjectFieldTypeProps) => {
const onClickAddFieldToObject = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
// Don't click on the field element.
event.stopPropagation();
_onClickAddFieldToObject(event);
},
[_onClickAddFieldToObject],
);

return (
<ObjectTypeContainer>
Copy link
Collaborator

@lchans lchans Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The {} and the icon are not quite aligned (See screenshot, the icon is a tad taller)
The plus icon is also not right aligned with the other types (there's a gap), is that intended?
Screenshot 2025-09-23 at 10 52 11 AM

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if you could add the Figma link as well in this PR? Thanks!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of IconButton also renders it weirdly when active / tabbing
Screenshot 2025-09-23 at 1 24 08 PM

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a 🎨 Figma link. I went with a custom button on the object field type add button to make that focus active state look better. We could share the same button here so they both have the same focus styles. How does that sound?
Answering your other question here as well, I went with a custom button and not icon button for the object field type as the leafygreen one was too large and I was thinking passing a lot of custom overriding styles would end up in a more broken state down the line if internals in LeafyGreen change and we upgrade.

{'{}'}
<AddNestedFieldIconButton
data-testid={dataTestId}
onClick={onClickAddFieldToObject}
aria-label="Add new field"
title="Add Field"
>
<PlusWithSquare />
</AddNestedFieldIconButton>
</ObjectTypeContainer>
);
};
17 changes: 17 additions & 0 deletions src/components/icons/plus-with-square.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 0.75H2C1.66848 0.75 1.35054 0.881696 1.11612 1.11612C0.881696 1.35054 0.75 1.66848 0.75 2V12C0.75 12.3315 0.881696 12.6495 1.11612 12.8839C1.35054 13.1183 1.66848 13.25 2 13.25H12C12.3315 13.25 12.6495 13.1183 12.8839 12.8839C13.1183 12.6495 13.25 12.3315 13.25 12V2C13.25 1.66848 13.1183 1.35054 12.8839 1.11612C12.6495 0.881696 12.3315 0.75 12 0.75ZM11.75 11.75H2.25V2.25H11.75V11.75ZM3.75 7C3.75 6.80109 3.82902 6.61032 3.96967 6.46967C4.11032 6.32902 4.30109 6.25 4.5 6.25H6.25V4.5C6.25 4.30109 6.32902 4.11032 6.46967 3.96967C6.61032 3.82902 6.80109 3.75 7 3.75C7.19891 3.75 7.38968 3.82902 7.53033 3.96967C7.67098 4.11032 7.75 4.30109 7.75 4.5V6.25H9.5C9.69891 6.25 9.88968 6.32902 10.0303 6.46967C10.171 6.61032 10.25 6.80109 10.25 7C10.25 7.19891 10.171 7.38968 10.0303 7.53033C9.88968 7.67098 9.69891 7.75 9.5 7.75H7.75V9.5C7.75 9.69891 7.67098 9.88968 7.53033 10.0303C7.38968 10.171 7.19891 10.25 7 10.25C6.80109 10.25 6.61032 10.171 6.46967 10.0303C6.32902 9.88968 6.25 9.69891 6.25 9.5V7.75H4.5C4.30109 7.75 4.11032 7.67098 3.96967 7.53033C3.82902 7.38968 3.75 7.19891 3.75 7Z"
fill={strokeColor}
/>
</svg>
);
};
Loading