Skip to content

Commit d050d64

Browse files
committed
feat/COMPASS-9935 collapse field level
1 parent 124e316 commit d050d64

File tree

14 files changed

+233
-43
lines changed

14 files changed

+233
-43
lines changed

src/components/canvas/canvas.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const Canvas = ({
6666
onFieldNameChange,
6767
onFieldTypeChange,
6868
onFieldClick,
69+
onFieldExpandToggle,
6970
onNodeContextMenu,
7071
onNodeDrag,
7172
onNodeDragStop,
@@ -153,6 +154,7 @@ export const Canvas = ({
153154
onFieldClick={onFieldClick}
154155
onAddFieldToNodeClick={onAddFieldToNodeClick}
155156
onNodeExpandToggle={onNodeExpandToggle}
157+
onFieldExpandToggle={onFieldExpandToggle}
156158
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
157159
onFieldNameChange={onFieldNameChange}
158160
onFieldTypeChange={onFieldTypeChange}

src/components/field/field-content.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import styled from '@emotion/styled';
22
import { fontWeights } from '@leafygreen-ui/tokens';
3+
import Icon from '@leafygreen-ui/icon';
4+
import { useTheme } from '@emotion/react';
35
import { useCallback, useEffect, useRef, useState } from 'react';
46

57
import { ellipsisTruncation } from '@/styles/styles';
68
import { FieldDepth } from '@/components/field/field-depth';
79
import { FieldType } from '@/components/field/field-type';
10+
import { DiagramIconButton } from '@/components/buttons/diagram-icon-button';
811
import { FieldId, NodeField } from '@/types';
912
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1013

@@ -26,14 +29,31 @@ interface FieldContentProps extends NodeField {
2629
id: FieldId;
2730
isEditable: boolean;
2831
isDisabled: boolean;
32+
isExpandable?: boolean;
2933
nodeId: string;
3034
}
3135

32-
export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id, nodeId }: FieldContentProps) => {
36+
export const FieldContent = ({
37+
isEditable,
38+
isDisabled,
39+
isExpandable,
40+
depth = 0,
41+
name,
42+
type,
43+
id,
44+
nodeId,
45+
expanded,
46+
}: FieldContentProps) => {
3347
const [isEditing, setIsEditing] = useState(false);
3448
const fieldContentRef = useRef<HTMLDivElement>(null);
49+
const theme = useTheme();
50+
51+
const { onChangeFieldName, onChangeFieldType, fieldTypes, onFieldExpandToggle } = useEditableDiagramInteractions();
52+
53+
const hasCollapseFunctionality = !!onFieldExpandToggle;
54+
const hasCollapseButton = hasCollapseFunctionality && isExpandable;
55+
const placeholderCollapse = hasCollapseFunctionality && !hasCollapseButton;
3556

36-
const { onChangeFieldName, onChangeFieldType, fieldTypes } = useEditableDiagramInteractions();
3757
const handleNameChange = useCallback(
3858
(newName: string) => onChangeFieldName?.(nodeId, Array.isArray(id) ? id : [id], newName),
3959
[onChangeFieldName, id, nodeId],
@@ -47,6 +67,16 @@ export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id
4767
setIsEditing(true);
4868
}, []);
4969

70+
const handleFieldExpandToggle = useCallback(
71+
(event: React.MouseEvent<HTMLButtonElement>) => {
72+
if (!onFieldExpandToggle) return;
73+
// Don't click on the field element.
74+
event.stopPropagation();
75+
onFieldExpandToggle(event, nodeId, Array.isArray(id) ? id : [id]);
76+
},
77+
[onFieldExpandToggle, nodeId, id],
78+
);
79+
5080
useEffect(() => {
5181
// When clicking outside of the field content while editing, stop editing.
5282
const container = fieldContentRef.current;
@@ -98,7 +128,18 @@ export const FieldContent = ({ isEditable, isDisabled, depth = 0, name, type, id
98128
isEditing={isTypeEditable}
99129
isDisabled={isDisabled}
100130
onChange={handleTypeChange}
131+
placeholderCollapse={placeholderCollapse}
101132
/>
133+
{hasCollapseButton && (
134+
<DiagramIconButton
135+
data-testid={`object-field-expand-toggle-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
136+
onClick={handleFieldExpandToggle}
137+
aria-label={expanded ? 'Collapse Field' : 'Expand Field'}
138+
title={expanded ? 'Collapse Field' : 'Expand Field'}
139+
>
140+
<Icon glyph={expanded ? 'ChevronDown' : 'ChevronLeft'} color={theme.node.fieldIconButton} size={14} />
141+
</DiagramIconButton>
142+
)}
102143
</FieldContentWrapper>
103144
);
104145
};

src/components/field/field-list.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useMemo } from 'react';
22
import styled from '@emotion/styled';
3-
import { spacing } from '@leafygreen-ui/tokens';
43

54
import { Field } from '@/components/field/field';
65
import { NodeField, NodeType } from '@/types';
@@ -10,7 +9,7 @@ import { getSelectedFieldGroupHeight, getSelectedId } from '@/utilities/get-sele
109
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1110

1211
const NodeFieldWrapper = styled.div`
13-
padding: ${DEFAULT_FIELD_PADDING}px ${spacing[400]}px;
12+
padding: ${DEFAULT_FIELD_PADDING}px;
1413
font-size: 12px;
1514
`;
1615

@@ -21,9 +20,45 @@ interface Props {
2120
fields: NodeField[];
2221
}
2322

24-
export const FieldList = ({ fields, nodeId, nodeType, isHovering }: Props) => {
23+
function hasChildren(fields: NodeField[], index: number): boolean {
24+
const fieldDepth = fields[index].depth ?? 0;
25+
const nextField = fields.length > index + 1 ? fields[index + 1] : null;
26+
if (!nextField) return false;
27+
return nextField.depth !== undefined && nextField.depth > fieldDepth;
28+
}
29+
30+
// Filter out all the fields that are children of explicitly collapsed fields.
31+
// We get fields as a flattened list with the depth indicaing the nesting
32+
// level, so everything that is deeper than the collapsed field (child) will
33+
// be filtered out as a child until we run into another element with the same
34+
// depth (a sibling)
35+
// We also annotate each field with whether it is expandable (has children)
36+
// This is more reliable than checking the type of the field, since the object could be hidden in arrays, or simply have no children
37+
function getFieldsWithExpandStatus(fields: NodeField[]): (NodeField & { expandable: boolean })[] {
38+
const visibleFields: (NodeField & { expandable: boolean })[] = [];
39+
let currentDepth = 0;
40+
let skipChildren = false;
41+
fields.forEach((field, index) => {
42+
const fieldDepth = field.depth ?? 0;
43+
if (skipChildren && fieldDepth > currentDepth) {
44+
return;
45+
}
46+
currentDepth = fieldDepth;
47+
skipChildren = field.expanded === false;
48+
visibleFields.push({
49+
...field,
50+
expandable: hasChildren(fields, index),
51+
});
52+
});
53+
return visibleFields;
54+
}
55+
56+
export const FieldList = ({ fields: allFields, nodeId, nodeType, isHovering }: Props) => {
2557
const { onClickField } = useEditableDiagramInteractions();
2658
const isFieldSelectionEnabled = !!onClickField;
59+
const fields = useMemo<(NodeField & { expandable: boolean })[]>(() => {
60+
return getFieldsWithExpandStatus(allFields);
61+
}, [allFields]);
2762

2863
const spacing = Math.max(0, ...fields.map(field => field.glyphs?.length || 0));
2964
const previewGroupArea = useMemo(() => getPreviewGroupArea(fields), [fields]);

src/components/field/field-type.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { FieldTypeContent } from '@/components/field/field-type-content';
1111
import { FieldId } from '@/types';
1212
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
1313

14-
const FieldTypeWrapper = styled.div<{ color: string }>`
14+
const FieldTypeWrapper = styled.div<{ color: string; placeholderCollapse?: boolean }>`
1515
color: ${props => props.color};
1616
font-weight: normal;
17-
padding-left:${spacing[100]}px;
18-
padding-right ${spacing[50]}px;
19-
flex: 0 0 ${spacing[200] * 10}px;
17+
padding-left: ${spacing[100]}px;
18+
padding-right: ${props => (props.placeholderCollapse ? spacing[600] : spacing[100])}px;
19+
flex: 0 0 100px;
2020
display: flex;
2121
justify-content: flex-end;
2222
align-items: center;
@@ -50,13 +50,15 @@ export function FieldType({
5050
isEditing,
5151
isDisabled,
5252
onChange,
53+
placeholderCollapse,
5354
}: {
5455
id: FieldId;
5556
nodeId: string;
5657
type: string | string[] | undefined;
5758
isEditing: boolean;
5859
isDisabled: boolean;
5960
onChange: (newType: string[]) => void;
61+
placeholderCollapse?: boolean;
6062
}) {
6163
const internalTheme = useTheme();
6264
const { theme } = useDarkMode();
@@ -86,6 +88,7 @@ export function FieldType({
8688
}
8789
: undefined)}
8890
color={getSecondaryTextColor()}
91+
placeholderCollapse={placeholderCollapse}
8992
>
9093
{/**
9194
* Rendering hidden select first so that whenever popover shows it, its relative

src/components/field/field.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import styled from '@emotion/styled';
2-
import { color, spacing as LGSpacing, spacing } from '@leafygreen-ui/tokens';
2+
import { color, spacing as LGSpacing } from '@leafygreen-ui/tokens';
33
import { palette } from '@leafygreen-ui/palette';
44
import Icon from '@leafygreen-ui/icon';
55
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
@@ -14,15 +14,15 @@ import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-int
1414

1515
import { FieldContent } from './field-content';
1616

17-
const FIELD_BORDER_ANIMATED_PADDING = spacing[100];
18-
const FIELD_GLYPH_SPACING = spacing[400];
17+
const FIELD_BORDER_ANIMATED_PADDING = LGSpacing[100];
18+
const FIELD_GLYPH_SPACING = LGSpacing[400];
1919

2020
const GlyphToIcon: Record<NodeGlyph, string> = {
2121
key: 'Key',
2222
link: 'Link',
2323
};
2424

25-
const SELECTED_FIELD_BORDER_PADDING = spacing[100];
25+
const SELECTED_FIELD_BORDER_PADDING = LGSpacing[100];
2626

2727
const FieldWrapper = styled.div<{
2828
color: string;
@@ -36,12 +36,13 @@ const FieldWrapper = styled.div<{
3636
width: auto;
3737
height: ${DEFAULT_FIELD_HEIGHT}px;
3838
color: ${props => props.color};
39+
padding-left: ${LGSpacing[200]}px;
3940
${props =>
4041
props.selectable &&
4142
`&:hover {
4243
cursor: pointer;
4344
background-color: ${props.selectableHoverBackgroundColor};
44-
box-shadow: -${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}, ${spacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor};
45+
box-shadow: -${LGSpacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor}, ${LGSpacing[100]}px 0px 0px 0px ${props.selectableHoverBackgroundColor};
4546
}`}
4647
${props =>
4748
props.selected &&
@@ -54,7 +55,7 @@ const FieldWrapper = styled.div<{
5455
position: absolute;
5556
outline: 2px solid ${palette.blue.base};
5657
width: calc(100% + ${SELECTED_FIELD_BORDER_PADDING * 2}px);
57-
border-radius: ${spacing[50]}px;
58+
border-radius: ${LGSpacing[50]}px;
5859
height: ${props.selectedGroupHeight * DEFAULT_FIELD_HEIGHT}px;
5960
left: -${SELECTED_FIELD_BORDER_PADDING}px;
6061
top: 0px;
@@ -98,7 +99,7 @@ const FieldRow = styled.div`
9899
`;
99100

100101
const IconWrapper = styled(Icon)`
101-
padding-right: ${spacing[100]}px;
102+
padding-right: ${LGSpacing[100]}px;
102103
flex-shrink: 0;
103104
`;
104105

@@ -110,6 +111,7 @@ interface Props extends NodeField {
110111
isHovering?: boolean;
111112
previewGroupArea: PreviewGroupArea;
112113
selectedGroupHeight?: number;
114+
expandable?: boolean;
113115
}
114116

115117
export const Field = ({
@@ -128,6 +130,8 @@ export const Field = ({
128130
spacing = 0,
129131
selectable = false,
130132
selected = false,
133+
expandable = false,
134+
expanded = false,
131135
editable = false,
132136
variant,
133137
}: Props) => {
@@ -188,10 +192,12 @@ export const Field = ({
188192
isDisabled={isDisabled}
189193
depth={depth}
190194
isEditable={selected && editable && !isDisabled}
195+
isExpandable={expandable}
191196
name={name}
192197
type={type}
193198
id={id}
194199
nodeId={nodeId}
200+
expanded={expanded}
195201
/>
196202
);
197203

src/components/icons/chevron-collapse.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const ChevronCollapse = ({ size = 14 }: { size?: number }) => {
77
<path
88
d="M4.25 3.25L8 6.5L11.75 3.25M4.25 12.75L8 9.5L11.75 12.75"
99
stroke={theme.node.headerIcon}
10-
strokeWidth="1.5"
10+
strokeWidth="2"
1111
strokeLinecap="round"
1212
strokeLinejoin="round"
1313
/>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useTheme } from '@emotion/react';
2+
3+
export const ChevronExpand = ({ size = 14 }: { size?: number }) => {
4+
const theme = useTheme();
5+
return (
6+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
7+
<path
8+
d="M4.25 6.5
9+
L8 3.25
10+
L11.75 6.5
11+
M4.25 9.5
12+
L8 12.7
13+
L11.75 9.5"
14+
stroke={theme.node.headerIcon}
15+
strokeWidth="2"
16+
strokeLinecap="round"
17+
strokeLinejoin="round"
18+
/>
19+
</svg>
20+
);
21+
};

src/components/node/node.stories.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,23 @@ export const NestedFields: Story = {
140140
{
141141
name: 'detail',
142142
type: '{}',
143+
expanded: true,
143144
},
144145
{
145146
name: 'companyName',
146-
type: 'string',
147+
type: '{}',
147148
depth: 1,
149+
expanded: false,
150+
},
151+
{
152+
name: 'acronym',
153+
type: 'string',
154+
depth: 2,
155+
},
156+
{
157+
name: 'fullName',
158+
type: 'string',
159+
depth: 2,
148160
},
149161
{
150162
name: 'phoneNumber',

0 commit comments

Comments
 (0)