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 = ({