Skip to content
Open
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 @@ -65,6 +65,7 @@ export const DiagramWithEditInteractions: Story = {
...field,
id: idFromDepthAccumulator(field.name, field.depth),
selectable: true,
editable: true,
})),
],
},
Expand All @@ -75,6 +76,7 @@ export const DiagramWithEditInteractions: Story = {
...field,
id: idFromDepthAccumulator(field.name, field.depth),
selectable: true,
editable: true,
})),
],
},
Expand Down
77 changes: 77 additions & 0 deletions src/components/field/field-name-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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

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]);

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?

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

return isEditing ? (
<InlineInput
type="text"
ref={textInputRef}
value={value}
onChange={e => {
setValue(e.target.value);
}}
onBlur={handleSubmit}
onKeyDown={e => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we move this and the onDoubleClick into its own function for better readability, like the other components in this repo?

if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') setIsEditing(false);
Copy link
Collaborator

Choose a reason for hiding this comment

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

To me it's more expected behaviour to reset the field name to it's original value on Escape as opposed to committing the changes.

}}
title="Edit field name"
/>
) : (
<InnerFieldName
onDoubleClick={
onChange && isEditable
? () => {
setIsEditing(true);
}
: 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
22 changes: 22 additions & 0 deletions 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;
}

export const DiagramEditableInteractionsDecorator: Decorator<DiagramProps> = (Story, context) => {
const [nodes, setNodes] = useState<NodeProps[]>(context.args.nodes);

Expand Down Expand Up @@ -107,6 +115,19 @@ export const DiagramEditableInteractionsDecorator: Decorator<DiagramProps> = (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: {
Expand All @@ -115,6 +136,7 @@ export const DiagramEditableInteractionsDecorator: Decorator<DiagramProps> = (St
onFieldClick,
onAddFieldToNodeClick,
onAddFieldToObjectFieldClick,
onFieldNameChange,
},
});
};
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;
}