8726 workflow add a test button in workflow code step (#9016)

- add test button to workflow code step
- add test tab to workflow code step


https://github.com/user-attachments/assets/e180a827-7321-49a2-8026-88490c557da2



![image](https://github.com/user-attachments/assets/cacbd756-de3f-4141-a84c-8e1853f6556b)

![image](https://github.com/user-attachments/assets/ee170d81-8a22-4178-bd6d-11a0e8c73365)
This commit is contained in:
martmull 2024-12-13 11:16:29 +01:00 committed by GitHub
parent 07aaf0801c
commit b10d831371
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 1537 additions and 1611 deletions

View File

@ -112,7 +112,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "VITE_DISABLE_ESLINT_CHECKER=true storybook build",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build",
"output-dir": "storybook-static",
"config-dir": ".storybook"
}

View File

@ -69,7 +69,7 @@
"test": {},
"storybook:build": {
"options": {
"env": { "NODE_OPTIONS": "--max_old_space_size=7000" }
"env": { "NODE_OPTIONS": "--max_old_space_size=8000" }
},
"configurations": {
"docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } },

View File

@ -1569,8 +1569,6 @@ export type UpdateServerlessFunctionInput = {
};
export type UpdateWorkflowVersionStepInput = {
/** Boolean to check if we need to update stepOutput */
shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']['input']>;
/** Step to update in JSON format */
step: Scalars['JSON']['input'];
/** Workflow version ID */

View File

@ -1277,8 +1277,6 @@ export type UpdateServerlessFunctionInput = {
};
export type UpdateWorkflowVersionStepInput = {
/** Boolean to check if we need to update stepOutput */
shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']>;
/** Step to update in JSON format */
step: Scalars['JSON'];
/** Workflow version ID */

View File

@ -1,32 +0,0 @@
import { useEffect, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
export const usePreventOverlapCallback = (
callback: () => Promise<void>,
wait?: number,
) => {
const [isRunning, setIsRunning] = useState(false);
const [pendingRun, setPendingRun] = useState(false);
const handleCallback = async () => {
if (isRunning) {
setPendingRun(true);
return;
}
setIsRunning(true);
try {
await callback();
} finally {
setIsRunning(false);
}
};
useEffect(() => {
if (!isRunning && pendingRun) {
setPendingRun(false);
callback();
}
}, [callback, isRunning, pendingRun, setPendingRun]);
return useDebouncedCallback(handleCallback, wait);
};

View File

@ -0,0 +1,30 @@
import { Button } from 'twenty-ui';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { Key } from 'ts-key-enum';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
export const CmdEnterActionButton = ({
title,
onClick,
}: {
title: string;
onClick: () => void;
}) => {
useScopedHotkeys(
[`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
() => onClick(),
RightDrawerHotkeyScope.RightDrawer,
[onClick],
);
return (
<Button
title={title}
variant="primary"
accent="blue"
size="medium"
onClick={onClick}
shortcut={'⌘⏎'}
/>
);
};

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
@ -43,8 +42,7 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const { activeTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
const { activeTabId } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const isLoading =
(activeTabId !== 'done' && tasksLoading) ||

View File

@ -83,11 +83,11 @@ const SettingsServerlessFunctions = lazy(() =>
).then((module) => ({ default: module.SettingsServerlessFunctions })),
);
const SettingsServerlessFunctionDetailWrapper = lazy(() =>
const SettingsServerlessFunctionDetail = lazy(() =>
import(
'~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper'
'~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail'
).then((module) => ({
default: module.SettingsServerlessFunctionDetailWrapper,
default: module.SettingsServerlessFunctionDetail,
})),
);
@ -353,7 +353,7 @@ export const SettingsRoutes = ({
/>
<Route
path={SettingsPath.ServerlessFunctionDetail}
element={<SettingsServerlessFunctionDetailWrapper />}
element={<SettingsServerlessFunctionDetail />}
/>
</>
)}

View File

@ -1,7 +1,7 @@
import { FormCountrySelectInput } from '@/object-record/record-field/form-types/components/FormCountrySelectInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
@ -39,9 +39,9 @@ export const FormAddressFieldInput = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormCompositeFieldInputContainer>
<FormNestedFieldInputContainer>
<FormTextFieldInput
label="Address 1"
defaultValue={defaultValue?.addressStreet1 ?? ''}
@ -88,7 +88,7 @@ export const FormAddressFieldInput = ({
readonly={readonly}
VariablePicker={VariablePicker}
/>
</StyledFormCompositeFieldInputContainer>
</StyledFormFieldInputContainer>
</FormNestedFieldInputContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { BooleanInput } from '@/ui/field/input/components/BooleanInput';
@ -80,11 +80,11 @@ export const FormBooleanFieldInput = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
@ -101,7 +101,7 @@ export const FormBooleanFieldInput = ({
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
</FormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
@ -109,7 +109,7 @@ export const FormBooleanFieldInput = ({
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,9 @@
import styled from '@emotion/styled';
const StyledFormFieldInputContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export const FormFieldInputContainer = StyledFormFieldInputContainer;

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
export const StyledFormFieldInputInputContainer = styled.div<{
const StyledFormFieldInputInputContainer = styled.div<{
hasRightElement: boolean;
multiline?: boolean;
readonly?: boolean;
@ -29,3 +29,5 @@ export const StyledFormFieldInputInputContainer = styled.div<{
overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
width: 100%;
`;
export const FormFieldInputInputContainer = StyledFormFieldInputInputContainer;

View File

@ -3,7 +3,7 @@ import styled from '@emotion/styled';
export const LINE_HEIGHT = 24;
export const StyledFormFieldInputRowContainer = styled.div<{
const StyledFormFieldInputRowContainer = styled.div<{
multiline?: boolean;
}>`
display: flex;
@ -21,3 +21,5 @@ export const StyledFormFieldInputRowContainer = styled.div<{
height: 32px;
`}
`;
export const FormFieldInputRowContainer = StyledFormFieldInputRowContainer;

View File

@ -1,6 +1,6 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/FirstNamePlaceholder';
import { LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/LastNamePlaceholder';
@ -37,9 +37,9 @@ export const FormFullNameFieldInput = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormCompositeFieldInputContainer>
<FormNestedFieldInputContainer>
<FormTextFieldInput
label="First Name"
defaultValue={defaultValue?.firstName}
@ -60,7 +60,7 @@ export const FormFullNameFieldInput = ({
readonly={readonly}
VariablePicker={VariablePicker}
/>
</StyledFormCompositeFieldInputContainer>
</StyledFormFieldInputContainer>
</FormNestedFieldInputContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
export const StyledFormCompositeFieldInputContainer = styled.div`
const StyledFormNestedFieldInputContainer = styled.div`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.secondary};
@ -9,3 +9,6 @@ export const StyledFormCompositeFieldInputContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const FormNestedFieldInputContainer =
StyledFormNestedFieldInputContainer;

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { TextInput } from '@/ui/field/input/components/TextInput';
@ -94,11 +94,11 @@ export const FormNumberFieldInput = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel htmlFor={inputId}>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
@ -116,7 +116,7 @@ export const FormNumberFieldInput = ({
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
</FormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
@ -124,7 +124,7 @@ export const FormNumberFieldInput = ({
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
@ -204,11 +204,11 @@ export const FormSelectFieldInput = ({
];
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<StyledFormFieldInputInputContainer
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
@ -234,7 +234,7 @@ export const FormSelectFieldInput = ({
onRemove={handleUnlinkVariable}
/>
)}
</StyledFormFieldInputInputContainer>
</FormFieldInputInputContainer>
<StyledSelectInputContainer>
{draftValue.type === 'static' &&
draftValue.editingMode === 'edit' && (
@ -260,7 +260,7 @@ export const FormSelectFieldInput = ({
onVariableSelect={handleVariableTagInsert}
/>
)}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor';
import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
@ -58,11 +58,11 @@ export const FormTextFieldInput = ({
}
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer multiline={multiline}>
<StyledFormFieldInputInputContainer
<FormFieldInputRowContainer multiline={multiline}>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)}
multiline={multiline}
>
@ -71,7 +71,7 @@ export const FormTextFieldInput = ({
multiline={multiline}
readonly={readonly}
/>
</StyledFormFieldInputInputContainer>
</FormFieldInputInputContainer>
{VariablePicker ? (
<VariablePicker
@ -80,7 +80,7 @@ export const FormTextFieldInput = ({
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -1,7 +0,0 @@
import styled from '@emotion/styled';
export const StyledFormFieldInputContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;

View File

@ -58,11 +58,13 @@ export const useTextVariableEditor = ({
const { tr } = state;
// Insert hard break using the view's state and dispatch
const transaction = tr.replaceSelectionWith(
state.schema.nodes.hardBreak.create(),
);
if (multiline === true) {
const transaction = tr.replaceSelectionWith(
state.schema.nodes.hardBreak.create(),
);
view.dispatch(transaction);
view.dispatch(transaction);
}
return true;
}

View File

@ -0,0 +1,73 @@
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
import {
CodeEditor,
CoreEditorHeader,
IconSquareRoundedCheck,
} from 'twenty-ui';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import {
DEFAULT_OUTPUT_VALUE,
ServerlessFunctionTestData,
} from '@/workflow/states/serverlessFunctionTestDataFamilyState';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, status }) =>
status === ServerlessFunctionExecutionStatus.Success
? theme.color.turquoise
: theme.color.red};
display: flex;
`;
export const ServerlessFunctionExecutionResult = ({
serverlessFunctionTestData,
}: {
serverlessFunctionTestData: ServerlessFunctionTestData;
}) => {
const theme = useTheme();
const result =
serverlessFunctionTestData.output.data ||
serverlessFunctionTestData.output.error ||
'';
const leftNode =
serverlessFunctionTestData.output.data === DEFAULT_OUTPUT_VALUE ? (
'Output'
) : (
<StyledOutput status={serverlessFunctionTestData.output.status}>
<IconSquareRoundedCheck size={theme.icon.size.md} />
{serverlessFunctionTestData.output.status ===
ServerlessFunctionExecutionStatus.Success
? '200 OK'
: '500 Error'}
{' - '}
{serverlessFunctionTestData.output.duration}ms
</StyledOutput>
);
return (
<StyledContainer>
<CoreEditorHeader
leftNodes={[leftNode]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<CodeEditor
value={result}
language={serverlessFunctionTestData.language}
height={serverlessFunctionTestData.height}
options={{ readOnly: true, domReadOnly: true }}
withHeader
/>
</StyledContainer>
);
};

View File

@ -0,0 +1 @@
export const INDEX_FILE_PATH = 'src/index.ts';

View File

@ -0,0 +1,51 @@
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { useRecoilState } from 'recoil';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { isDefined } from 'twenty-ui';
export const useTestServerlessFunction = (
serverlessFunctionId: string,
callback?: (testResult: object) => void,
) => {
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const testServerlessFunction = async () => {
const result = await executeOneServerlessFunction({
id: serverlessFunctionId,
payload: serverlessFunctionTestData.input,
version: 'draft',
});
if (isDefined(result?.data?.executeOneServerlessFunction?.data)) {
callback?.(result?.data?.executeOneServerlessFunction?.data);
}
setServerlessFunctionTestData((prev) => ({
...prev,
language: 'json',
height: 300,
output: {
data: result?.data?.executeOneServerlessFunction?.data
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.data,
null,
4,
)
: undefined,
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.error,
null,
4,
)
: undefined,
},
}));
};
return { testServerlessFunction };
};

View File

@ -0,0 +1,44 @@
import { InputSchema } from '@/workflow/types/InputSchema';
import { getDefaultFunctionInputFromInputSchema } from '@/serverless-functions/utils/getDefaultFunctionInputFromInputSchema';
describe('getDefaultFunctionInputFromInputSchema', () => {
it('should init function input properly', () => {
const inputSchema = [
{
type: 'object',
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
c: {
type: 'array',
items: { type: 'string' },
},
d: {
type: 'object',
properties: {
da: { type: 'string', enum: ['my', 'enum'] },
db: { type: 'number' },
},
},
e: { type: 'object' },
},
},
] as InputSchema;
const expectedResult = [
{
a: null,
b: null,
c: [],
d: { da: 'my', db: null },
e: {},
},
];
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
expectedResult,
);
});
});

View File

@ -0,0 +1,50 @@
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
describe('getFunctionInputFromSourceCode', () => {
it('should return empty input if not parameter', () => {
const fileContent = 'function testFunction() { return }';
const result = getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return first input if multiple parameters', () => {
const fileContent =
'function testFunction(params1: {}, params2: {}) { return }';
const result = getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return empty input if wrong parameter', () => {
const fileContent = 'function testFunction(params: string) { return }';
const result = getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return input from source code', () => {
const fileContent = `
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const result = getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({
param1: null,
param2: null,
param3: null,
param4: {},
param5: {
subParam1: null,
},
param6: 'my',
param7: [],
});
});
});

View File

@ -0,0 +1,67 @@
import { getFunctionInputSchema } from '@/serverless-functions/utils/getFunctionInputSchema';
describe('getFunctionInputSchema', () => {
it('should analyze a simple function correctly', () => {
const fileContent = `
function testFunction(param1: string, param2: number): void {
return;
}
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([{ type: 'string' }, { type: 'number' }]);
});
it('should analyze a arrow function correctly', () => {
const fileContent = `
export const main = async (
param1: string,
param2: number,
): Promise<object> => {
return param1;
};
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([{ type: 'string' }, { type: 'number' }]);
});
it('should analyze a complex function correctly', () => {
const fileContent = `
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const result = getFunctionInputSchema(fileContent);
expect(result).toEqual([
{
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
param3: { type: 'boolean' },
param4: { type: 'object' },
param5: {
type: 'object',
properties: {
subParam1: { type: 'string' },
},
},
param6: { type: 'string', enum: ['my', 'enum'] },
param7: { type: 'array', items: { type: 'string' } },
},
},
]);
});
});

View File

@ -0,0 +1,24 @@
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
describe('getFunctionOutputSchema', () => {
it('should compute outputSchema properly', () => {
const testResult = {
a: null,
b: 'b',
c: { cc: 1 },
d: true,
e: [1, 2, 3],
};
const expectedOutputSchema = {
a: { isLeaf: true, type: 'unknown', value: null },
b: { isLeaf: true, type: 'string', value: 'b' },
c: {
isLeaf: false,
value: { cc: { isLeaf: true, type: 'number', value: 1 } },
},
d: { isLeaf: true, type: 'boolean', value: true },
e: { isLeaf: true, type: 'array', value: [1, 2, 3] },
};
expect(getFunctionOutputSchema(testResult)).toEqual(expectedOutputSchema);
});
});

View File

@ -2,19 +2,25 @@ import { mergeDefaultFunctionInputAndFunctionInput } from '../mergeDefaultFuncti
describe('mergeDefaultFunctionInputAndFunctionInput', () => {
it('should merge properly', () => {
const defaultFunctionInput = {
params: { a: null, b: null, c: { cc: null } },
};
const functionInput = {
params: { a: 'a', c: 'c' },
const newInput = {
a: null,
b: null,
c: { cc: null },
d: null,
e: { ee: null },
};
const oldInput = { a: 'a', c: 'c', d: { da: null }, e: { ee: 'ee' } };
const expectedResult = {
params: { a: 'a', b: null, c: { cc: null } },
a: 'a',
b: null,
c: { cc: null },
d: null,
e: { ee: 'ee' },
};
expect(
mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput,
functionInput,
newInput: newInput,
oldInput: oldInput,
}),
).toEqual(expectedResult);
});

View File

@ -0,0 +1,24 @@
import { InputSchema } from '@/workflow/types/InputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isDefined } from '~/utils/isDefined';
export const getDefaultFunctionInputFromInputSchema = (
inputSchema: InputSchema,
): FunctionInput => {
return inputSchema.map((param) => {
if (['string', 'number', 'boolean'].includes(param.type)) {
return param.enum && param.enum.length > 0 ? param.enum[0] : null;
} else if (param.type === 'object') {
const result: FunctionInput = {};
if (isDefined(param.properties)) {
Object.entries(param.properties).forEach(([key, val]) => {
result[key] = getDefaultFunctionInputFromInputSchema([val])[0];
});
}
return result;
} else if (param.type === 'array' && isDefined(param.items)) {
return [];
}
return null;
});
};

View File

@ -0,0 +1,23 @@
import { getDefaultFunctionInputFromInputSchema } from '@/serverless-functions/utils/getDefaultFunctionInputFromInputSchema';
import { getFunctionInputSchema } from '@/serverless-functions/utils/getFunctionInputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-ui';
export const getFunctionInputFromSourceCode = (
sourceCode?: string,
): FunctionInput => {
if (!isDefined(sourceCode)) {
throw new Error('Source code is not defined');
}
const functionInputSchema = getFunctionInputSchema(sourceCode);
const result = getDefaultFunctionInputFromInputSchema(functionInputSchema)[0];
if (!isObject(result)) {
return {};
}
return result;
};

View File

@ -3,12 +3,14 @@ import {
ArrowFunction,
createSourceFile,
FunctionDeclaration,
FunctionLikeDeclaration,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
Node,
UnionTypeNode,
VariableStatement,
} from 'typescript';
@ -31,7 +33,7 @@ const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
case SyntaxKind.ObjectKeyword:
return { type: 'object' };
case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {};
const properties: InputSchemaProperty['properties'] = {};
(typeNode as any).members.forEach((member: PropertySignature) => {
if (isDefined(member.name) && isDefined(member.type)) {
@ -74,6 +76,42 @@ const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
}
};
const computeFunctionParameters = (
funcNode: FunctionDeclaration | FunctionLikeDeclaration | ArrowFunction,
schema: InputSchema,
): InputSchema => {
const params = funcNode.parameters;
return params.reduce((updatedSchema, param) => {
const typeNode = param.type;
if (isDefined(typeNode)) {
return [...updatedSchema, getTypeString(typeNode)];
} else {
return [...updatedSchema, { type: 'unknown' }];
}
}, schema);
};
const extractFunctions = (node: Node): FunctionLikeDeclaration[] => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
return [node as FunctionDeclaration];
}
if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
return varStatement.declarationList.declarations
.filter(
(declaration) =>
isDefined(declaration.initializer) &&
declaration.initializer.kind === SyntaxKind.ArrowFunction,
)
.map((declaration) => declaration.initializer as ArrowFunction);
}
return [];
};
export const getFunctionInputSchema = (fileContent: string): InputSchema => {
const sourceFile = createSourceFile(
'temp.ts',
@ -81,46 +119,16 @@ export const getFunctionInputSchema = (fileContent: string): InputSchema => {
ScriptTarget.ESNext,
true,
);
const schema: InputSchema = {};
let schema: InputSchema = [];
sourceFile.forEachChild((node) => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
const funcNode = node as FunctionDeclaration;
const params = funcNode.parameters;
params.forEach((param) => {
const paramName = param.name.getText();
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
} else if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
varStatement.declarationList.declarations.forEach((declaration) => {
if (
isDefined(declaration.initializer) &&
declaration.initializer.kind === SyntaxKind.ArrowFunction
) {
const arrowFunction = declaration.initializer as ArrowFunction;
const params = arrowFunction.parameters;
params.forEach((param: any) => {
const paramName = param.name.text;
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
}
if (
node.kind === SyntaxKind.FunctionDeclaration ||
node.kind === SyntaxKind.VariableStatement
) {
const functions = extractFunctions(node);
functions.forEach((func) => {
schema = computeFunctionParameters(func, schema);
});
}
});

View File

@ -0,0 +1,49 @@
import { BaseOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import { isObject } from '@sniptt/guards';
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { isDefined } from 'twenty-ui';
const getValueType = (value: any): InputSchemaPropertyType => {
if (!isDefined(value) || value === null) {
return 'unknown';
}
if (typeof value === 'string') {
return 'string';
}
if (typeof value === 'number') {
return 'number';
}
if (typeof value === 'boolean') {
return 'boolean';
}
if (Array.isArray(value)) {
return 'array';
}
if (isObject(value)) {
return 'object';
}
return 'unknown';
};
export const getFunctionOutputSchema = (testResult: object) => {
return testResult
? Object.entries(testResult).reduce(
(acc: BaseOutputSchema, [key, value]) => {
if (isObject(value) && !Array.isArray(value)) {
acc[key] = {
isLeaf: false,
value: getFunctionOutputSchema(value),
};
} else {
acc[key] = {
isLeaf: true,
value,
type: getValueType(value),
};
}
return acc;
},
{},
)
: {};
};

View File

@ -0,0 +1,32 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isObject } from '@sniptt/guards';
export const mergeDefaultFunctionInputAndFunctionInput = ({
newInput,
oldInput,
}: {
newInput: FunctionInput;
oldInput: FunctionInput;
}): FunctionInput => {
const result: FunctionInput = {};
for (const key of Object.keys(newInput)) {
const newValue = newInput[key];
const oldValue = oldInput[key];
if (!(key in oldInput)) {
result[key] = newValue;
} else if (newValue === null && isObject(oldValue)) {
result[key] = null;
} else if (isObject(newValue)) {
result[key] = mergeDefaultFunctionInputAndFunctionInput({
newInput: newValue,
oldInput: isObject(oldValue) ? oldValue : {},
});
} else {
result[key] = oldValue;
}
}
return result;
};

View File

@ -19,10 +19,9 @@ const StyledCalenderContainer = styled.div`
`;
export const SettingsAccountsCalendarChannelsContainer = () => {
const { activeTabIdState } = useTabList(
const { activeTabId } = useTabList(
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({

View File

@ -18,10 +18,9 @@ const StyledMessageContainer = styled.div`
`;
export const SettingsAccountsMessageChannelsContainer = () => {
const { activeTabIdState } = useTabList(
const { activeTabId } = useTabList(
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({

View File

@ -1,39 +0,0 @@
import { useRecoilValue } from 'recoil';
import {
DEFAULT_OUTPUT_VALUE,
settingsServerlessFunctionOutputState,
} from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import styled from '@emotion/styled';
import { IconSquareRoundedCheck } from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, status }) =>
status === ServerlessFunctionExecutionStatus.Success
? theme.color.turquoise
: theme.color.red};
display: flex;
`;
export const SettingsServerlessFunctionsOutputMetadataInfo = () => {
const theme = useTheme();
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
return settingsServerlessFunctionOutput.data === DEFAULT_OUTPUT_VALUE ? (
'Output'
) : (
<StyledOutput status={settingsServerlessFunctionOutput.status}>
<IconSquareRoundedCheck size={theme.icon.size.md} />
{settingsServerlessFunctionOutput.status ===
ServerlessFunctionExecutionStatus.Success
? '200 OK'
: '500 Error'}
{' - '}
{settingsServerlessFunctionOutput.duration}ms
</StyledOutput>
);
};

View File

@ -11,7 +11,6 @@ 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 {
Button,
@ -47,10 +46,9 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
onChange: (filePath: string, value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
}) => {
const { activeTabIdState } = useTabList(
const { activeTabId } = useTabList(
SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const TestButton = (
<Button
title="Test"

View File

@ -7,20 +7,17 @@ import {
Section,
} from 'twenty-ui';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { SettingsServerlessFunctionsOutputMetadataInfo } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsOutputMetadataInfo';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
const StyledInputsContainer = styled.div`
display: flex;
@ -28,24 +25,27 @@ const StyledInputsContainer = styled.div`
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column;
`;
export const SettingsServerlessFunctionTestTab = ({
handleExecute,
serverlessFunctionId,
}: {
handleExecute: () => void;
serverlessFunctionId: string;
}) => {
const settingsServerlessFunctionCodeEditorOutputParams = useRecoilValue(
settingsServerlessFunctionCodeEditorOutputParamsState,
);
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
const [settingsServerlessFunctionInput, setSettingsServerlessFunctionInput] =
useRecoilState(settingsServerlessFunctionInputState);
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const result =
settingsServerlessFunctionOutput.data ||
settingsServerlessFunctionOutput.error ||
'';
const onChange = (newInput: string) => {
setServerlessFunctionTestData((prev) => ({
...prev,
input: JSON.parse(newInput),
}));
};
const navigate = useNavigate();
useHotkeyScopeOnMount(
@ -67,7 +67,7 @@ export const SettingsServerlessFunctionTestTab = ({
description='Insert a JSON input, then press "Run" to test your function.'
/>
<StyledInputsContainer>
<div>
<StyledCodeEditorContainer>
<CoreEditorHeader
title={'Input'}
rightNodes={[
@ -82,26 +82,16 @@ export const SettingsServerlessFunctionTestTab = ({
]}
/>
<CodeEditor
value={settingsServerlessFunctionInput}
value={JSON.stringify(serverlessFunctionTestData.input, null, 4)}
language="json"
height={200}
onChange={setSettingsServerlessFunctionInput}
onChange={onChange}
withHeader
/>
</div>
<div>
<CoreEditorHeader
leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<CodeEditor
value={result}
language={settingsServerlessFunctionCodeEditorOutputParams.language}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
withHeader
/>
</div>
</StyledCodeEditorContainer>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
/>
</StyledInputsContainer>
</Section>
);

View File

@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
DEFAULT_OUTPUT_VALUE,
settingsServerlessFunctionOutputState,
} from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
export const SettingsServerlessFunctionTestTabEffect = () => {
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState(
settingsServerlessFunctionCodeEditorOutputParamsState,
);
useEffect(() => {
if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) {
setSettingsServerlessFunctionCodeEditorOutputParams({
language: 'json',
height: 300,
});
}
}, [
settingsServerlessFunctionOutput.data,
setSettingsServerlessFunctionCodeEditorOutputParams,
]);
return <></>;
};

View File

@ -2,6 +2,10 @@ import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hoo
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { Dispatch, SetStateAction, useState } from 'react';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { useSetRecoilState } from 'recoil';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
export type ServerlessFunctionNewFormValues = {
name: string;
@ -29,6 +33,10 @@ export const useServerlessFunctionUpdateFormState = (
code: undefined,
});
const setServerlessFunctionTestData = useSetRecoilState(
serverlessFunctionTestDataFamilyState(serverlessFunctionId),
);
const { serverlessFunction } = useGetOneServerlessFunction({
id: serverlessFunctionId,
});
@ -46,6 +54,12 @@ export const useServerlessFunctionUpdateFormState = (
...prevState,
...newState,
}));
const sourceCode =
data?.getServerlessFunctionSourceCode?.[INDEX_FILE_PATH];
setServerlessFunctionTestData((prev) => ({
...prev,
input: getFunctionInputFromSourceCode(sourceCode),
}));
},
});

View File

@ -1,7 +0,0 @@
import { createState } from 'twenty-ui';
export const settingsServerlessFunctionCodeEditorOutputParamsState =
createState<{ language: string; height: number }>({
key: 'settingsServerlessFunctionCodeEditorOutputParamsState',
defaultValue: { language: 'plaintext', height: 64 },
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const settingsServerlessFunctionInputState = createState<string>({
key: 'settingsServerlessFunctionInputState',
defaultValue: '{}',
});

View File

@ -1,18 +0,0 @@
import { createState } from 'twenty-ui';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
type settingsServerlessFunctionOutput = {
data?: string;
duration?: number;
status?: ServerlessFunctionExecutionStatus;
error?: string;
};
export const DEFAULT_OUTPUT_VALUE =
'Enter an input above then press "run Function"';
export const settingsServerlessFunctionOutputState =
createState<settingsServerlessFunctionOutput>({
key: 'settingsServerlessFunctionOutputState',
defaultValue: { data: DEFAULT_OUTPUT_VALUE },
});

View File

@ -1,5 +1,7 @@
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z'));
describe('getHighlightedDates', () => {
it('should should return empty if range is undefined', () => {
const dateRange = undefined;

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { Fragment } from 'react';
const StyledContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
bottom: 0;
box-sizing: border-box;
display: flex;
justify-content: flex-end;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
type RightDrawerFooterProps = {
actions: React.ReactNode[];
};
export const RightDrawerFooter = ({ actions }: RightDrawerFooterProps) => {
return (
<StyledContainer>
{actions.map((action, index) => (
<Fragment key={index}>{action}</Fragment>
))}
</StyledContainer>
);
};

View File

@ -13,6 +13,7 @@ import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
display: flex;
@ -34,19 +35,6 @@ const StyledTabListContainer = styled.div<{ shouldDisplay: boolean }>`
height: 40px;
`;
const StyledButtonContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
bottom: 0;
box-sizing: border-box;
display: flex;
justify-content: flex-end;
padding: ${({ theme }) => theme.spacing(2)};
position: absolute;
width: 100%;
`;
const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
flex: 1;
overflow-y: auto;
@ -57,7 +45,7 @@ const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
export const TAB_LIST_COMPONENT_ID = 'show-page-right-tab-list';
type ShowPageSubContainerProps = {
layout: RecordLayout;
layout?: RecordLayout;
tabs: SingleTabProps[];
targetableObject: Pick<
ActivityTargetableObject,
@ -76,10 +64,9 @@ export const ShowPageSubContainer = ({
isInRightDrawer = false,
isNewRightDrawerItemLoading = false,
}: ShowPageSubContainerProps) => {
const { activeTabIdState } = useTabList(
const { activeTabId } = useTabList(
`${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`,
);
const activeTabId = useRecoilValue(activeTabIdState);
const isMobile = useIsMobile();
@ -125,9 +112,12 @@ export const ShowPageSubContainer = ({
const visibleTabs = tabs.filter((tab) => !tab.hide);
const displaySummaryAndFields =
layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer;
return (
<>
{!layout.hideSummaryAndFields && !isMobile && !isInRightDrawer && (
{displaySummaryAndFields && (
<ShowPageLeftContainer forceMobile={isMobile}>
{summaryCard}
{fieldsCard}
@ -147,9 +137,7 @@ export const ShowPageSubContainer = ({
{renderActiveTabContent()}
</StyledContentContainer>
{isInRightDrawer && recordFromStore && !recordFromStore.deletedAt && (
<StyledButtonContainer>
<RecordShowRightDrawerActionMenu />
</StyledButtonContainer>
<RightDrawerFooter actions={[<RecordShowRightDrawerActionMenu />]} />
)}
</StyledShowPageRightContainer>
</>

View File

@ -1,15 +1,14 @@
import styled from '@emotion/styled';
import * as React from 'react';
import { useRecoilValue } from 'recoil';
import { IconComponent } from 'twenty-ui';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { TabListScope } from '@/ui/layout/tab/scopes/TabListScope';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect';
import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard';
import { Tab } from './Tab';
import { useEffect } from 'react';
export type SingleTabProps = {
title: string;
@ -30,13 +29,17 @@ type TabListProps = {
behaveAsLinks?: boolean;
};
const StyledContainer = styled.div`
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
const StyledTabsContainer = styled.div`
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
user-select: none;
margin-bottom: -1px;
`;
const StyledContainer = styled.div`
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
`;
export const TabList = ({
@ -50,11 +53,9 @@ export const TabList = ({
const initialActiveTabId = visibleTabs[0]?.id || '';
const { activeTabIdState, setActiveTabId } = useTabList(tabListInstanceId);
const { activeTabId, setActiveTabId } = useTabList(tabListInstanceId);
const activeTabId = useRecoilValue(activeTabIdState);
React.useEffect(() => {
useEffect(() => {
setActiveTabId(initialActiveTabId);
}, [initialActiveTabId, setActiveTabId]);
@ -63,13 +64,13 @@ export const TabList = ({
}
return (
<TabListScope tabListScopeId={tabListInstanceId}>
<TabListFromUrlOptionalEffect
componentInstanceId={tabListInstanceId}
tabListIds={tabs.map((tab) => tab.id)}
/>
<ScrollWrapper enableYScroll={false} contextProviderName="tabList">
<StyledContainer className={className}>
<StyledContainer className={className}>
<TabListScope tabListScopeId={tabListInstanceId}>
<TabListFromUrlOptionalEffect
componentInstanceId={tabListInstanceId}
tabListIds={tabs.map((tab) => tab.id)}
/>
<StyledTabsContainer>
{visibleTabs.map((tab) => (
<Tab
id={tab.id}
@ -88,8 +89,8 @@ export const TabList = ({
}}
/>
))}
</StyledContainer>
</ScrollWrapper>
</TabListScope>
</StyledTabsContainer>
</TabListScope>
</StyledContainer>
);
};

View File

@ -1,7 +1,6 @@
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
type TabListFromUrlOptionalEffectProps = {
componentInstanceId: string;
@ -13,11 +12,9 @@ export const TabListFromUrlOptionalEffect = ({
tabListIds,
}: TabListFromUrlOptionalEffectProps) => {
const location = useLocation();
const { activeTabIdState } = useTabList(componentInstanceId);
const { setActiveTabId } = useTabList(componentInstanceId);
const { activeTabId, setActiveTabId } = useTabList(componentInstanceId);
const hash = location.hash.replace('#', '');
const activeTabId = useRecoilValue(activeTabIdState);
useEffect(() => {
if (hash === activeTabId) {

View File

@ -1,6 +1,6 @@
import { act } from 'react-dom/test-utils';
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { RecoilRoot } from 'recoil';
import { useTabList } from '../useTabList';
@ -8,9 +8,7 @@ describe('useTabList', () => {
it('Should update the activeTabId state', async () => {
const { result } = renderHook(
() => {
const { activeTabIdState, setActiveTabId } =
useTabList('TEST_TAB_LIST_ID');
const activeTabId = useRecoilValue(activeTabIdState);
const { activeTabId, setActiveTabId } = useTabList('TEST_TAB_LIST_ID');
return {
activeTabId,

View File

@ -1,4 +1,4 @@
import { useSetRecoilState } from 'recoil';
import { useRecoilState } from 'recoil';
import { useTabListStates } from '@/ui/layout/tab/hooks/internal/useTabListStates';
@ -7,10 +7,10 @@ export const useTabList = (tabListId?: string) => {
tabListScopeId: tabListId,
});
const setActiveTabId = useSetRecoilState(activeTabIdState);
const [activeTabId, setActiveTabId] = useRecoilState(activeTabIdState);
return {
activeTabIdState,
activeTabId,
setActiveTabId,
};
};

View File

@ -1,11 +1,12 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react';
import { IconPlaylistAdd, isDefined } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger;
@ -60,88 +61,91 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
: '-';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
outputSchema: {},
},
},
);
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS}
disabled={triggerOptions.readonly}
onChange={(updatedEvent) => {
if (triggerOptions.readonly === true) {
return;
}
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
outputSchema: {},
},
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata[0].value}.${updatedEvent}`,
outputSchema: {},
);
}}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS}
disabled={triggerOptions.readonly}
onChange={(updatedEvent) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata?.[0].value}.${updatedEvent}`,
outputSchema: {},
},
},
},
);
}}
/>
</WorkflowEditGenericFormBase>
);
}}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/constants/ManualTriggerAvailabilityOptions';
import {
WorkflowManualTrigger,
@ -9,6 +9,7 @@ import {
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined, useIcons } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerManualFormProps = {
trigger: WorkflowManualTrigger;
@ -47,67 +48,70 @@ export const WorkflowEditTriggerManualForm = ({
const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
disabled={triggerOptions.readonly}
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
name: newName,
});
}}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
/>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(updatedObject) => {
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
outputSchema: {},
},
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
});
}}
/>
) : null}
</WorkflowEditGenericFormBase>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(updatedObject) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
outputSchema: {},
},
});
}}
/>
) : null}
</WorkflowStepBody>
</>
);
};

View File

@ -6,8 +6,8 @@ import {
} from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
@ -137,9 +137,9 @@ export const WorkflowSingleRecordPicker = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<FormFieldInputRowContainer>
<StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip
draftValue={draftValue}
@ -193,7 +193,7 @@ export const WorkflowSingleRecordPicker = ({
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
const StyledWorkflowStepBody = styled.div`
display: flex;
flex-direction: column;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(6)};
row-gap: ${({ theme }) => theme.spacing(6)};
flex: 1 1 auto;
`;
export { StyledWorkflowStepBody as WorkflowStepBody };

View File

@ -10,8 +10,8 @@ import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrTh
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { Suspense, lazy } from 'react';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';

View File

@ -43,27 +43,18 @@ const StyledHeaderIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditGenericFormBase = ({
export const WorkflowStepHeader = ({
onTitleChange,
Icon,
iconColor,
initialTitle,
headerType,
children,
}: {
onTitleChange: (newTitle: string) => void;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
children: React.ReactNode;
}) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
@ -74,33 +65,30 @@ export const WorkflowEditGenericFormBase = ({
};
return (
<>
<StyledHeader>
<StyledHeaderIconContainer>
{
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
}
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
<StyledContentContainer>{children}</StyledContentContainer>
</>
<StyledHeader>
<StyledHeaderIconContainer>
{
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
}
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
);
};

View File

@ -14,10 +14,7 @@ export const useUpdateStep = ({
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const updateStep = async <T extends WorkflowStep>(
updatedStep: T,
shouldUpdateStepOutput = true,
) => {
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
@ -26,7 +23,6 @@ export const useUpdateStep = ({
await updateWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
step: updatedStep,
shouldUpdateStepOutput,
});
};

View File

@ -0,0 +1,30 @@
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export type ServerlessFunctionTestData = {
input: { [field: string]: any };
output: {
data?: string;
duration?: number;
status?: ServerlessFunctionExecutionStatus;
error?: string;
};
language: 'plaintext' | 'json';
height: number;
};
export const DEFAULT_OUTPUT_VALUE =
'Enter an input above then press "run Function"';
export const serverlessFunctionTestDataFamilyState = createFamilyState<
ServerlessFunctionTestData,
string
>({
key: 'serverlessFunctionTestDataFamilyState',
defaultValue: {
language: 'plaintext',
height: 64,
input: {},
output: { data: DEFAULT_OUTPUT_VALUE },
},
});

View File

@ -13,9 +13,11 @@ export type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty;
properties?: InputSchema;
properties?: Properties;
};
export type InputSchema = {
type Properties = {
[name: string]: InputSchemaProperty;
};
export type InputSchema = InputSchemaProperty[];

View File

@ -1,29 +0,0 @@
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { InputSchema } from '@/workflow/types/InputSchema';
describe('getDefaultFunctionInputFromInputSchema', () => {
it('should init function input properly', () => {
const inputSchema = {
params: {
type: 'object',
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
},
} as InputSchema;
const expectedResult = {
params: {
a: null,
b: null,
},
};
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
expectedResult,
);
});
});

View File

@ -1,22 +0,0 @@
import { InputSchema } from '@/workflow/types/InputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isDefined } from '~/utils/isDefined';
export const getDefaultFunctionInputFromInputSchema = (
inputSchema: InputSchema | undefined,
): FunctionInput => {
return isDefined(inputSchema)
? Object.entries(inputSchema).reduce((acc, [key, value]) => {
if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = null;
} else if (value.type === 'object') {
acc[key] = isDefined(value.properties)
? getDefaultFunctionInputFromInputSchema(value.properties)
: {};
} else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [];
}
return acc;
}, {} as FunctionInput)
: {};
};

View File

@ -3,7 +3,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -17,6 +17,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormCreateRecordProps = {
action: WorkflowCreateRecordAction;
@ -136,58 +137,61 @@ export const WorkflowEditActionFormCreateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
{inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.metadata.fieldName] as JsonValue;
saveAction(newFormData);
}}
/>
return (
<FormFieldInput
key={field.metadata.fieldName}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowEditGenericFormBase>
<HorizontalSeparator noMargin />
{inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.metadata.fieldName] as JsonValue;
return (
<FormFieldInput
key={field.metadata.fieldName}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowStepBody>
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormDeleteRecordProps = {
action: WorkflowDeleteRecordAction;
@ -118,52 +119,55 @@ export const WorkflowEditActionFormDeleteRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowEditGenericFormBase>
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -5,7 +5,7 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
@ -15,6 +15,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormSendEmailProps = {
action: WorkflowSendEmailAction;
@ -171,99 +172,104 @@ export const WorkflowEditActionFormSendEmail = ({
return (
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', { redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
disabled={actionOptions.readonly}
/>
)}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
</WorkflowEditGenericFormBase>
<WorkflowStepBody>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', {
redirectLocation: redirectUrl,
}),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
disabled={actionOptions.readonly}
/>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
</WorkflowStepBody>
</>
)
);
};

View File

@ -1,48 +1,53 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { getFunctionInputSchema } from '@/workflow/utils/getFunctionInputSchema';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-actions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { Fragment, ReactNode, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
CodeEditor,
HorizontalSeparator,
IconCode,
isDefined,
} from 'twenty-ui';
import { useEffect, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { CodeEditor, IconCode, isDefined, IconPlayerPlay } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
const StyledContainer = styled.div`
display: inline-flex;
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-top: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
const StyledTabList = styled(TabList)`
background: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowEditActionFormServerlessFunctionProps = {
@ -53,10 +58,7 @@ type WorkflowEditActionFormServerlessFunctionProps = {
}
| {
readonly?: false;
onActionUpdate: (
action: WorkflowCodeAction,
shouldUpdateStepOutput?: boolean,
) => void;
onActionUpdate: (action: WorkflowCodeAction) => void;
};
};
@ -64,14 +66,14 @@ type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
const INDEX_FILE_PATH = 'src/index.ts';
const TAB_LIST_COMPONENT_ID = 'serverless-function-code-step';
export const WorkflowEditActionFormServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
@ -81,6 +83,9 @@ export const WorkflowEditActionFormServerlessFunction = ({
id: serverlessFunctionId,
});
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>(
action.settings.input.serverlessFunctionInput,
@ -89,83 +94,80 @@ export const WorkflowEditActionFormServerlessFunction = ({
const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const headerTitle = action.name || 'Code - Serverless Function';
const save = async () => {
try {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
const updateOutputSchemaFromTestResult = async (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getFunctionOutputSchema(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const handleSave = usePreventOverlapCallback(save, 1000);
const { testServerlessFunction } = useTestServerlessFunction(
serverlessFunctionId,
updateOutputSchemaFromTestResult,
);
const onCodeChange = async (value: string) => {
const handleSave = useDebouncedCallback(async () => {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
}, 1_000);
const onCodeChange = async (newCode: string) => {
if (actionOptions.readonly === true) {
return;
}
setFormValues((prevState) => ({
...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: value },
code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
}));
await handleSave();
await handleUpdateFunctionInputSchema();
};
const updateFunctionInputSchema = async () => {
if (actionOptions.readonly === true) {
return;
}
const sourceCode = formValues.code?.[INDEX_FILE_PATH];
if (!isDefined(sourceCode)) {
return;
}
const functionInputSchema = getFunctionInputSchema(sourceCode);
const newMergedInputSchema = mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput:
getDefaultFunctionInputFromInputSchema(functionInputSchema),
functionInput: action.settings.input.serverlessFunctionInput,
});
setFunctionInput(newMergedInputSchema);
await updateFunctionInput(newMergedInputSchema);
await handleUpdateFunctionInputSchema(newCode);
};
const handleUpdateFunctionInputSchema = useDebouncedCallback(
updateFunctionInputSchema,
100,
);
const updateFunctionInput = useDebouncedCallback(
async (newFunctionInput: object, shouldUpdateStepOutput = true) => {
async (sourceCode: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate(
{
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: newFunctionInput,
},
if (!isDefined(sourceCode)) {
return;
}
const newFunctionInput = getFunctionInputFromSourceCode(sourceCode);
const newMergedInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: action.settings.input.serverlessFunctionInput,
});
const newMergedTestInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: serverlessFunctionTestData.input,
});
setFunctionInput(newMergedInput);
setServerlessFunctionTestData((prev) => ({
...prev,
input: newMergedTestInput,
}));
updateAction({
...action,
settings: {
...action.settings,
outputSchema: {},
input: {
...action.settings.input,
serverlessFunctionInput: newMergedInput,
},
},
shouldUpdateStepOutput,
);
});
},
1_000,
);
@ -175,63 +177,33 @@ export const WorkflowEditActionFormServerlessFunction = ({
setFunctionInput(updatedFunctionInput);
await updateFunctionInput(updatedFunctionInput, false);
updateAction({
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: updatedFunctionInput,
},
},
});
};
const renderFields = (
functionInput: FunctionInput,
path: string[] = [],
isRoot = true,
): ReactNode[] => {
const displaySeparator = (functionInput: FunctionInput) => {
const keys = Object.keys(functionInput);
if (keys.length > 1) {
return true;
}
if (keys.length === 1) {
const subKeys = Object.keys(functionInput[keys[0]]);
return subKeys.length > 0;
}
return false;
};
const handleTestInputChange = async (value: any, path: string[]) => {
const updatedTestFunctionInput = setNestedValue(
serverlessFunctionTestData.input,
path,
value,
);
setServerlessFunctionTestData((prev) => ({
...prev,
input: updatedTestFunctionInput,
}));
};
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && typeof inputValue === 'object') {
if (isRoot) {
return (
<Fragment key={pathKey}>
{displaySeparator(functionInput) && (
<HorizontalSeparator noMargin />
)}
{renderFields(inputValue, currentPath, false)}
</Fragment>
);
}
return (
<StyledContainer key={pathKey}>
<StyledLabel>{inputKey}</StyledLabel>
<StyledFormCompositeFieldInputContainer>
{renderFields(inputValue, currentPath, false)}
</StyledFormCompositeFieldInputContainer>
</StyledContainer>
);
} else {
return (
<FormTextFieldInput
key={pathKey}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={actionOptions.readonly}
onPersist={(value) => handleInputChange(value, currentPath)}
VariablePicker={WorkflowVariablePicker}
/>
);
}
});
const handleRunFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
};
const handleEditorDidMount = async (
@ -247,58 +219,103 @@ export const WorkflowEditActionFormServerlessFunction = ({
});
};
const onActionUpdate = (actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
const updateAction = useDebouncedCallback(
(actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions?.onActionUpdate(
{
actionOptions.onActionUpdate({
...action,
...actionUpdate,
},
false,
);
};
});
},
500,
);
const checkWorkflowUpdatable = async () => {
const handleCodeChange = async (value: string) => {
if (actionOptions.readonly === true || !isDefined(workflow)) {
return;
}
await getUpdatableWorkflowVersion(workflow);
await onCodeChange(value);
};
const tabs = [
{ id: 'code', title: 'Code', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconPlayerPlay },
];
useEffect(() => {
setFunctionInput(action.settings.input.serverlessFunctionInput);
}, [action]);
return (
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
onActionUpdate({ name: newName });
}}
Icon={IconCode}
iconColor={theme.color.orange}
initialTitle={headerTitle}
headerType="Code"
>
<CodeEditor
height={340}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onChange={async (value) => {
await checkWorkflowUpdatable();
await onCodeChange(value);
}}
onMount={handleEditorDidMount}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
}}
<StyledContainer>
<StyledTabList
tabListInstanceId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
{renderFields(functionInput)}
</WorkflowEditGenericFormBase>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
updateAction({ name: newName });
}}
Icon={IconCode}
iconColor={theme.color.orange}
initialTitle={action.name || 'Code - Serverless Function'}
headerType="Code"
/>
<WorkflowStepBody>
{activeTabId === 'code' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Code</InputLabel>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onChange={handleCodeChange}
onMount={handleEditorDidMount}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
}}
/>
</StyledCodeEditorContainer>
</>
)}
{activeTabId === 'test' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={serverlessFunctionTestData.input}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Result</InputLabel>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
/>
</StyledCodeEditorContainer>
</>
)}
</WorkflowStepBody>
{activeTabId === 'test' && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton title="Test" onClick={handleRunFunction} />,
]}
/>
)}
</StyledContainer>
)
);
};

View File

@ -0,0 +1,85 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { isObject } from '@sniptt/guards';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
export const WorkflowEditActionFormServerlessFunctionFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}) => {
const renderFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}): ReactNode[] => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && isObject(inputValue)) {
return (
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
{renderFields({
functionInput: inputValue,
path: currentPath,
VariablePicker,
onInputChange,
})}
</FormNestedFieldInputContainer>
</StyledContainer>
);
} else {
return (
<FormTextFieldInput
key={pathKey}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={readonly}
onPersist={(value) => onInputChange(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
});
};
return (
<>
{renderFields({
functionInput,
path,
VariablePicker,
onInputChange,
readonly,
})}
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction;
@ -123,52 +124,55 @@ export const WorkflowEditActionFormUpdateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-update-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-update-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName,
objectRecordId: '',
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowEditGenericFormBase>
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -1,32 +0,0 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
export const mergeDefaultFunctionInputAndFunctionInput = ({
defaultFunctionInput,
functionInput,
}: {
defaultFunctionInput: FunctionInput;
functionInput: FunctionInput;
}): FunctionInput => {
const result: FunctionInput = {};
for (const key of Object.keys(defaultFunctionInput)) {
if (!(key in functionInput)) {
result[key] = defaultFunctionInput[key];
} else {
if (
defaultFunctionInput[key] !== null &&
typeof defaultFunctionInput[key] === 'object'
) {
result[key] = mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput: defaultFunctionInput[key],
functionInput:
typeof functionInput[key] === 'object' ? functionInput[key] : {},
});
} else {
result[key] = functionInput[key];
}
}
}
return result;
};

View File

@ -154,7 +154,9 @@ export const PasswordReset = () => {
} catch (err) {
logError(err);
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating password',
err instanceof Error
? err.message
: 'An error occurred while updating password',
{
variant: SnackBarVariant.Error,
},

View File

@ -14,7 +14,6 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
Button,
getImageAbsoluteURI,
@ -68,10 +67,9 @@ const StyledContentContainer = styled.div`
export const SettingsAdminFeatureFlags = () => {
const [userIdentifier, setUserIdentifier] = useState('');
const { activeTabIdState, setActiveTabId } = useTabList(
const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const {
userLookupResult,

View File

@ -78,10 +78,9 @@ export const SettingsObjectDetailPage = () => {
findActiveObjectMetadataItemBySlug(objectSlug) ??
findActiveObjectMetadataItemBySlug(updatedObjectSlug);
const { activeTabIdState } = useTabList(
const { activeTabId } = useTabList(
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const isUniqueIndexesEnabled = useIsFeatureEnabled(

View File

@ -1,27 +0,0 @@
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { useResetRecoilState } from 'recoil';
import { useEffect } from 'react';
export const ResetServerlessFunctionStatesEffect = () => {
const resetSettingsServerlessFunctionInput = useResetRecoilState(
settingsServerlessFunctionInputState,
);
const resetSettingsServerlessFunctionOutput = useResetRecoilState(
settingsServerlessFunctionOutputState,
);
const resetSettingsServerlessFunctionCodeEditorOutputParamsState =
useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState);
useEffect(() => {
resetSettingsServerlessFunctionInput();
resetSettingsServerlessFunctionOutput();
resetSettingsServerlessFunctionCodeEditorOutputParamsState();
}, [
resetSettingsServerlessFunctionInput,
resetSettingsServerlessFunctionOutput,
resetSettingsServerlessFunctionCodeEditorOutputParamsState,
]);
return <></>;
};

View File

@ -4,14 +4,10 @@ import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-f
import { SettingsServerlessFunctionMonitoringTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionMonitoringTab';
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { usePublishOneServerlessFunction } from '@/settings/serverless-functions/hooks/usePublishOneServerlessFunction';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -22,63 +18,39 @@ import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
IconCode,
IconGauge,
IconSettings,
IconTestPipe,
Section,
} from 'twenty-ui';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { useRecoilValue } from 'recoil';
import { IconCode, IconGauge, IconSettings, IconTestPipe } from 'twenty-ui';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { useDebouncedCallback } from 'use-debounce';
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const { activeTabIdState, setActiveTabId } = useTabList(
TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
const [isCodeValid, setIsCodeValid] = useState(true);
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { publishOneServerlessFunction } = usePublishOneServerlessFunction();
const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const { testServerlessFunction } =
useTestServerlessFunction(serverlessFunctionId);
const { code: latestVersionCode } = useGetOneServerlessFunctionSourceCode({
id: serverlessFunctionId,
version: 'latest',
});
const setSettingsServerlessFunctionOutput = useSetRecoilState(
settingsServerlessFunctionOutputState,
);
const settingsServerlessFunctionInput = useRecoilValue(
settingsServerlessFunctionInputState,
);
const save = async () => {
try {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleSave = usePreventOverlapCallback(save, 1000);
const handleSave = useDebouncedCallback(async () => {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
}, 1_000);
const onChange = (key: string) => {
return async (value: string) => {
@ -143,41 +115,11 @@ export const SettingsServerlessFunctionDetail = () => {
}
};
const handleExecute = async () => {
try {
const result = await executeOneServerlessFunction({
id: serverlessFunctionId,
payload: JSON.parse(settingsServerlessFunctionInput),
version: 'draft',
});
setSettingsServerlessFunctionOutput({
data: result?.data?.executeOneServerlessFunction?.data
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.data,
null,
4,
)
: undefined,
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.error,
null,
4,
)
: undefined,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while executing function',
{
variant: SnackBarVariant.Error,
},
);
}
const handleTestFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
};
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
const isAnalyticsV2Enabled = useIsFeatureEnabled('IS_ANALYTICS_V2_ENABLED');
@ -209,7 +151,7 @@ export const SettingsServerlessFunctionDetail = () => {
return (
<SettingsServerlessFunctionCodeEditorTab
files={files}
handleExecute={handleExecute}
handleExecute={handleTestFunction}
handlePublish={handlePublish}
handleReset={handleReset}
resetDisabled={resetDisabled}
@ -220,10 +162,10 @@ export const SettingsServerlessFunctionDetail = () => {
);
case 'test':
return (
<>
<SettingsServerlessFunctionTestTabEffect />
<SettingsServerlessFunctionTestTab handleExecute={handleExecute} />
</>
<SettingsServerlessFunctionTestTab
serverlessFunctionId={serverlessFunctionId}
handleExecute={handleTestFunction}
/>
);
case 'settings':
return (
@ -262,9 +204,11 @@ export const SettingsServerlessFunctionDetail = () => {
]}
>
<SettingsPageContainer>
<Section>
<TabList tabListInstanceId={TAB_LIST_COMPONENT_ID} tabs={tabs} />
</Section>
<TabList
tabListInstanceId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>

View File

@ -1,11 +0,0 @@
import { ResetServerlessFunctionStatesEffect } from '~/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect';
import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
export const SettingsServerlessFunctionDetailWrapper = () => {
return (
<>
<ResetServerlessFunctionStatesEffect />
<SettingsServerlessFunctionDetail />
</>
);
};

View File

@ -55,7 +55,7 @@ export class BaseGraphQLError extends GraphQLError {
}
if (extensions?.extensions) {
throw Error(
throw new Error(
'Pass extensions directly as the third argument of the ApolloError constructor: `new ' +
'ApolloError(message, code, {myExt: value})`, not `new ApolloError(message, code, ' +
'{extensions: {myExt: value}})`',

View File

@ -0,0 +1,4 @@
export const BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA = {
a: null,
b: null,
};

View File

@ -17,11 +17,4 @@ export class UpdateWorkflowVersionStepInput {
nullable: false,
})
step: WorkflowAction;
@Field(() => Boolean, {
description: 'Boolean to check if we need to update stepOutput',
nullable: true,
defaultValue: true,
})
shouldUpdateStepOutput: boolean;
}

View File

@ -37,17 +37,12 @@ export class WorkflowVersionStepResolver {
async updateWorkflowVersionStep(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args('input')
{
step,
workflowVersionId,
shouldUpdateStepOutput,
}: UpdateWorkflowVersionStepInput,
{ step, workflowVersionId }: UpdateWorkflowVersionStepInput,
): Promise<WorkflowActionDTO> {
return this.workflowVersionStepWorkspaceService.updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step,
shouldUpdateStepOutput,
});
}

View File

@ -22,7 +22,7 @@ import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ServerlessFunctionSyncStatus } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { InputSchema } from 'src/modules/code-introspection/types/input-schema.type';
import { InputSchema } from 'src/modules/workflow/workflow-builder/types/input-schema.type';
registerEnumType(ServerlessFunctionSyncStatus, {
name: 'ServerlessFunctionSyncStatus',

View File

@ -6,7 +6,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { InputSchema } from 'src/modules/code-introspection/types/input-schema.type';
import { InputSchema } from 'src/modules/workflow/workflow-builder/types/input-schema.type';
export enum ServerlessFunctionSyncStatus {
NOT_READY = 'NOT_READY',

View File

@ -1,138 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
describe('CodeIntrospectionService', () => {
let service: CodeIntrospectionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CodeIntrospectionService],
}).compile();
service = module.get<CodeIntrospectionService>(CodeIntrospectionService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getFunctionInputSchema', () => {
it('should analyze a simple function correctly', () => {
const fileContent = `
function testFunction(param1: string, param2: number): void {
return;
}
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual({
param1: { type: 'string' },
param2: { type: 'number' },
});
});
it('should analyze a arrow function correctly', () => {
const fileContent = `
export const main = async (
param1: string,
param2: number,
): Promise<object> => {
return params;
};
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual({
param1: { type: 'string' },
param2: { type: 'number' },
});
});
it('should analyze a complex function correctly', () => {
const fileContent = `
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual({
params: {
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
param3: { type: 'boolean' },
param4: { type: 'object' },
param5: {
type: 'object',
properties: {
subParam1: { type: 'string' },
},
},
param6: { type: 'string', enum: ['my', 'enum'] },
param7: { type: 'array', items: { type: 'string' } },
},
},
});
});
});
describe('generateInputData', () => {
it('should generate fake data for simple function', () => {
const fileContent = `
function testFunction(param1: string, param2: number): void {
return;
}
`;
const inputSchema = service.getFunctionInputSchema(fileContent);
const result = service.generateInputData(inputSchema);
expect(result).toEqual({ param1: 'generated-string-value', param2: 1 });
});
it('should generate fake data for complex function', () => {
const fileContent = `
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const inputSchema = service.getFunctionInputSchema(fileContent);
const result = service.generateInputData(inputSchema);
expect(result).toEqual({
params: {
param1: 'generated-string-value',
param2: 1,
param3: true,
param4: {},
param5: { subParam1: 'generated-string-value' },
param6: 'my',
param7: ['generated-string-value'],
},
});
});
});
});

View File

@ -1,12 +0,0 @@
import { CustomException } from 'src/utils/custom-exception';
export class CodeIntrospectionException extends CustomException {
code: CodeIntrospectionExceptionCode;
constructor(message: string, code: CodeIntrospectionExceptionCode) {
super(message, code);
}
}
export enum CodeIntrospectionExceptionCode {
ONLY_ONE_FUNCTION_ALLOWED = 'ONLY_ONE_FUNCTION_ALLOWED',
}

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
@Module({
providers: [CodeIntrospectionService],
exports: [CodeIntrospectionService],
})
export class CodeIntrospectionModule {}

View File

@ -1,157 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
ArrayTypeNode,
createSourceFile,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
UnionTypeNode,
VariableStatement,
ArrowFunction,
FunctionDeclaration,
} from 'typescript';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { isDefined } from 'src/utils/is-defined';
import {
InputSchema,
InputSchemaProperty,
} from 'src/modules/code-introspection/types/input-schema.type';
@Injectable()
export class CodeIntrospectionService {
public generateInputData(inputSchema: InputSchema, setNullValue = false) {
return Object.entries(inputSchema).reduce((acc, [key, value]) => {
if (isDefined(value.enum)) {
acc[key] = value.enum?.[0];
} else if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = setNullValue ? null : generateFakeValue(value.type);
} else if (value.type === 'object') {
acc[key] = isDefined(value.properties)
? this.generateInputData(value.properties, setNullValue)
: {};
} else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [generateFakeValue(value.items.type)];
}
return acc;
}, {});
}
public getFunctionInputSchema(fileContent: string): InputSchema {
const sourceFile = createSourceFile(
'temp.ts',
fileContent,
ScriptTarget.ESNext,
true,
);
const schema: InputSchema = {};
sourceFile.forEachChild((node) => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
const funcNode = node as FunctionDeclaration;
const params = funcNode.parameters;
params.forEach((param) => {
const paramName = param.name.getText();
const typeNode = param.type;
if (typeNode) {
schema[paramName] = this.getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
} else if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
varStatement.declarationList.declarations.forEach((declaration) => {
if (
declaration.initializer &&
declaration.initializer.kind === SyntaxKind.ArrowFunction
) {
const arrowFunction = declaration.initializer as ArrowFunction;
const params = arrowFunction.parameters;
params.forEach((param: any) => {
const paramName = param.name.text;
const typeNode = param.type;
if (typeNode) {
schema[paramName] = this.getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
}
});
}
});
return schema;
}
private getTypeString(typeNode: TypeNode): InputSchemaProperty {
switch (typeNode.kind) {
case SyntaxKind.NumberKeyword:
return { type: 'number' };
case SyntaxKind.StringKeyword:
return { type: 'string' };
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
return {
type: 'array',
items: this.getTypeString((typeNode as ArrayTypeNode).elementType),
};
case SyntaxKind.ObjectKeyword:
return { type: 'object' };
case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {};
(typeNode as any).members.forEach((member: PropertySignature) => {
if (member.name && member.type) {
const memberName = (member.name as any).text;
properties[memberName] = this.getTypeString(member.type);
}
});
return { type: 'object', properties };
}
case SyntaxKind.UnionType: {
const unionNode = typeNode as UnionTypeNode;
const enumValues: string[] = [];
let isEnum = true;
unionNode.types.forEach((subType) => {
if (subType.kind === SyntaxKind.LiteralType) {
const literal = (subType as LiteralTypeNode).literal;
if (literal.kind === SyntaxKind.StringLiteral) {
enumValues.push((literal as StringLiteral).text);
} else {
isEnum = false;
}
} else {
isEnum = false;
}
});
if (isEnum) {
return { type: 'string', enum: enumValues };
}
return { type: 'unknown' };
}
default:
return { type: 'unknown' };
}
}
}

View File

@ -9,7 +9,6 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/common
import { WorkflowBuilderModule } from 'src/modules/workflow/workflow-builder/workflow-builder.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-introspection.module';
@Module({
imports: [
@ -17,7 +16,6 @@ import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-int
WorkflowCommandModule,
WorkflowBuilderModule,
ServerlessFunctionModule,
CodeIntrospectionModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
],
providers: [

View File

@ -1,17 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
@ -24,6 +20,7 @@ import {
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
const TRIGGER_STEP_ID = 'trigger';
@ -46,7 +43,6 @@ export class WorkflowVersionStepWorkspaceService {
private readonly twentyORMManager: TwentyORMManager,
private readonly workflowBuilderWorkspaceService: WorkflowBuilderWorkspaceService,
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly codeIntrospectionService: CodeIntrospectionService,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
) {}
@ -78,21 +74,6 @@ export class WorkflowVersionStepWorkspaceService {
);
}
const sourceCode = (
await this.serverlessFunctionService.getServerlessFunctionSourceCode(
workspaceId,
newServerlessFunction.id,
'draft',
)
)?.[join('src', INDEX_FILE_NAME)];
const inputSchema = isDefined(sourceCode)
? this.codeIntrospectionService.getFunctionInputSchema(sourceCode)
: {};
const serverlessFunctionInput =
this.codeIntrospectionService.generateInputData(inputSchema, true);
return {
id: newStepId,
name: 'Code - Serverless Function',
@ -103,7 +84,7 @@ export class WorkflowVersionStepWorkspaceService {
input: {
serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft',
serverlessFunctionInput,
serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA,
},
},
};
@ -201,6 +182,11 @@ export class WorkflowVersionStepWorkspaceService {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code workflow action. OutputSchema is computed and updated when testing the serverless function
if (step.type === WorkflowActionType.CODE) {
return step;
}
const result = { ...step };
const outputSchema =
await this.workflowBuilderWorkspaceService.computeStepOutputSchema({
@ -262,12 +248,10 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId,
workflowVersionId,
step,
shouldUpdateStepOutput,
}: {
workspaceId: string;
workflowVersionId: string;
step: WorkflowAction;
shouldUpdateStepOutput: boolean;
}): Promise<WorkflowAction> {
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
@ -294,12 +278,10 @@ export class WorkflowVersionStepWorkspaceService {
);
}
const enrichedNewStep = shouldUpdateStepOutput
? await this.enrichOutputSchema({
step,
workspaceId,
})
: step;
const enrichedNewStep = await this.enrichOutputSchema({
step,
workspaceId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {

View File

@ -13,9 +13,11 @@ export type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty; // used to describe array type elements
properties?: InputSchema; // used to describe object type elements
properties?: Properties; // used to describe object type elements
};
export type InputSchema = {
type Properties = {
[name: string]: InputSchemaProperty;
};
export type InputSchema = InputSchemaProperty[];

View File

@ -1,9 +1,9 @@
import { InputSchemaPropertyType } from 'src/modules/code-introspection/types/input-schema.type';
import { InputSchemaPropertyType } from 'src/modules/workflow/workflow-builder/types/input-schema.type';
export type Leaf = {
isLeaf: true;
icon?: string;
type?: InputSchemaPropertyType;
icon?: string;
label?: string;
value: any;
};

View File

@ -3,14 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-introspection.module';
import { WorkflowBuilderWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-builder.workspace-service';
@Module({
imports: [
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
ServerlessFunctionModule,
CodeIntrospectionModule,
],
providers: [WorkflowBuilderWorkspaceService],
exports: [WorkflowBuilderWorkspaceService],

View File

@ -1,23 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import { InputSchemaPropertyType } from 'src/modules/code-introspection/types/input-schema.type';
import {
Leaf,
Node,
OutputSchema,
} from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event';
import {
@ -34,7 +25,6 @@ import { isDefined } from 'src/utils/is-defined';
export class WorkflowBuilderWorkspaceService {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly codeIntrospectionService: CodeIntrospectionService,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
) {}
@ -72,18 +62,6 @@ export class WorkflowBuilderWorkspaceService {
case WorkflowActionType.SEND_EMAIL: {
return this.computeSendEmailActionOutputSchema();
}
case WorkflowActionType.CODE: {
const { serverlessFunctionId, serverlessFunctionVersion } =
step.settings.input;
return this.computeCodeActionOutputSchema({
serverlessFunctionId,
serverlessFunctionVersion,
workspaceId,
serverlessFunctionService: this.serverlessFunctionService,
codeIntrospectionService: this.codeIntrospectionService,
});
}
case WorkflowActionType.CREATE_RECORD:
case WorkflowActionType.UPDATE_RECORD:
case WorkflowActionType.DELETE_RECORD:
@ -98,6 +76,7 @@ export class WorkflowBuilderWorkspaceService {
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
});
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default:
return {};
}
@ -194,63 +173,4 @@ export class WorkflowBuilderWorkspaceService {
private computeSendEmailActionOutputSchema(): OutputSchema {
return { success: { isLeaf: true, type: 'boolean', value: true } };
}
private async computeCodeActionOutputSchema({
serverlessFunctionId,
serverlessFunctionVersion,
workspaceId,
serverlessFunctionService,
codeIntrospectionService,
}: {
serverlessFunctionId: string;
serverlessFunctionVersion: string;
workspaceId: string;
serverlessFunctionService: ServerlessFunctionService;
codeIntrospectionService: CodeIntrospectionService;
}): Promise<OutputSchema> {
if (serverlessFunctionId === '') {
return {};
}
const sourceCode = (
await serverlessFunctionService.getServerlessFunctionSourceCode(
workspaceId,
serverlessFunctionId,
serverlessFunctionVersion,
)
)?.[join('src', INDEX_FILE_NAME)];
if (!isDefined(sourceCode)) {
return {};
}
const inputSchema =
codeIntrospectionService.getFunctionInputSchema(sourceCode);
const fakeFunctionInput =
codeIntrospectionService.generateInputData(inputSchema);
const resultFromFakeInput =
await serverlessFunctionService.executeOneServerlessFunction(
serverlessFunctionId,
workspaceId,
Object.values(fakeFunctionInput)?.[0] || {},
serverlessFunctionVersion,
);
return resultFromFakeInput.data
? Object.entries(resultFromFakeInput.data).reduce(
(acc: Record<string, Leaf | Node>, [key, value]) => {
acc[key] = {
isLeaf: true,
value,
type: typeof value as InputSchemaPropertyType,
};
return acc;
},
{},
)
: {};
}
}

View File

@ -35,7 +35,7 @@ export class CodeWorkflowAction implements WorkflowAction {
await this.serverlessFunctionService.executeOneServerlessFunction(
workflowActionInput.serverlessFunctionId,
workspaceId,
Object.values(workflowActionInput.serverlessFunctionInput)?.[0] || {},
workflowActionInput.serverlessFunctionInput,
workflowActionInput.serverlessFunctionVersion,
);

View File

@ -367,7 +367,7 @@ const StyledSeparator = styled.div<{
background: ${({ theme, accent }) => {
switch (accent) {
case 'blue':
return theme.color.blue30;
return theme.border.color.blue;
case 'danger':
return theme.border.color.danger;
default:
@ -387,7 +387,7 @@ const StyledShortcutLabel = styled.div<{
color: ${({ theme, variant, accent }) => {
switch (accent) {
case 'blue':
return theme.color.blue30;
return theme.border.color.blue;
case 'danger':
return variant === 'primary'
? theme.border.color.danger

View File

@ -43,36 +43,34 @@ export const CodeEditor = ({
const theme = useTheme();
return (
<div>
<StyledEditor
height={height}
withHeader={withHeader}
value={value}
language={language}
onMount={(editor, monaco) => {
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
<StyledEditor
height={height}
withHeader={withHeader}
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,
}}
/>
</div>
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

@ -10,6 +10,7 @@ export const BORDER_DARK = {
secondaryInverted: GRAY_SCALE.gray35,
inverted: GRAY_SCALE.gray20,
danger: COLOR.red70,
blue: COLOR.blue30,
},
...BORDER_COMMON,
};

View File

@ -10,6 +10,7 @@ export const BORDER_LIGHT = {
secondaryInverted: GRAY_SCALE.gray50,
inverted: GRAY_SCALE.gray60,
danger: COLOR.red20,
blue: COLOR.blue30,
},
...BORDER_COMMON,
};