From aebd150cd1506078a8c33f34dba57883ed928c08 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Wed, 24 Sep 2025 14:30:02 +0200 Subject: [PATCH 1/5] add tests --- src/components/field/field.test.tsx | 63 ++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/components/field/field.test.tsx b/src/components/field/field.test.tsx index 86c619b..33f6a7c 100644 --- a/src/components/field/field.test.tsx +++ b/src/components/field/field.test.tsx @@ -15,12 +15,17 @@ const Field = (props: React.ComponentProps) => ( const FieldWithEditableInteractions = ({ onAddFieldToObjectFieldClick, + onFieldNameChange, ...fieldProps }: React.ComponentProps & { onAddFieldToObjectFieldClick?: () => void; + onFieldNameChange?: (newName: string) => void; }) => { return ( - + ); @@ -81,7 +86,63 @@ describe('field', () => { const button = screen.queryByRole('button'); expect(button).not.toBeInTheDocument(); }); + + it('Should allow field name editing an editable field', async () => { + const onFieldNameChangeMock = vi.fn(); + + const fieldId = ['ordersId']; + const newFieldName = 'newFieldName'; + render( + , + ); + const fieldName = screen.getByText('ordersId'); + expect(fieldName).toBeInTheDocument(); + await userEvent.dblClick(fieldName); + const input = screen.getByDisplayValue('ordersId'); + expect(input).toBeInTheDocument(); + await userEvent.clear(input); + await userEvent.type(input, newFieldName); + expect(input).toHaveValue(newFieldName); + expect(onFieldNameChangeMock).not.toHaveBeenCalled(); + await userEvent.type(input, '{enter}'); + expect(onFieldNameChangeMock).toHaveBeenCalledWith(DEFAULT_PROPS.nodeId, fieldId, newFieldName); + }); + + it('Should not allow field name editing if a field is not editable', async () => { + const onFieldNameChangeMock = vi.fn(); + + const fieldId = ['ordersId']; + render( + , + ); + const fieldName = screen.getByText('ordersId'); + expect(fieldName).toBeInTheDocument(); + await userEvent.dblClick(fieldName); + expect(screen.queryByDisplayValue('ordersId')).not.toBeUndefined(); + }); + + it('Should not allow editing if there is no callback', async () => { + const fieldId = ['ordersId']; + render( + , + ); + const fieldName = screen.getByText('ordersId'); + expect(fieldName).toBeInTheDocument(); + await userEvent.dblClick(fieldName); + expect(screen.queryByDisplayValue('ordersId')).not.toBeUndefined(); + }); }); + describe('With specific types', () => { it('shows [] with "array"', () => { render(); From 994357a755cbb5daeb1f9f77619aae59a443a81e Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Wed, 24 Sep 2025 13:30:20 +0200 Subject: [PATCH 2/5] feat/COMPASS-9798 inline field name editing --- src/components/canvas/canvas.tsx | 2 + src/components/diagram.stories.tsx | 2 + src/components/field/field-name-content.tsx | 62 +++++++++++++++++++ src/components/field/field.tsx | 19 ++++-- .../use-editable-diagram-interactions.tsx | 17 ++++- ...iagram-editable-interactions.decorator.tsx | 23 +++++++ src/types/component-props.ts | 10 +++ src/types/node.ts | 5 ++ 8 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/components/field/field-name-content.tsx diff --git a/src/components/canvas/canvas.tsx b/src/components/canvas/canvas.tsx index a3cbeda..2eb3bf4 100644 --- a/src/components/canvas/canvas.tsx +++ b/src/components/canvas/canvas.tsx @@ -59,6 +59,7 @@ export const Canvas = ({ id, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, + onFieldNameChange, onFieldClick, onNodeContextMenu, onNodeDrag, @@ -147,6 +148,7 @@ export const Canvas = ({ onFieldClick={onFieldClick} onAddFieldToNodeClick={onAddFieldToNodeClick} onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick} + onFieldNameChange={onFieldNameChange} > void; + onBlur?: () => void; +} + +export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => { + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(name); + + const handleSubmit = useCallback(() => { + setIsEditing(false); + onChange?.(value); + }, [value, onChange]); + + return isEditing ? ( + { + setValue(e.target.value); + }} + onBlur={handleSubmit} + onKeyDown={e => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') setIsEditing(false); + }} + /> + ) : ( + { + setIsEditing(true); + } + : undefined + } + > + {value} + + ); +}; diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index ec07894..eb4d601 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -14,6 +14,8 @@ import { NodeField, NodeGlyph, NodeType } from '@/types'; import { PreviewGroupArea } from '@/utilities/get-preview-group-area'; import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions'; +import { FieldNameContent } from './field-name-content'; + const FIELD_BORDER_ANIMATED_PADDING = spacing[100]; const FIELD_GLYPH_SPACING = spacing[400]; @@ -105,10 +107,6 @@ const FieldName = styled.div` ${ellipsisTruncation} `; -const InnerFieldName = styled.div` - ${ellipsisTruncation} -`; - const FieldType = styled.div` color: ${props => props.color}; flex: 0 0 ${LGSpacing[200] * 10}px; @@ -149,11 +147,12 @@ export const Field = ({ spacing = 0, selectable = false, selected = false, + editable = false, variant, }: Props) => { const { theme } = useDarkMode(); - const { onClickField } = useEditableDiagramInteractions(); + const { onClickField, onChangeFieldName } = useEditableDiagramInteractions(); const internalTheme = useTheme(); @@ -215,7 +214,15 @@ export const Field = ({ <> - {name} + onChangeFieldName(nodeId, Array.isArray(id) ? id : [id], newName) + : undefined + } + /> diff --git a/src/hooks/use-editable-diagram-interactions.tsx b/src/hooks/use-editable-diagram-interactions.tsx index 3761d9f..4da8b3e 100644 --- a/src/hooks/use-editable-diagram-interactions.tsx +++ b/src/hooks/use-editable-diagram-interactions.tsx @@ -1,11 +1,17 @@ import React, { createContext, useContext, useMemo, ReactNode } from 'react'; -import { OnFieldClickHandler, OnAddFieldToNodeClickHandler, OnAddFieldToObjectFieldClickHandler } from '@/types'; +import { + OnFieldClickHandler, + OnAddFieldToNodeClickHandler, + OnAddFieldToObjectFieldClickHandler, + OnFieldNameChangeHandler, +} from '@/types'; interface EditableDiagramInteractionsContextType { onClickField?: OnFieldClickHandler; onClickAddFieldToNode?: OnAddFieldToNodeClickHandler; onClickAddFieldToObjectField?: OnAddFieldToObjectFieldClickHandler; + onChangeFieldName?: OnFieldNameChangeHandler; } const EditableDiagramInteractionsContext = createContext(undefined); @@ -15,6 +21,7 @@ interface EditableDiagramInteractionsProviderProps { onFieldClick?: OnFieldClickHandler; onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler; onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; + onFieldNameChange?: OnFieldNameChangeHandler; } export const EditableDiagramInteractionsProvider: React.FC = ({ @@ -22,6 +29,7 @@ export const EditableDiagramInteractionsProvider: React.FC { const value: EditableDiagramInteractionsContextType = useMemo(() => { return { @@ -40,8 +48,13 @@ export const EditableDiagramInteractionsProvider: React.FC{children} diff --git a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx index 2442272..e316a4d 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -49,6 +49,15 @@ function addFieldToNode(existingFields: NodeField[], parentFieldPath: string[]) return fields; } +function renameField(existingFields: NodeField[], fieldPath: string[], newName: string) { + const fields = existingFields.map(field => { + if (JSON.stringify(field.id) !== JSON.stringify(fieldPath)) return field; + return { ...field, name: newName, id: [...fieldPath.slice(0, -1), newName] }; + }); + console.log('Renamed fields:', fields); + return fields; +} + export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { const [nodes, setNodes] = useState(context.args.nodes); @@ -107,6 +116,19 @@ export const DiagramEditableInteractionsDecorator: Decorator = (St [], ); + const onFieldNameChange = useCallback((nodeId: string, fieldPath: string[], newName: string) => { + setNodes(nodes => + nodes.map(node => + node.id === nodeId + ? { + ...node, + fields: renameField(node.fields, fieldPath, newName), + } + : node, + ), + ); + }, []); + return Story({ ...context, args: { @@ -115,6 +137,7 @@ export const DiagramEditableInteractionsDecorator: Decorator = (St onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, + onFieldNameChange, }, }); }; diff --git a/src/types/component-props.ts b/src/types/component-props.ts index a5c1377..a9fb74a 100644 --- a/src/types/component-props.ts +++ b/src/types/component-props.ts @@ -30,6 +30,11 @@ export type OnAddFieldToNodeClickHandler = (event: ReactMouseEvent, nodeId: stri */ export type OnAddFieldToObjectFieldClickHandler = (event: ReactMouseEvent, nodeId: string, fieldPath: string[]) => void; +/** + * Called when a field's name is edited. + */ +export type OnFieldNameChangeHandler = (nodeId: string, fieldPath: string[], newName: string) => void; + /** * Called when the canvas (pane) is clicked. */ @@ -184,6 +189,11 @@ export interface DiagramProps { */ onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler; + /** + * Callback when a field's name is changed. + */ + onFieldNameChange?: OnFieldNameChangeHandler; + /** * Whether the diagram should pan when dragging elements. */ diff --git a/src/types/node.ts b/src/types/node.ts index 8a0a860..3ff51f9 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -188,4 +188,9 @@ export interface NodeField { * Indicates if the field is currently selected. */ selected?: boolean; + + /** + * Indicates if the field is editable (name and type can be changed). + */ + editable?: boolean; } From a5bb83e432d0acd8e0e021abe1fb755316a69c42 Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 26 Sep 2025 12:40:49 +0200 Subject: [PATCH 3/5] cleanup and callback --- src/components/field/field.tsx | 13 +++++++------ .../diagram-editable-interactions.decorator.tsx | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index eb4d601..ae0a957 100644 --- a/src/components/field/field.tsx +++ b/src/components/field/field.tsx @@ -4,7 +4,7 @@ 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 { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles'; import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants'; @@ -210,6 +210,11 @@ export const Field = ({ return internalTheme.node.mongoDBAccent; }; + const handleNameChange = useCallback( + (newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName), + [onChangeFieldName, id, nodeId], + ); + const content = ( <> @@ -217,11 +222,7 @@ export const Field = ({ onChangeFieldName(nodeId, Array.isArray(id) ? id : [id], newName) - : undefined - } + onChange={onChangeFieldName ? handleNameChange : undefined} /> diff --git a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx index e316a4d..cca6e35 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -54,7 +54,6 @@ function renameField(existingFields: NodeField[], fieldPath: string[], newName: if (JSON.stringify(field.id) !== JSON.stringify(fieldPath)) return field; return { ...field, name: newName, id: [...fieldPath.slice(0, -1), newName] }; }); - console.log('Renamed fields:', fields); return fields; } From 07541559a08ac20b683573cb44ef604818444a9c Mon Sep 17 00:00:00 2001 From: Paula Stachova Date: Fri, 26 Sep 2025 13:09:57 +0200 Subject: [PATCH 4/5] autofocus --- src/components/field/field-name-content.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/field/field-name-content.tsx b/src/components/field/field-name-content.tsx index 4cc9a1f..6a9a5b0 100644 --- a/src/components/field/field-name-content.tsx +++ b/src/components/field/field-name-content.tsx @@ -1,5 +1,5 @@ import { styled } from 'storybook/internal/theming'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ellipsisTruncation } from '@/styles/styles'; import { DEFAULT_FIELD_HEIGHT } from '@/utilities/constants'; @@ -28,14 +28,25 @@ interface FieldNameProps { export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(name); + const textareaRef = useRef(null); const handleSubmit = useCallback(() => { setIsEditing(false); onChange?.(value); }, [value, onChange]); + useEffect(() => { + if (isEditing) { + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.select(); + }); + } + }, [isEditing]); + return isEditing ? ( { setValue(e.target.value); @@ -45,6 +56,7 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) if (e.key === 'Enter') handleSubmit(); if (e.key === 'Escape') setIsEditing(false); }} + title="Edit field name" /> ) : ( Date: Tue, 30 Sep 2025 10:42:19 +0200 Subject: [PATCH 5/5] update input --- src/components/field/field-name-content.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/field/field-name-content.tsx b/src/components/field/field-name-content.tsx index 6a9a5b0..fb38ce9 100644 --- a/src/components/field/field-name-content.tsx +++ b/src/components/field/field-name-content.tsx @@ -5,10 +5,12 @@ import { ellipsisTruncation } from '@/styles/styles'; import { DEFAULT_FIELD_HEIGHT } from '@/utilities/constants'; const InnerFieldName = styled.div` + width: 100%; + min-height: ${DEFAULT_FIELD_HEIGHT}px; ${ellipsisTruncation} `; -const InlineTextarea = styled.textarea` +const InlineInput = styled.input` border: none; background: none; height: ${DEFAULT_FIELD_HEIGHT}px; @@ -28,7 +30,7 @@ interface FieldNameProps { export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(name); - const textareaRef = useRef(null); + const textInputRef = useRef(null); const handleSubmit = useCallback(() => { setIsEditing(false); @@ -38,15 +40,16 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) useEffect(() => { if (isEditing) { setTimeout(() => { - textareaRef.current?.focus(); - textareaRef.current?.select(); + textInputRef.current?.focus(); + textInputRef.current?.select(); }); } }, [isEditing]); return isEditing ? ( - { setValue(e.target.value);