Extract the JSON visualizer component in twenty-ui (#10937)

- Move the JsonTree component and the other components to twenty-ui
- Rely on a React Context to provide translations

## Future work

It would be good to migrate the `createRequiredContext` function to
`twenty-ui`. I didn't want to migrate it in this PR but would have liked
to use it.
This commit is contained in:
Baptiste Devessier
2025-03-17 16:00:06 +01:00
committed by GitHub
parent 428499e222
commit 093d6c0a1a
22 changed files with 189 additions and 89 deletions

View File

@ -0,0 +1,34 @@
import { IconBrackets } from '@ui/display';
import { JsonNestedNode } from '@ui/json-visualizer/components/JsonNestedNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { JsonArray } from 'type-fest';
export const JsonArrayNode = ({
label,
value,
depth,
keyPath,
}: {
label?: string;
value: JsonArray;
depth: number;
keyPath: string;
}) => {
const { emptyArrayLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
elements={[...value.entries()].map(([key, value]) => ({
id: key,
label: String(key),
value,
}))}
renderElementsCount={(count) => `[${count}]`}
label={label}
Icon={IconBrackets}
depth={depth}
emptyElementsText={emptyArrayLabel}
keyPath={keyPath}
/>
);
};

View File

@ -0,0 +1,102 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { IconComponent } from '@ui/display';
import { JsonArrow } from '@ui/json-visualizer/components/internal/JsonArrow';
import { JsonList } from '@ui/json-visualizer/components/internal/JsonList';
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
import { useState } from 'react';
import { isDefined } from 'twenty-shared';
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)};
`;
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,
keyPath,
}: {
label?: string;
Icon: IconComponent;
elements: Array<{ id: string | number; label: string; value: JsonValue }>;
renderElementsCount?: (count: number) => string;
emptyElementsText: string;
depth: number;
keyPath: string;
}) => {
const hideRoot = !isDefined(label);
const [isOpen, setIsOpen] = useState(true);
const renderedChildren = (
<JsonList depth={depth}>
{elements.length === 0 ? (
<StyledEmptyState>{emptyElementsText}</StyledEmptyState>
) : (
elements.map(({ id, label, value }) => {
const nextKeyPath = isNonEmptyString(keyPath)
? `${keyPath}.${id}`
: String(id);
return (
<JsonNode
key={id}
label={label}
value={value}
depth={depth + 1}
keyPath={nextKeyPath}
/>
);
})
)}
</JsonList>
);
const handleArrowClick = () => {
setIsOpen(!isOpen);
};
if (hideRoot) {
return <StyledContainer>{renderedChildren}</StyledContainer>;
}
return (
<StyledContainer>
<StyledLabelContainer>
<JsonArrow isOpen={isOpen} onClick={handleArrowClick} />
<JsonNodeLabel label={label} Icon={Icon} />
{renderElementsCount && (
<StyledElementsCount>
{renderElementsCount(elements.length)}
</StyledElementsCount>
)}
</StyledLabelContainer>
{isOpen && renderedChildren}
</StyledContainer>
);
};

View File

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

View File

@ -0,0 +1,34 @@
import { IconCube } from '@ui/display';
import { JsonNestedNode } from '@ui/json-visualizer/components/JsonNestedNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { JsonObject } from 'type-fest';
export const JsonObjectNode = ({
label,
value,
depth,
keyPath,
}: {
label?: string;
value: JsonObject;
depth: number;
keyPath: string;
}) => {
const { emptyObjectLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
elements={Object.entries(value).map(([key, value]) => ({
id: key,
label: key,
value,
}))}
renderElementsCount={(count) => `{${count}}`}
label={label}
Icon={IconCube}
depth={depth}
emptyElementsText={emptyObjectLabel}
keyPath={keyPath}
/>
);
};

View File

@ -0,0 +1,36 @@
import { JsonList } from '@ui/json-visualizer/components/internal/JsonList';
import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
import { JsonTreeContextProvider } from '@ui/json-visualizer/components/JsonTreeContextProvider';
import { JsonValue } from 'type-fest';
export const JsonTree = ({
value,
shouldHighlightNode,
emptyArrayLabel,
emptyObjectLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
}: {
value: JsonValue;
shouldHighlightNode?: (keyPath: string) => boolean;
emptyArrayLabel: string;
emptyObjectLabel: string;
arrowButtonCollapsedLabel: string;
arrowButtonExpandedLabel: string;
}) => {
return (
<JsonTreeContextProvider
value={{
shouldHighlightNode,
emptyArrayLabel,
emptyObjectLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
}}
>
<JsonList depth={0}>
<JsonNode value={value} depth={0} keyPath="" />
</JsonList>
</JsonTreeContextProvider>
);
};

View File

@ -0,0 +1,18 @@
import {
JsonTreeContext,
JsonTreeContextType,
} from '@ui/json-visualizer/contexts/JsonTreeContext';
export const JsonTreeContextProvider = ({
value,
children,
}: {
value: JsonTreeContextType;
children: React.ReactNode;
}) => {
return (
<JsonTreeContext.Provider value={value}>
{children}
</JsonTreeContext.Provider>
);
};

View File

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

View File

@ -0,0 +1,52 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { VisibilityHidden } from '@ui/accessibility';
import { IconChevronDown } from '@ui/display';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { motion } from 'framer-motion';
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.create(IconChevronDown);
export const JsonArrow = ({
isOpen,
onClick,
}: {
isOpen: boolean;
onClick: () => void;
}) => {
const theme = useTheme();
const { arrowButtonCollapsedLabel, arrowButtonExpandedLabel } =
useJsonTreeContextOrThrow();
return (
<StyledButton onClick={onClick}>
<VisibilityHidden>
{isOpen ? arrowButtonExpandedLabel : arrowButtonCollapsedLabel}
</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,10 @@
import styled from '@emotion/styled';
const StyledListItem = styled.li`
align-items: center;
display: flex;
list-style-type: none;
white-space: nowrap;
`;
export { StyledListItem as JsonListItem };

View File

@ -0,0 +1,46 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@ui/display';
const StyledLabelContainer = styled.span<{ isHighlighted?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border-color: ${({ theme }) => theme.border.color.medium};
color: ${({ theme, isHighlighted }) =>
isHighlighted ? theme.color.blue : theme.font.color.primary};
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,
isHighlighted,
}: {
label: string;
Icon: IconComponent;
isHighlighted?: boolean;
}) => {
const theme = useTheme();
return (
<StyledLabelContainer isHighlighted={isHighlighted}>
<Icon
size={theme.icon.size.md}
color={isHighlighted ? theme.color.blue : theme.font.color.tertiary}
/>
<span>{label}</span>
</StyledLabelContainer>
);
};

View File

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