Skip to content

Commit 124e316

Browse files
authored
feat/COMPASS-9799 Add support to change field type (#160)
* feat: add field type select * position correctly * add tests * storybook * dismiss on click * add lg ticket * return multiple types and fix closing of popover * fix test * make editable on double click * clean up and testing * tests * validate this compass * yarn lock
1 parent c473f4a commit 124e316

File tree

12 files changed

+733
-82
lines changed

12 files changed

+733
-82
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@leafygreen-ui/inline-definition": "^9.0.5",
5656
"@leafygreen-ui/leafygreen-provider": "^5.0.2",
5757
"@leafygreen-ui/palette": "^5.0.0",
58+
"@leafygreen-ui/select": "^16.2.0",
5859
"@leafygreen-ui/tokens": "^3.2.1",
5960
"@leafygreen-ui/tooltip": "^14.2.1",
6061
"@leafygreen-ui/typography": "^22.1.0",

src/components/canvas/canvas.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,12 @@ export const Canvas = ({
5959
edges: externalEdges,
6060
onConnect,
6161
id,
62+
fieldTypes,
6263
onAddFieldToNodeClick,
6364
onNodeExpandToggle,
6465
onAddFieldToObjectFieldClick,
6566
onFieldNameChange,
67+
onFieldTypeChange,
6668
onFieldClick,
6769
onNodeContextMenu,
6870
onNodeDrag,
@@ -153,6 +155,8 @@ export const Canvas = ({
153155
onNodeExpandToggle={onNodeExpandToggle}
154156
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
155157
onFieldNameChange={onFieldNameChange}
158+
onFieldTypeChange={onFieldTypeChange}
159+
fieldTypes={fieldTypes}
156160
>
157161
<ReactFlowWrapper>
158162
<ReactFlow
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import styled from '@emotion/styled';
2+
import { fontWeights } from '@leafygreen-ui/tokens';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
5+
import { ellipsisTruncation } from '@/styles/styles';
6+
import { FieldDepth } from '@/components/field/field-depth';
7+
import { FieldType } from '@/components/field/field-type';
8+
import { FieldId, NodeField } from '@/types';
9+
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
10+
11+
import { FieldNameContent } from './field-name-content';
12+
13+
const FieldContentWrapper = styled.div`
14+
display: contents;
15+
`;
16+
17+
const FieldName = styled.div`
18+
display: flex;
19+
flex-grow: 1;
20+
align-items: center;
21+
font-weight: ${fontWeights.medium};
22+
${ellipsisTruncation};
23+
`;
24+
25+
interface FieldContentProps extends NodeField {
26+
id: FieldId;
27+
isEditable: boolean;
28+
isDisabled: boolean;
29+
nodeId: string;
30+
}
31+
32+
export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id, nodeId }: FieldContentProps) => {
33+
const [isEditing, setIsEditing] = useState(false);
34+
const fieldContentRef = useRef<HTMLDivElement>(null);
35+
36+
const { onChangeFieldName, onChangeFieldType, fieldTypes } = useEditableDiagramInteractions();
37+
const handleNameChange = useCallback(
38+
(newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName),
39+
[onChangeFieldName, id, nodeId],
40+
);
41+
const handleTypeChange = useCallback(
42+
(newType: string[]) => onChangeFieldType?.(nodeId, Array.isArray(id) ? id : [id], newType),
43+
[onChangeFieldType, id, nodeId],
44+
);
45+
46+
const handleDoubleClick = useCallback(() => {
47+
setIsEditing(true);
48+
}, []);
49+
50+
useEffect(() => {
51+
// When clicking outside of the field content while editing, stop editing.
52+
const container = fieldContentRef.current;
53+
const listener = (event: Event) => {
54+
if (event.composedPath().includes(container!)) {
55+
return;
56+
}
57+
setIsEditing(false);
58+
};
59+
60+
if (container && isEditable) {
61+
document.addEventListener('click', listener);
62+
} else {
63+
document.removeEventListener('click', listener);
64+
}
65+
return () => {
66+
document.removeEventListener('click', listener);
67+
};
68+
}, [isEditable]);
69+
70+
useEffect(() => {
71+
if (!isEditable) {
72+
setIsEditing(false);
73+
}
74+
}, [isEditable]);
75+
76+
const isNameEditable = isEditing && isEditable && !!onChangeFieldName;
77+
const isTypeEditable = isEditing && isEditable && !!onChangeFieldType && (fieldTypes ?? []).length > 0;
78+
79+
return (
80+
<FieldContentWrapper
81+
data-testid={`field-content-${name}`}
82+
onDoubleClick={isEditable ? handleDoubleClick : undefined}
83+
ref={fieldContentRef}
84+
>
85+
<FieldName>
86+
<FieldDepth depth={depth} />
87+
<FieldNameContent
88+
name={name}
89+
isEditing={isNameEditable}
90+
onChange={handleNameChange}
91+
onCancelEditing={() => setIsEditing(false)}
92+
/>
93+
</FieldName>
94+
<FieldType
95+
type={type}
96+
nodeId={nodeId}
97+
id={id}
98+
isEditing={isTypeEditable}
99+
isDisabled={isDisabled}
100+
onChange={handleTypeChange}
101+
/>
102+
</FieldContentWrapper>
103+
);
104+
};

src/components/field/field-name-content.tsx

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,17 @@ const InlineInput = styled.input`
1818
font-size: inherit;
1919
font-family: inherit;
2020
font-style: inherit;
21+
width: 100%;
2122
`;
2223

2324
interface FieldNameProps {
2425
name: string;
25-
isEditable?: boolean;
26-
onChange?: (newName: string) => void;
27-
onBlur?: () => void;
26+
isEditing: boolean;
27+
onChange: (newName: string) => void;
28+
onCancelEditing: () => void;
2829
}
2930

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

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

3939
const handleSubmit = useCallback(() => {
40-
setIsEditing(false);
41-
onChange?.(value);
40+
onChange(value);
4241
}, [value, onChange]);
4342

4443
const handleKeyboardEvent = useCallback(
4544
(e: React.KeyboardEvent<HTMLInputElement>) => {
4645
if (e.key === 'Enter') handleSubmit();
4746
if (e.key === 'Escape') {
4847
setValue(name);
49-
setIsEditing(false);
48+
onCancelEditing();
5049
}
5150
},
52-
[handleSubmit, name],
51+
[handleSubmit, onCancelEditing, name],
5352
);
5453

55-
const handleNameDoubleClick = useCallback(() => {
56-
setIsEditing(true);
57-
}, []);
58-
5954
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
6055
setValue(e.target.value);
6156
}, []);
6257

63-
useEffect(() => {
64-
if (isEditing) {
65-
setTimeout(() => {
66-
textInputRef.current?.focus();
67-
textInputRef.current?.select();
68-
});
69-
}
70-
}, [isEditing]);
71-
7258
return isEditing ? (
7359
<InlineInput
7460
type="text"
@@ -80,6 +66,6 @@ export const FieldNameContent = ({ name, isEditable, onChange }: FieldNameProps)
8066
title="Edit field name"
8167
/>
8268
) : (
83-
<InnerFieldName onDoubleClick={onChange && isEditable ? handleNameDoubleClick : undefined}>{value}</InnerFieldName>
69+
<InnerFieldName title={value}>{value}</InnerFieldName>
8470
);
8571
};

src/components/field/field-type-content.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ export const FieldTypeContent = ({
3131
type,
3232
nodeId,
3333
id,
34+
isAddFieldToObjectDisabled,
3435
}: {
3536
id: string | string[];
3637
nodeId: string;
3738
type?: string | string[];
39+
isAddFieldToObjectDisabled?: boolean;
3840
}) => {
3941
const { onClickAddFieldToObjectField: _onClickAddFieldToObjectField } = useEditableDiagramInteractions();
4042

@@ -53,8 +55,8 @@ export const FieldTypeContent = ({
5355
if (type === 'object') {
5456
return (
5557
<ObjectTypeContainer>
56-
{'{}'}
57-
{onClickAddFieldToObject && (
58+
<span title="object">{'{}'}</span>
59+
{onClickAddFieldToObject && !isAddFieldToObjectDisabled && (
5860
<DiagramIconButton
5961
data-testid={`object-field-type-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
6062
onClick={onClickAddFieldToObject}
@@ -69,7 +71,7 @@ export const FieldTypeContent = ({
6971
}
7072

7173
if (type === 'array') {
72-
return '[]';
74+
return <span title="array">{'[]'}</span>;
7375
}
7476

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

8082
if (type.length === 1) {
81-
return <>{type}</>;
83+
return <span title={type[0]}>{type}</span>;
8284
}
8385

8486
const typesString = type.join(', ');
@@ -93,5 +95,5 @@ export const FieldTypeContent = ({
9395
);
9496
}
9597

96-
return <>{type}</>;
98+
return <span title={type}>{type}</span>;
9799
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { useTheme } from '@emotion/react';
2+
import styled from '@emotion/styled';
3+
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
4+
import { spacing, color } from '@leafygreen-ui/tokens';
5+
import { Select, Option } from '@leafygreen-ui/select';
6+
import Icon from '@leafygreen-ui/icon';
7+
import { useEffect, useRef, useState } from 'react';
8+
9+
import { ellipsisTruncation } from '@/styles/styles';
10+
import { FieldTypeContent } from '@/components/field/field-type-content';
11+
import { FieldId } from '@/types';
12+
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
13+
14+
const FieldTypeWrapper = styled.div<{ color: string }>`
15+
color: ${props => props.color};
16+
font-weight: normal;
17+
padding-left:${spacing[100]}px;
18+
padding-right ${spacing[50]}px;
19+
flex: 0 0 ${spacing[200] * 10}px;
20+
display: flex;
21+
justify-content: flex-end;
22+
align-items: center;
23+
`;
24+
25+
const FieldContentWrapper = styled.div`
26+
max-width: ${spacing[200] * 10}px;
27+
${ellipsisTruncation}
28+
`;
29+
30+
const CaretIconWrapper = styled.div`
31+
display: flex;
32+
`;
33+
34+
const StyledSelect = styled(Select)`
35+
visibility: hidden;
36+
height: 0;
37+
width: 0;
38+
& > button {
39+
height: 0;
40+
width: 0;
41+
border: none;
42+
box-shadow: none;
43+
}
44+
`;
45+
46+
export function FieldType({
47+
id,
48+
type,
49+
nodeId,
50+
isEditing,
51+
isDisabled,
52+
onChange,
53+
}: {
54+
id: FieldId;
55+
nodeId: string;
56+
type: string | string[] | undefined;
57+
isEditing: boolean;
58+
isDisabled: boolean;
59+
onChange: (newType: string[]) => void;
60+
}) {
61+
const internalTheme = useTheme();
62+
const { theme } = useDarkMode();
63+
const { fieldTypes } = useEditableDiagramInteractions();
64+
const [isSelectOpen, setIsSelectOpen] = useState(false);
65+
const fieldTypeRef = useRef<HTMLDivElement>(null);
66+
67+
useEffect(() => {
68+
if (!isEditing) {
69+
setIsSelectOpen(false);
70+
}
71+
}, [isEditing]);
72+
73+
const getSecondaryTextColor = () => {
74+
if (isDisabled) {
75+
return internalTheme.node.disabledColor;
76+
}
77+
return color[theme].text.secondary.default;
78+
};
79+
80+
return (
81+
<FieldTypeWrapper
82+
ref={fieldTypeRef}
83+
{...(isEditing
84+
? {
85+
onClick: () => setIsSelectOpen(!isSelectOpen),
86+
}
87+
: undefined)}
88+
color={getSecondaryTextColor()}
89+
>
90+
{/**
91+
* Rendering hidden select first so that whenever popover shows it, its relative
92+
* to the field type position. LG Select does not provide a way to set the
93+
* position of the popover using refs.
94+
*/}
95+
{isEditing && (
96+
<StyledSelect
97+
aria-label="Select field type"
98+
size="xsmall"
99+
renderMode="portal"
100+
open={isSelectOpen}
101+
onChange={val => {
102+
if (val) {
103+
// Currently its a single select, so we are returning it as an array.
104+
// That way once we have multi-select support, we don't need to change
105+
// the API and it should work seemlessly for clients.
106+
// Trigger onChange only if the value is different
107+
if (type !== val) {
108+
onChange([val]);
109+
}
110+
setIsSelectOpen(false);
111+
}
112+
}}
113+
// As its not multi-select, we can just use the first value. Once LG-5657
114+
// is implemented, we can use ComboBox component for multi-select support
115+
value={Array.isArray(type) ? type[0] : type || ''}
116+
allowDeselect={false}
117+
dropdownWidthBasis="option"
118+
tabIndex={0}
119+
>
120+
{fieldTypes!.map(fieldType => (
121+
<Option key={fieldType} value={fieldType}>
122+
{fieldType}
123+
</Option>
124+
))}
125+
</StyledSelect>
126+
)}
127+
<FieldContentWrapper>
128+
<FieldTypeContent type={type} nodeId={nodeId} id={id} isAddFieldToObjectDisabled={isEditing} />
129+
</FieldContentWrapper>
130+
{isEditing && (
131+
<CaretIconWrapper title="Select field type" aria-label="Select field type">
132+
<Icon glyph="CaretDown" />
133+
</CaretIconWrapper>
134+
)}
135+
</FieldTypeWrapper>
136+
);
137+
}

0 commit comments

Comments
 (0)