mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-22 03:17:40 +03:00
Fix state mutation serverless action (#8580)
In this PR: - Ensure the `setNestedValue` does a deep copy of the provided object and doesn't mutate it directly. - Store the form's state in a `useState` instead of relying entirely on the backend's state. This makes the form more resilient to slow network connections. - Ensure the input settings are reset when selecting another function. - The Inner component now expects the serverless functions data to be resolved before being mounted, so I split it. Closes https://github.com/twentyhq/twenty/issues/8523
This commit is contained in:
parent
a744515303
commit
c133129eb0
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
|
||||
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
|
||||
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import {
|
||||
GetManyServerlessFunctionsQuery,
|
||||
GetManyServerlessFunctionsQueryVariables,
|
||||
@ -8,13 +8,17 @@ import {
|
||||
|
||||
export const useGetManyServerlessFunctions = () => {
|
||||
const apolloMetadataClient = useApolloMetadataClient();
|
||||
const { data } = useQuery<
|
||||
|
||||
const { data, loading, error } = useQuery<
|
||||
GetManyServerlessFunctionsQuery,
|
||||
GetManyServerlessFunctionsQueryVariables
|
||||
>(FIND_MANY_SERVERLESS_FUNCTIONS, {
|
||||
client: apolloMetadataClient ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
serverlessFunctions: data?.findManyServerlessFunctions || [],
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
@ -1,41 +1,6 @@
|
||||
import { ReactNode, Fragment } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
|
||||
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
|
||||
import { FunctionInput } from '@/workflow/types/FunctionInput';
|
||||
import { WorkflowEditActionFormServerlessFunctionInner } from '@/workflow/components/WorkflowEditActionFormServerlessFunctionInner';
|
||||
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
|
||||
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
|
||||
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
|
||||
import { setNestedValue } from '@/workflow/utils/setNestedValue';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { HorizontalSeparator, IconCode, isDefined } from 'twenty-ui';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
type WorkflowEditActionFormServerlessFunctionProps = {
|
||||
action: WorkflowCodeAction;
|
||||
@ -53,167 +18,17 @@ export const WorkflowEditActionFormServerlessFunction = ({
|
||||
action,
|
||||
actionOptions,
|
||||
}: WorkflowEditActionFormServerlessFunctionProps) => {
|
||||
const theme = useTheme();
|
||||
const { serverlessFunctions } = useGetManyServerlessFunctions();
|
||||
const { loading: isLoadingServerlessFunctions } =
|
||||
useGetManyServerlessFunctions();
|
||||
|
||||
const getFunctionInput = (serverlessFunctionId: string) => {
|
||||
if (!serverlessFunctionId) {
|
||||
return {};
|
||||
if (isLoadingServerlessFunctions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverlessFunction = serverlessFunctions.find(
|
||||
(f) => f.id === serverlessFunctionId,
|
||||
);
|
||||
const inputSchema = serverlessFunction?.latestVersionInputSchema;
|
||||
const defaultFunctionInput =
|
||||
getDefaultFunctionInputFromInputSchema(inputSchema);
|
||||
|
||||
const existingFunctionInput = action.settings.input.serverlessFunctionInput;
|
||||
|
||||
return mergeDefaultFunctionInputAndFunctionInput({
|
||||
defaultFunctionInput,
|
||||
functionInput: existingFunctionInput,
|
||||
});
|
||||
};
|
||||
|
||||
const functionInput = getFunctionInput(
|
||||
action.settings.input.serverlessFunctionId,
|
||||
);
|
||||
|
||||
const updateFunctionInput = useDebouncedCallback(
|
||||
async (newFunctionInput: object) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
...action.settings.input,
|
||||
serverlessFunctionInput: newFunctionInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
1_000,
|
||||
);
|
||||
|
||||
const handleInputChange = (value: any, path: string[]) => {
|
||||
updateFunctionInput(setNestedValue(functionInput, path, value));
|
||||
};
|
||||
|
||||
const availableFunctions: Array<SelectOption<string>> = [
|
||||
...serverlessFunctions
|
||||
.filter((serverlessFunction) =>
|
||||
isDefined(serverlessFunction.latestVersion),
|
||||
)
|
||||
.map((serverlessFunction) => ({
|
||||
label: serverlessFunction.name,
|
||||
value: serverlessFunction.id,
|
||||
latestVersionInputSchema: serverlessFunction.latestVersionInputSchema,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleFunctionChange = (newServerlessFunctionId: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverlessFunction = serverlessFunctions.find(
|
||||
(f) => f.id === newServerlessFunctionId,
|
||||
);
|
||||
|
||||
const newProps = {
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
serverlessFunctionId: newServerlessFunctionId,
|
||||
serverlessFunctionVersion:
|
||||
serverlessFunction?.latestVersion || 'latest',
|
||||
serverlessFunctionInput: getFunctionInput(newServerlessFunctionId),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
actionOptions.onActionUpdate(newProps);
|
||||
};
|
||||
|
||||
const renderFields = (
|
||||
functionInput: FunctionInput,
|
||||
path: string[] = [],
|
||||
isRoot = true,
|
||||
): ReactNode[] => {
|
||||
const displaySeparator = (functionInput: FunctionInput) => {
|
||||
const keys = Object.keys(functionInput);
|
||||
if (keys.length > 1) {
|
||||
return true;
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
const subKeys = Object.keys(functionInput[keys[0]]);
|
||||
return subKeys.length > 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
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>
|
||||
<StyledInputContainer>
|
||||
{renderFields(inputValue, currentPath, false)}
|
||||
</StyledInputContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<VariableTagInput
|
||||
key={pathKey}
|
||||
inputId={`input-${inputKey}`}
|
||||
label={inputKey}
|
||||
placeholder="Enter value"
|
||||
value={`${inputValue || ''}`}
|
||||
onChange={(value) => handleInputChange(value, currentPath)}
|
||||
readonly={actionOptions.readonly}
|
||||
<WorkflowEditActionFormServerlessFunctionInner
|
||||
action={action}
|
||||
actionOptions={actionOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkflowEditGenericFormBase
|
||||
HeaderIcon={<IconCode color={theme.color.orange} />}
|
||||
headerTitle="Code - Serverless Function"
|
||||
headerType="Code"
|
||||
>
|
||||
<Select
|
||||
dropdownId="select-serverless-function-id"
|
||||
label="Function"
|
||||
fullWidth
|
||||
value={action.settings.input.serverlessFunctionId}
|
||||
options={availableFunctions}
|
||||
emptyOption={{ label: 'None', value: '' }}
|
||||
disabled={actionOptions.readonly}
|
||||
onChange={handleFunctionChange}
|
||||
/>
|
||||
{renderFields(functionInput)}
|
||||
</WorkflowEditGenericFormBase>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,239 @@
|
||||
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
|
||||
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
|
||||
import { FunctionInput } from '@/workflow/types/FunctionInput';
|
||||
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
|
||||
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
|
||||
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
|
||||
import { setNestedValue } from '@/workflow/utils/setNestedValue';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Fragment, ReactNode, useState } from 'react';
|
||||
import { HorizontalSeparator, IconCode, isDefined } from 'twenty-ui';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
type WorkflowEditActionFormServerlessFunctionInnerProps = {
|
||||
action: WorkflowCodeAction;
|
||||
actionOptions:
|
||||
| {
|
||||
readonly: true;
|
||||
}
|
||||
| {
|
||||
readonly?: false;
|
||||
onActionUpdate: (action: WorkflowCodeAction) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ServerlessFunctionInputFormData = {
|
||||
[field: string]: string | ServerlessFunctionInputFormData;
|
||||
};
|
||||
|
||||
export const WorkflowEditActionFormServerlessFunctionInner = ({
|
||||
action,
|
||||
actionOptions,
|
||||
}: WorkflowEditActionFormServerlessFunctionInnerProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const { serverlessFunctions } = useGetManyServerlessFunctions();
|
||||
|
||||
const getFunctionInput = (serverlessFunctionId: string) => {
|
||||
if (!serverlessFunctionId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const serverlessFunction = serverlessFunctions.find(
|
||||
(f) => f.id === serverlessFunctionId,
|
||||
);
|
||||
const inputSchema = serverlessFunction?.latestVersionInputSchema;
|
||||
const defaultFunctionInput =
|
||||
getDefaultFunctionInputFromInputSchema(inputSchema);
|
||||
|
||||
return defaultFunctionInput;
|
||||
};
|
||||
|
||||
const [selectedFunctionId, setSelectedFunctionId] = useState(
|
||||
action.settings.input.serverlessFunctionId,
|
||||
);
|
||||
|
||||
const [functionInput, setFunctionInput] =
|
||||
useState<ServerlessFunctionInputFormData>(
|
||||
mergeDefaultFunctionInputAndFunctionInput({
|
||||
defaultFunctionInput: getFunctionInput(selectedFunctionId),
|
||||
functionInput: action.settings.input.serverlessFunctionInput,
|
||||
}),
|
||||
);
|
||||
|
||||
const updateFunctionInput = useDebouncedCallback(
|
||||
async (newFunctionInput: object) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
...action.settings.input,
|
||||
serverlessFunctionInput: newFunctionInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
1_000,
|
||||
);
|
||||
|
||||
const handleInputChange = (value: any, path: string[]) => {
|
||||
const updatedFunctionInput = setNestedValue(functionInput, path, value);
|
||||
|
||||
setFunctionInput(updatedFunctionInput);
|
||||
|
||||
updateFunctionInput(updatedFunctionInput);
|
||||
};
|
||||
|
||||
const availableFunctions: Array<SelectOption<string>> = [
|
||||
...serverlessFunctions
|
||||
.filter((serverlessFunction) =>
|
||||
isDefined(serverlessFunction.latestVersion),
|
||||
)
|
||||
.map((serverlessFunction) => ({
|
||||
label: serverlessFunction.name,
|
||||
value: serverlessFunction.id,
|
||||
latestVersionInputSchema: serverlessFunction.latestVersionInputSchema,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleFunctionChange = (newServerlessFunctionId: string) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateFunctionInput.cancel();
|
||||
|
||||
setSelectedFunctionId(newServerlessFunctionId);
|
||||
|
||||
const serverlessFunction = serverlessFunctions.find(
|
||||
(f) => f.id === newServerlessFunctionId,
|
||||
);
|
||||
|
||||
const newFunctionInput = getFunctionInput(newServerlessFunctionId);
|
||||
|
||||
const newProps = {
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
serverlessFunctionId: newServerlessFunctionId,
|
||||
serverlessFunctionVersion:
|
||||
serverlessFunction?.latestVersion || 'latest',
|
||||
serverlessFunctionInput: newFunctionInput,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setFunctionInput(newFunctionInput);
|
||||
|
||||
actionOptions.onActionUpdate(newProps);
|
||||
};
|
||||
|
||||
const renderFields = (
|
||||
functionInput: FunctionInput,
|
||||
path: string[] = [],
|
||||
isRoot = true,
|
||||
): ReactNode[] => {
|
||||
const displaySeparator = (functionInput: FunctionInput) => {
|
||||
const keys = Object.keys(functionInput);
|
||||
if (keys.length > 1) {
|
||||
return true;
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
const subKeys = Object.keys(functionInput[keys[0]]);
|
||||
return subKeys.length > 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
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>
|
||||
<StyledInputContainer>
|
||||
{renderFields(inputValue, currentPath, false)}
|
||||
</StyledInputContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<VariableTagInput
|
||||
key={pathKey}
|
||||
inputId={`input-${inputKey}`}
|
||||
label={inputKey}
|
||||
placeholder="Enter value"
|
||||
readonly={actionOptions.readonly}
|
||||
value={`${inputValue || ''}`}
|
||||
onChange={(value) => handleInputChange(value, currentPath)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkflowEditGenericFormBase
|
||||
HeaderIcon={<IconCode color={theme.color.orange} />}
|
||||
headerTitle="Code - Serverless Function"
|
||||
headerType="Code"
|
||||
>
|
||||
<Select
|
||||
dropdownId="select-serverless-function-id"
|
||||
label="Function"
|
||||
fullWidth
|
||||
value={selectedFunctionId}
|
||||
options={availableFunctions}
|
||||
emptyOption={{ label: 'None', value: '' }}
|
||||
disabled={actionOptions.readonly}
|
||||
onChange={handleFunctionChange}
|
||||
/>
|
||||
{renderFields(functionInput)}
|
||||
</WorkflowEditGenericFormBase>
|
||||
);
|
||||
};
|
@ -8,4 +8,19 @@ describe('setNestedValue', () => {
|
||||
const expectedResult = { a: { b: newValue } };
|
||||
expect(setNestedValue(obj, path, newValue)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should not mutate the initial object', () => {
|
||||
const expectedObject = { a: { b: 'b' } };
|
||||
|
||||
const initialObject = structuredClone(expectedObject);
|
||||
const path = ['a', 'b'];
|
||||
const newValue = 'bb';
|
||||
|
||||
const updatedObject = setNestedValue(initialObject, path, newValue);
|
||||
|
||||
expect(initialObject).toEqual(expectedObject);
|
||||
|
||||
expect(updatedObject).not.toBe(initialObject);
|
||||
expect(updatedObject.a).not.toBe(initialObject.a);
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
export const setNestedValue = (obj: any, path: string[], value: any) => {
|
||||
const newObj = { ...obj };
|
||||
const newObj = structuredClone(obj);
|
||||
path.reduce((o, key, index) => {
|
||||
if (index === path.length - 1) {
|
||||
o[key] = value;
|
||||
|
Loading…
Reference in New Issue
Block a user