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}"], "outputs": ["{projectRoot}/{options.output-dir}"],
"options": { "options": {
"cwd": "{projectRoot}", "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", "output-dir": "storybook-static",
"config-dir": ".storybook" "config-dir": ".storybook"
} }

View File

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

View File

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

View File

@ -1277,8 +1277,6 @@ export type UpdateServerlessFunctionInput = {
}; };
export type UpdateWorkflowVersionStepInput = { export type UpdateWorkflowVersionStepInput = {
/** Boolean to check if we need to update stepOutput */
shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']>;
/** Step to update in JSON format */ /** Step to update in JSON format */
step: Scalars['JSON']; step: Scalars['JSON'];
/** Workflow version ID */ /** 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 styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { import {
AnimatedPlaceholder, AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptyContainer,
@ -43,8 +42,7 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
activityObjectNameSingular: CoreObjectNameSingular.Task, activityObjectNameSingular: CoreObjectNameSingular.Task,
}); });
const { activeTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID); const { activeTabId } = useTabList(TASKS_TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
const isLoading = const isLoading =
(activeTabId !== 'done' && tasksLoading) || (activeTabId !== 'done' && tasksLoading) ||

View File

@ -83,11 +83,11 @@ const SettingsServerlessFunctions = lazy(() =>
).then((module) => ({ default: module.SettingsServerlessFunctions })), ).then((module) => ({ default: module.SettingsServerlessFunctions })),
); );
const SettingsServerlessFunctionDetailWrapper = lazy(() => const SettingsServerlessFunctionDetail = lazy(() =>
import( import(
'~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper' '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail'
).then((module) => ({ ).then((module) => ({
default: module.SettingsServerlessFunctionDetailWrapper, default: module.SettingsServerlessFunctionDetail,
})), })),
); );
@ -353,7 +353,7 @@ export const SettingsRoutes = ({
/> />
<Route <Route
path={SettingsPath.ServerlessFunctionDetail} 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 { FormCountrySelectInput } from '@/object-record/record-field/form-types/components/FormCountrySelectInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer'; import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
@ -39,9 +39,9 @@ export const FormAddressFieldInput = ({
}; };
return ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormCompositeFieldInputContainer> <FormNestedFieldInputContainer>
<FormTextFieldInput <FormTextFieldInput
label="Address 1" label="Address 1"
defaultValue={defaultValue?.addressStreet1 ?? ''} defaultValue={defaultValue?.addressStreet1 ?? ''}
@ -88,7 +88,7 @@ export const FormAddressFieldInput = ({
readonly={readonly} readonly={readonly}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
/> />
</StyledFormCompositeFieldInputContainer> </FormNestedFieldInputContainer>
</StyledFormFieldInputContainer> </FormFieldInputContainer>
); );
}; };

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip'; import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { BooleanInput } from '@/ui/field/input/components/BooleanInput'; import { BooleanInput } from '@/ui/field/input/components/BooleanInput';
@ -80,11 +80,11 @@ export const FormBooleanFieldInput = ({
}; };
return ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer> <FormFieldInputRowContainer>
<StyledFormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker)}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
@ -101,7 +101,7 @@ export const FormBooleanFieldInput = ({
onRemove={handleUnlinkVariable} onRemove={handleUnlinkVariable}
/> />
)} )}
</StyledFormFieldInputInputContainer> </FormFieldInputInputContainer>
{VariablePicker ? ( {VariablePicker ? (
<VariablePicker <VariablePicker
@ -109,7 +109,7 @@ export const FormBooleanFieldInput = ({
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}
/> />
) : null} ) : null}
</StyledFormFieldInputRowContainer> </FormFieldInputRowContainer>
</StyledFormFieldInputContainer> </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 { css } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const StyledFormFieldInputInputContainer = styled.div<{ const StyledFormFieldInputInputContainer = styled.div<{
hasRightElement: boolean; hasRightElement: boolean;
multiline?: boolean; multiline?: boolean;
readonly?: boolean; readonly?: boolean;
@ -29,3 +29,5 @@ export const StyledFormFieldInputInputContainer = styled.div<{
overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')}; overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
width: 100%; width: 100%;
`; `;
export const FormFieldInputInputContainer = StyledFormFieldInputInputContainer;

View File

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

View File

@ -1,6 +1,6 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer'; import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; 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 { 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'; 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 ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormCompositeFieldInputContainer> <FormNestedFieldInputContainer>
<FormTextFieldInput <FormTextFieldInput
label="First Name" label="First Name"
defaultValue={defaultValue?.firstName} defaultValue={defaultValue?.firstName}
@ -60,7 +60,7 @@ export const FormFullNameFieldInput = ({
readonly={readonly} readonly={readonly}
VariablePicker={VariablePicker} VariablePicker={VariablePicker}
/> />
</StyledFormCompositeFieldInputContainer> </FormNestedFieldInputContainer>
</StyledFormFieldInputContainer> </FormFieldInputContainer>
); );
}; };

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
export const StyledFormCompositeFieldInputContainer = styled.div` const StyledFormNestedFieldInputContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: ${({ theme }) => theme.background.secondary}; background: ${({ theme }) => theme.background.secondary};
@ -9,3 +9,6 @@ export const StyledFormCompositeFieldInputContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ 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 { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip'; import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { TextInput } from '@/ui/field/input/components/TextInput'; import { TextInput } from '@/ui/field/input/components/TextInput';
@ -94,11 +94,11 @@ export const FormNumberFieldInput = ({
}; };
return ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel htmlFor={inputId}>{label}</InputLabel> : null} {label ? <InputLabel htmlFor={inputId}>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer> <FormFieldInputRowContainer>
<StyledFormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker)}
> >
{draftValue.type === 'static' ? ( {draftValue.type === 'static' ? (
@ -116,7 +116,7 @@ export const FormNumberFieldInput = ({
onRemove={handleUnlinkVariable} onRemove={handleUnlinkVariable}
/> />
)} )}
</StyledFormFieldInputInputContainer> </FormFieldInputInputContainer>
{VariablePicker ? ( {VariablePicker ? (
<VariablePicker <VariablePicker
@ -124,7 +124,7 @@ export const FormNumberFieldInput = ({
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}
/> />
) : null} ) : null}
</StyledFormFieldInputRowContainer> </FormFieldInputRowContainer>
</StyledFormFieldInputContainer> </FormFieldInputContainer>
); );
}; };

View File

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

View File

@ -1,6 +1,6 @@
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { StyledFormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputInputContainer'; import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor'; import { TextVariableEditor } from '@/object-record/record-field/form-types/components/TextVariableEditor';
import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor'; import { useTextVariableEditor } from '@/object-record/record-field/form-types/hooks/useTextVariableEditor';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
@ -58,11 +58,11 @@ export const FormTextFieldInput = ({
} }
return ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer multiline={multiline}> <FormFieldInputRowContainer multiline={multiline}>
<StyledFormFieldInputInputContainer <FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker)} hasRightElement={isDefined(VariablePicker)}
multiline={multiline} multiline={multiline}
> >
@ -71,7 +71,7 @@ export const FormTextFieldInput = ({
multiline={multiline} multiline={multiline}
readonly={readonly} readonly={readonly}
/> />
</StyledFormFieldInputInputContainer> </FormFieldInputInputContainer>
{VariablePicker ? ( {VariablePicker ? (
<VariablePicker <VariablePicker
@ -80,7 +80,7 @@ export const FormTextFieldInput = ({
onVariableSelect={handleVariableTagInsert} onVariableSelect={handleVariableTagInsert}
/> />
) : null} ) : null}
</StyledFormFieldInputRowContainer> </FormFieldInputRowContainer>
</StyledFormFieldInputContainer> </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; const { tr } = state;
// Insert hard break using the view's state and dispatch // Insert hard break using the view's state and dispatch
if (multiline === true) {
const transaction = tr.replaceSelectionWith( const transaction = tr.replaceSelectionWith(
state.schema.nodes.hardBreak.create(), state.schema.nodes.hardBreak.create(),
); );
view.dispatch(transaction); view.dispatch(transaction);
}
return true; 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', () => { describe('mergeDefaultFunctionInputAndFunctionInput', () => {
it('should merge properly', () => { it('should merge properly', () => {
const defaultFunctionInput = { const newInput = {
params: { a: null, b: null, c: { cc: null } }, a: null,
}; b: null,
const functionInput = { c: { cc: null },
params: { a: 'a', c: 'c' }, d: null,
e: { ee: null },
}; };
const oldInput = { a: 'a', c: 'c', d: { da: null }, e: { ee: 'ee' } };
const expectedResult = { const expectedResult = {
params: { a: 'a', b: null, c: { cc: null } }, a: 'a',
b: null,
c: { cc: null },
d: null,
e: { ee: 'ee' },
}; };
expect( expect(
mergeDefaultFunctionInputAndFunctionInput({ mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput, newInput: newInput,
functionInput, oldInput: oldInput,
}), }),
).toEqual(expectedResult); ).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, ArrowFunction,
createSourceFile, createSourceFile,
FunctionDeclaration, FunctionDeclaration,
FunctionLikeDeclaration,
LiteralTypeNode, LiteralTypeNode,
PropertySignature, PropertySignature,
ScriptTarget, ScriptTarget,
StringLiteral, StringLiteral,
SyntaxKind, SyntaxKind,
TypeNode, TypeNode,
Node,
UnionTypeNode, UnionTypeNode,
VariableStatement, VariableStatement,
} from 'typescript'; } from 'typescript';
@ -31,7 +33,7 @@ const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
case SyntaxKind.ObjectKeyword: case SyntaxKind.ObjectKeyword:
return { type: 'object' }; return { type: 'object' };
case SyntaxKind.TypeLiteral: { case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {}; const properties: InputSchemaProperty['properties'] = {};
(typeNode as any).members.forEach((member: PropertySignature) => { (typeNode as any).members.forEach((member: PropertySignature) => {
if (isDefined(member.name) && isDefined(member.type)) { 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 => { export const getFunctionInputSchema = (fileContent: string): InputSchema => {
const sourceFile = createSourceFile( const sourceFile = createSourceFile(
'temp.ts', 'temp.ts',
@ -81,46 +119,16 @@ export const getFunctionInputSchema = (fileContent: string): InputSchema => {
ScriptTarget.ESNext, ScriptTarget.ESNext,
true, true,
); );
let schema: InputSchema = [];
const schema: InputSchema = {};
sourceFile.forEachChild((node) => { 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 ( if (
isDefined(declaration.initializer) && node.kind === SyntaxKind.FunctionDeclaration ||
declaration.initializer.kind === SyntaxKind.ArrowFunction node.kind === SyntaxKind.VariableStatement
) { ) {
const arrowFunction = declaration.initializer as ArrowFunction; const functions = extractFunctions(node);
const params = arrowFunction.parameters; functions.forEach((func) => {
schema = computeFunctionParameters(func, schema);
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' };
}
});
}
}); });
} }
}); });

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 = () => { export const SettingsAccountsCalendarChannelsContainer = () => {
const { activeTabIdState } = useTabList( const { activeTabId } = useTabList(
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID, SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({ const { records: accounts } = useFindManyRecords<ConnectedAccount>({

View File

@ -18,10 +18,9 @@ const StyledMessageContainer = styled.div`
`; `;
export const SettingsAccountsMessageChannelsContainer = () => { export const SettingsAccountsMessageChannelsContainer = () => {
const { activeTabIdState } = useTabList( const { activeTabId } = useTabList(
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID, SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({ 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 { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { import {
Button, Button,
@ -47,10 +46,9 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
onChange: (filePath: string, value: string) => void; onChange: (filePath: string, value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void; setIsCodeValid: (isCodeValid: boolean) => void;
}) => { }) => {
const { activeTabIdState } = useTabList( const { activeTabId } = useTabList(
SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID, SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const TestButton = ( const TestButton = (
<Button <Button
title="Test" title="Test"

View File

@ -7,20 +7,17 @@ import {
Section, Section,
} from 'twenty-ui'; } 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 { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
const StyledInputsContainer = styled.div` const StyledInputsContainer = styled.div`
display: flex; display: flex;
@ -28,24 +25,27 @@ const StyledInputsContainer = styled.div`
gap: ${({ theme }) => theme.spacing(4)}; gap: ${({ theme }) => theme.spacing(4)};
`; `;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column;
`;
export const SettingsServerlessFunctionTestTab = ({ export const SettingsServerlessFunctionTestTab = ({
handleExecute, handleExecute,
serverlessFunctionId,
}: { }: {
handleExecute: () => void; handleExecute: () => void;
serverlessFunctionId: string;
}) => { }) => {
const settingsServerlessFunctionCodeEditorOutputParams = useRecoilValue( const [serverlessFunctionTestData, setServerlessFunctionTestData] =
settingsServerlessFunctionCodeEditorOutputParamsState, useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
);
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
const [settingsServerlessFunctionInput, setSettingsServerlessFunctionInput] =
useRecoilState(settingsServerlessFunctionInputState);
const result = const onChange = (newInput: string) => {
settingsServerlessFunctionOutput.data || setServerlessFunctionTestData((prev) => ({
settingsServerlessFunctionOutput.error || ...prev,
''; input: JSON.parse(newInput),
}));
};
const navigate = useNavigate(); const navigate = useNavigate();
useHotkeyScopeOnMount( useHotkeyScopeOnMount(
@ -67,7 +67,7 @@ export const SettingsServerlessFunctionTestTab = ({
description='Insert a JSON input, then press "Run" to test your function.' description='Insert a JSON input, then press "Run" to test your function.'
/> />
<StyledInputsContainer> <StyledInputsContainer>
<div> <StyledCodeEditorContainer>
<CoreEditorHeader <CoreEditorHeader
title={'Input'} title={'Input'}
rightNodes={[ rightNodes={[
@ -82,26 +82,16 @@ export const SettingsServerlessFunctionTestTab = ({
]} ]}
/> />
<CodeEditor <CodeEditor
value={settingsServerlessFunctionInput} value={JSON.stringify(serverlessFunctionTestData.input, null, 4)}
language="json" language="json"
height={200} height={200}
onChange={setSettingsServerlessFunctionInput} onChange={onChange}
withHeader withHeader
/> />
</div> </StyledCodeEditorContainer>
<div> <ServerlessFunctionExecutionResult
<CoreEditorHeader serverlessFunctionTestData={serverlessFunctionTestData}
leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/> />
<CodeEditor
value={result}
language={settingsServerlessFunctionCodeEditorOutputParams.language}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
withHeader
/>
</div>
</StyledInputsContainer> </StyledInputsContainer>
</Section> </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 { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { Dispatch, SetStateAction, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql'; 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 = { export type ServerlessFunctionNewFormValues = {
name: string; name: string;
@ -29,6 +33,10 @@ export const useServerlessFunctionUpdateFormState = (
code: undefined, code: undefined,
}); });
const setServerlessFunctionTestData = useSetRecoilState(
serverlessFunctionTestDataFamilyState(serverlessFunctionId),
);
const { serverlessFunction } = useGetOneServerlessFunction({ const { serverlessFunction } = useGetOneServerlessFunction({
id: serverlessFunctionId, id: serverlessFunctionId,
}); });
@ -46,6 +54,12 @@ export const useServerlessFunctionUpdateFormState = (
...prevState, ...prevState,
...newState, ...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'; import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z'));
describe('getHighlightedDates', () => { describe('getHighlightedDates', () => {
it('should should return empty if range is undefined', () => { it('should should return empty if range is undefined', () => {
const dateRange = 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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>` const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>`
display: flex; display: flex;
@ -34,19 +35,6 @@ const StyledTabListContainer = styled.div<{ shouldDisplay: boolean }>`
height: 40px; 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 }>` const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@ -57,7 +45,7 @@ const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
export const TAB_LIST_COMPONENT_ID = 'show-page-right-tab-list'; export const TAB_LIST_COMPONENT_ID = 'show-page-right-tab-list';
type ShowPageSubContainerProps = { type ShowPageSubContainerProps = {
layout: RecordLayout; layout?: RecordLayout;
tabs: SingleTabProps[]; tabs: SingleTabProps[];
targetableObject: Pick< targetableObject: Pick<
ActivityTargetableObject, ActivityTargetableObject,
@ -76,10 +64,9 @@ export const ShowPageSubContainer = ({
isInRightDrawer = false, isInRightDrawer = false,
isNewRightDrawerItemLoading = false, isNewRightDrawerItemLoading = false,
}: ShowPageSubContainerProps) => { }: ShowPageSubContainerProps) => {
const { activeTabIdState } = useTabList( const { activeTabId } = useTabList(
`${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`, `${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -125,9 +112,12 @@ export const ShowPageSubContainer = ({
const visibleTabs = tabs.filter((tab) => !tab.hide); const visibleTabs = tabs.filter((tab) => !tab.hide);
const displaySummaryAndFields =
layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer;
return ( return (
<> <>
{!layout.hideSummaryAndFields && !isMobile && !isInRightDrawer && ( {displaySummaryAndFields && (
<ShowPageLeftContainer forceMobile={isMobile}> <ShowPageLeftContainer forceMobile={isMobile}>
{summaryCard} {summaryCard}
{fieldsCard} {fieldsCard}
@ -147,9 +137,7 @@ export const ShowPageSubContainer = ({
{renderActiveTabContent()} {renderActiveTabContent()}
</StyledContentContainer> </StyledContentContainer>
{isInRightDrawer && recordFromStore && !recordFromStore.deletedAt && ( {isInRightDrawer && recordFromStore && !recordFromStore.deletedAt && (
<StyledButtonContainer> <RightDrawerFooter actions={[<RecordShowRightDrawerActionMenu />]} />
<RecordShowRightDrawerActionMenu />
</StyledButtonContainer>
)} )}
</StyledShowPageRightContainer> </StyledShowPageRightContainer>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow'; import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName'; import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { IconPlaylistAdd, isDefined } from 'twenty-ui'; import { IconPlaylistAdd, isDefined } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerDatabaseEventFormProps = { type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger; trigger: WorkflowDatabaseEventTrigger;
@ -60,7 +61,8 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
: '-'; : '-';
return ( return (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) { if (triggerOptions.readonly === true) {
return; return;
@ -75,7 +77,8 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
iconColor={theme.font.color.tertiary} iconColor={theme.font.color.tertiary}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType={headerType} headerType={headerType}
> />
<WorkflowStepBody>
<Select <Select
dropdownId="workflow-edit-trigger-record-type" dropdownId="workflow-edit-trigger-record-type"
label="Record Type" label="Record Type"
@ -135,13 +138,14 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
name: headerTitle, name: headerTitle,
type: 'DATABASE_EVENT', type: 'DATABASE_EVENT',
settings: { settings: {
eventName: `${availableMetadata[0].value}.${updatedEvent}`, eventName: `${availableMetadata?.[0].value}.${updatedEvent}`,
outputSchema: {}, outputSchema: {},
}, },
}, },
); );
}} }}
/> />
</WorkflowEditGenericFormBase> </WorkflowStepBody>
</>
); );
}; };

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/constants/ManualTriggerAvailabilityOptions';
import { import {
WorkflowManualTrigger, WorkflowManualTrigger,
@ -9,6 +9,7 @@ import {
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings'; import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined, useIcons } from 'twenty-ui'; import { IconHandMove, isDefined, useIcons } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerManualFormProps = { type WorkflowEditTriggerManualFormProps = {
trigger: WorkflowManualTrigger; trigger: WorkflowManualTrigger;
@ -47,7 +48,8 @@ export const WorkflowEditTriggerManualForm = ({
const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger'; const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger';
return ( return (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) { if (triggerOptions.readonly === true) {
return; return;
@ -62,7 +64,8 @@ export const WorkflowEditTriggerManualForm = ({
iconColor={theme.font.color.tertiary} iconColor={theme.font.color.tertiary}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType="Trigger · Manual" headerType="Trigger · Manual"
> />
<WorkflowStepBody>
<Select <Select
dropdownId="workflow-edit-manual-trigger-availability" dropdownId="workflow-edit-manual-trigger-availability"
label="Available" label="Available"
@ -108,6 +111,7 @@ export const WorkflowEditTriggerManualForm = ({
}} }}
/> />
) : null} ) : null}
</WorkflowEditGenericFormBase> </WorkflowStepBody>
</>
); );
}; };

View File

@ -6,8 +6,8 @@ import {
} from 'twenty-ui'; } from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer'; import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer'; import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect'; import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker'; import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext'; import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
@ -137,9 +137,9 @@ export const WorkflowSingleRecordPicker = ({
}; };
return ( return (
<StyledFormFieldInputContainer> <FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null} {label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer> <FormFieldInputRowContainer>
<StyledFormSelectContainer> <StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip <WorkflowSingleRecordFieldChip
draftValue={draftValue} draftValue={draftValue}
@ -193,7 +193,7 @@ export const WorkflowSingleRecordPicker = ({
objectNameSingularToSelect={objectNameSingular} objectNameSingularToSelect={objectNameSingular}
/> />
</StyledSearchVariablesDropdownContainer> </StyledSearchVariablesDropdownContainer>
</StyledFormFieldInputRowContainer> </FormFieldInputRowContainer>
</StyledFormFieldInputContainer> </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 { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord'; import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail'; import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { Suspense, lazy } from 'react';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord'; import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader'; import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';

View File

@ -43,27 +43,18 @@ const StyledHeaderIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledContentContainer = styled.div` export const WorkflowStepHeader = ({
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditGenericFormBase = ({
onTitleChange, onTitleChange,
Icon, Icon,
iconColor, iconColor,
initialTitle, initialTitle,
headerType, headerType,
children,
}: { }: {
onTitleChange: (newTitle: string) => void; onTitleChange: (newTitle: string) => void;
Icon: IconComponent; Icon: IconComponent;
iconColor: string; iconColor: string;
initialTitle: string; initialTitle: string;
headerType: string; headerType: string;
children: React.ReactNode;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const [title, setTitle] = useState(initialTitle); const [title, setTitle] = useState(initialTitle);
@ -74,7 +65,6 @@ export const WorkflowEditGenericFormBase = ({
}; };
return ( return (
<>
<StyledHeader> <StyledHeader>
<StyledHeaderIconContainer> <StyledHeaderIconContainer>
{ {
@ -100,7 +90,5 @@ export const WorkflowEditGenericFormBase = ({
<StyledHeaderType>{headerType}</StyledHeaderType> <StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo> </StyledHeaderInfo>
</StyledHeader> </StyledHeader>
<StyledContentContainer>{children}</StyledContentContainer>
</>
); );
}; };

View File

@ -14,10 +14,7 @@ export const useUpdateStep = ({
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep(); const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const updateStep = async <T extends WorkflowStep>( const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
updatedStep: T,
shouldUpdateStepOutput = true,
) => {
if (!isDefined(workflow.currentVersion)) { if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.'); throw new Error('Can not update an undefined workflow version.');
} }
@ -26,7 +23,6 @@ export const useUpdateStep = ({
await updateWorkflowVersionStep({ await updateWorkflowVersionStep({
workflowVersionId: workflowVersion.id, workflowVersionId: workflowVersion.id,
step: updatedStep, 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; type: InputSchemaPropertyType;
enum?: string[]; enum?: string[];
items?: InputSchemaProperty; items?: InputSchemaProperty;
properties?: InputSchema; properties?: Properties;
}; };
export type InputSchema = { type Properties = {
[name: string]: InputSchemaProperty; [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 { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput'; import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow'; import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
@ -17,6 +17,7 @@ import {
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormCreateRecordProps = { type WorkflowEditActionFormCreateRecordProps = {
action: WorkflowCreateRecordAction; action: WorkflowCreateRecordAction;
@ -136,7 +137,8 @@ export const WorkflowEditActionFormCreateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Create Record`; const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return ( return (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
@ -151,7 +153,8 @@ export const WorkflowEditActionFormCreateRecord = ({
iconColor={theme.font.color.tertiary} iconColor={theme.font.color.tertiary}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType="Action" headerType="Action"
> />
<WorkflowStepBody>
<Select <Select
dropdownId="workflow-edit-action-record-create-object-name" dropdownId="workflow-edit-action-record-create-object-name"
label="Object" label="Object"
@ -188,6 +191,7 @@ export const WorkflowEditActionFormCreateRecord = ({
/> />
); );
})} })}
</WorkflowEditGenericFormBase> </WorkflowStepBody>
</>
); );
}; };

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow'; import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormDeleteRecordProps = { type WorkflowEditActionFormDeleteRecordProps = {
action: WorkflowDeleteRecordAction; action: WorkflowDeleteRecordAction;
@ -118,7 +119,8 @@ export const WorkflowEditActionFormDeleteRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`; const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
return ( return (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
@ -133,7 +135,8 @@ export const WorkflowEditActionFormDeleteRecord = ({
iconColor={theme.font.color.tertiary} iconColor={theme.font.color.tertiary}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType="Action" headerType="Action"
> />
<WorkflowStepBody>
<Select <Select
dropdownId="workflow-edit-action-record-delete-object-name" dropdownId="workflow-edit-action-record-delete-object-name"
label="Object" label="Object"
@ -164,6 +167,7 @@ export const WorkflowEditActionFormDeleteRecord = ({
objectNameSingular={formData.objectName} objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId} defaultValue={formData.objectRecordId}
/> />
</WorkflowEditGenericFormBase> </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 { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow'; import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
@ -15,6 +15,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormSendEmailProps = { type WorkflowEditActionFormSendEmailProps = {
action: WorkflowSendEmailAction; action: WorkflowSendEmailAction;
@ -171,7 +172,8 @@ export const WorkflowEditActionFormSendEmail = ({
return ( return (
!loading && ( !loading && (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
@ -186,7 +188,8 @@ export const WorkflowEditActionFormSendEmail = ({
iconColor={theme.color.blue} iconColor={theme.color.blue}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType="Email" headerType="Email"
> />
<WorkflowStepBody>
<Controller <Controller
name="connectedAccountId" name="connectedAccountId"
control={form.control} control={form.control}
@ -200,7 +203,9 @@ export const WorkflowEditActionFormSendEmail = ({
options={connectedAccountOptions} options={connectedAccountOptions}
callToActionButton={{ callToActionButton={{
onClick: () => onClick: () =>
triggerApisOAuth('google', { redirectLocation: redirectUrl }), triggerApisOAuth('google', {
redirectLocation: redirectUrl,
}),
Icon: IconPlus, Icon: IconPlus,
text: 'Add account', text: 'Add account',
}} }}
@ -263,7 +268,8 @@ export const WorkflowEditActionFormSendEmail = ({
/> />
)} )}
/> />
</WorkflowEditGenericFormBase> </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 { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction'; import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion'; import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow'; 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 { setNestedValue } from '@/workflow/utils/setNestedValue';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-actions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react'; import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor'; import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings'; import { AutoTypings } from 'monaco-editor-auto-typings';
import { Fragment, ReactNode, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { import { CodeEditor, IconCode, isDefined, IconPlayerPlay } from 'twenty-ui';
CodeEditor,
HorizontalSeparator,
IconCode,
isDefined,
} from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; 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` const StyledContainer = styled.div`
display: inline-flex; display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCodeEditorContainer = styled.div`
display: flex;
flex-direction: column; flex-direction: column;
`; `;
const StyledLabel = styled.div` const StyledTabList = styled(TabList)`
color: ${({ theme }) => theme.font.color.light}; background: ${({ theme }) => theme.background.secondary};
font-size: ${({ theme }) => theme.font.size.md}; padding-left: ${({ theme }) => theme.spacing(2)};
font-weight: ${({ theme }) => theme.font.weight.semiBold}; padding-right: ${({ theme }) => theme.spacing(2)};
margin-top: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`; `;
type WorkflowEditActionFormServerlessFunctionProps = { type WorkflowEditActionFormServerlessFunctionProps = {
@ -53,10 +58,7 @@ type WorkflowEditActionFormServerlessFunctionProps = {
} }
| { | {
readonly?: false; readonly?: false;
onActionUpdate: ( onActionUpdate: (action: WorkflowCodeAction) => void;
action: WorkflowCodeAction,
shouldUpdateStepOutput?: boolean,
) => void;
}; };
}; };
@ -64,14 +66,14 @@ type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData; [field: string]: string | ServerlessFunctionInputFormData;
}; };
const INDEX_FILE_PATH = 'src/index.ts'; const TAB_LIST_COMPONENT_ID = 'serverless-function-code-step';
export const WorkflowEditActionFormServerlessFunction = ({ export const WorkflowEditActionFormServerlessFunction = ({
action, action,
actionOptions, actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => { }: WorkflowEditActionFormServerlessFunctionProps) => {
const theme = useTheme(); const theme = useTheme();
const { enqueueSnackBar } = useSnackBar(); const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction(); const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const serverlessFunctionId = action.settings.input.serverlessFunctionId; const serverlessFunctionId = action.settings.input.serverlessFunctionId;
@ -81,6 +83,9 @@ export const WorkflowEditActionFormServerlessFunction = ({
id: serverlessFunctionId, id: serverlessFunctionId,
}); });
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const [functionInput, setFunctionInput] = const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>( useState<ServerlessFunctionInputFormData>(
action.settings.input.serverlessFunctionInput, action.settings.input.serverlessFunctionInput,
@ -89,83 +94,80 @@ export const WorkflowEditActionFormServerlessFunction = ({
const { formValues, setFormValues, loading } = const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId); useServerlessFunctionUpdateFormState(serverlessFunctionId);
const headerTitle = action.name || 'Code - Serverless Function'; const updateOutputSchemaFromTestResult = async (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getFunctionOutputSchema(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const save = async () => { const { testServerlessFunction } = useTestServerlessFunction(
try { serverlessFunctionId,
updateOutputSchemaFromTestResult,
);
const handleSave = useDebouncedCallback(async () => {
await updateOneServerlessFunction({ await updateOneServerlessFunction({
id: serverlessFunctionId, id: serverlessFunctionId,
name: formValues.name, name: formValues.name,
description: formValues.description, description: formValues.description,
code: formValues.code, code: formValues.code,
}); });
} catch (err) { }, 1_000);
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleSave = usePreventOverlapCallback(save, 1000); const onCodeChange = async (newCode: string) => {
const onCodeChange = async (value: string) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
} }
setFormValues((prevState) => ({ setFormValues((prevState) => ({
...prevState, ...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: value }, code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
})); }));
await handleSave(); await handleSave();
await handleUpdateFunctionInputSchema(); await handleUpdateFunctionInputSchema(newCode);
};
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);
}; };
const handleUpdateFunctionInputSchema = useDebouncedCallback( const handleUpdateFunctionInputSchema = useDebouncedCallback(
updateFunctionInputSchema, async (sourceCode: string) => {
100,
);
const updateFunctionInput = useDebouncedCallback(
async (newFunctionInput: object, shouldUpdateStepOutput = true) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
} }
actionOptions.onActionUpdate( 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, ...action,
settings: { settings: {
...action.settings, ...action.settings,
outputSchema: {},
input: { input: {
...action.settings.input, ...action.settings.input,
serverlessFunctionInput: newFunctionInput, serverlessFunctionInput: newMergedInput,
}, },
}, },
}, });
shouldUpdateStepOutput,
);
}, },
1_000, 1_000,
); );
@ -175,65 +177,35 @@ export const WorkflowEditActionFormServerlessFunction = ({
setFunctionInput(updatedFunctionInput); setFunctionInput(updatedFunctionInput);
await updateFunctionInput(updatedFunctionInput, false); updateAction({
}; ...action,
settings: {
const renderFields = ( ...action.settings,
functionInput: FunctionInput, input: {
path: string[] = [], ...action.settings.input,
isRoot = true, serverlessFunctionInput: updatedFunctionInput,
): 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;
};
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 handleTestInputChange = async (value: any, path: string[]) => {
const updatedTestFunctionInput = setNestedValue(
serverlessFunctionTestData.input,
path,
value,
);
setServerlessFunctionTestData((prev) => ({
...prev,
input: updatedTestFunctionInput,
}));
};
const handleRunFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
};
const handleEditorDidMount = async ( const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor, editor: editor.IStandaloneCodeEditor,
monaco: Monaco, monaco: Monaco,
@ -247,58 +219,103 @@ export const WorkflowEditActionFormServerlessFunction = ({
}); });
}; };
const onActionUpdate = (actionUpdate: Partial<WorkflowCodeAction>) => { const updateAction = useDebouncedCallback(
(actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
} }
actionOptions?.onActionUpdate( actionOptions.onActionUpdate({
{
...action, ...action,
...actionUpdate, ...actionUpdate,
});
}, },
false, 500,
); );
};
const checkWorkflowUpdatable = async () => { const handleCodeChange = async (value: string) => {
if (actionOptions.readonly === true || !isDefined(workflow)) { if (actionOptions.readonly === true || !isDefined(workflow)) {
return; return;
} }
await getUpdatableWorkflowVersion(workflow); await getUpdatableWorkflowVersion(workflow);
await onCodeChange(value);
}; };
const tabs = [
{ id: 'code', title: 'Code', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconPlayerPlay },
];
useEffect(() => { useEffect(() => {
setFunctionInput(action.settings.input.serverlessFunctionInput); setFunctionInput(action.settings.input.serverlessFunctionInput);
}, [action]); }, [action]);
return ( return (
!loading && ( !loading && (
<WorkflowEditGenericFormBase <StyledContainer>
<StyledTabList
tabListInstanceId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
onActionUpdate({ name: newName }); updateAction({ name: newName });
}} }}
Icon={IconCode} Icon={IconCode}
iconColor={theme.color.orange} iconColor={theme.color.orange}
initialTitle={headerTitle} initialTitle={action.name || 'Code - Serverless Function'}
headerType="Code" headerType="Code"
> />
<WorkflowStepBody>
{activeTabId === 'code' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Code</InputLabel>
<CodeEditor <CodeEditor
height={340} height={343}
value={formValues.code?.[INDEX_FILE_PATH]} value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'} language={'typescript'}
onChange={async (value) => { onChange={handleCodeChange}
await checkWorkflowUpdatable();
await onCodeChange(value);
}}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}
options={{ options={{
readOnly: actionOptions.readonly, readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly, domReadOnly: actionOptions.readonly,
}} }}
/> />
{renderFields(functionInput)} </StyledCodeEditorContainer>
</WorkflowEditGenericFormBase> </>
)}
{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 { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow'; import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormUpdateRecordProps = { type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction; action: WorkflowUpdateRecordAction;
@ -123,7 +124,8 @@ export const WorkflowEditActionFormUpdateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Update Record`; const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
return ( return (
<WorkflowEditGenericFormBase <>
<WorkflowStepHeader
onTitleChange={(newName: string) => { onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
return; return;
@ -138,7 +140,8 @@ export const WorkflowEditActionFormUpdateRecord = ({
iconColor={theme.font.color.tertiary} iconColor={theme.font.color.tertiary}
initialTitle={headerTitle} initialTitle={headerTitle}
headerType="Action" headerType="Action"
> />
<WorkflowStepBody>
<Select <Select
dropdownId="workflow-edit-action-record-update-object-name" dropdownId="workflow-edit-action-record-update-object-name"
label="Object" label="Object"
@ -169,6 +172,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
objectNameSingular={formData.objectName} objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId} defaultValue={formData.objectRecordId}
/> />
</WorkflowEditGenericFormBase> </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) { } catch (err) {
logError(err); logError(err);
enqueueSnackBar( 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, 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 { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { import {
Button, Button,
getImageAbsoluteURI, getImageAbsoluteURI,
@ -68,10 +67,9 @@ const StyledContentContainer = styled.div`
export const SettingsAdminFeatureFlags = () => { export const SettingsAdminFeatureFlags = () => {
const [userIdentifier, setUserIdentifier] = useState(''); const [userIdentifier, setUserIdentifier] = useState('');
const { activeTabIdState, setActiveTabId } = useTabList( const { activeTabId, setActiveTabId } = useTabList(
SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID, SETTINGS_ADMIN_FEATURE_FLAGS_TAB_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const { const {
userLookupResult, userLookupResult,

View File

@ -78,10 +78,9 @@ export const SettingsObjectDetailPage = () => {
findActiveObjectMetadataItemBySlug(objectSlug) ?? findActiveObjectMetadataItemBySlug(objectSlug) ??
findActiveObjectMetadataItemBySlug(updatedObjectSlug); findActiveObjectMetadataItemBySlug(updatedObjectSlug);
const { activeTabIdState } = useTabList( const { activeTabId } = useTabList(
SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID, SETTINGS_OBJECT_DETAIL_TABS.COMPONENT_INSTANCE_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState);
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState); const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const isUniqueIndexesEnabled = useIsFeatureEnabled( 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 { SettingsServerlessFunctionMonitoringTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionMonitoringTab';
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab'; import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab'; 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 { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { usePublishOneServerlessFunction } from '@/settings/serverless-functions/hooks/usePublishOneServerlessFunction'; import { usePublishOneServerlessFunction } from '@/settings/serverless-functions/hooks/usePublishOneServerlessFunction';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction'; 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 { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; 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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useState } from 'react'; import { useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { import { IconCode, IconGauge, IconSettings, IconTestPipe } from 'twenty-ui';
IconCode,
IconGauge,
IconSettings,
IconTestPipe,
Section,
} from 'twenty-ui';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined'; 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'; const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
export const SettingsServerlessFunctionDetail = () => { export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '' } = useParams(); const { serverlessFunctionId = '' } = useParams();
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const { activeTabIdState, setActiveTabId } = useTabList( const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const [isCodeValid, setIsCodeValid] = useState(true); const [isCodeValid, setIsCodeValid] = useState(true);
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction(); const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { publishOneServerlessFunction } = usePublishOneServerlessFunction(); const { publishOneServerlessFunction } = usePublishOneServerlessFunction();
const { formValues, setFormValues, loading } = const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId); useServerlessFunctionUpdateFormState(serverlessFunctionId);
const { testServerlessFunction } =
useTestServerlessFunction(serverlessFunctionId);
const { code: latestVersionCode } = useGetOneServerlessFunctionSourceCode({ const { code: latestVersionCode } = useGetOneServerlessFunctionSourceCode({
id: serverlessFunctionId, id: serverlessFunctionId,
version: 'latest', version: 'latest',
}); });
const setSettingsServerlessFunctionOutput = useSetRecoilState(
settingsServerlessFunctionOutputState,
);
const settingsServerlessFunctionInput = useRecoilValue(
settingsServerlessFunctionInputState,
);
const save = async () => { const handleSave = useDebouncedCallback(async () => {
try {
await updateOneServerlessFunction({ await updateOneServerlessFunction({
id: serverlessFunctionId, id: serverlessFunctionId,
name: formValues.name, name: formValues.name,
description: formValues.description, description: formValues.description,
code: formValues.code, code: formValues.code,
}); });
} catch (err) { }, 1_000);
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleSave = usePreventOverlapCallback(save, 1000);
const onChange = (key: string) => { const onChange = (key: string) => {
return async (value: string) => { return async (value: string) => {
@ -143,41 +115,11 @@ export const SettingsServerlessFunctionDetail = () => {
} }
}; };
const handleExecute = async () => { const handleTestFunction = async () => {
try { await testServerlessFunction();
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,
},
);
}
setActiveTabId('test'); setActiveTabId('test');
}; };
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState); const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
const isAnalyticsV2Enabled = useIsFeatureEnabled('IS_ANALYTICS_V2_ENABLED'); const isAnalyticsV2Enabled = useIsFeatureEnabled('IS_ANALYTICS_V2_ENABLED');
@ -209,7 +151,7 @@ export const SettingsServerlessFunctionDetail = () => {
return ( return (
<SettingsServerlessFunctionCodeEditorTab <SettingsServerlessFunctionCodeEditorTab
files={files} files={files}
handleExecute={handleExecute} handleExecute={handleTestFunction}
handlePublish={handlePublish} handlePublish={handlePublish}
handleReset={handleReset} handleReset={handleReset}
resetDisabled={resetDisabled} resetDisabled={resetDisabled}
@ -220,10 +162,10 @@ export const SettingsServerlessFunctionDetail = () => {
); );
case 'test': case 'test':
return ( return (
<> <SettingsServerlessFunctionTestTab
<SettingsServerlessFunctionTestTabEffect /> serverlessFunctionId={serverlessFunctionId}
<SettingsServerlessFunctionTestTab handleExecute={handleExecute} /> handleExecute={handleTestFunction}
</> />
); );
case 'settings': case 'settings':
return ( return (
@ -262,9 +204,11 @@ export const SettingsServerlessFunctionDetail = () => {
]} ]}
> >
<SettingsPageContainer> <SettingsPageContainer>
<Section> <TabList
<TabList tabListInstanceId={TAB_LIST_COMPONENT_ID} tabs={tabs} /> tabListInstanceId={TAB_LIST_COMPONENT_ID}
</Section> tabs={tabs}
behaveAsLinks={false}
/>
{renderActiveTabContent()} {renderActiveTabContent()}
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </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) { if (extensions?.extensions) {
throw Error( throw new Error(
'Pass extensions directly as the third argument of the ApolloError constructor: `new ' + 'Pass extensions directly as the third argument of the ApolloError constructor: `new ' +
'ApolloError(message, code, {myExt: value})`, not `new ApolloError(message, code, ' + 'ApolloError(message, code, {myExt: value})`, not `new ApolloError(message, code, ' +
'{extensions: {myExt: value}})`', '{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, nullable: false,
}) })
step: WorkflowAction; 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( async updateWorkflowVersionStep(
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
@Args('input') @Args('input')
{ { step, workflowVersionId }: UpdateWorkflowVersionStepInput,
step,
workflowVersionId,
shouldUpdateStepOutput,
}: UpdateWorkflowVersionStepInput,
): Promise<WorkflowActionDTO> { ): Promise<WorkflowActionDTO> {
return this.workflowVersionStepWorkspaceService.updateWorkflowVersionStep({ return this.workflowVersionStepWorkspaceService.updateWorkflowVersionStep({
workspaceId, workspaceId,
workflowVersionId, workflowVersionId,
step, 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 { 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 { 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, { registerEnumType(ServerlessFunctionSyncStatus, {
name: 'ServerlessFunctionSyncStatus', name: 'ServerlessFunctionSyncStatus',

View File

@ -6,7 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } 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 { export enum ServerlessFunctionSyncStatus {
NOT_READY = 'NOT_READY', 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 { WorkflowBuilderModule } from 'src/modules/workflow/workflow-builder/workflow-builder.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; 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 { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-introspection.module';
@Module({ @Module({
imports: [ imports: [
@ -17,7 +16,6 @@ import { CodeIntrospectionModule } from 'src/modules/code-introspection/code-int
WorkflowCommandModule, WorkflowCommandModule,
WorkflowBuilderModule, WorkflowBuilderModule,
ServerlessFunctionModule, ServerlessFunctionModule,
CodeIntrospectionModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
], ],
providers: [ providers: [

View File

@ -1,17 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { v4 } from 'uuid'; 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 { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; 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 { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import { import {
WorkflowVersionStepException, WorkflowVersionStepException,
WorkflowVersionStepExceptionCode, WorkflowVersionStepExceptionCode,
@ -24,6 +20,7 @@ import {
WorkflowActionType, WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined'; 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'; const TRIGGER_STEP_ID = 'trigger';
@ -46,7 +43,6 @@ export class WorkflowVersionStepWorkspaceService {
private readonly twentyORMManager: TwentyORMManager, private readonly twentyORMManager: TwentyORMManager,
private readonly workflowBuilderWorkspaceService: WorkflowBuilderWorkspaceService, private readonly workflowBuilderWorkspaceService: WorkflowBuilderWorkspaceService,
private readonly serverlessFunctionService: ServerlessFunctionService, private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly codeIntrospectionService: CodeIntrospectionService,
@InjectRepository(ObjectMetadataEntity, 'metadata') @InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>, 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 { return {
id: newStepId, id: newStepId,
name: 'Code - Serverless Function', name: 'Code - Serverless Function',
@ -103,7 +84,7 @@ export class WorkflowVersionStepWorkspaceService {
input: { input: {
serverlessFunctionId: newServerlessFunction.id, serverlessFunctionId: newServerlessFunction.id,
serverlessFunctionVersion: 'draft', serverlessFunctionVersion: 'draft',
serverlessFunctionInput, serverlessFunctionInput: BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA,
}, },
}, },
}; };
@ -201,6 +182,11 @@ export class WorkflowVersionStepWorkspaceService {
step: WorkflowAction; step: WorkflowAction;
workspaceId: string; workspaceId: string;
}): Promise<WorkflowAction> { }): 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 result = { ...step };
const outputSchema = const outputSchema =
await this.workflowBuilderWorkspaceService.computeStepOutputSchema({ await this.workflowBuilderWorkspaceService.computeStepOutputSchema({
@ -262,12 +248,10 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId, workspaceId,
workflowVersionId, workflowVersionId,
step, step,
shouldUpdateStepOutput,
}: { }: {
workspaceId: string; workspaceId: string;
workflowVersionId: string; workflowVersionId: string;
step: WorkflowAction; step: WorkflowAction;
shouldUpdateStepOutput: boolean;
}): Promise<WorkflowAction> { }): Promise<WorkflowAction> {
const workflowVersionRepository = const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>( await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
@ -294,12 +278,10 @@ export class WorkflowVersionStepWorkspaceService {
); );
} }
const enrichedNewStep = shouldUpdateStepOutput const enrichedNewStep = await this.enrichOutputSchema({
? await this.enrichOutputSchema({
step, step,
workspaceId, workspaceId,
}) });
: step;
const updatedSteps = workflowVersion.steps.map((existingStep) => { const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) { if (existingStep.id === step.id) {

View File

@ -13,9 +13,11 @@ export type InputSchemaProperty = {
type: InputSchemaPropertyType; type: InputSchemaPropertyType;
enum?: string[]; enum?: string[];
items?: InputSchemaProperty; // used to describe array type elements 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; [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 = { export type Leaf = {
isLeaf: true; isLeaf: true;
icon?: string;
type?: InputSchemaPropertyType; type?: InputSchemaPropertyType;
icon?: string;
label?: string; label?: string;
value: any; 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; 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'; import { WorkflowBuilderWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-builder.workspace-service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
ServerlessFunctionModule, ServerlessFunctionModule,
CodeIntrospectionModule,
], ],
providers: [WorkflowBuilderWorkspaceService], providers: [WorkflowBuilderWorkspaceService],
exports: [WorkflowBuilderWorkspaceService], exports: [WorkflowBuilderWorkspaceService],

View File

@ -1,23 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; 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 { 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value'; import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service'; import { OutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
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 { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record'; 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 { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event';
import { import {
@ -34,7 +25,6 @@ import { isDefined } from 'src/utils/is-defined';
export class WorkflowBuilderWorkspaceService { export class WorkflowBuilderWorkspaceService {
constructor( constructor(
private readonly serverlessFunctionService: ServerlessFunctionService, private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly codeIntrospectionService: CodeIntrospectionService,
@InjectRepository(ObjectMetadataEntity, 'metadata') @InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>, private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
) {} ) {}
@ -72,18 +62,6 @@ export class WorkflowBuilderWorkspaceService {
case WorkflowActionType.SEND_EMAIL: { case WorkflowActionType.SEND_EMAIL: {
return this.computeSendEmailActionOutputSchema(); 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.CREATE_RECORD:
case WorkflowActionType.UPDATE_RECORD: case WorkflowActionType.UPDATE_RECORD:
case WorkflowActionType.DELETE_RECORD: case WorkflowActionType.DELETE_RECORD:
@ -98,6 +76,7 @@ export class WorkflowBuilderWorkspaceService {
workspaceId, workspaceId,
objectMetadataRepository: this.objectMetadataRepository, objectMetadataRepository: this.objectMetadataRepository,
}); });
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default: default:
return {}; return {};
} }
@ -194,63 +173,4 @@ export class WorkflowBuilderWorkspaceService {
private computeSendEmailActionOutputSchema(): OutputSchema { private computeSendEmailActionOutputSchema(): OutputSchema {
return { success: { isLeaf: true, type: 'boolean', value: true } }; 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( await this.serverlessFunctionService.executeOneServerlessFunction(
workflowActionInput.serverlessFunctionId, workflowActionInput.serverlessFunctionId,
workspaceId, workspaceId,
Object.values(workflowActionInput.serverlessFunctionInput)?.[0] || {}, workflowActionInput.serverlessFunctionInput,
workflowActionInput.serverlessFunctionVersion, workflowActionInput.serverlessFunctionVersion,
); );

View File

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

View File

@ -43,7 +43,6 @@ export const CodeEditor = ({
const theme = useTheme(); const theme = useTheme();
return ( return (
<div>
<StyledEditor <StyledEditor
height={height} height={height}
withHeader={withHeader} withHeader={withHeader}
@ -73,6 +72,5 @@ export const CodeEditor = ({
...options, ...options,
}} }}
/> />
</div>
); );
}; };

View File

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

View File

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