Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const Canvas = ({
id,
onAddFieldToNodeClick,
onAddFieldToObjectFieldClick,
onFieldNameChange,
onFieldClick,
onNodeContextMenu,
onNodeDrag,
Expand Down Expand Up @@ -147,6 +148,7 @@ export const Canvas = ({
onFieldClick={onFieldClick}
onAddFieldToNodeClick={onAddFieldToNodeClick}
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
onFieldNameChange={onFieldNameChange}
>
<ReactFlowWrapper>
<ReactFlow
Expand Down
2 changes: 2 additions & 0 deletions src/components/diagram.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const DiagramWithEditInteractions: Story = {
...field,
id: idFromDepthAccumulator(field.name, field.depth),
selectable: true,
editable: true,
})),
],
},
Expand All @@ -76,6 +77,7 @@ export const DiagramWithEditInteractions: Story = {
...field,
id: idFromDepthAccumulator(field.name, field.depth),
selectable: true,
editable: true,
})),
],
},
Expand Down
81 changes: 81 additions & 0 deletions src/components/field/field-name-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { styled } from 'storybook/internal/theming';
import { useCallback, useEffect, useRef, useState } from 'react';

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 InlineInput = styled.input`
border: none;
background: none;
height: ${DEFAULT_FIELD_HEIGHT}px;
color: inherit;
font-size: inherit;
font-family: inherit;
font-style: inherit;
`;

interface FieldNameProps {
name: string;
isEditable?: boolean;
onChange?: (newName: string) => void;
onBlur?: () => void;
}

export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => {
Copy link
Collaborator

@lchans lchans Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to have two seperate components, one which is editable and the other (the current flow) which is not? At the moment we're passing in quite a few (optional) props which require a conditional check, especially in that useEffect. I could see someone accidentally adding in a change which re-renders this whole component too, rather than just the edited component and vice-versa

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on the edge about this one, the split components could accidentally start diverging more than intended (even in the same app we'll have editable & not editable fields - like _id, so we expect them to be consistent)

const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(name);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would name ever change externally? Would we need a useEffect to re-hydrate this state?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might actually, we have a sidebar where they can edit too

const textInputRef = useRef<HTMLInputElement>(null);

const handleSubmit = useCallback(() => {
setIsEditing(false);
onChange?.(value);
}, [value, onChange]);

const handleKeyboardEvent = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') {
setValue(name);
setIsEditing(false);
}
},
[handleSubmit, name],
);

const handleNameDoubleClick = useCallback(() => {
setIsEditing(true);
}, []);

const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}, []);

useEffect(() => {
if (isEditing) {
setTimeout(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this setTimeout used for? Do we need this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to postpone the focus action until the input is rendered. I also wasn't sure if it was needed but apparently it is. From the docs:

If your Effect wasn’t caused by an interaction (like a click), React will generally let the browser paint the updated screen first before running your Effect.
https://react.dev/reference/react/useEffect#caveats

textInputRef.current?.focus();
textInputRef.current?.select();
});
}
}, [isEditing]);

return isEditing ? (
<InlineInput
type="text"
ref={textInputRef}
value={value}
onChange={handleChange}
onBlur={handleSubmit}
onKeyDown={handleKeyboardEvent}
title="Edit field name"
/>
) : (
<InnerFieldName onDoubleClick={onChange && isEditable ? handleNameDoubleClick : undefined}>{value}</InnerFieldName>
);
};
63 changes: 62 additions & 1 deletion src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@ const Field = (props: React.ComponentProps<typeof FieldComponent>) => (

const FieldWithEditableInteractions = ({
onAddFieldToObjectFieldClick,
onFieldNameChange,
...fieldProps
}: React.ComponentProps<typeof FieldComponent> & {
onAddFieldToObjectFieldClick?: () => void;
onFieldNameChange?: (newName: string) => void;
}) => {
return (
<EditableDiagramInteractionsProvider onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}>
<EditableDiagramInteractionsProvider
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
onFieldNameChange={onFieldNameChange}
>
<FieldComponent {...fieldProps} />
</EditableDiagramInteractionsProvider>
);
Expand Down Expand Up @@ -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(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={fieldId}
editable={true}
onFieldNameChange={onFieldNameChangeMock}
/>,
);
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(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={fieldId}
editable={false}
onFieldNameChange={onFieldNameChangeMock}
/>,
);
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(
<FieldWithEditableInteractions {...DEFAULT_PROPS} id={fieldId} editable={true} onFieldNameChange={undefined} />,
);
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(<Field {...DEFAULT_PROPS} type="array" />);
Expand Down
22 changes: 15 additions & 7 deletions src/components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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];

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 = (
<>
<FieldName>
<FieldDepth depth={depth} />
<InnerFieldName>{name}</InnerFieldName>
<FieldNameContent
name={name}
isEditable={editable}
onChange={onChangeFieldName ? handleNameChange : undefined}
/>
</FieldName>
<FieldType color={getSecondaryTextColor()}>
<FieldTypeContent type={type} nodeId={nodeId} id={id} />
Expand Down
17 changes: 15 additions & 2 deletions src/hooks/use-editable-diagram-interactions.tsx
Original file line number Diff line number Diff line change
@@ -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<EditableDiagramInteractionsContextType | undefined>(undefined);
Expand All @@ -15,13 +21,15 @@ interface EditableDiagramInteractionsProviderProps {
onFieldClick?: OnFieldClickHandler;
onAddFieldToNodeClick?: OnAddFieldToNodeClickHandler;
onAddFieldToObjectFieldClick?: OnAddFieldToObjectFieldClickHandler;
onFieldNameChange?: OnFieldNameChangeHandler;
}

export const EditableDiagramInteractionsProvider: React.FC<EditableDiagramInteractionsProviderProps> = ({
children,
onFieldClick,
onAddFieldToNodeClick,
onAddFieldToObjectFieldClick,
onFieldNameChange,
}) => {
const value: EditableDiagramInteractionsContextType = useMemo(() => {
return {
Expand All @@ -40,8 +48,13 @@ export const EditableDiagramInteractionsProvider: React.FC<EditableDiagramIntera
onClickAddFieldToObjectField: onAddFieldToObjectFieldClick,
}
: undefined),
...(onFieldNameChange
? {
onChangeFieldName: onFieldNameChange,
}
: undefined),
};
}, [onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick]);
}, [onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, onFieldNameChange]);

return (
<EditableDiagramInteractionsContext.Provider value={value}>{children}</EditableDiagramInteractionsContext.Provider>
Expand Down
23 changes: 22 additions & 1 deletion src/mocks/decorators/diagram-editable-interactions.decorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

let idAccumulator: string[];
let lastDepth = 0;
// Used to build a string array id based on field depth.
Expand Down Expand Up @@ -164,7 +172,20 @@ export const useEditableNodes = (initialNodes: NodeProps[]) => {
[],
);

return { nodes, onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick };
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 { nodes, onFieldClick, onAddFieldToNodeClick, onAddFieldToObjectFieldClick, onFieldNameChange };
};

export const DiagramEditableInteractionsDecorator: Decorator<DiagramProps> = (Story, context) => {
Expand Down
10 changes: 10 additions & 0 deletions src/types/component-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions src/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}