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 textInputRef = useRef(null); + + const handleSubmit = useCallback(() => { + setIsEditing(false); + onChange?.(value); + }, [value, onChange]); + + useEffect(() => { + if (isEditing) { + setTimeout(() => { + textInputRef.current?.focus(); + textInputRef.current?.select(); + }); + } + }, [isEditing]); + + return isEditing ? ( + { + setValue(e.target.value); + }} + onBlur={handleSubmit} + onKeyDown={e => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') setIsEditing(false); + }} + title="Edit field name" + /> + ) : ( + { + setIsEditing(true); + } + : undefined + } + > + {value} + + ); +}; 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(); diff --git a/src/components/field/field.tsx b/src/components/field/field.tsx index ec07894..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'; @@ -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(); @@ -211,11 +210,20 @@ 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 = ( <> - {name} + 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..cca6e35 100644 --- a/src/mocks/decorators/diagram-editable-interactions.decorator.tsx +++ b/src/mocks/decorators/diagram-editable-interactions.decorator.tsx @@ -49,6 +49,14 @@ 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] }; + }); + return fields; +} + export const DiagramEditableInteractionsDecorator: Decorator = (Story, context) => { const [nodes, setNodes] = useState(context.args.nodes); @@ -107,6 +115,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 +136,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; }