Add Workflow Run show page (#7719)

In this PR:

- Display a workflow version visualizer for the version of the workflow
the run was executed on.
- Display the output of the run as code.


https://github.com/user-attachments/assets/d617300a-bff4-4328-a35c-291dc86d81cf
This commit is contained in:
Baptiste Devessier
2024-10-21 12:04:44 +02:00
committed by GitHub
parent b914182b78
commit e7eeb3b820
12 changed files with 335 additions and 169 deletions

View File

@ -8,6 +8,7 @@ import {
IconMail,
IconNotes,
IconPaperclip,
IconPrinter,
IconSettings,
IconTimelineEvent,
} from 'twenty-ui';
@ -26,6 +27,10 @@ export const useRecordShowContainerTabs = (
const isWorkflowVersion =
isWorkflowEnabled &&
targetObjectNameSingular === CoreObjectNameSingular.WorkflowVersion;
const isWorkflowRun =
isWorkflowEnabled &&
targetObjectNameSingular === CoreObjectNameSingular.WorkflowRun;
const isWorkflowRelated = isWorkflow || isWorkflowVersion || isWorkflowRun;
const isCompanyOrPerson = [
CoreObjectNameSingular.Company,
@ -54,7 +59,7 @@ export const useRecordShowContainerTabs = (
id: 'timeline',
title: 'Timeline',
Icon: IconTimelineEvent,
hide: isInRightDrawer || isWorkflow || isWorkflowVersion,
hide: isInRightDrawer || isWorkflowRelated,
},
{
id: 'tasks',
@ -63,8 +68,7 @@ export const useRecordShowContainerTabs = (
hide:
targetObjectNameSingular === CoreObjectNameSingular.Note ||
targetObjectNameSingular === CoreObjectNameSingular.Task ||
isWorkflow ||
isWorkflowVersion,
isWorkflowRelated,
},
{
id: 'notes',
@ -73,14 +77,13 @@ export const useRecordShowContainerTabs = (
hide:
targetObjectNameSingular === CoreObjectNameSingular.Note ||
targetObjectNameSingular === CoreObjectNameSingular.Task ||
isWorkflow ||
isWorkflowVersion,
isWorkflowRelated,
},
{
id: 'files',
title: 'Files',
Icon: IconPaperclip,
hide: isWorkflow || isWorkflowVersion,
hide: isWorkflowRelated,
},
{
id: 'emails',
@ -102,9 +105,21 @@ export const useRecordShowContainerTabs = (
},
{
id: 'workflowVersion',
title: 'Workflow Version',
title: 'Flow',
Icon: IconSettings,
hide: !isWorkflowVersion,
},
{
id: 'workflowRunOutput',
title: 'Output',
Icon: IconPrinter,
hide: !isWorkflowRun,
},
{
id: 'workflowRunFlow',
title: 'Flow',
Icon: IconSettings,
hide: !isWorkflowRun,
},
];
};

View File

@ -0,0 +1,130 @@
import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor';
import { EditorProps, Monaco } from '@monaco-editor/react';
import dotenv from 'dotenv';
import { editor, MarkerSeverity } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { isDefined } from '~/utils/isDefined';
export type File = {
language: string;
content: string;
path: string;
};
type SettingsServerlessFunctionCodeEditorProps = Omit<
EditorProps,
'onChange'
> & {
currentFilePath: string;
files: File[];
onChange: (value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
};
export const SettingsServerlessFunctionCodeEditor = ({
currentFilePath,
files,
onChange,
setIsCodeValid,
height = 450,
options = undefined,
}: SettingsServerlessFunctionCodeEditorProps) => {
const { availablePackages } = useGetAvailablePackages();
const currentFile = files.find((file) => file.path === currentFilePath);
const environmentVariablesFile = files.find((file) => file.path === '.env');
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
if (files.length > 1) {
files.forEach((file) => {
const model = monaco.editor.getModel(monaco.Uri.file(file.path));
if (!isDefined(model)) {
monaco.editor.createModel(
file.content,
file.language,
monaco.Uri.file(file.path),
);
}
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
moduleResolution:
monaco.languages.typescript.ModuleResolutionKind.NodeJs,
baseUrl: 'file:///src',
paths: {
'src/*': ['file:///src/*'],
},
allowSyntheticDefaultImports: true,
esModuleInterop: true,
noEmit: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
});
if (isDefined(environmentVariablesFile)) {
const environmentVariables = dotenv.parse(
environmentVariablesFile.content,
);
const environmentDefinition = `
declare namespace NodeJS {
interface ProcessEnv {
${Object.keys(environmentVariables)
.map((key) => `${key}: string;`)
.join('\n')}
}
}
declare const process: {
env: NodeJS.ProcessEnv;
};
`;
monaco.languages.typescript.typescriptDefaults.addExtraLib(
environmentDefinition,
'ts:process-env.d.ts',
);
}
await AutoTypings.create(editor, {
monaco,
preloadPackages: true,
onlySpecifiedPackages: true,
versions: availablePackages,
debounceDuration: 0,
});
}
};
const handleEditorValidation = (markers: editor.IMarker[]) => {
for (const marker of markers) {
if (marker.severity === MarkerSeverity.Error) {
setIsCodeValid?.(false);
return;
}
}
setIsCodeValid?.(true);
};
return (
isDefined(currentFile) &&
isDefined(availablePackages) && (
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
height={height}
value={currentFile.content}
language={currentFile.language}
onMount={handleEditorDidMount}
onChange={onChange}
onValidate={handleEditorValidation}
options={options}
/>
</SettingsServerlessFunctionCodeEditorContainer>
)
);
};

View File

@ -0,0 +1,11 @@
import styled from '@emotion/styled';
const StyledEditorContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top: none;
border-radius: 0 0 ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm};
`;
export const SettingsServerlessFunctionCodeEditorContainer =
StyledEditorContainer;

View File

@ -1,20 +1,23 @@
import {
File,
SettingsServerlessFunctionCodeEditor,
} from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor';
import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { CodeEditor, File } from '@/ui/input/code-editor/components/CodeEditor';
import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader';
import { Section } from '@/ui/layout/section/components/Section';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { H2Title, IconGitCommit, IconPlayerPlay, IconRestore } from 'twenty-ui';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useRecoilValue } from 'recoil';
const StyledTabList = styled(TabList)`
border-bottom: none;
@ -107,7 +110,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
rightNodes={[ResetButton, PublishButton, TestButton]}
/>
{activeTabId && (
<CodeEditor
<SettingsServerlessFunctionCodeEditor
files={files}
currentFilePath={activeTabId}
onChange={(newCodeValue) => onChange(activeTabId, newCodeValue)}

View File

@ -2,6 +2,7 @@ import { Section } from '@/ui/layout/section/components/Section';
import { H2Title, IconPlayerPlay } from 'twenty-ui';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer';
import { SettingsServerlessFunctionsOutputMetadataInfo } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsOutputMetadataInfo';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
@ -78,37 +79,30 @@ export const SettingsServerlessFunctionTestTab = ({
/>,
]}
/>
<CodeEditor
files={[
{
content: settingsServerlessFunctionInput,
language: 'json',
path: 'input.json',
},
]}
currentFilePath={'input.json'}
height={200}
onChange={setSettingsServerlessFunctionInput}
/>
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={settingsServerlessFunctionInput}
language="json"
height={200}
onChange={setSettingsServerlessFunctionInput}
/>
</SettingsServerlessFunctionCodeEditorContainer>
</div>
<div>
<CoreEditorHeader
leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<CodeEditor
files={[
{
content: result,
language:
settingsServerlessFunctionCodeEditorOutputParams.language,
path: 'result.any',
},
]}
currentFilePath={'result.any'}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
/>
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={result}
language={
settingsServerlessFunctionCodeEditorOutputParams.language
}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
/>
</SettingsServerlessFunctionCodeEditorContainer>
</div>
</StyledInputsContainer>
</Section>

View File

@ -1,148 +1,51 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { codeEditorTheme } from '@/ui/input/code-editor/utils/codeEditorTheme';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Editor, { EditorProps, Monaco } from '@monaco-editor/react';
import dotenv from 'dotenv';
import { MarkerSeverity, editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { isDefined } from '~/utils/isDefined';
const StyledEditor = styled(Editor)`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top: none;
border-radius: 0 0 ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm};
`;
export type File = {
language: string;
content: string;
path: string;
};
import Editor, { EditorProps } from '@monaco-editor/react';
import { isDefined } from 'twenty-ui';
type CodeEditorProps = Omit<EditorProps, 'onChange'> & {
currentFilePath: string;
files: File[];
onChange?: (value: string) => void;
setIsCodeValid?: (isCodeValid: boolean) => void;
};
export const CodeEditor = ({
currentFilePath,
files,
value,
language,
onMount,
onChange,
setIsCodeValid,
onValidate,
height = 450,
options = undefined,
options,
}: CodeEditorProps) => {
const theme = useTheme();
const { availablePackages } = useGetAvailablePackages();
const currentFile = files.find((file) => file.path === currentFilePath);
const environmentVariablesFile = files.find((file) => file.path === '.env');
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
if (files.length > 1) {
files.forEach((file) => {
const model = monaco.editor.getModel(monaco.Uri.file(file.path));
if (!isDefined(model)) {
monaco.editor.createModel(
file.content,
file.language,
monaco.Uri.file(file.path),
);
}
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(),
moduleResolution:
monaco.languages.typescript.ModuleResolutionKind.NodeJs,
baseUrl: 'file:///src',
paths: {
'src/*': ['file:///src/*'],
},
allowSyntheticDefaultImports: true,
esModuleInterop: true,
noEmit: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
});
if (isDefined(environmentVariablesFile)) {
const environmentVariables = dotenv.parse(
environmentVariablesFile.content,
);
const environmentDefinition = `
declare namespace NodeJS {
interface ProcessEnv {
${Object.keys(environmentVariables)
.map((key) => `${key}: string;`)
.join('\n')}
}
}
declare const process: {
env: NodeJS.ProcessEnv;
};
`;
monaco.languages.typescript.typescriptDefaults.addExtraLib(
environmentDefinition,
'ts:process-env.d.ts',
);
}
await AutoTypings.create(editor, {
monaco,
preloadPackages: true,
onlySpecifiedPackages: true,
versions: availablePackages,
debounceDuration: 0,
});
}
};
const handleEditorValidation = (markers: editor.IMarker[]) => {
for (const marker of markers) {
if (marker.severity === MarkerSeverity.Error) {
setIsCodeValid?.(false);
return;
}
}
setIsCodeValid?.(true);
};
return (
isDefined(currentFile) &&
isDefined(availablePackages) && (
<StyledEditor
height={height}
value={currentFile.content}
language={currentFile.language}
onMount={handleEditorDidMount}
onChange={(value?: string) => value && onChange?.(value)}
onValidate={handleEditorValidation}
options={{
...options,
overviewRulerLanes: 0,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
minimap: {
enabled: false,
},
}}
/>
)
<Editor
height={height}
value={value}
language={language}
onMount={(editor, monaco) => {
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
onMount?.(editor, monaco);
}}
onChange={(value) => {
if (isDefined(value)) {
onChange?.(value);
}
}}
onValidate={onValidate}
options={{
overviewRulerLanes: 0,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
minimap: {
enabled: false,
},
...options,
}}
/>
);
};

View File

@ -18,6 +18,8 @@ import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPage
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { WorkflowRunOutputVisualizer } from '@/workflow/components/WorkflowRunOutputVisualizer';
import { WorkflowRunVersionVisualizer } from '@/workflow/components/WorkflowRunVersionVisualizer';
import { WorkflowVersionVisualizer } from '@/workflow/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/components/WorkflowVisualizer';
@ -182,6 +184,14 @@ export const ShowPageSubContainer = ({
/>
</>
);
case 'workflowRunFlow':
return (
<WorkflowRunVersionVisualizer workflowRunId={targetableObject.id} />
);
case 'workflowRunOutput':
return (
<WorkflowRunOutputVisualizer workflowRunId={targetableObject.id} />
);
default:
return <></>;
}

View File

@ -0,0 +1,32 @@
import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-ui';
const StyledSourceCodeContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
margin: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
`;
export const WorkflowRunOutputVisualizer = ({
workflowRunId,
}: {
workflowRunId: string;
}) => {
const workflowRun = useWorkflowRun({ workflowRunId });
if (!isDefined(workflowRun)) {
return null;
}
return (
<StyledSourceCodeContainer>
<CodeEditor
value={JSON.stringify(workflowRun.output, null, 2)}
language="json"
options={{ readOnly: true, domReadOnly: true }}
/>
</StyledSourceCodeContainer>
);
};

View File

@ -0,0 +1,29 @@
import { WorkflowVersionVisualizer } from '@/workflow/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/components/WorkflowVersionVisualizerEffect';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { isDefined } from 'twenty-ui';
export const WorkflowRunVersionVisualizer = ({
workflowRunId,
}: {
workflowRunId: string;
}) => {
const workflowRun = useWorkflowRun({
workflowRunId,
});
if (!isDefined(workflowRun)) {
return null;
}
return (
<>
<WorkflowVersionVisualizerEffect
workflowVersionId={workflowRun.workflowVersionId}
/>
<WorkflowVersionVisualizer
workflowVersionId={workflowRun.workflowVersionId}
/>
</>
);
};

View File

@ -0,0 +1,16 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { WorkflowRun } from '@/workflow/types/Workflow';
export const useWorkflowRun = ({
workflowRunId,
}: {
workflowRunId: string;
}) => {
const { record } = useFindOneRecord<WorkflowRun>({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
objectRecordId: workflowRunId,
});
return record;
};

View File

@ -84,6 +84,28 @@ export type WorkflowVersion = {
__typename: 'WorkflowVersion';
};
type StepRunOutput = {
id: string;
name: string;
type: string;
outputs: {
attemptCount: number;
result: object | undefined;
error: string | undefined;
}[];
};
export type WorkflowRunOutput = {
steps: Record<string, StepRunOutput>;
};
export type WorkflowRun = {
__typename: 'WorkflowRun';
id: string;
workflowVersionId: string;
output: WorkflowRunOutput;
};
export type Workflow = {
__typename: 'Workflow';
id: string;

View File

@ -215,6 +215,7 @@ export {
IconTimelineEvent,
IconTool,
IconTrash,
IconPrinter,
IconUnlink,
IconUpload,
IconUser,