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} ) : null}
{activeTabId === 'input' ? ( {activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} /> <WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null} ) : null}
{activeTabId === 'output' ? ( {activeTabId === 'output' ? (
<WorkflowRunStepOutputDetail stepId={workflowSelectedNode} /> <WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null} ) : null}
</WorkflowStepContextProvider> </WorkflowStepContextProvider>
); );

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThro
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { JsonTree } from 'twenty-ui'; import { isTwoFirstDepths, JsonTree } from 'twenty-ui';
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: grid; display: grid;
@ -28,8 +28,10 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
<StyledContainer> <StyledContainer>
<JsonTree <JsonTree
value={stepOutput} value={stepOutput}
shouldExpandNodeInitially={isTwoFirstDepths}
emptyArrayLabel={t`Empty Array`} emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`} emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`} arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`} 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, within,
} from '@storybook/test'; } from '@storybook/test';
import { JsonTree } from '@ui/json-visualizer/components/JsonTree'; import { JsonTree } from '@ui/json-visualizer/components/JsonTree';
import { isTwoFirstDepths } from '@ui/json-visualizer/utils/isTwoFirstDepths';
const meta: Meta<typeof JsonTree> = { const meta: Meta<typeof JsonTree> = {
title: 'UI/JsonVisualizer/JsonTree', title: 'UI/JsonVisualizer/JsonTree',
component: JsonTree, component: JsonTree,
args: { args: {
shouldExpandNodeInitially: () => true,
emptyArrayLabel: 'Empty Array', emptyArrayLabel: 'Empty Array',
emptyObjectLabel: 'Empty Object', emptyObjectLabel: 'Empty Object',
emptyStringLabel: '[empty string]',
arrowButtonCollapsedLabel: 'Expand', arrowButtonCollapsedLabel: 'Expand',
arrowButtonExpandedLabel: 'Collapse', 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 = { export const ReallyDeepNestedObject: Story = {
args: { args: {
value: { 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 { JsonList } from '@ui/json-visualizer/components/internal/JsonList';
import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel'; import { JsonNodeLabel } from '@ui/json-visualizer/components/internal/JsonNodeLabel';
import { JsonNode } from '@ui/json-visualizer/components/JsonNode'; import { JsonNode } from '@ui/json-visualizer/components/JsonNode';
import { useJsonTreeContextOrThrow } from '@ui/json-visualizer/hooks/useJsonTreeContextOrThrow';
import { ANIMATION } from '@ui/theme'; import { ANIMATION } from '@ui/theme';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { useState } from 'react'; import { useState } from 'react';
@ -49,9 +50,13 @@ export const JsonNestedNode = ({
depth: number; depth: number;
keyPath: string; keyPath: string;
}) => { }) => {
const { shouldExpandNodeInitially } = useJsonTreeContextOrThrow();
const hideRoot = !isDefined(label); const hideRoot = !isDefined(label);
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(
shouldExpandNodeInitially({ keyPath, depth }),
);
const renderedChildren = ( const renderedChildren = (
<StyledJsonList <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 { import {
IconCheckbox, IconCheckbox,
IconCircleOff, IconCircleOff,
@ -23,7 +29,7 @@ export const JsonNode = ({
depth: number; depth: number;
keyPath: string; keyPath: string;
}) => { }) => {
const { shouldHighlightNode } = useJsonTreeContextOrThrow(); const { shouldHighlightNode, emptyStringLabel } = useJsonTreeContextOrThrow();
const isHighlighted = shouldHighlightNode?.(keyPath) ?? false; const isHighlighted = shouldHighlightNode?.(keyPath) ?? false;
@ -31,7 +37,7 @@ export const JsonNode = ({
return ( return (
<JsonValueNode <JsonValueNode
label={label} label={label}
valueAsString="[null]" valueAsString="null"
Icon={IconCircleOff} Icon={IconCircleOff}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
/> />
@ -42,7 +48,7 @@ export const JsonNode = ({
return ( return (
<JsonValueNode <JsonValueNode
label={label} label={label}
valueAsString={value} valueAsString={isNonEmptyString(value) ? value : emptyStringLabel}
Icon={IconTypography} Icon={IconTypography}
isHighlighted={isHighlighted} isHighlighted={isHighlighted}
/> />

View File

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

View File

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

View File

@ -8,3 +8,4 @@ export * from './components/JsonValueNode';
export * from './contexts/JsonTreeContext'; export * from './contexts/JsonTreeContext';
export * from './hooks/useJsonTreeContextOrThrow'; export * from './hooks/useJsonTreeContextOrThrow';
export * from './utils/isArray'; 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;