Limit nodes opened by default in the JSON Tree component (#11002)

- Add a parameter to choose which nodes to open by default
- On the Admin Panel, open all nodes by default
- On the Workflow Run step output, open only the two first depths
- On the Workflow Run step input, open only the previous step first
depth
- Display `[empty string]` when a node is an empty string
- Now, display `null` instead of `[null]`

## Demo


https://github.com/user-attachments/assets/99b3078a-da3c-4330-b0ff-ddb2e360d933

Closes https://github.com/twentyhq/core-team-issues/issues/538
This commit is contained in:
Baptiste Devessier
2025-03-19 11:44:34 +01:00
committed by GitHub
parent 15a2cb5141
commit 1ecc5e2bf6
13 changed files with 141 additions and 11 deletions

View File

@ -85,11 +85,17 @@ export const CommandMenuWorkflowRunViewStep = () => {
) : null}
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} />
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === 'output' ? (
<WorkflowRunStepOutputDetail stepId={workflowSelectedNode} />
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
</WorkflowStepContextProvider>
);

View File

@ -33,6 +33,8 @@ export const JsonDataIndicatorHealthStatus = () => {
!indicatorHealth.status ||
indicatorHealth.status === AdminPanelHealthServiceStatus.OUTAGE;
const isAnyNode = () => true;
return (
<Section>
{isDown && (
@ -45,8 +47,10 @@ export const JsonDataIndicatorHealthStatus = () => {
<StyledDetailsContainer>
<JsonTree
value={parsedDetails}
shouldExpandNodeInitially={isAnyNode}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
/>

View File

@ -85,11 +85,17 @@ export const RightDrawerWorkflowRunViewStep = () => {
) : null}
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} />
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === 'output' ? (
<WorkflowRunStepOutputDetail stepId={workflowSelectedNode} />
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
</WorkflowStepContextProvider>
);

View File

@ -1,5 +1,6 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getWorkflowPreviousStepId } from '@/workflow/workflow-steps/utils/getWorkflowPreviousStep';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep';
import styled from '@emotion/styled';
@ -9,6 +10,7 @@ import {
IconBrackets,
JsonNestedNode,
JsonTreeContextProvider,
ShouldExpandNodeInitiallyProps,
} from 'twenty-ui';
const StyledContainer = styled.div`
@ -38,6 +40,15 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
return null;
}
const previousStepId = getWorkflowPreviousStepId({
stepId,
steps: workflowRun.output.flow.steps,
});
if (previousStepId === undefined) {
return null;
}
const variablesUsedInStep = getWorkflowVariablesUsedInStep({
step,
});
@ -47,20 +58,27 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
flow: workflowRun.output.flow,
stepId,
});
if (stepContext.length === 0) {
throw new Error('The input tab must be rendered with a non-empty context.');
}
const isFirstNodeDepthOfPreviousStep = ({
keyPath,
depth,
}: ShouldExpandNodeInitiallyProps) =>
keyPath.startsWith(previousStepId) && depth < 2;
return (
<StyledContainer>
<JsonTreeContextProvider
value={{
emptyArrayLabel: t`Empty Array`,
emptyObjectLabel: t`Empty Object`,
emptyStringLabel: t`[empty string]`,
arrowButtonCollapsedLabel: t`Expand`,
arrowButtonExpandedLabel: t`Collapse`,
shouldHighlightNode: (keyPath) => variablesUsedInStep.has(keyPath),
shouldExpandNodeInitially: isFirstNodeDepthOfPreviousStep,
}}
>
<JsonNestedNode

View File

@ -3,7 +3,7 @@ import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThro
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import { JsonTree } from 'twenty-ui';
import { isTwoFirstDepths, JsonTree } from 'twenty-ui';
const StyledContainer = styled.div`
display: grid;
@ -28,8 +28,10 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
<StyledContainer>
<JsonTree
value={stepOutput}
shouldExpandNodeInitially={isTwoFirstDepths}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
/>

View File

@ -0,0 +1,25 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
export const getWorkflowPreviousStepId = ({
stepId,
steps,
}: {
stepId: string;
steps: Array<WorkflowStep>;
}) => {
if (stepId === TRIGGER_STEP_ID) {
return undefined;
}
if (stepId === steps[0].id) {
return TRIGGER_STEP_ID;
}
const stepIndex = steps.findIndex((step) => step.id === stepId);
if (stepIndex === -1) {
throw new Error('Step not found');
}
return steps[stepIndex - 1].id;
};

View File

@ -6,13 +6,16 @@ import {
within,
} from '@storybook/test';
import { JsonTree } from '@ui/json-visualizer/components/JsonTree';
import { isTwoFirstDepths } from '@ui/json-visualizer/utils/isTwoFirstDepths';
const meta: Meta<typeof JsonTree> = {
title: 'UI/JsonVisualizer/JsonTree',
component: JsonTree,
args: {
shouldExpandNodeInitially: () => true,
emptyArrayLabel: 'Empty Array',
emptyObjectLabel: 'Empty Object',
emptyStringLabel: '[empty string]',
arrowButtonCollapsedLabel: 'Expand',
arrowButtonExpandedLabel: 'Collapse',
},
@ -273,6 +276,41 @@ export const ExpandingElementExpandsAllItsDescendants: Story = {
},
};
export const ExpandTwoFirstDepths: Story = {
args: {
value: {
person: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'New York',
country: {
name: 'USA',
code: 'US',
},
},
},
isActive: true,
},
shouldExpandNodeInitially: isTwoFirstDepths,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nameElement = await canvas.findByText('name');
expect(nameElement).toBeVisible();
const addressElement = await canvas.findByText('address');
expect(addressElement).toBeVisible();
const streetElement = canvas.queryByText('street');
expect(streetElement).not.toBeInTheDocument();
const countrCodeElement = canvas.queryByText('code');
expect(countrCodeElement).not.toBeInTheDocument();
},
};
export const ReallyDeepNestedObject: Story = {
args: {
value: {

View File

@ -5,6 +5,7 @@ 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 { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { ANIMATION } from '@ui/theme';
import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react';
@ -49,9 +50,13 @@ export const JsonNestedNode = ({
depth: number;
keyPath: string;
}) => {
const { shouldExpandNodeInitially } = useJsonTreeContextOrThrow();
const hideRoot = !isDefined(label);
const [isOpen, setIsOpen] = useState(true);
const [isOpen, setIsOpen] = useState(
shouldExpandNodeInitially({ keyPath, depth }),
);
const renderedChildren = (
<StyledJsonList

View File

@ -1,4 +1,10 @@
import { isBoolean, isNull, isNumber, isString } from '@sniptt/guards';
import {
isBoolean,
isNonEmptyString,
isNull,
isNumber,
isString,
} from '@sniptt/guards';
import {
IconCheckbox,
IconCircleOff,
@ -23,7 +29,7 @@ export const JsonNode = ({
depth: number;
keyPath: string;
}) => {
const { shouldHighlightNode } = useJsonTreeContextOrThrow();
const { shouldHighlightNode, emptyStringLabel } = useJsonTreeContextOrThrow();
const isHighlighted = shouldHighlightNode?.(keyPath) ?? false;
@ -31,7 +37,7 @@ export const JsonNode = ({
return (
<JsonValueNode
label={label}
valueAsString="[null]"
valueAsString="null"
Icon={IconCircleOff}
isHighlighted={isHighlighted}
/>
@ -42,7 +48,7 @@ export const JsonNode = ({
return (
<JsonValueNode
label={label}
valueAsString={value}
valueAsString={isNonEmptyString(value) ? value : emptyStringLabel}
Icon={IconTypography}
isHighlighted={isHighlighted}
/>

View File

@ -1,20 +1,27 @@
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 { ShouldExpandNodeInitiallyProps } from '@ui/json-visualizer/contexts/JsonTreeContext';
import { JsonValue } from 'type-fest';
export const JsonTree = ({
value,
shouldHighlightNode,
shouldExpandNodeInitially,
emptyArrayLabel,
emptyObjectLabel,
emptyStringLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
}: {
value: JsonValue;
shouldHighlightNode?: (keyPath: string) => boolean;
shouldExpandNodeInitially: (
params: ShouldExpandNodeInitiallyProps,
) => boolean;
emptyArrayLabel: string;
emptyObjectLabel: string;
emptyStringLabel: string;
arrowButtonCollapsedLabel: string;
arrowButtonExpandedLabel: string;
}) => {
@ -22,8 +29,10 @@ export const JsonTree = ({
<JsonTreeContextProvider
value={{
shouldHighlightNode,
shouldExpandNodeInitially,
emptyArrayLabel,
emptyObjectLabel,
emptyStringLabel,
arrowButtonCollapsedLabel,
arrowButtonExpandedLabel,
}}

View File

@ -1,7 +1,13 @@
import { createContext } from 'react';
export type ShouldExpandNodeInitiallyProps = { keyPath: string; depth: number };
export type JsonTreeContextType = {
shouldHighlightNode?: (keyPath: string) => boolean;
shouldExpandNodeInitially: (
params: ShouldExpandNodeInitiallyProps,
) => boolean;
emptyStringLabel: string;
emptyArrayLabel: string;
emptyObjectLabel: string;
arrowButtonCollapsedLabel: string;

View File

@ -8,3 +8,4 @@ export * from './components/JsonValueNode';
export * from './contexts/JsonTreeContext';
export * from './hooks/useJsonTreeContextOrThrow';
export * from './utils/isArray';
export * from './utils/isTwoFirstDepths';

View File

@ -0,0 +1,4 @@
import { ShouldExpandNodeInitiallyProps } from '@ui/json-visualizer/contexts/JsonTreeContext';
export const isTwoFirstDepths = ({ depth }: ShouldExpandNodeInitiallyProps) =>
depth <= 1;