Skip to content

Commit ae15c97

Browse files
authored
feat/COMPASS-9656 add field selected state and field click handler (#112)
1 parent f80737f commit ae15c97

File tree

14 files changed

+498
-46
lines changed

14 files changed

+498
-46
lines changed

src/components/canvas/canvas.tsx

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +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';
2425

2526
const MAX_ZOOM = 3;
2627
const MIN_ZOOM = 0.1;
@@ -56,6 +57,7 @@ export const Canvas = ({
5657
edges: externalEdges,
5758
onConnect,
5859
id,
60+
onFieldClick,
5961
onNodeContextMenu,
6062
onNodeDrag,
6163
onNodeDragStop,
@@ -139,40 +141,42 @@ export const Canvas = ({
139141
);
140142

141143
return (
142-
<ReactFlowWrapper>
143-
<ReactFlow
144-
id={id}
145-
deleteKeyCode={null}
146-
proOptions={PRO_OPTIONS}
147-
maxZoom={MAX_ZOOM}
148-
minZoom={MIN_ZOOM}
149-
nodeTypes={nodeTypes}
150-
edgeTypes={edgeTypes}
151-
nodes={nodes}
152-
onlyRenderVisibleElements={true}
153-
edges={edges}
154-
connectionLineComponent={ConnectionLine}
155-
connectionMode={ConnectionMode.Loose}
156-
onNodesChange={onNodesChange}
157-
onEdgesChange={onEdgesChange}
158-
selectionMode={SelectionMode.Partial}
159-
nodesDraggable={true}
160-
onConnect={onConnect}
161-
onNodeContextMenu={_onNodeContextMenu}
162-
onNodeDrag={_onNodeDrag}
163-
onNodeDragStop={_onNodeDragStop}
164-
onSelectionDragStop={_onSelectionDragStop}
165-
onEdgeClick={_onEdgeClick}
166-
onNodeClick={_onNodeClick}
167-
onSelectionContextMenu={_onSelectionContextMenu}
168-
onSelectionChange={_onSelectionChange}
169-
{...rest}
170-
>
171-
<MarkerList />
172-
<Background id={id} />
173-
<Controls title={title} />
174-
<MiniMap />
175-
</ReactFlow>
176-
</ReactFlowWrapper>
144+
<FieldSelectionProvider onFieldClick={onFieldClick}>
145+
<ReactFlowWrapper>
146+
<ReactFlow
147+
id={id}
148+
deleteKeyCode={null}
149+
proOptions={PRO_OPTIONS}
150+
maxZoom={MAX_ZOOM}
151+
minZoom={MIN_ZOOM}
152+
nodeTypes={nodeTypes}
153+
edgeTypes={edgeTypes}
154+
nodes={nodes}
155+
onlyRenderVisibleElements={true}
156+
edges={edges}
157+
connectionLineComponent={ConnectionLine}
158+
connectionMode={ConnectionMode.Loose}
159+
onNodesChange={onNodesChange}
160+
onEdgesChange={onEdgesChange}
161+
selectionMode={SelectionMode.Partial}
162+
nodesDraggable={true}
163+
onConnect={onConnect}
164+
onNodeContextMenu={_onNodeContextMenu}
165+
onNodeDrag={_onNodeDrag}
166+
onNodeDragStop={_onNodeDragStop}
167+
onSelectionDragStop={_onSelectionDragStop}
168+
onEdgeClick={_onEdgeClick}
169+
onNodeClick={_onNodeClick}
170+
onSelectionContextMenu={_onSelectionContextMenu}
171+
onSelectionChange={_onSelectionChange}
172+
{...rest}
173+
>
174+
<MarkerList />
175+
<Background id={id} />
176+
<Controls title={title} />
177+
<MiniMap />
178+
</ReactFlow>
179+
</ReactFlowWrapper>
180+
</FieldSelectionProvider>
177181
);
178182
};

src/components/diagram.stories.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +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';
89

910
const diagram: Meta<typeof Diagram> = {
1011
title: 'Diagram',
@@ -33,6 +34,54 @@ export const DiagramWithConnectableNodes: Story = {
3334
},
3435
};
3536

37+
let idAccumulator: string[];
38+
let lastDepth = 0;
39+
// Used to build a string array id based on field depth.
40+
function idFromDepthAccumulator(name: string, depth?: number) {
41+
if (!depth) {
42+
idAccumulator = [name];
43+
} else if (depth > lastDepth) {
44+
idAccumulator.push(name);
45+
} else if (depth === lastDepth) {
46+
idAccumulator[idAccumulator.length - 1] = name;
47+
} else {
48+
idAccumulator = idAccumulator.slice(0, depth);
49+
idAccumulator[depth] = name;
50+
}
51+
lastDepth = depth ?? 0;
52+
return [...idAccumulator];
53+
}
54+
export const DiagramWithSelectableFields: Story = {
55+
decorators: [DiagramSelectableFieldsDecorator],
56+
args: {
57+
title: 'MongoDB Diagram',
58+
isDarkMode: true,
59+
edges: [],
60+
nodes: [
61+
{
62+
...ORDERS_NODE,
63+
fields: [
64+
...ORDERS_NODE.fields.map(field => ({
65+
...field,
66+
id: idFromDepthAccumulator(field.name, field.depth),
67+
selectable: true,
68+
})),
69+
],
70+
},
71+
{
72+
...EMPLOYEES_NODE,
73+
fields: [
74+
...EMPLOYEES_NODE.fields.map(field => ({
75+
...field,
76+
id: idFromDepthAccumulator(field.name, field.depth),
77+
selectable: true,
78+
})),
79+
],
80+
},
81+
],
82+
},
83+
};
84+
3685
export const DiagramStressTest: Story = {
3786
decorators: [DiagramStressTestDecorator],
3887
args: {

src/components/field/field-list.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { useMemo } from 'react';
12
import styled from '@emotion/styled';
23
import { spacing } from '@leafygreen-ui/tokens';
34

45
import { Field } from '@/components/field/field';
56
import { NodeField, NodeType } from '@/types';
67
import { DEFAULT_PREVIEW_GROUP_AREA, getPreviewGroupArea, getPreviewId } from '@/utilities/get-preview-group-area';
78
import { DEFAULT_FIELD_PADDING } from '@/utilities/constants';
9+
import { useFieldSelection } from '@/hooks/use-field-selection';
10+
import { getSelectedFieldGroupHeight, getSelectedId } from '@/utilities/get-selected-field-group-height';
811

912
const NodeFieldWrapper = styled.div`
1013
padding: ${DEFAULT_FIELD_PADDING}px ${spacing[400]}px;
@@ -14,21 +17,29 @@ const NodeFieldWrapper = styled.div`
1417
interface Props {
1518
nodeType: NodeType;
1619
isHovering?: boolean;
20+
nodeId: string;
1721
fields: NodeField[];
1822
}
1923

20-
export const FieldList = ({ fields, nodeType, isHovering }: Props) => {
24+
export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => {
25+
const { enabled: isFieldSelectionEnabled } = useFieldSelection();
26+
2127
const spacing = Math.max(0, ...fields.map(field => field.glyphs?.length || 0));
22-
const previewGroupArea = getPreviewGroupArea(fields);
28+
const previewGroupArea = useMemo(() => getPreviewGroupArea(fields), [fields]);
29+
const selectedGroupHeight = useMemo(() => {
30+
return isFieldSelectionEnabled ? getSelectedFieldGroupHeight(fields) : undefined;
31+
}, [fields, isFieldSelectionEnabled]);
2332
return (
2433
<NodeFieldWrapper>
2534
{fields.map(({ name, type: fieldType, ...rest }, i) => (
2635
<Field
2736
key={i}
2837
name={name}
38+
nodeId={nodeId}
2939
nodeType={nodeType}
3040
isHovering={isHovering}
3141
previewGroupArea={previewGroupArea[getPreviewId(i, name)] || DEFAULT_PREVIEW_GROUP_AREA}
42+
selectedGroupHeight={selectedGroupHeight?.[getSelectedId(i, name)]}
3243
type={fieldType}
3344
spacing={spacing}
3445
{...rest}

src/components/field/field.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@ import { palette } from '@leafygreen-ui/palette';
22
import { ComponentProps } from 'react';
33

44
import { render, screen } from '@/mocks/testing-utils';
5-
import { Field } from '@/components/field/field';
5+
import { Field as FieldComponent } from '@/components/field/field';
66
import { DEFAULT_PREVIEW_GROUP_AREA } from '@/utilities/get-preview-group-area';
7+
import { FieldSelectionProvider } from '@/hooks/use-field-selection';
8+
9+
const Field = (props: React.ComponentProps<typeof FieldComponent>) => (
10+
<FieldSelectionProvider>
11+
<FieldComponent {...props} />
12+
</FieldSelectionProvider>
13+
);
714

815
describe('field', () => {
916
const DEFAULT_PROPS: ComponentProps<typeof Field> = {
1017
nodeType: 'collection',
1118
name: 'ordersId',
19+
nodeId: 'pineapple',
1220
type: 'objectId',
1321
glyphs: ['key', 'link'],
1422
previewGroupArea: DEFAULT_PREVIEW_GROUP_AREA,

src/components/field/field.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ 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';
78

89
import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles';
910
import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants';
1011
import { FieldDepth } from '@/components/field/field-depth';
1112
import { NodeField, NodeGlyph, NodeType } from '@/types';
1213
import { PreviewGroupArea } from '@/utilities/get-preview-group-area';
14+
import { useFieldSelection } from '@/hooks/use-field-selection';
1315

1416
const FIELD_BORDER_ANIMATED_PADDING = spacing[100];
1517
const FIELD_GLYPH_SPACING = spacing[400];
@@ -19,12 +21,44 @@ const GlyphToIcon: Record<NodeGlyph, string> = {
1921
link: 'Link',
2022
};
2123

22-
const FieldWrapper = styled.div<{ color: string }>`
24+
const SELECTED_FIELD_BORDER_PADDING = spacing[100];
25+
26+
const FieldWrapper = styled.div<{
27+
color: string;
28+
selectableHoverBackgroundColor?: string;
29+
selectable?: boolean;
30+
selected?: boolean;
31+
selectedGroupHeight: number;
32+
}>`
2333
display: flex;
2434
align-items: center;
2535
width: auto;
2636
height: ${DEFAULT_FIELD_HEIGHT}px;
2737
color: ${props => props.color};
38+
${props =>
39+
props.selectable &&
40+
`&:hover {
41+
cursor: pointer;
42+
background-color: ${props.selectableHoverBackgroundColor};
43+
box-shadow: -${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}, ${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor};
44+
}`}
45+
${props =>
46+
props.selected &&
47+
`
48+
position: relative;
49+
50+
&::before {
51+
content: '';
52+
pointer-events: none;
53+
position: absolute;
54+
outline: 2px solid ${palette.blue.base};
55+
width: calc(100% + ${SELECTED_FIELD_BORDER_PADDING * 2}px);
56+
border-radius: ${spacing[50]}px;
57+
height: ${props.selectedGroupHeight * DEFAULT_FIELD_HEIGHT}px;
58+
left: -${SELECTED_FIELD_BORDER_PADDING}px;
59+
top: 0px;
60+
}
61+
`}
2862
`;
2963

3064
const InnerFieldWrapper = styled.div<{ width: number }>`
@@ -90,31 +124,56 @@ const IconWrapper = styled(Icon)`
90124
`;
91125

92126
interface Props extends NodeField {
127+
nodeId: string;
93128
nodeType: NodeType;
94129
spacing: number;
95130
isHovering?: boolean;
96131
previewGroupArea: PreviewGroupArea;
132+
selectedGroupHeight?: number;
97133
}
98134

99135
export const Field = ({
100136
hoverVariant,
101137
isHovering = false,
102138
name,
139+
nodeId,
140+
id = name,
103141
depth = 0,
104142
type,
105143
nodeType,
106144
glyphs = [],
145+
selectedGroupHeight = 0,
107146
previewGroupArea,
108147
glyphSize = LGSpacing[300],
109148
spacing = 0,
149+
selectable = false,
150+
selected = false,
110151
variant,
111152
}: Props) => {
112153
const { theme } = useDarkMode();
113154

155+
const { fieldProps } = useFieldSelection();
156+
114157
const internalTheme = useTheme();
115158

116159
const isDisabled = variant === 'disabled' && !(hoverVariant === 'default' && isHovering);
117160

161+
const getSelectableHoverBackgroundColor = () => {
162+
return fieldSelectionProps?.selectable ? color[theme].background.primary.hover : undefined;
163+
};
164+
165+
/**
166+
* Create the field selection props when the field is selectable.
167+
*/
168+
const fieldSelectionProps = useMemo(() => {
169+
return selectable && fieldProps
170+
? {
171+
selectable: true,
172+
onClick: (event: ReactMouseEvent) => fieldProps.onClick(event, { id, nodeId }),
173+
}
174+
: undefined;
175+
}, [fieldProps, selectable, id, nodeId]);
176+
118177
const getTextColor = () => {
119178
if (isDisabled) {
120179
return internalTheme.node.disabledColor;
@@ -188,7 +247,13 @@ export const Field = ({
188247
const previewBorderLeft = `${depth * DEFAULT_DEPTH_SPACING - previewWidthGlyphOffset}px`;
189248

190249
return (
191-
<FieldWrapper color={getTextColor()}>
250+
<FieldWrapper
251+
selected={selected}
252+
color={getTextColor()}
253+
selectableHoverBackgroundColor={getSelectableHoverBackgroundColor()}
254+
selectedGroupHeight={selectedGroupHeight}
255+
{...fieldSelectionProps}
256+
>
192257
<InnerFieldWrapper width={spacing}>
193258
{glyphs.map(glyph => (
194259
<IconWrapper key={glyph} color={getIconColor(glyph)} glyph={GlyphToIcon[glyph]} size={glyphSize} />

0 commit comments

Comments
 (0)