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 98235570d..05d9eec89 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 000000000..ce9a0b10b --- /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 0e48721c0..5c5c1fdbb 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 40efed913..321ba81cd 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 0fc1c7b66..f1aa8e060 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 4608b9faf..f899ef970 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 e16543a49..b05ff53da 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 000000000..e0eff3e68 --- /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 d5f85820f..2bc0f17ac 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 f26da9b5f..e730271f6 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 57e0b1a77..82fd5e88d 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 576d85b82..6e06d10a9 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 = ({