Create a JSON tree visualizer (#10579)

Inspired by
https://github.com/reduxjs/redux-devtools/tree/main/packages/react-json-tree

- Created a reusable JSON tree visualizer
- For now, the visualizer is only meant to render raw JSON. It can't
determine if a string is a UUID and display a specific icon.

**The visualizer is not rendered in the app. You must use Storybook to
review it.**

## Demo



https://github.com/user-attachments/assets/ffd4fc94-b33d-4481-9ac1-fa3a348b7c81
This commit is contained in:
Baptiste Devessier
2025-03-03 16:55:16 +01:00
committed by GitHub
parent 2325e0ae0f
commit 3d56e5394f
76 changed files with 817 additions and 31 deletions

View File

@ -0,0 +1,189 @@
import { JsonTree } from '@/workflow/components/json-visualizer/components/JsonTree';
import { Meta, StoryObj } from '@storybook/react';
import {
expect,
userEvent,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
const meta: Meta<typeof JsonTree> = {
title: 'Modules/Workflow/JsonVisualizer/JsonTree',
component: JsonTree,
args: {},
argTypes: {},
decorators: [ComponentDecorator, I18nFrontDecorator],
};
export default meta;
type Story = StoryObj<typeof JsonTree>;
export const String: Story = {
args: {
value: 'Hello',
},
};
export const Number: Story = {
args: {
value: 42,
},
};
export const Boolean: Story = {
args: {
value: true,
},
};
export const Null: Story = {
args: {
value: null,
},
};
export const ArraySimple: Story = {
args: {
value: [1, 2, 3],
},
};
export const ArrayNested: Story = {
args: {
value: [1, 2, ['a', 'b', 'c'], 3],
},
};
export const ArrayWithObjects: Story = {
args: {
value: [
{
name: 'John Doe',
age: 30,
},
{
name: 'John Dowl',
age: 42,
},
],
},
};
export const ObjectSimple: Story = {
args: {
value: {
name: 'John Doe',
age: 30,
},
},
};
export const ObjectNested: Story = {
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
},
},
isActive: true,
},
},
};
export const ObjectWithArray: Story = {
args: {
value: {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
settings: {
theme: 'dark',
notifications: true,
},
},
},
};
export const NestedElementCanBeCollapsed: Story = {
args: {
value: {
person: {
name: 'John Doe',
age: 12,
},
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggleButton = await canvas.findByRole('button', {
name: 'Collapse',
});
const ageElement = canvas.getByText('age');
await Promise.all([
waitForElementToBeRemoved(ageElement),
userEvent.click(toggleButton),
]);
expect(toggleButton).toHaveTextContent('Expand');
},
};
export const ExpandingElementExpandsAllItsDescendants: Story = {
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
country: {
name: 'USA',
code: 'US',
},
},
},
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
{
const allCollapseButtons = await canvas.findAllByRole('button', {
name: 'Collapse',
});
expect(allCollapseButtons).toHaveLength(3);
for (const collapseButton of allCollapseButtons.reverse()) {
await userEvent.click(collapseButton);
}
}
const rootExpandButton = await canvas.findByRole('button', {
name: 'Expand',
});
await userEvent.click(rootExpandButton);
{
const allCollapseButtons = await canvas.findAllByRole('button', {
name: 'Collapse',
});
expect(allCollapseButtons).toHaveLength(3);
}
},
};

View File

@ -0,0 +1,25 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { IconBrackets } from 'twenty-ui';
import { JsonArray } from 'type-fest';
export const JsonArrayNode = ({
label,
value,
depth,
}: {
label?: string;
value: JsonArray;
depth: number;
}) => {
return (
<JsonNestedNode
elements={[...value.entries()].map(([key, value]) => ({
key: String(key),
value,
}))}
label={label}
Icon={IconBrackets}
depth={depth}
/>
);
};

View File

@ -0,0 +1,65 @@
import { JsonArrow } from '@/workflow/components/json-visualizer/components/internal/JsonArrow';
import { JsonList } from '@/workflow/components/json-visualizer/components/internal/JsonList';
import { JsonNodeLabel } from '@/workflow/components/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNode } from '@/workflow/components/json-visualizer/components/JsonNode';
import styled from '@emotion/styled';
import { useState } from 'react';
import { isDefined } from 'twenty-shared';
import { IconComponent } from 'twenty-ui';
import { JsonValue } from 'type-fest';
const StyledContainer = styled.li`
list-style-type: none;
display: grid;
row-gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledLabelContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const JsonNestedNode = ({
label,
Icon,
elements,
depth,
}: {
label?: string;
Icon: IconComponent;
elements: Array<{ key: string; value: JsonValue }>;
depth: number;
}) => {
const hideRoot = !isDefined(label);
const [isOpen, setIsOpen] = useState(true);
const renderedChildren = (
<JsonList depth={depth}>
{elements.map(({ key, value }) => (
<JsonNode key={key} label={key} value={value} depth={depth + 1} />
))}
</JsonList>
);
const handleArrowClick = () => {
setIsOpen(!isOpen);
};
if (hideRoot) {
return <StyledContainer>{renderedChildren}</StyledContainer>;
}
return (
<StyledContainer>
<StyledLabelContainer>
<JsonArrow isOpen={isOpen} onClick={handleArrowClick} />
<JsonNodeLabel label={label} Icon={Icon} />
</StyledLabelContainer>
{isOpen && renderedChildren}
</StyledContainer>
);
};

View File

@ -0,0 +1,68 @@
import { JsonArrayNode } from '@/workflow/components/json-visualizer/components/JsonArrayNode';
import { JsonObjectNode } from '@/workflow/components/json-visualizer/components/JsonObjectNode';
import { JsonValueNode } from '@/workflow/components/json-visualizer/components/JsonValueNode';
import { isArray } from '@/workflow/components/json-visualizer/utils/isArray';
import { isBoolean, isNull, isNumber, isString } from '@sniptt/guards';
import {
IconCheckbox,
IconCircleOff,
IconNumber9,
IconTypography,
} from 'twenty-ui';
import { JsonValue } from 'type-fest';
export const JsonNode = ({
label,
value,
depth,
}: {
label?: string;
value: JsonValue;
depth: number;
}) => {
if (isNull(value)) {
return (
<JsonValueNode
label={label}
valueAsString="[null]"
Icon={IconCircleOff}
/>
);
}
if (isString(value)) {
return (
<JsonValueNode
label={label}
valueAsString={value}
Icon={IconTypography}
/>
);
}
if (isNumber(value)) {
return (
<JsonValueNode
label={label}
valueAsString={String(value)}
Icon={IconNumber9}
/>
);
}
if (isBoolean(value)) {
return (
<JsonValueNode
label={label}
valueAsString={String(value)}
Icon={IconCheckbox}
/>
);
}
if (isArray(value)) {
return <JsonArrayNode label={label} value={value} depth={depth} />;
}
return <JsonObjectNode label={label} value={value} depth={depth} />;
};

View File

@ -0,0 +1,25 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { IconCube } from 'twenty-ui';
import { JsonObject } from 'type-fest';
export const JsonObjectNode = ({
label,
value,
depth,
}: {
label?: string;
value: JsonObject;
depth: number;
}) => {
return (
<JsonNestedNode
elements={Object.entries(value).map(([key, value]) => ({
key,
value,
}))}
label={label}
Icon={IconCube}
depth={depth}
/>
);
};

View File

@ -0,0 +1,11 @@
import { JsonList } from '@/workflow/components/json-visualizer/components/internal/JsonList';
import { JsonNode } from '@/workflow/components/json-visualizer/components/JsonNode';
import { JsonValue } from 'type-fest';
export const JsonTree = ({ value }: { value: JsonValue }) => {
return (
<JsonList depth={0}>
<JsonNode value={value} depth={0} />
</JsonList>
);
};

View File

@ -0,0 +1,32 @@
import { JsonListItem } from '@/workflow/components/json-visualizer/components/internal/JsonListItem';
import { JsonNodeLabel } from '@/workflow/components/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNodeValue } from '@/workflow/components/json-visualizer/components/internal/JsonNodeValue';
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
const StyledListItem = styled(JsonListItem)`
column-gap: ${({ theme }) => theme.spacing(2)};
`;
type JsonValueNodeProps = {
valueAsString: string;
} & (
| {
label: string;
Icon: IconComponent;
}
| {
label?: never;
Icon?: unknown;
}
);
export const JsonValueNode = (props: JsonValueNodeProps) => {
return (
<StyledListItem>
{props.label && <JsonNodeLabel label={props.label} Icon={props.Icon} />}
<JsonNodeValue valueAsString={props.valueAsString} />
</StyledListItem>
);
};

View File

@ -0,0 +1,48 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { IconChevronDown, VisibilityHidden } from 'twenty-ui';
const StyledButton = styled(motion.button)`
align-items: center;
border-color: ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
border-style: solid;
border-width: 1px;
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
background-color: ${({ theme }) => theme.background.transparent.lighter};
height: 24px;
width: 24px;
box-sizing: border-box;
cursor: pointer;
`;
const MotionIconChevronDown = motion(IconChevronDown);
export const JsonArrow = ({
isOpen,
onClick,
}: {
isOpen: boolean;
onClick: () => void;
}) => {
const { t } = useLingui();
const theme = useTheme();
return (
<StyledButton onClick={onClick}>
<VisibilityHidden>{isOpen ? t`Collapse` : t`Expand`}</VisibilityHidden>
<MotionIconChevronDown
size={theme.icon.size.md}
color={theme.font.color.secondary}
initial={false}
animate={{ rotate: isOpen ? -180 : 0 }}
/>
</StyledButton>
);
};

View File

@ -0,0 +1,18 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
const StyledList = styled.ul<{ depth: number }>`
margin: 0;
padding: 0;
display: grid;
row-gap: ${({ theme }) => theme.spacing(2)};
${({ theme, depth }) =>
depth > 0 &&
css`
padding-left: ${theme.spacing(8)};
`}
`;
export { StyledList as JsonList };

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledListItem = styled.li`
align-items: center;
display: flex;
list-style-type: none;
`;
export { StyledListItem as JsonListItem };

View File

@ -0,0 +1,39 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
const StyledLabelContainer = styled.span`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border-color: ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
border-style: solid;
border-width: 1px;
height: 24px;
box-sizing: border-box;
column-gap: ${({ theme }) => theme.spacing(2)};
display: flex;
font-variant-numeric: tabular-nums;
justify-content: center;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
width: fit-content;
`;
export const JsonNodeLabel = ({
label,
Icon,
}: {
label: string;
Icon: IconComponent;
}) => {
const theme = useTheme();
return (
<StyledLabelContainer>
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
<span>{label}</span>
</StyledLabelContainer>
);
};

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledText = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const JsonNodeValue = ({ valueAsString }: { valueAsString: string }) => {
return <StyledText>{valueAsString}</StyledText>;
};

View File

@ -0,0 +1,5 @@
import { isArray as _isArray } from '@sniptt/guards';
export const isArray = (
value: unknown,
): value is unknown[] | readonly unknown[] => _isArray(value);