Skip to content

Commit 1d5885c

Browse files
authored
refactor/COMPASS-9877 Add editable node handlers and interactions (#135)
1 parent bed0178 commit 1d5885c

26 files changed

+516
-218
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@testing-library/jest-dom": "^6.6.3",
7474
"@testing-library/react": "^12.1.5",
7575
"@testing-library/react-hooks": "^8.0.1",
76+
"@testing-library/user-event": "^14.6.1",
7677
"@types/d3-path": "^3",
7778
"@types/jest": "30.0.0",
7879
"@types/node": "22.15.29",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { ButtonHTMLAttributes } from 'react';
2+
import styled from '@emotion/styled';
3+
import { spacing, transitionDuration } from '@leafygreen-ui/tokens';
4+
import { palette } from '@leafygreen-ui/palette';
5+
6+
const StyledDiagramIconButton = styled.button`
7+
background: none;
8+
border: none;
9+
outline: none;
10+
padding: ${spacing[100]}px;
11+
margin: 0;
12+
margin-left: ${spacing[100]}px;
13+
cursor: pointer;
14+
color: inherit;
15+
display: flex;
16+
position: relative;
17+
color: ${props => props.theme.node.fieldIconButton};
18+
19+
&::before {
20+
content: '';
21+
transition: ${transitionDuration.default}ms all ease-in-out;
22+
position: absolute;
23+
top: 0;
24+
bottom: 0;
25+
left: 0;
26+
right: 0;
27+
border-radius: 100%;
28+
transform: scale(0.8);
29+
}
30+
31+
&:active::before,
32+
&:hover::before,
33+
&:focus::before,
34+
&[data-hover='true']::before,
35+
&[data-focus='true']::before {
36+
transform: scale(1);
37+
}
38+
39+
&:active,
40+
&:hover,
41+
&[data-hover='true'],
42+
&:focus-visible,
43+
&[data-focus='true'] {
44+
color: ${palette.black};
45+
46+
&::before {
47+
background-color: ${props => props.theme.node.fieldIconButtonHoverBackground};
48+
}
49+
}
50+
51+
// Focus ring styles.
52+
&::after {
53+
position: absolute;
54+
content: '';
55+
pointer-events: none;
56+
top: 3px;
57+
right: 3px;
58+
bottom: 3px;
59+
left: 3px;
60+
border-radius: ${spacing[100]}px;
61+
box-shadow: 0 0 0 0 transparent;
62+
transition: box-shadow 0.16s ease-in;
63+
z-index: 1;
64+
}
65+
&:focus-visible {
66+
&::after {
67+
box-shadow: 0 0 0 3px ${palette.blue.light1} !important;
68+
transition-timing-function: ease-out;
69+
}
70+
}
71+
`;
72+
73+
// Use a custom button component instead of LeafyGreen's IconButton
74+
// to allow us to have a smaller focus ring and icon size without overwriting internal styles.
75+
export const DiagramIconButton = ({
76+
children,
77+
...props
78+
}: ButtonHTMLAttributes<HTMLButtonElement> & {
79+
children?: React.ReactNode;
80+
}) => {
81+
return <StyledDiagramIconButton {...props}>{children}</StyledDiagramIconButton>;
82+
};

src/components/canvas/canvas.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { MarkerList } from '@/components/markers/marker-list';
2121
import { ConnectionLine } from '@/components/line/connection-line';
2222
import { convertToExternalNode, convertToExternalNodes, convertToInternalNodes } from '@/utilities/convert-nodes';
2323
import { convertToExternalEdge, convertToExternalEdges, convertToInternalEdges } from '@/utilities/convert-edges';
24-
import { FieldSelectionProvider } from '@/hooks/use-field-selection';
24+
import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions';
2525

2626
const MAX_ZOOM = 3;
2727
const MIN_ZOOM = 0.1;
@@ -57,6 +57,8 @@ export const Canvas = ({
5757
edges: externalEdges,
5858
onConnect,
5959
id,
60+
onAddFieldToNodeClick,
61+
onAddFieldToObjectFieldClick,
6062
onFieldClick,
6163
onNodeContextMenu,
6264
onNodeDrag,
@@ -141,7 +143,11 @@ export const Canvas = ({
141143
);
142144

143145
return (
144-
<FieldSelectionProvider onFieldClick={onFieldClick}>
146+
<EditableDiagramInteractionsProvider
147+
onFieldClick={onFieldClick}
148+
onAddFieldToNodeClick={onAddFieldToNodeClick}
149+
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
150+
>
145151
<ReactFlowWrapper>
146152
<ReactFlow
147153
id={id}
@@ -177,6 +183,6 @@ export const Canvas = ({
177183
<MiniMap />
178184
</ReactFlow>
179185
</ReactFlowWrapper>
180-
</FieldSelectionProvider>
186+
</EditableDiagramInteractionsProvider>
181187
);
182188
};

src/components/diagram.stories.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { EMPLOYEE_TERRITORIES_NODE, EMPLOYEES_NODE, ORDERS_NODE } from '@/mocks/
55
import { EMPLOYEES_TO_EMPLOYEES_EDGE, ORDERS_TO_EMPLOYEES_EDGE } from '@/mocks/datasets/edges';
66
import { DiagramStressTestDecorator } from '@/mocks/decorators/diagram-stress-test.decorator';
77
import { DiagramConnectableDecorator } from '@/mocks/decorators/diagram-connectable.decorator';
8-
import { DiagramSelectableFieldsDecorator } from '@/mocks/decorators/diagram-selectable-fields.decorator';
8+
import { DiagramEditableInteractionsDecorator } from '@/mocks/decorators/diagram-editable-interactions.decorator';
99

1010
const diagram: Meta<typeof Diagram> = {
1111
title: 'Diagram',
@@ -51,8 +51,8 @@ function idFromDepthAccumulator(name: string, depth?: number) {
5151
lastDepth = depth ?? 0;
5252
return [...idAccumulator];
5353
}
54-
export const DiagramWithSelectableFields: Story = {
55-
decorators: [DiagramSelectableFieldsDecorator],
54+
export const DiagramWithEditInteractions: Story = {
55+
decorators: [DiagramEditableInteractionsDecorator],
5656
args: {
5757
title: 'MongoDB Diagram',
5858
isDarkMode: true,

src/components/field/field-list.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { Field } from '@/components/field/field';
66
import { NodeField, NodeType } from '@/types';
77
import { DEFAULT_PREVIEW_GROUP_AREA, getPreviewGroupArea, getPreviewId } from '@/utilities/get-preview-group-area';
88
import { DEFAULT_FIELD_PADDING } from '@/utilities/constants';
9-
import { useFieldSelection } from '@/hooks/use-field-selection';
109
import { getSelectedFieldGroupHeight, getSelectedId } from '@/utilities/get-selected-field-group-height';
10+
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1111

1212
const NodeFieldWrapper = styled.div`
1313
padding: ${DEFAULT_FIELD_PADDING}px ${spacing[400]}px;
@@ -22,7 +22,8 @@ interface Props {
2222
}
2323

2424
export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => {
25-
const { enabled: isFieldSelectionEnabled } = useFieldSelection();
25+
const { onClickField } = useEditableDiagramInteractions();
26+
const isFieldSelectionEnabled = !!onClickField;
2627

2728
const spacing = Math.max(0, ...fields.map(field => field.glyphs?.length || 0));
2829
const previewGroupArea = useMemo(() => getPreviewGroupArea(fields), [fields]);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useMemo } from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
5+
import { PlusWithSquare } from '@/components/icons/plus-with-square';
6+
import { DiagramIconButton } from '@/components/buttons/diagram-icon-button';
7+
8+
const ObjectTypeContainer = styled.div`
9+
display: flex;
10+
justify-content: flex-end;
11+
align-items: center;
12+
line-height: 20px;
13+
`;
14+
15+
export const FieldTypeContent = ({
16+
type,
17+
nodeId,
18+
id,
19+
}: {
20+
id: string | string[];
21+
nodeId: string;
22+
type: React.ReactNode;
23+
}) => {
24+
const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions();
25+
26+
const onClickAddFieldToObject = useMemo(
27+
() =>
28+
_onClickAddFieldToObjectField
29+
? (event: React.MouseEvent<HTMLButtonElement>) => {
30+
// Don't click on the field element.
31+
event.stopPropagation();
32+
_onClickAddFieldToObjectField(event, nodeId, Array.isArray(id) ? id : [id]);
33+
}
34+
: undefined,
35+
[_onClickAddFieldToObjectField, nodeId, id],
36+
);
37+
38+
if (type === 'object') {
39+
return (
40+
<ObjectTypeContainer>
41+
{'{}'}
42+
{onClickAddFieldToObject && (
43+
<DiagramIconButton
44+
data-testid={`object-field-type-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
45+
onClick={onClickAddFieldToObject}
46+
aria-label="Add new field"
47+
title="Add Field"
48+
>
49+
<PlusWithSquare />
50+
</DiagramIconButton>
51+
)}
52+
</ObjectTypeContainer>
53+
);
54+
}
55+
56+
if (type === 'array') {
57+
return '[]';
58+
}
59+
60+
return <>{type}</>;
61+
};

src/components/field/field.test.tsx

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
import { palette } from '@leafygreen-ui/palette';
22
import { ComponentProps } from 'react';
3+
import { userEvent } from '@testing-library/user-event';
34

45
import { render, screen } from '@/mocks/testing-utils';
56
import { Field as FieldComponent } from '@/components/field/field';
67
import { DEFAULT_PREVIEW_GROUP_AREA } from '@/utilities/get-preview-group-area';
7-
import { FieldSelectionProvider } from '@/hooks/use-field-selection';
8+
import { EditableDiagramInteractionsProvider } from '@/hooks/use-editable-diagram-interactions';
89

910
const Field = (props: React.ComponentProps<typeof FieldComponent>) => (
10-
<FieldSelectionProvider>
11+
<EditableDiagramInteractionsProvider>
1112
<FieldComponent {...props} />
12-
</FieldSelectionProvider>
13+
</EditableDiagramInteractionsProvider>
1314
);
1415

16+
const FieldWithEditableInteractions = ({
17+
onAddFieldToObjectFieldClick,
18+
...fieldProps
19+
}: React.ComponentProps<typeof FieldComponent> & {
20+
onAddFieldToObjectFieldClick?: () => void;
21+
}) => {
22+
return (
23+
<EditableDiagramInteractionsProvider onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}>
24+
<FieldComponent {...fieldProps} />
25+
</EditableDiagramInteractionsProvider>
26+
);
27+
};
28+
1529
describe('field', () => {
1630
const DEFAULT_PROPS: ComponentProps<typeof Field> = {
1731
nodeType: 'collection',
@@ -30,6 +44,52 @@ describe('field', () => {
3044
expect(screen.getByRole('img', { name: 'Key Icon' })).toBeInTheDocument();
3145
expect(screen.getByRole('img', { name: 'Link Icon' })).toBeInTheDocument();
3246
});
47+
it('Should not have a button to add a field on an object type', () => {
48+
render(<Field {...DEFAULT_PROPS} type={'object'} id={['ordersId']} />);
49+
expect(screen.getByText('ordersId')).toBeInTheDocument();
50+
expect(screen.getByText('{}')).toBeInTheDocument();
51+
const button = screen.queryByRole('button');
52+
expect(button).not.toBeInTheDocument();
53+
});
54+
describe('With editable interactions supplied', () => {
55+
it('Should have a button to add a field on an object type', async () => {
56+
const onAddFieldToObjectFieldClickMock = vi.fn();
57+
58+
render(
59+
<FieldWithEditableInteractions
60+
{...DEFAULT_PROPS}
61+
type={'object'}
62+
id={['ordersId']}
63+
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClickMock}
64+
/>,
65+
);
66+
expect(screen.getByText('ordersId')).toBeInTheDocument();
67+
expect(screen.getByText('{}')).toBeInTheDocument();
68+
const button = screen.getByRole('button');
69+
expect(button).toBeInTheDocument();
70+
expect(button).toHaveAttribute('data-testid', 'object-field-type-pineapple-ordersId');
71+
expect(button).toHaveAttribute('title', 'Add Field');
72+
expect(onAddFieldToObjectFieldClickMock).not.toHaveBeenCalled();
73+
await userEvent.click(button);
74+
expect(onAddFieldToObjectFieldClickMock).toHaveBeenCalled();
75+
});
76+
77+
it('Should not have a button to add a field with non-object types', () => {
78+
render(<FieldWithEditableInteractions {...DEFAULT_PROPS} id={['ordersId']} />);
79+
expect(screen.getByText('ordersId')).toBeInTheDocument();
80+
expect(screen.getByText('objectId')).toBeInTheDocument();
81+
const button = screen.queryByRole('button');
82+
expect(button).not.toBeInTheDocument();
83+
});
84+
});
85+
describe('With specific types', () => {
86+
it('shows [] with "array"', () => {
87+
render(<Field {...DEFAULT_PROPS} type="array" />);
88+
expect(screen.getByText('[]')).toBeInTheDocument();
89+
expect(screen.queryByText('array')).not.toBeInTheDocument();
90+
});
91+
});
92+
3393
describe('With glyphs', () => {
3494
it('With disabled', () => {
3595
render(<Field {...DEFAULT_PROPS} variant={'disabled'} />);

src/components/field/field.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { palette } from '@leafygreen-ui/palette';
44
import Icon from '@leafygreen-ui/icon';
55
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
66
import { useTheme } from '@emotion/react';
7-
import { MouseEvent as ReactMouseEvent, useMemo } from 'react';
7+
import { useMemo } from 'react';
88

99
import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles';
1010
import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants';
1111
import { FieldDepth } from '@/components/field/field-depth';
12+
import { FieldTypeContent } from '@/components/field/field-type-content';
1213
import { NodeField, NodeGlyph, NodeType } from '@/types';
1314
import { PreviewGroupArea } from '@/utilities/get-preview-group-area';
14-
import { useFieldSelection } from '@/hooks/use-field-selection';
15+
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1516

1617
const FIELD_BORDER_ANIMATED_PADDING = spacing[100];
1718
const FIELD_GLYPH_SPACING = spacing[400];
@@ -145,15 +146,14 @@ export const Field = ({
145146
selectedGroupHeight = 0,
146147
previewGroupArea,
147148
glyphSize = LGSpacing[300],
148-
renderName,
149149
spacing = 0,
150150
selectable = false,
151151
selected = false,
152152
variant,
153153
}: Props) => {
154154
const { theme } = useDarkMode();
155155

156-
const { fieldProps } = useFieldSelection();
156+
const { onClickField } = useEditableDiagramInteractions();
157157

158158
const internalTheme = useTheme();
159159

@@ -167,14 +167,14 @@ export const Field = ({
167167
* Create the field selection props when the field is selectable.
168168
*/
169169
const fieldSelectionProps = useMemo(() => {
170-
return selectable && fieldProps
170+
return selectable && !!onClickField
171171
? {
172172
'data-testid': `selectable-field-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`,
173173
selectable: true,
174-
onClick: (event: ReactMouseEvent) => fieldProps.onClick(event, { id, nodeId }),
174+
onClick: (event: React.MouseEvent) => onClickField(event, { id, nodeId }),
175175
}
176176
: undefined;
177-
}, [fieldProps, selectable, id, nodeId]);
177+
}, [onClickField, selectable, id, nodeId]);
178178

179179
const getTextColor = () => {
180180
if (isDisabled) {
@@ -215,9 +215,11 @@ export const Field = ({
215215
<>
216216
<FieldName>
217217
<FieldDepth depth={depth} />
218-
<InnerFieldName>{renderName || name}</InnerFieldName>
218+
<InnerFieldName>{name}</InnerFieldName>
219219
</FieldName>
220-
<FieldType color={getSecondaryTextColor()}>{type}</FieldType>
220+
<FieldType color={getSecondaryTextColor()}>
221+
<FieldTypeContent type={type} nodeId={nodeId} id={id} />
222+
</FieldType>
221223
</>
222224
);
223225

0 commit comments

Comments
 (0)