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
8 changes: 4 additions & 4 deletions src/components/field/field-type-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const FieldTypeContent = ({
if (type === 'object') {
return (
<ObjectTypeContainer>
{'{}'}
<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 && (
<DiagramIconButton
data-testid={`object-field-type-${nodeId}-${typeof id === 'string' ? id : id.join('.')}`}
Expand All @@ -69,7 +69,7 @@ export const FieldTypeContent = ({
}

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

if (Array.isArray(type)) {
Expand All @@ -78,7 +78,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 +93,5 @@ export const FieldTypeContent = ({
);
}

return <>{type}</>;
return <span title={type}>{type}</span>;
};
124 changes: 124 additions & 0 deletions src/components/field/field-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { color, spacing as LGSpacing } from '@leafygreen-ui/tokens';
import { Select, Option } from '@leafygreen-ui/select';
import Icon from '@leafygreen-ui/icon';
import { useMemo, 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: ${props => props.color};
font-weight: normal;
padding-left:${LGSpacing[100]}px;
padding-right ${LGSpacing[50]}px;
flex: 0 0 ${LGSpacing[200] * 10}px;
display: flex;
justify-content: flex-end;
align-items: center;
`;

const FieldContentWrapper = styled.div`
max-width: ${LGSpacing[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,
isDisabled,
isEditable,
}: {
id: FieldId;
nodeId: string;
type: string | string[] | undefined;
isDisabled: boolean;
isEditable: boolean;
}) {
const internalTheme = useTheme();
const { theme } = useDarkMode();
const { onChangeFieldType, fieldTypes } = useEditableDiagramInteractions();
const [isSelectOpen, setIsSelectOpen] = useState(false);

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

const isFieldTypeEditable = useMemo(() => {
return isEditable && !isDisabled && !!onChangeFieldType && (fieldTypes ?? []).length > 0;
}, [onChangeFieldType, isDisabled, isEditable, fieldTypes]);

return (
<FieldTypeWrapper
{...(isFieldTypeEditable
? {
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.
*/}
{isFieldTypeEditable && (
<StyledSelect
aria-label="Select field type"
size="xsmall"
renderMode="portal"
open={isSelectOpen}
setOpen={setIsSelectOpen}
onChange={val => {
if (val) {
onChangeFieldType?.(nodeId, Array.isArray(id) ? id : [id], val);
}
}}
// As its not multi-select, we can just use the first value
value={Array.isArray(type) ? type[0] : type || ''}
allowDeselect={false}
dropdownWidthBasis="option"
justify="middle"
>
{fieldTypes!.map(fieldType => (
<Option key={fieldType} value={fieldType}>
{fieldType}
</Option>
))}
</StyledSelect>
)}
<FieldContentWrapper>
<FieldTypeContent type={type} nodeId={nodeId} id={id} />
</FieldContentWrapper>
{isFieldTypeEditable && (
<CaretIconWrapper title="Select field type" aria-label="Select field type">
<Icon glyph="CaretDown" />
</CaretIconWrapper>
)}
</FieldTypeWrapper>
);
}
107 changes: 107 additions & 0 deletions src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ const Field = (props: React.ComponentProps<typeof FieldComponent>) => (
const FieldWithEditableInteractions = ({
onAddFieldToObjectFieldClick,
onFieldNameChange,
onFieldTypeChange,
fieldTypes,
...fieldProps
}: React.ComponentProps<typeof FieldComponent> & {
onAddFieldToObjectFieldClick?: () => void;
onFieldNameChange?: (newName: string) => void;
onFieldTypeChange?: (nodeId: string, fieldPath: string[], newType: string) => void;
fieldTypes?: string[];
}) => {
return (
<EditableDiagramInteractionsProvider
onAddFieldToObjectFieldClick={onAddFieldToObjectFieldClick}
onFieldNameChange={onFieldNameChange}
onFieldTypeChange={onFieldTypeChange}
fieldTypes={fieldTypes}
>
<FieldComponent {...fieldProps} />
</EditableDiagramInteractionsProvider>
Expand Down Expand Up @@ -153,6 +159,107 @@ describe('field', () => {
await rerender(<FieldWithEditableInteractions {...DEFAULT_PROPS} editable={true} name={newName} />);
expect(screen.getByText(newName)).toBeInTheDocument();
});

describe('Field type editing', () => {
it('Should not allow editing when field is not selected', async () => {
render(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={['ordersId']}
selected={false}
variant="default"
isHovering={true}
editable={true}
onFieldTypeChange={vi.fn()}
fieldTypes={['string']}
/>,
);
expect(screen.queryByText('Select field type')).not.toBeInTheDocument();
});
it('Should not allow editing when field is disabled', async () => {
render(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={['ordersId']}
selected={true}
variant="disabled"
isHovering={true}
editable={true}
onFieldTypeChange={vi.fn()}
fieldTypes={['string']}
/>,
);
expect(screen.queryByText('Select field type')).not.toBeInTheDocument();
});
it('Should not allow editing when no callback is provided', async () => {
render(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={['ordersId']}
selected={true}
variant="default"
isHovering={true}
editable={true}
fieldTypes={['string']}
/>,
);
expect(screen.queryByText('Select field type')).not.toBeInTheDocument();
});
it('Should not allow editing when no fieldTypes are provided', async () => {
render(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={['ordersId']}
selected={true}
variant="default"
isHovering={true}
editable={true}
onFieldTypeChange={vi.fn()}
fieldTypes={[]}
/>,
);
expect(screen.queryByText('Select field type')).not.toBeInTheDocument();
});
it('Should allow editing', async () => {
const onFieldTypeChangeMock = vi.fn();
render(
<FieldWithEditableInteractions
{...DEFAULT_PROPS}
id={['ordersId']}
selected={true}
variant="default"
isHovering={true}
editable={true}
onFieldTypeChange={onFieldTypeChangeMock}
fieldTypes={['objectId', 'string', 'number']}
/>,
);
const caretWrapper = screen.getByLabelText('Select field type');
expect(caretWrapper).toBeInTheDocument();
await userEvent.click(caretWrapper);

expect(onFieldTypeChangeMock).not.toHaveBeenCalled();
const stringOption = screen.getByRole('option', { name: 'string' });
await userEvent.click(stringOption);
expect(onFieldTypeChangeMock).toHaveBeenCalledWith(
DEFAULT_PROPS.nodeId,
Array.isArray(DEFAULT_PROPS.id) ? DEFAULT_PROPS.id : [DEFAULT_PROPS.id],
'string',
);
expect(onFieldTypeChangeMock).toHaveBeenCalledTimes(1);

// Try changing to number type
await userEvent.click(caretWrapper);
const numberOption = screen.getByRole('option', { name: 'number' });
await userEvent.click(numberOption);
expect(onFieldTypeChangeMock).toHaveBeenCalledWith(
DEFAULT_PROPS.nodeId,
Array.isArray(DEFAULT_PROPS.id) ? DEFAULT_PROPS.id : [DEFAULT_PROPS.id],
'number',
);
expect(onFieldTypeChangeMock).toHaveBeenCalledTimes(2);
});
});
});

describe('With specific types', () => {
Expand Down
24 changes: 2 additions & 22 deletions src/components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react';
import { animatedBlueBorder, ellipsisTruncation } from '@/styles/styles';
import { DEFAULT_DEPTH_SPACING, DEFAULT_FIELD_HEIGHT } from '@/utilities/constants';
import { FieldDepth } from '@/components/field/field-depth';
import { FieldTypeContent } from '@/components/field/field-type-content';
import { FieldType } from '@/components/field/field-type';
import { FieldId, NodeField, NodeGlyph, NodeType } from '@/types';
import { PreviewGroupArea } from '@/utilities/get-preview-group-area';
import { useEditableDiagramInteractions } from '@/hooks/use-editable-diagram-interactions';
Expand Down Expand Up @@ -107,16 +107,6 @@ const FieldName = styled.div`
${ellipsisTruncation}
`;

const FieldType = styled.div`
color: ${props => props.color};
flex: 0 0 ${LGSpacing[200] * 10}px;
font-weight: normal;
text-align: right;
padding-left:${LGSpacing[100]}px;
padding-right ${LGSpacing[50]}px;
${ellipsisTruncation}
`;

const IconWrapper = styled(Icon)`
padding-right: ${spacing[100]}px;
flex-shrink: 0;
Expand Down Expand Up @@ -184,14 +174,6 @@ export const Field = ({
}
};

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

const getIconColor = (glyph: NodeGlyph) => {
if (isDisabled) {
return color[theme].text.disabled.default;
Expand Down Expand Up @@ -226,9 +208,7 @@ export const Field = ({
onChange={onChangeFieldName ? handleNameChange : undefined}
/>
</FieldName>
<FieldType color={getSecondaryTextColor()}>
<FieldTypeContent type={type} nodeId={nodeId} id={id} />
</FieldType>
<FieldType type={type} nodeId={nodeId} id={id} isDisabled={isDisabled} isEditable={selected && editable} />
</>
);

Expand Down
Loading