Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@leafygreen-ui/inline-definition": "^9.0.5",
"@leafygreen-ui/leafygreen-provider": "^5.0.2",
"@leafygreen-ui/palette": "^5.0.0",
"@leafygreen-ui/select": "^16.2.0",
"@leafygreen-ui/tokens": "^3.2.1",
"@leafygreen-ui/typography": "^22.1.0",
"@xyflow/react": "12.5.1",
Expand Down
4 changes: 4 additions & 0 deletions src/components/canvas/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ export const Canvas = ({
edges: externalEdges,
onConnect,
id,
fieldTypes,
onAddFieldToNodeClick,
onNodeExpandToggle,
onAddFieldToObjectFieldClick,
onFieldNameChange,
onFieldTypeChange,
onFieldClick,
onNodeContextMenu,
onNodeDrag,
Expand Down Expand Up @@ -153,6 +155,8 @@ export const Canvas = ({
onNodeExpandToggle={onNodeExpandToggle}
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
onFieldNameChange={onFieldNameChange}
onFieldTypeChange={onFieldTypeChange}
fieldTypes={fieldTypes}
>
<ReactFlowWrapper>
<ReactFlow
Expand Down
104 changes: 104 additions & 0 deletions src/components/field/field-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import styled from '@emotion/styled';
import { fontWeights } from '@leafygreen-ui/tokens';
import { useCallback, useEffect, useRef, useState } from 'react';

import { ellipsisTruncation } from '@/styles/styles';
import { FieldDepth } from '@/components/field/field-depth';
import { FieldType } from '@/components/field/field-type';
import { FieldId, NodeField } from '@/types';
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';

import { FieldNameContent } from './field-name-content';

const FieldContentWrapper = styled.div`
display: contents;
`;

const FieldName = styled.div`
display: flex;
flex-grow: 1;
align-items: center;
font-weight: ${fontWeights.medium};
${ellipsisTruncation};
`;

interface FieldContentProps extends NodeField {
id: FieldId;
isEditable: boolean;
isDisabled: boolean;
nodeId: string;
}

export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id, nodeId }: FieldContentProps) => {
const [isEditing, setIsEditing] = useState(false);
const fieldContentRef = useRef<HTMLDivElement>(null);

const { onChangeFieldName, onChangeFieldType, fieldTypes } = useEditableDiagramInteractions();
const handleNameChange = useCallback(
(newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName),
[onChangeFieldName, id, nodeId],
);
const handleTypeChange = useCallback(
(newType: string[]) => onChangeFieldType?.(nodeId, Array.isArray(id) ? id : [id], newType),
[onChangeFieldType, id, nodeId],
);

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

useEffect(() => {
// When clicking outside of the field content while editing, stop editing.
const container = fieldContentRef.current;
const listener = (event: Event) => {
if (event.composedPath().includes(container!)) {
return;
}
setIsEditing(false);
};

if (container && isEditable) {
document.addEventListener('click', listener);
} else {
document.removeEventListener('click', listener);
}
return () => {
document.removeEventListener('click', listener);
};
}, [isEditable]);

useEffect(() => {
if (!isEditable) {
setIsEditing(false);
}
}, [isEditable]);

const isNameEditable = isEditing && isEditable && !!onChangeFieldName;
const isTypeEditable = isEditing && isEditable && !!onChangeFieldType && (fieldTypes ?? []).length > 0;

return (
<FieldContentWrapper
data-testid={`field-content-${name}`}
onDoubleClick={isEditable ? handleDoubleClick : undefined}
ref={fieldContentRef}
>
<FieldName>
<FieldDepth depth={depth} />
<FieldNameContent
name={name}
isEditing={isNameEditable}
onChange={handleNameChange}
onCancelEditing={() => setIsEditing(false)}
/>
</FieldName>
<FieldType
type={type}
nodeId={nodeId}
id={id}
isEditing={isTypeEditable}
isDisabled={isDisabled}
onChange={handleTypeChange}
/>
</FieldContentWrapper>
);
};
32 changes: 9 additions & 23 deletions src/components/field/field-name-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ const InlineInput = styled.input`
font-size: inherit;
font-family: inherit;
font-style: inherit;
width: 100%;
`;

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

export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps) => {
const [isEditing, setIsEditing] = useState(false);
export const FieldNameContent = ({ name, isEditing, onChange, onCancelEditing }: FieldNameProps) => {
const [value, setValue] = useState(name);
const textInputRef = useRef<HTMLInputElement>(null);

Expand All @@ -37,38 +37,24 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps)
}, [name]);

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

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

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

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

useEffect(() => {
if (isEditing) {
setTimeout(() => {
textInputRef.current?.focus();
textInputRef.current?.select();
});
}
}, [isEditing]);

return isEditing ? (
<InlineInput
type="text"
Expand All @@ -80,6 +66,6 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps)
title="Edit field name"
/>
) : (
<InnerFieldName onDoubleClick={onChange && isEditable ? handleNameDoubleClick : undefined}>{value}</InnerFieldName>
<InnerFieldName title={value}>{value}</InnerFieldName>
);
};
12 changes: 7 additions & 5 deletions src/components/field/field-type-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ export const FieldTypeContent = ({
type,
nodeId,
id,
isAddFieldToObjectDisabled,
}: {
id: string | string[];
nodeId: string;
type?: string | string[];
isAddFieldToObjectDisabled?: boolean;
}) => {
const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions();

Expand All @@ -53,8 +55,8 @@ export const FieldTypeContent = ({
if (type === 'object') {
return (
<ObjectTypeContainer>
{'{}'}
{onClickAddFieldToObject && (
<span title="object">{'{}'}</span>
Copy link
Collaborator

Choose a reason for hiding this comment

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

ah, thanks for adding the titles as well!

{onClickAddFieldToObject && !isAddFieldToObjectDisabled && (
<DiagramIconButton
data-testid={`object-field-type-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
onClick={onClickAddFieldToObject}
Expand All @@ -69,7 +71,7 @@ export const FieldTypeContent = ({
}

if (type === 'array') {
return '[]';
return <span title="array">{'[]'}</span>;
}

if (Array.isArray(type)) {
Expand All @@ -78,7 +80,7 @@ export const FieldTypeContent = ({
}

if (type.length === 1) {
return <>{type}</>;
return <span title={type[0]}>{type}</span>;
}

const typesString = type.join(', ');
Expand All @@ -93,5 +95,5 @@ export const FieldTypeContent = ({
);
}

return <>{type}</>;
return <span title={type}>{type}</span>;
};
137 changes: 137 additions & 0 deletions src/components/field/field-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { spacing, color } from '@leafygreen-ui/tokens';
import { Select, Option } from '@leafygreen-ui/select';
import Icon from '@leafygreen-ui/icon';
import { useEffect, useRef, useState } from 'react';

import { ellipsisTruncation } from '@/styles/styles';
import { FieldTypeContent } from '@/components/field/field-type-content';
import { FieldId } from '@/types';
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';

const FieldTypeWrapper = styled.div<{ color: string }>`
color: ${props => props.color};
font-weight: normal;
padding-left:${spacing[100]}px;
padding-right ${spacing[50]}px;
flex: 0 0 ${spacing[200] * 10}px;
display: flex;
justify-content: flex-end;
align-items: center;
`;

const FieldContentWrapper = styled.div`
max-width: ${spacing[200] * 10}px;
${ellipsisTruncation}
`;

const CaretIconWrapper = styled.div`
display: flex;
`;

const StyledSelect = styled(Select)`
visibility: hidden;
height: 0;
width: 0;
& > button {
height: 0;
width: 0;
border: none;
box-shadow: none;
}
`;

export function FieldType({
id,
type,
nodeId,
isEditing,
isDisabled,
onChange,
}: {
id: FieldId;
nodeId: string;
type: string | string[] | undefined;
isEditing: boolean;
isDisabled: boolean;
onChange: (newType: string[]) => void;
}) {
const internalTheme = useTheme();
const { theme } = useDarkMode();
const { fieldTypes } = useEditableDiagramInteractions();
const [isSelectOpen, setIsSelectOpen] = useState(false);
const fieldTypeRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isEditing) {
setIsSelectOpen(false);
}
}, [isEditing]);

const getSecondaryTextColor = () => {
if (isDisabled) {
return internalTheme.node.disabledColor;
}
return color[theme].text.secondary.default;
};

return (
<FieldTypeWrapper
ref={fieldTypeRef}
{...(isEditing
? {
onClick: () => setIsSelectOpen(!isSelectOpen),
}
: undefined)}
color={getSecondaryTextColor()}
>
{/**
* Rendering hidden select first so that whenever popover shows it, its relative
* to the field type position. LG Select does not provide a way to set the
* position of the popover using refs.
*/}
{isEditing && (
<StyledSelect
aria-label="Select field type"
size="xsmall"
renderMode="portal"
open={isSelectOpen}
onChange={val => {
if (val) {
// Currently its a single select, so we are returning it as an array.
// That way once we have multi-select support, we don't need to change
// the API and it should work seemlessly for clients.
// Trigger onChange only if the value is different
if (type !== val) {
onChange([val]);
}
setIsSelectOpen(false);
}
}}
// As its not multi-select, we can just use the first value. Once LG-5657
// is implemented, we can use ComboBox component for multi-select support
value={Array.isArray(type) ? type[0] : type || ''}
allowDeselect={false}
dropdownWidthBasis="option"
tabIndex={0}
>
{fieldTypes!.map(fieldType => (
<Option key={fieldType} value={fieldType}>
{fieldType}
</Option>
))}
</StyledSelect>
)}
<FieldContentWrapper>
<FieldTypeContent type={type} nodeId={nodeId} id={id} isAddFieldToObjectDisabled={isEditing} />
</FieldContentWrapper>
{isEditing && (
<CaretIconWrapper title="Select field type" aria-label="Select field type">
<Icon glyph="CaretDown" />
</CaretIconWrapper>
)}
</FieldTypeWrapper>
);
}
Loading