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

@ -1,8 +1,8 @@
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import { JsonTree } from '@/workflow/components/json-visualizer/components/JsonTree';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { Section } from 'twenty-ui';
import { JsonTree, Section } from 'twenty-ui';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledDetailsContainer = styled.div`
@ -21,6 +21,8 @@ const StyledErrorMessage = styled.div`
`;
export const JsonDataIndicatorHealthStatus = () => {
const { t } = useLingui();
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const parsedDetails = indicatorHealth.details
@ -41,7 +43,13 @@ export const JsonDataIndicatorHealthStatus = () => {
)}
{parsedDetails && (
<StyledDetailsContainer>
<JsonTree value={parsedDetails} />
<JsonTree
value={parsedDetails}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
/>
</StyledDetailsContainer>
)}
</Section>

View File

@ -1,22 +0,0 @@
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,
shouldHighlightNode,
}: {
value: JsonValue;
shouldHighlightNode?: (keyPath: string) => boolean;
}) => {
return (
<JsonList depth={0}>
<JsonNode
value={value}
depth={0}
keyPath=""
shouldHighlightNode={shouldHighlightNode}
/>
</JsonList>
);
};

View File

@ -1,11 +1,15 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import { IconBrackets } from 'twenty-ui';
import {
IconBrackets,
JsonNestedNode,
JsonTreeContextProvider,
} from 'twenty-ui';
const StyledContainer = styled.div`
display: grid;
@ -15,6 +19,8 @@ const StyledContainer = styled.div`
`;
export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const { t } = useLingui();
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const step = workflowRun?.output?.flow.steps.find(
@ -48,18 +54,27 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
return (
<StyledContainer>
<JsonNestedNode
elements={stepContext.map(({ id, name, context }) => ({
id,
label: name,
value: context,
}))}
Icon={IconBrackets}
emptyElementsText=""
depth={0}
keyPath=""
shouldHighlightNode={(keyPath) => variablesUsedInStep.has(keyPath)}
/>
<JsonTreeContextProvider
value={{
emptyArrayLabel: t`Empty Array`,
emptyObjectLabel: t`Empty Object`,
arrowButtonCollapsedLabel: t`Expand`,
arrowButtonExpandedLabel: t`Collapse`,
shouldHighlightNode: (keyPath) => variablesUsedInStep.has(keyPath),
}}
>
<JsonNestedNode
elements={stepContext.map(({ id, name, context }) => ({
id,
label: name,
value: context,
}))}
Icon={IconBrackets}
depth={0}
keyPath=""
emptyElementsText=""
/>
</JsonTreeContextProvider>
</StyledContainer>
);
};

View File

@ -1,8 +1,9 @@
import { JsonTree } from '@/workflow/components/json-visualizer/components/JsonTree';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import { JsonTree } from 'twenty-ui';
const StyledContainer = styled.div`
display: grid;
@ -15,6 +16,8 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const { t } = useLingui();
if (!isDefined(workflowRun?.output?.stepsOutput)) {
return null;
}
@ -23,7 +26,13 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
return (
<StyledContainer>
<JsonTree value={stepOutput} />
<JsonTree
value={stepOutput}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
/>
</StyledContainer>
);
};

View File

@ -3,6 +3,7 @@ export * from './components';
export * from './display';
export * from './feedback';
export * from './input';
export * from './json-visualizer';
export * from './layout';
export * from './navigation';
export * from './testing';

View File

@ -1,4 +1,3 @@
import { JsonTree } from '@/workflow/components/json-visualizer/components/JsonTree';
import { Meta, StoryObj } from '@storybook/react';
import {
expect,
@ -6,14 +5,18 @@ import {
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { JsonTree } from '@ui/json-visualizer/components/JsonTree';
const meta: Meta<typeof JsonTree> = {
title: 'Modules/Workflow/JsonVisualizer/JsonTree',
title: 'UI/JsonVisualizer/JsonTree',
component: JsonTree,
args: {},
args: {
emptyArrayLabel: 'Empty Array',
emptyObjectLabel: 'Empty Object',
arrowButtonCollapsedLabel: 'Expand',
arrowButtonExpandedLabel: 'Collapse',
},
argTypes: {},
decorators: [I18nFrontDecorator],
};
export default meta;

View File

@ -1,6 +1,6 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useLingui } from '@lingui/react/macro';
import { IconBrackets } from 'twenty-ui';
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 = ({
@ -8,15 +8,13 @@ export const JsonArrayNode = ({
value,
depth,
keyPath,
shouldHighlightNode,
}: {
label?: string;
value: JsonArray;
depth: number;
keyPath: string;
shouldHighlightNode?: (keyPath: string) => boolean;
}) => {
const { t } = useLingui();
const { emptyArrayLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
@ -29,9 +27,8 @@ export const JsonArrayNode = ({
label={label}
Icon={IconBrackets}
depth={depth}
emptyElementsText={t`Empty Array`}
emptyElementsText={emptyArrayLabel}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
};

View File

@ -1,12 +1,12 @@
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 { 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 { IconComponent } from 'twenty-ui';
import { JsonValue } from 'type-fest';
const StyledContainer = styled.li`
@ -37,7 +37,6 @@ export const JsonNestedNode = ({
emptyElementsText,
depth,
keyPath,
shouldHighlightNode,
}: {
label?: string;
Icon: IconComponent;
@ -46,7 +45,6 @@ export const JsonNestedNode = ({
emptyElementsText: string;
depth: number;
keyPath: string;
shouldHighlightNode?: (keyPath: string) => boolean;
}) => {
const hideRoot = !isDefined(label);
@ -69,7 +67,6 @@ export const JsonNestedNode = ({
value={value}
depth={depth + 1}
keyPath={nextKeyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
})

View File

@ -1,14 +1,15 @@
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';
} 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 = ({
@ -16,14 +17,14 @@ export const JsonNode = ({
value,
depth,
keyPath,
shouldHighlightNode,
}: {
label?: string;
value: JsonValue;
depth: number;
keyPath: string;
shouldHighlightNode?: (keyPath: string) => boolean;
}) => {
const { shouldHighlightNode } = useJsonTreeContextOrThrow();
const isHighlighted = shouldHighlightNode?.(keyPath) ?? false;
if (isNull(value)) {
@ -77,7 +78,6 @@ export const JsonNode = ({
value={value}
depth={depth}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
}
@ -88,7 +88,6 @@ export const JsonNode = ({
value={value}
depth={depth}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
};

View File

@ -1,6 +1,6 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useLingui } from '@lingui/react/macro';
import { IconCube } from 'twenty-ui';
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 = ({
@ -8,15 +8,13 @@ export const JsonObjectNode = ({
value,
depth,
keyPath,
shouldHighlightNode,
}: {
label?: string;
value: JsonObject;
depth: number;
keyPath: string;
shouldHighlightNode?: (keyPath: string) => boolean;
}) => {
const { t } = useLingui();
const { emptyObjectLabel } = useJsonTreeContextOrThrow();
return (
<JsonNestedNode
@ -29,9 +27,8 @@ export const JsonObjectNode = ({
label={label}
Icon={IconCube}
depth={depth}
emptyElementsText={t`Empty Object`}
emptyElementsText={emptyObjectLabel}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
};

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

@ -1,8 +1,8 @@
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';
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)};

View File

@ -1,8 +1,9 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { VisibilityHidden } from '@ui/accessibility';
import { IconChevronDown } from '@ui/display';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { motion } from 'framer-motion';
import { IconChevronDown, VisibilityHidden } from 'twenty-ui';
const StyledButton = styled(motion.button)`
align-items: center;
@ -29,13 +30,16 @@ export const JsonArrow = ({
isOpen: boolean;
onClick: () => void;
}) => {
const { t } = useLingui();
const theme = useTheme();
const { arrowButtonCollapsedLabel, arrowButtonExpandedLabel } =
useJsonTreeContextOrThrow();
return (
<StyledButton onClick={onClick}>
<VisibilityHidden>{isOpen ? t`Collapse` : t`Expand`}</VisibilityHidden>
<VisibilityHidden>
{isOpen ? arrowButtonExpandedLabel : arrowButtonCollapsedLabel}
</VisibilityHidden>
<MotionIconChevronDown
size={theme.icon.size.md}

View File

@ -1,6 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
import { IconComponent } from '@ui/display';
const StyledLabelContainer = styled.span<{ isHighlighted?: boolean }>`
align-items: center;

View File

@ -0,0 +1,13 @@
import { createContext } from 'react';
export type JsonTreeContextType = {
shouldHighlightNode?: (keyPath: string) => boolean;
emptyArrayLabel: string;
emptyObjectLabel: string;
arrowButtonCollapsedLabel: string;
arrowButtonExpandedLabel: string;
};
export const JsonTreeContext = createContext<JsonTreeContextType | undefined>(
undefined,
);

View File

@ -0,0 +1,15 @@
import { JsonTreeContext } from '@ui/json-visualizer/contexts/JsonTreeContext';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared';
export const useJsonTreeContextOrThrow = () => {
const value = useContext(JsonTreeContext);
if (!isDefined(value)) {
throw new Error(
'useJsonTreeContextOrThrow must be used within a JsonTreeContextProvider',
);
}
return value;
};

View File

@ -0,0 +1,10 @@
export * from './components/JsonArrayNode';
export * from './components/JsonNestedNode';
export * from './components/JsonNode';
export * from './components/JsonObjectNode';
export * from './components/JsonTree';
export * from './components/JsonTreeContextProvider';
export * from './components/JsonValueNode';
export * from './contexts/JsonTreeContext';
export * from './hooks/useJsonTreeContextOrThrow';
export * from './utils/isArray';