From 6521d1923811c0d32ad2a9ef3de81ef438abdbee Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Tue, 8 Apr 2025 18:18:36 +0200 Subject: [PATCH] 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 --- .../meta-types/hooks/useJsonField.ts | 6 + .../hooks/usePrecomputedJsonDraftValue.ts | 48 +++++ .../input/components/RawJsonFieldInput.tsx | 167 +++++++++++++++--- .../utils/isFieldValueReadOnly.ts | 7 + .../ServerlessFunctionExecutionResult.tsx | 2 +- .../SettingsServerlessFunctionCodeEditor.tsx | 2 +- .../SettingsServerlessFunctionTestTab.tsx | 2 +- .../code-editor/components/CodeEditor.tsx | 99 ++++++++--- .../constants/BaseCodeEditorThemeId.ts | 1 + .../twenty-ui/src/input/code-editor/index.ts | 2 +- ...itorTheme.ts => getBaseCodeEditorTheme.ts} | 11 +- packages/twenty-ui/src/input/index.ts | 3 +- .../components/internal/JsonNodeLabel.tsx | 8 +- 13 files changed, 300 insertions(+), 58 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts create mode 100644 packages/twenty-ui/src/input/code-editor/constants/BaseCodeEditorThemeId.ts rename packages/twenty-ui/src/input/code-editor/theme/utils/{codeEditorTheme.ts => getBaseCodeEditorTheme.ts} (77%) diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts index 98235570de..05d9eec895 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts new file mode 100644 index 0000000000..ce9a0b10bf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePrecomputedJsonDraftValue.ts @@ -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(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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx index 0e48721c04..5c5c1fdbb3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx @@ -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(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 ( - + + {isEditing ? ( + + + + ) : ( + <> + {showEditingButton && ( + + + + )} + + + + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts index 40efed9137..321ba81cd7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts @@ -23,6 +23,13 @@ export const isFieldValueReadOnly = ({ return true; } + if ( + objectNameSingular === CoreObjectNameSingular.WorkflowRun && + fieldName === 'output' + ) { + return false; + } + if (isWorkflowSubObjectMetadata(objectNameSingular)) { return true; } diff --git a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx index 0fc1c7b66a..f1aa8e0600 100644 --- a/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx +++ b/packages/twenty-front/src/modules/serverless-functions/components/ServerlessFunctionExecutionResult.tsx @@ -106,7 +106,7 @@ export const ServerlessFunctionExecutionResult = ({ height={serverlessFunctionTestData.height} options={{ readOnly: true, domReadOnly: true }} isLoading={isTesting} - withHeader + variant="with-header" /> ); diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx index 4608b9faf0..f899ef9703 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx @@ -127,7 +127,7 @@ export const SettingsServerlessFunctionCodeEditor = ({ onChange={onChange} onValidate={handleEditorValidation} options={options} - withHeader + variant="with-header" /> ) ); diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx index e16543a494..b05ff53daf 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx @@ -79,7 +79,7 @@ export const SettingsServerlessFunctionTestTab = ({ language="json" height={200} onChange={onChange} - withHeader + variant="with-header" /> & { +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 ? ( - + ) : ( { 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); }} diff --git a/packages/twenty-ui/src/input/code-editor/constants/BaseCodeEditorThemeId.ts b/packages/twenty-ui/src/input/code-editor/constants/BaseCodeEditorThemeId.ts new file mode 100644 index 0000000000..e0eff3e687 --- /dev/null +++ b/packages/twenty-ui/src/input/code-editor/constants/BaseCodeEditorThemeId.ts @@ -0,0 +1 @@ +export const BASE_CODE_EDITOR_THEME_ID = 'baseCodeEditorTheme'; diff --git a/packages/twenty-ui/src/input/code-editor/index.ts b/packages/twenty-ui/src/input/code-editor/index.ts index d5f85820f4..2bc0f17ac0 100644 --- a/packages/twenty-ui/src/input/code-editor/index.ts +++ b/packages/twenty-ui/src/input/code-editor/index.ts @@ -1,3 +1,3 @@ export * from './components/CodeEditor'; export * from './components/CodeEditorHeader'; -export * from './theme/utils/codeEditorTheme'; +export * from './theme/utils/getBaseCodeEditorTheme'; diff --git a/packages/twenty-ui/src/input/code-editor/theme/utils/codeEditorTheme.ts b/packages/twenty-ui/src/input/code-editor/theme/utils/getBaseCodeEditorTheme.ts similarity index 77% rename from packages/twenty-ui/src/input/code-editor/theme/utils/codeEditorTheme.ts rename to packages/twenty-ui/src/input/code-editor/theme/utils/getBaseCodeEditorTheme.ts index f26da9b5fc..e730271f69 100644 --- a/packages/twenty-ui/src/input/code-editor/theme/utils/codeEditorTheme.ts +++ b/packages/twenty-ui/src/input/code-editor/theme/utils/getBaseCodeEditorTheme.ts @@ -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, diff --git a/packages/twenty-ui/src/input/index.ts b/packages/twenty-ui/src/input/index.ts index 57e0b1a774..82fd5e88dc 100644 --- a/packages/twenty-ui/src/input/index.ts +++ b/packages/twenty-ui/src/input/index.ts @@ -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, diff --git a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx index 576d85b824..6e06d10a95 100644 --- a/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx +++ b/packages/twenty-ui/src/json-visualizer/components/internal/JsonNodeLabel.tsx @@ -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 = ({