Use JSON visualizer for JSON fields (#11428)

- Updates on the JSON field input
  - Previously, we were editing json fields in a textarea 
- Now, we display a JSON visualizer and the user can click on an Edit
button to edit the JSON in Monaco
- The JSON field input has a special behavior for workflow run output.
We want the error to be displayed first in the visualizer. Displaying
the error in red was optional but makes the output clearer in the
context of a workflow run record board.
- Made the code editor transparent in the json field input
- Ensure workflow run's output is not considered readonly in
`packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts`;
we want the json visualizer to always be displayed in this specific case

## Demo

### Failed Workflow Run


https://github.com/user-attachments/assets/7a438d11-53fb-4425-a982-25bbea4ee7a8

### Any JSON field in the record table


https://github.com/user-attachments/assets/b5591abe-3483-4473-bd87-062a45e653e3

Closes https://github.com/twentyhq/core-team-issues/issues/539
This commit is contained in:
Baptiste Devessier
2025-04-08 18:18:36 +02:00
committed by GitHub
parent c1d421de06
commit 6521d19238
13 changed files with 300 additions and 58 deletions

View File

@ -7,6 +7,7 @@ import { FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePrecomputedJsonDraftValue } from '@/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRawJson } from '../../types/guards/isFieldRawJson';
@ -46,8 +47,13 @@ export const useJsonField = () => {
const draftValue = useRecoilValue(getDraftValueSelector());
const precomputedDraftValue = usePrecomputedJsonDraftValue({
draftValue,
});
return {
draftValue,
precomputedDraftValue,
setDraftValue,
maxWidth,
fieldDefinition,

View File

@ -0,0 +1,48 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { workflowRunOutputSchema } from '@/workflow/validation-schemas/workflowSchema';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { JsonObject, JsonValue } from 'type-fest';
import { parseJson } from '~/utils/parseJson';
export const usePrecomputedJsonDraftValue = ({
draftValue,
}: {
draftValue: string | undefined;
}): JsonValue => {
const { fieldDefinition } = useContext(FieldContext);
const parsedJsonValue = parseJson<JsonValue>(draftValue);
if (
fieldDefinition.metadata.objectMetadataNameSingular ===
CoreObjectNameSingular.WorkflowRun &&
fieldDefinition.metadata.fieldName === 'output' &&
isDefined(draftValue)
) {
const parsedValue = workflowRunOutputSchema.safeParse(parsedJsonValue);
if (!parsedValue.success) {
return null;
}
const orderedWorkflowRunOutput: WorkflowRunOutput = {
...(isDefined(parsedValue.data.error)
? {
error: parsedValue.data.error,
}
: {}),
...(isDefined(parsedValue.data.stepsOutput)
? {
stepsOutput: parsedValue.data.stepsOutput,
}
: {}),
flow: parsedValue.data.flow,
};
return orderedWorkflowRunOutput as JsonObject;
}
return parsedJsonValue;
};

View File

@ -1,10 +1,20 @@
import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput';
import styled from '@emotion/styled';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from '@/object-record/record-field/types/FieldInputEvent';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useLingui } from '@lingui/react/macro';
import { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { IconPencil } from 'twenty-ui/display';
import { CodeEditor, FloatingIconButton } from 'twenty-ui/input';
import { JsonTree, isTwoFirstDepths } from 'twenty-ui/json-visualizer';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { useJsonField } from '../../hooks/useJsonField';
type RawJsonFieldInputProps = {
@ -15,19 +25,53 @@ type RawJsonFieldInputProps = {
onShiftTab?: FieldInputEvent;
};
const CONTAINER_HEIGHT = 300;
const StyledContainer = styled.div`
box-sizing: border-box;
height: ${CONTAINER_HEIGHT}px;
width: 400px;
position: relative;
overflow-y: auto;
`;
const StyledSwitchModeButtonContainer = styled.div`
position: fixed;
top: ${({ theme }) => theme.spacing(1)};
right: ${({ theme }) => theme.spacing(1)};
`;
const StyledCodeEditorContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledJsonTreeContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
width: min-content;
`;
export const RawJsonFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: RawJsonFieldInputProps) => {
const { fieldDefinition, draftValue, setDraftValue, persistJsonField } =
useJsonField();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const handleEnter = (newText: string) => {
onEnter?.(() => persistJsonField(newText));
};
const {
draftValue,
precomputedDraftValue,
setDraftValue,
persistJsonField,
fieldDefinition,
} = useJsonField();
const hotkeyScope = DEFAULT_CELL_SCOPE.scope;
const containerRef = useRef<HTMLDivElement>(null);
const [isEditing, setIsEditing] = useState(false);
const handleEscape = (newText: string) => {
onEscape?.(() => persistJsonField(newText));
@ -52,19 +96,102 @@ export const RawJsonFieldInput = ({
setDraftValue(newText);
};
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
handleClickOutside(event, draftValue ?? '');
},
listenerId: hotkeyScope,
});
useScopedHotkeys(
[Key.Escape],
() => {
handleEscape(draftValue ?? '');
},
hotkeyScope,
[handleEscape, draftValue],
);
useScopedHotkeys(
'tab',
() => {
handleTab(draftValue ?? '');
},
hotkeyScope,
[handleTab, draftValue],
);
useScopedHotkeys(
'shift+tab',
() => {
handleShiftTab(draftValue ?? '');
},
hotkeyScope,
[handleShiftTab, draftValue],
);
// FIXME: This is temporary. We'll soon introduce a new display mode for all fields and we'll have to remove this code.
const isWorkflowRunOutputField =
fieldDefinition.metadata.objectMetadataNameSingular ===
CoreObjectNameSingular.WorkflowRun &&
fieldDefinition.metadata.fieldName === 'output';
const showEditingButton = !isWorkflowRunOutputField;
const handleStartEditing = () => {
setIsEditing(true);
};
return (
<TextAreaInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={draftValue ?? ''}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={DEFAULT_CELL_SCOPE.scope}
onChange={handleChange}
maxRows={25}
/>
<StyledContainer ref={containerRef}>
{isEditing ? (
<StyledCodeEditorContainer>
<CodeEditor
value={draftValue}
language="application/json"
height={CONTAINER_HEIGHT - 8}
variant="borderless"
transparentBackground
options={{
lineNumbers: 'off',
folding: false,
overviewRulerBorder: false,
lineDecorationsWidth: 0,
scrollbar: {
useShadows: false,
vertical: 'hidden',
horizontal: 'hidden',
},
}}
onChange={handleChange}
/>
</StyledCodeEditorContainer>
) : (
<>
{showEditingButton && (
<StyledSwitchModeButtonContainer>
<FloatingIconButton
Icon={IconPencil}
onClick={handleStartEditing}
/>
</StyledSwitchModeButtonContainer>
)}
<StyledJsonTreeContainer>
<JsonTree
value={precomputedDraftValue}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
shouldExpandNodeInitially={isTwoFirstDepths}
onNodeValueClick={copyToClipboard}
/>
</StyledJsonTreeContainer>
</>
)}
</StyledContainer>
);
};

View File

@ -23,6 +23,13 @@ export const isFieldValueReadOnly = ({
return true;
}
if (
objectNameSingular === CoreObjectNameSingular.WorkflowRun &&
fieldName === 'output'
) {
return false;
}
if (isWorkflowSubObjectMetadata(objectNameSingular)) {
return true;
}

View File

@ -106,7 +106,7 @@ export const ServerlessFunctionExecutionResult = ({
height={serverlessFunctionTestData.height}
options={{ readOnly: true, domReadOnly: true }}
isLoading={isTesting}
withHeader
variant="with-header"
/>
</StyledContainer>
);

View File

@ -127,7 +127,7 @@ export const SettingsServerlessFunctionCodeEditor = ({
onChange={onChange}
onValidate={handleEditorValidation}
options={options}
withHeader
variant="with-header"
/>
)
);

View File

@ -79,7 +79,7 @@ export const SettingsServerlessFunctionTestTab = ({
language="json"
height={200}
onChange={onChange}
withHeader
variant="with-header"
/>
</StyledCodeEditorContainer>
<ServerlessFunctionExecutionResult

View File

@ -2,21 +2,28 @@ import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Editor, { EditorProps, Monaco } from '@monaco-editor/react';
import { Loader } from '@ui/feedback/loader/components/Loader';
import { codeEditorTheme } from '@ui/input';
import { BASE_CODE_EDITOR_THEME_ID } from '@ui/input/code-editor/constants/BaseCodeEditorThemeId';
import { getBaseCodeEditorTheme } from '@ui/input/code-editor/theme/utils/getBaseCodeEditorTheme';
import { editor } from 'monaco-editor';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
type CodeEditorProps = Omit<EditorProps, 'onChange'> & {
type CodeEditorVariant = 'default' | 'with-header' | 'borderless';
type CodeEditorProps = Pick<
EditorProps,
'value' | 'language' | 'onMount' | 'onValidate' | 'height' | 'options'
> & {
onChange?: (value: string) => void;
setMarkers?: (value: string) => editor.IMarkerData[];
withHeader?: boolean;
variant?: CodeEditorVariant;
isLoading?: boolean;
transparentBackground?: boolean;
};
const StyledEditorLoader = styled.div<{
height: string | number;
withHeader?: boolean;
variant: CodeEditorVariant;
}>`
align-items: center;
display: flex;
@ -24,35 +31,66 @@ const StyledEditorLoader = styled.div<{
justify-content: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background-color: ${({ theme }) => theme.background.transparent.lighter};
${({ withHeader, theme }) =>
withHeader
? css`
${({ variant, theme }) => {
switch (variant) {
case 'default':
return css`
border-radius: ${theme.border.radius.sm};
`;
case 'borderless':
return css`
border: none;
`;
case 'with-header':
return css`
border-radius: 0 0 ${theme.border.radius.sm} ${theme.border.radius.sm};
border-top: none;
`
: css`
border-radius: ${theme.border.radius.sm};
`}
`;
}
}}
`;
const StyledEditor = styled(Editor)<{ withHeader: boolean }>`
const StyledEditor = styled(Editor)<{
variant: CodeEditorVariant;
transparentBackground?: boolean;
}>`
.monaco-editor {
border-radius: ${({ theme }) => theme.border.radius.sm};
outline-width: 0;
${({ theme, transparentBackground }) =>
!transparentBackground &&
css`
background-color: ${theme.background.secondary};
`}
${({ variant, theme }) =>
variant !== 'borderless' &&
css`
border-radius: ${theme.border.radius.sm};
`}
}
.overflow-guard {
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
${({ withHeader, theme }) =>
withHeader
? css`
${({ variant, theme }) => {
switch (variant) {
case 'default': {
return css`
border: 1px solid ${theme.border.color.medium};
border-radius: ${theme.border.radius.sm};
`;
}
case 'with-header': {
return css`
border: 1px solid ${theme.border.color.medium};
border-radius: 0 0 ${theme.border.radius.sm}
${theme.border.radius.sm};
border-top: none;
`
: css`
border-radius: ${theme.border.radius.sm};
`}
`;
}
}
}}
}
`;
@ -64,7 +102,8 @@ export const CodeEditor = ({
setMarkers,
onValidate,
height = 450,
withHeader = false,
variant = 'default',
transparentBackground,
isLoading = false,
options,
}: CodeEditorProps) => {
@ -89,21 +128,29 @@ export const CodeEditor = ({
};
return isLoading ? (
<StyledEditorLoader height={height} withHeader={withHeader}>
<StyledEditorLoader height={height} variant={variant}>
<Loader />
</StyledEditorLoader>
) : (
<StyledEditor
height={height}
withHeader={withHeader}
variant={variant}
value={isLoading ? '' : value}
language={language}
loading=""
transparentBackground={transparentBackground}
onMount={(editor, monaco) => {
setMonaco(monaco);
setEditor(editor);
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
monaco.editor.defineTheme(
BASE_CODE_EDITOR_THEME_ID,
getBaseCodeEditorTheme({
theme,
}),
);
monaco.editor.setTheme(BASE_CODE_EDITOR_THEME_ID);
onMount?.(editor, monaco);
setModelMarkers(editor, monaco);
}}

View File

@ -0,0 +1 @@
export const BASE_CODE_EDITOR_THEME_ID = 'baseCodeEditorTheme';

View File

@ -1,3 +1,3 @@
export * from './components/CodeEditor';
export * from './components/CodeEditorHeader';
export * from './theme/utils/codeEditorTheme';
export * from './theme/utils/getBaseCodeEditorTheme';

View File

@ -1,9 +1,13 @@
import { ThemeType } from '@ui/theme';
import { editor } from 'monaco-editor';
export const codeEditorTheme = (theme: ThemeType) => {
export const getBaseCodeEditorTheme = ({
theme,
}: {
theme: ThemeType;
}): editor.IStandaloneThemeData => {
return {
base: 'vs' as editor.BuiltinTheme,
base: 'vs',
inherit: true,
rules: [
{
@ -23,7 +27,8 @@ export const codeEditorTheme = (theme: ThemeType) => {
},
],
colors: {
'editor.background': theme.background.secondary,
// eslint-disable-next-line @nx/workspace-no-hardcoded-colors
'editor.background': '#00000000',
'editorCursor.foreground': theme.font.color.primary,
'editorLineNumber.foreground': theme.font.color.extraLight,
'editorLineNumber.activeForeground': theme.font.color.light,

View File

@ -68,7 +68,8 @@ export { RoundedIconButton } from './button/components/RoundedIconButton';
export { CodeEditor } from './code-editor/components/CodeEditor';
export type { CoreEditorHeaderProps } from './code-editor/components/CodeEditorHeader';
export { CoreEditorHeader } from './code-editor/components/CodeEditorHeader';
export { codeEditorTheme } from './code-editor/theme/utils/codeEditorTheme';
export { BASE_CODE_EDITOR_THEME_ID } from './code-editor/constants/BaseCodeEditorThemeId';
export { getBaseCodeEditorTheme } from './code-editor/theme/utils/getBaseCodeEditorTheme';
export type {
ColorSchemeSegmentProps,
ColorSchemeCardProps,

View File

@ -31,12 +31,12 @@ const StyledLabelContainer = styled.span<{
height: 24px;
box-sizing: border-box;
column-gap: ${({ theme }) => theme.spacing(2)};
display: flex;
font-variant-numeric: tabular-nums;
justify-content: center;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
white-space: nowrap;
padding-block: ${({ theme }) => theme.spacing(1)};
padding-inline: ${({ theme }) => theme.spacing(2)};
width: fit-content;
`;
export const JsonNodeLabel = ({