Handle JSON viewer empty states (#10750)

- Display the number of descendants for object and array elements
- Display an empty state for arrays and objects
- Make the input and output visualizer scrollable horizontally 
  - Prevent JSON visualizer's text to wrap

## Demo: input


https://github.com/user-attachments/assets/d6bd6acf-a779-4fc7-a8b1-12b857cee7f9

Closes https://github.com/twentyhq/core-team-issues/issues/497
This commit is contained in:
Baptiste Devessier
2025-03-10 17:39:49 +01:00
committed by GitHub
parent dc55fac1d5
commit dd26001372
70 changed files with 533 additions and 39 deletions

View File

@ -6,7 +6,6 @@ import {
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
const meta: Meta<typeof JsonTree> = {
@ -14,7 +13,7 @@ const meta: Meta<typeof JsonTree> = {
component: JsonTree,
args: {},
argTypes: {},
decorators: [ComponentDecorator, I18nFrontDecorator],
decorators: [I18nFrontDecorator],
};
export default meta;
@ -51,10 +50,47 @@ export const ArraySimple: Story = {
},
};
export const ArrayEmpty: Story = {
args: {
value: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyState = await canvas.findByText('Empty Array');
expect(emptyState).toBeVisible();
},
};
export const ArrayNested: Story = {
args: {
value: [1, 2, ['a', 'b', 'c'], 3],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedArrayElements = await canvas.findByText('[3]');
expect(nestedArrayElements).toBeVisible();
},
};
export const ArrayNestedEmpty: Story = {
args: {
value: [1, 2, [], 3],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedArrayElements = await canvas.findByText('[0]');
expect(nestedArrayElements).toBeVisible();
const emptyState = await canvas.findByText('Empty Array');
expect(emptyState).toBeVisible();
},
};
export const ArrayWithObjects: Story = {
@ -70,6 +106,13 @@ export const ArrayWithObjects: Story = {
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCounts = await canvas.findAllByText('{2}');
expect(nestedObjectItemsCounts).toHaveLength(2);
},
};
export const ObjectSimple: Story = {
@ -81,6 +124,19 @@ export const ObjectSimple: Story = {
},
};
export const ObjectEmpty: Story = {
args: {
value: {},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const emptyState = await canvas.findByText('Empty Object');
expect(emptyState).toBeVisible();
},
};
export const ObjectNested: Story = {
args: {
value: {
@ -94,6 +150,32 @@ export const ObjectNested: Story = {
isActive: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCounts = await canvas.findAllByText('{2}');
expect(nestedObjectItemsCounts).toHaveLength(2);
},
};
export const ObjectNestedEmpty: Story = {
args: {
value: {
person: {},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nestedObjectItemsCount = await canvas.findByText('{0}');
expect(nestedObjectItemsCount).toBeVisible();
const emptyState = await canvas.findByText('Empty Object');
expect(emptyState).toBeVisible();
},
};
export const ObjectWithArray: Story = {
@ -187,3 +269,123 @@ export const ExpandingElementExpandsAllItsDescendants: Story = {
}
},
};
export const ReallyDeepNestedObject: Story = {
args: {
value: {
a: {
b: {
c: {
d: {
e: {
f: {
g: {
h: {
i: {
j: {
k: {
l: {
m: {
n: {
o: {
p: {
q: {
r: {
s: {
t: {
u: {
v: {
w: {
x: {
y: {
z: {
end: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
bis: {
c: {
d: {
e: {
f: {
g: {
h: {
i: {
j: {
k: {
l: {
m: {
n: {
o: {
p: {
q: {
r: {
s: {
t: {
u: {
v: {
w: {
x: {
y: {
z: {
end: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
};
export const LongText: Story = {
args: {
value: {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum iaculis est tincidunt, sagittis neque vitae, sodales purus.':
'Ut lobortis ultricies purus, sit amet porta eros. Suspendisse efficitur quam vitae diam imperdiet feugiat. Etiam vel bibendum elit.',
},
},
};

View File

@ -1,4 +1,5 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useLingui } from '@lingui/react/macro';
import { IconBrackets } from 'twenty-ui';
import { JsonArray } from 'type-fest';
@ -11,6 +12,8 @@ export const JsonArrayNode = ({
value: JsonArray;
depth: number;
}) => {
const { t } = useLingui();
return (
<JsonNestedNode
elements={[...value.entries()].map(([key, value]) => ({
@ -18,9 +21,11 @@ export const JsonArrayNode = ({
label: String(key),
value,
}))}
renderElementsCount={(count) => `[${count}]`}
label={label}
Icon={IconBrackets}
depth={depth}
emptyElementsText={t`Empty Array`}
/>
);
};

View File

@ -20,15 +20,27 @@ const StyledLabelContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledElementsCount = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledEmptyState = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const JsonNestedNode = ({
label,
Icon,
elements,
renderElementsCount,
emptyElementsText,
depth,
}: {
label?: string;
Icon: IconComponent;
elements: Array<{ id: string | number; label: string; value: JsonValue }>;
renderElementsCount?: (count: number) => string;
emptyElementsText: string;
depth: number;
}) => {
const hideRoot = !isDefined(label);
@ -37,9 +49,13 @@ export const JsonNestedNode = ({
const renderedChildren = (
<JsonList depth={depth}>
{elements.map(({ id, label, value }) => (
<JsonNode key={id} label={label} value={value} depth={depth + 1} />
))}
{elements.length === 0 ? (
<StyledEmptyState>{emptyElementsText}</StyledEmptyState>
) : (
elements.map(({ id, label, value }) => (
<JsonNode key={id} label={label} value={value} depth={depth + 1} />
))
)}
</JsonList>
);
@ -57,6 +73,12 @@ export const JsonNestedNode = ({
<JsonArrow isOpen={isOpen} onClick={handleArrowClick} />
<JsonNodeLabel label={label} Icon={Icon} />
{renderElementsCount && (
<StyledElementsCount>
{renderElementsCount(elements.length)}
</StyledElementsCount>
)}
</StyledLabelContainer>
{isOpen && renderedChildren}

View File

@ -1,4 +1,5 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useLingui } from '@lingui/react/macro';
import { IconCube } from 'twenty-ui';
import { JsonObject } from 'type-fest';
@ -11,6 +12,8 @@ export const JsonObjectNode = ({
value: JsonObject;
depth: number;
}) => {
const { t } = useLingui();
return (
<JsonNestedNode
elements={Object.entries(value).map(([key, value]) => ({
@ -18,9 +21,11 @@ export const JsonObjectNode = ({
label: key,
value,
}))}
renderElementsCount={(count) => `{${count}}`}
label={label}
Icon={IconCube}
depth={depth}
emptyElementsText={t`Empty Object`}
/>
);
};

View File

@ -4,6 +4,7 @@ const StyledListItem = styled.li`
align-items: center;
display: flex;
list-style-type: none;
white-space: nowrap;
`;
export { StyledListItem as JsonListItem };