Skip to content

Commit eb90fe9

Browse files
authored
feat/COMPASS-9798 inline field name editing (#136)
1 parent 5384a21 commit eb90fe9

File tree

9 files changed

+214
-11
lines changed

9 files changed

+214
-11
lines changed

src/components/canvas/canvas.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const Canvas = ({
5959
id,
6060
onAddFieldToNodeClick,
6161
onAddFieldToObjectFieldClick,
62+
onFieldNameChange,
6263
onFieldClick,
6364
onNodeContextMenu,
6465
onNodeDrag,
@@ -147,6 +148,7 @@ export const Canvas = ({
147148
onFieldClick={onFieldClick}
148149
onAddFieldToNodeClick={onAddFieldToNodeClick}
149150
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
151+
onFieldNameChange={onFieldNameChange}
150152
>
151153
<ReactFlowWrapper>
152154
<ReactFlow

src/components/diagram.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const DiagramWithEditInteractions: Story = {
6666
...field,
6767
id: idFromDepthAccumulator(field.name, field.depth),
6868
selectable: true,
69+
editable: true,
6970
})),
7071
],
7172
},
@@ -76,6 +77,7 @@ export const DiagramWithEditInteractions: Story = {
7677
...field,
7778
id: idFromDepthAccumulator(field.name, field.depth),
7879
selectable: true,
80+
editable: true,
7981
})),
8082
],
8183
},
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { styled } from 'storybook/internal/theming';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
4+
import { ellipsisTruncation } from '@/styles/styles';
5+
import { DEFAULT_FIELD_HEIGHT } from '@/utilities/constants';
6+
7+
const InnerFieldName = styled.div`
8+
width: 100%;
9+
min-height: ${DEFAULT_FIELD_HEIGHT}px;
10+
${ellipsisTruncation}
11+
`;
12+
13+
const InlineInput = styled.input`
14+
border: none;
15+
background: none;
16+
height: ${DEFAULT_FIELD_HEIGHT}px;
17+
color: inherit;
18+
font-size: inherit;
19+
font-family: inherit;
20+
font-style: inherit;
21+
`;
22+
23+
interface FieldNameProps {
24+
name: string;
25+
isEditable?: boolean;
26+
onChange?: (newName: string) => void;
27+
onBlur?: () => void;
28+
}
29+
30+
export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => {
31+
const [isEditing, setIsEditing] = useState(false);
32+
const [value, setValue] = useState(name);
33+
const textInputRef = useRef<HTMLInputElement>(null);
34+
35+
const handleSubmit = useCallback(() => {
36+
setIsEditing(false);
37+
onChange?.(value);
38+
}, [value, onChange]);
39+
40+
const handleKeyboardEvent = useCallback(
41+
(e: React.KeyboardEvent<HTMLInputElement>) => {
42+
if (e.key === 'Enter') handleSubmit();
43+
if (e.key === 'Escape') {
44+
setValue(name);
45+
setIsEditing(false);
46+
}
47+
},
48+
[handleSubmit, name],
49+
);
50+
51+
const handleNameDoubleClick = useCallback(() => {
52+
setIsEditing(true);
53+
}, []);
54+
55+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
56+
setValue(e.target.value);
57+
}, []);
58+
59+
useEffect(() => {
60+
if (isEditing) {
61+
setTimeout(() => {
62+
textInputRef.current?.focus();
63+
textInputRef.current?.select();
64+
});
65+
}
66+
}, [isEditing]);
67+
68+
return isEditing ? (
69+
<InlineInput
70+
type="text"
71+
ref={textInputRef}
72+
value={value}
73+
onChange={handleChange}
74+
onBlur={handleSubmit}
75+
onKeyDown={handleKeyboardEvent}
76+
title="Edit field name"
77+
/>
78+
) : (
79+
<InnerFieldName onDoubleClick={onChange && isEditable ? handleNameDoubleClick : undefined}>{value}</InnerFieldName>
80+
);
81+
};

src/components/field/field.test.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ const Field = (props: React.ComponentProps<typeof FieldComponent>) => (
1515

1616
const FieldWithEditableInteractions = ({
1717
onAddFieldToObjectFieldClick,
18+
onFieldNameChange,
1819
...fieldProps
1920
}: React.ComponentProps<typeof FieldComponent> & {
2021
onAddFieldToObjectFieldClick?: () => void;
22+
onFieldNameChange?: (newName: string) => void;
2123
}) => {
2224
return (
23-
<EditableDiagramInteractionsProvider onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}>
25+
<EditableDiagramInteractionsProvider
26+
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
27+
onFieldNameChange={onFieldNameChange}
28+
>
2429
<FieldComponent {...fieldProps} />
2530
</EditableDiagramInteractionsProvider>
2631
);
@@ -81,7 +86,63 @@ describe('field', () => {
8186
const button = screen.queryByRole('button');
8287
expect(button).not.toBeInTheDocument();
8388
});
89+
90+
it('Should allow field name editing an editable field', async () => {
91+
const onFieldNameChangeMock = vi.fn();
92+
93+
const fieldId = ['ordersId'];
94+
const newFieldName = 'newFieldName';
95+
render(
96+
<FieldWithEditableInteractions
97+
{...DEFAULT_PROPS}
98+
id={fieldId}
99+
editable={true}
100+
onFieldNameChange={onFieldNameChangeMock}
101+
/>,
102+
);
103+
const fieldName = screen.getByText('ordersId');
104+
expect(fieldName).toBeInTheDocument();
105+
await userEvent.dblClick(fieldName);
106+
const input = screen.getByDisplayValue('ordersId');
107+
expect(input).toBeInTheDocument();
108+
await userEvent.clear(input);
109+
await userEvent.type(input, newFieldName);
110+
expect(input).toHaveValue(newFieldName);
111+
expect(onFieldNameChangeMock).not.toHaveBeenCalled();
112+
await userEvent.type(input, '{enter}');
113+
expect(onFieldNameChangeMock).toHaveBeenCalledWith(DEFAULT_PROPS.nodeId, fieldId, newFieldName);
114+
});
115+
116+
it('Should not allow field name editing if a field is not editable', async () => {
117+
const onFieldNameChangeMock = vi.fn();
118+
119+
const fieldId = ['ordersId'];
120+
render(
121+
<FieldWithEditableInteractions
122+
{...DEFAULT_PROPS}
123+
id={fieldId}
124+
editable={false}
125+
onFieldNameChange={onFieldNameChangeMock}
126+
/>,
127+
);
128+
const fieldName = screen.getByText('ordersId');
129+
expect(fieldName).toBeInTheDocument();
130+
await userEvent.dblClick(fieldName);
131+
expect(screen.queryByDisplayValue('ordersId')).not.toBeUndefined();
132+
});
133+
134+
it('Should not allow editing if there is no callback', async () => {
135+
const fieldId = ['ordersId'];
136+
render(
137+
<FieldWithEditableInteractions {...DEFAULT_PROPS} id={fieldId} editable={true} onFieldNameChange={undefined} />,
138+
);
139+
const fieldName = screen.getByText('ordersId');
140+
expect(fieldName).toBeInTheDocument();
141+
await userEvent.dblClick(fieldName);
142+
expect(screen.queryByDisplayValue('ordersId')).not.toBeUndefined();
143+
});
84144
});
145+
85146
describe('With specific types', () => {
86147
it('shows [] with "array"', () => {
87148
render(<Field {...DEFAULT_PROPS} type="array" />);

src/components/field/field.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ 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 { useMemo } from 'react';
7+
import { useCallback, useMemo } from 'react';
88

99
import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles';
1010
import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants';
@@ -14,6 +14,8 @@ import { NodeField, NodeGlyph, NodeType } from '@/types';
1414
import { PreviewGroupArea } from '@/utilities/get-preview-group-area';
1515
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1616

17+
import { FieldNameContent } from './field-name-content';
18+
1719
const FIELD_BORDER_ANIMATED_PADDING = spacing[100];
1820
const FIELD_GLYPH_SPACING = spacing[400];
1921

@@ -105,10 +107,6 @@ const FieldName = styled.div`
105107
${ellipsisTruncation}
106108
`;
107109

108-
const InnerFieldName = styled.div`
109-
${ellipsisTruncation}
110-
`;
111-
112110
const FieldType = styled.div`
113111
color: ${props => props.color};
114112
flex: 0 0 ${LGSpacing[200] * 10}px;
@@ -149,11 +147,12 @@ export const Field = ({
149147
spacing = 0,
150148
selectable = false,
151149
selected = false,
150+
editable = false,
152151
variant,
153152
}: Props) => {
154153
const { theme } = useDarkMode();
155154

156-
const { onClickField } = useEditableDiagramInteractions();
155+
const { onClickField, onChangeFieldName } = useEditableDiagramInteractions();
157156

158157
const internalTheme = useTheme();
159158

@@ -211,11 +210,20 @@ export const Field = ({
211210
return internalTheme.node.mongoDBAccent;
212211
};
213212

213+
const handleNameChange = useCallback(
214+
(newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName),
215+
[onChangeFieldName, id, nodeId],
216+
);
217+
214218
const content = (
215219
<>
216220
<FieldName>
217221
<FieldDepth depth={depth} />
218-
<InnerFieldName>{name}</InnerFieldName>
222+
<FieldNameContent
223+
name={name}
224+
isEditable={editable}
225+
onChange={onChangeFieldName ? handleNameChange : undefined}
226+
/>
219227
</FieldName>
220228
<FieldType color={getSecondaryTextColor()}>
221229
<FieldTypeContent type={type} nodeId={nodeId} id={id} />

src/hooks/use-editable-diagram-interactions.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import React, { createContext, useContext, useMemo, ReactNode } from 'react';
22

3-
import { OnFieldClickHandler, OnAddFieldToNodeClickHandler, OnAddFieldToObjectFieldClickHandler } from '@/types';
3+
import {
4+
OnFieldClickHandler,
5+
OnAddFieldToNodeClickHandler,
6+
OnAddFieldToObjectFieldClickHandler,
7+
OnFieldNameChangeHandler,
8+
} from '@/types';
49

510
interface EditableDiagramInteractionsContextType {
611
onClickField?: OnFieldClickHandler;
712
onClickAddFieldToNode?: OnAddFieldToNodeClickHandler;
813
onClickAddFieldToObjectField?: OnAddFieldToObjectFieldClickHandler;
14+
onChangeFieldName?: OnFieldNameChangeHandler;
915
}
1016

1117
const EditableDiagramInteractionsContext = createContext<EditableDiagramInteractionsContextType | undefined>(undefined);
@@ -15,13 +21,15 @@ interface EditableDiagramInteractionsProviderProps {
1521
onFieldClick?: OnFieldClickHandler;
1622
onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler;
1723
onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler;
24+
onFieldNameChange?: OnFieldNameChangeHandler;
1825
}
1926

2027
export const EditableDiagramInteractionsProvider: React.FC<EditableDiagramInteractionsProviderProps> = ({
2128
children,
2229
onFieldClick,
2330
onAddFieldToNodeClick,
2431
onAddFieldToObjectFieldClick,
32+
onFieldNameChange,
2533
}) => {
2634
const value: EditableDiagramInteractionsContextType = useMemo(() => {
2735
return {
@@ -40,8 +48,13 @@ export const EditableDiagramInteractionsProvider: React.FC<EditableDiagramIntera
4048
onClickAddFieldToObjectField: onAddFieldToObjectFieldClick,
4149
}
4250
: undefined),
51+
...(onFieldNameChange
52+
? {
53+
onChangeFieldName: onFieldNameChange,
54+
}
55+
: undefined),
4356
};
44-
}, [onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick]);
57+
}, [onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, onFieldNameChange]);
4558

4659
return (
4760
<EditableDiagramInteractionsContext.Provider value={value}>{children}</EditableDiagramInteractionsContext.Provider>

src/mocks/decorators/diagram-editable-interactions.decorator.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ function addFieldToNode(existingFields: NodeField[], parentFieldPath: string[])
4949
return fields;
5050
}
5151

52+
function renameField(existingFields: NodeField[], fieldPath: string[], newName: string) {
53+
const fields = existingFields.map(field => {
54+
if (JSON.stringify(field.id) !== JSON.stringify(fieldPath)) return field;
55+
return { ...field, name: newName, id: [...fieldPath.slice(0, -1), newName] };
56+
});
57+
return fields;
58+
}
59+
5260
let idAccumulator: string[];
5361
let lastDepth = 0;
5462
// Used to build a string array id based on field depth.
@@ -164,7 +172,20 @@ export const useEditableNodes = (initialNodes: NodeProps[]) => {
164172
[],
165173
);
166174

167-
return { nodes, onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick };
175+
const onFieldNameChange = useCallback((nodeId: string, fieldPath: string[], newName: string) => {
176+
setNodes(nodes =>
177+
nodes.map(node =>
178+
node.id === nodeId
179+
? {
180+
...node,
181+
fields: renameField(node.fields, fieldPath, newName),
182+
}
183+
: node,
184+
),
185+
);
186+
}, []);
187+
188+
return { nodes, onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, onFieldNameChange };
168189
};
169190

170191
export const DiagramEditableInteractionsDecorator: Decorator<DiagramProps> = (Story, context) => {

src/types/component-props.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export type OnAddFieldToNodeClickHandler = (event: ReactMouseEvent, nodeId: stri
3030
*/
3131
export type OnAddFieldToObjectFieldClickHandler = (event: ReactMouseEvent, nodeId: string, fieldPath: string[]) => void;
3232

33+
/**
34+
* Called when a field's name is edited.
35+
*/
36+
export type OnFieldNameChangeHandler = (nodeId: string, fieldPath: string[], newName: string) => void;
37+
3338
/**
3439
* Called when the canvas (pane) is clicked.
3540
*/
@@ -184,6 +189,11 @@ export interface DiagramProps {
184189
*/
185190
onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler;
186191

192+
/**
193+
* Callback when a field's name is changed.
194+
*/
195+
onFieldNameChange?: OnFieldNameChangeHandler;
196+
187197
/**
188198
* Whether the diagram should pan when dragging elements.
189199
*/

src/types/node.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,9 @@ export interface NodeField {
188188
* Indicates if the field is currently selected.
189189
*/
190190
selected?: boolean;
191+
192+
/**
193+
* Indicates if the field is editable (name and type can be changed).
194+
*/
195+
editable?: boolean;
191196
}

0 commit comments

Comments
 (0)