Logic function refactorization (#17861)

As title
This commit is contained in:
martmull
2026-02-12 11:40:49 +01:00
committed by GitHub
parent b456f79167
commit a4ed043d43
122 changed files with 1441 additions and 1897 deletions
@@ -1 +0,0 @@
export const INDEX_FILE_NAME = 'index.ts';
@@ -1 +0,0 @@
export const SOURCE_FOLDER_NAME = 'src';
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const LOGIC_FUNCTION_FRAGMENT = gql`
fragment LogicFunctionFields on LogicFunction {
id
name
description
runtime
timeoutSeconds
sourceHandlerPath
handlerName
toolInputSchema
isTool
applicationId
createdAt
updatedAt
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { LOGIC_FUNCTION_FRAGMENT } from '@/logic-functions/graphql/fragments/logicFunctionFragment';
export const CREATE_ONE_LOGIC_FUNCTION = gql`
${LOGIC_FUNCTION_FRAGMENT}
mutation CreateOneLogicFunction($input: CreateLogicFunctionFromSourceInput!) {
createOneLogicFunction(input: $input) {
...LogicFunctionFields
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { LOGIC_FUNCTION_FRAGMENT } from '@/logic-functions/graphql/fragments/logicFunctionFragment';
export const DELETE_ONE_LOGIC_FUNCTION = gql`
${LOGIC_FUNCTION_FRAGMENT}
mutation DeleteOneLogicFunction($input: LogicFunctionIdInput!) {
deleteOneLogicFunction(input: $input) {
...LogicFunctionFields
}
}
`;
@@ -1,7 +0,0 @@
import { gql } from '@apollo/client';
export const UPDATE_LOGIC_FUNCTION_SOURCE = gql`
mutation UpdateLogicFunctionSource($input: UpdateLogicFunctionSourceInput!) {
updateLogicFunctionSource(input: $input)
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const UPDATE_ONE_LOGIC_FUNCTION = gql`
mutation UpdateOneLogicFunction($input: UpdateLogicFunctionFromSourceInput!) {
updateOneLogicFunction(input: $input)
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const FIND_MANY_AVAILABLE_PACKAGES = gql`
query FindManyAvailablePackages($input: LogicFunctionIdInput!) {
getAvailablePackages(input: $input)
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { LOGIC_FUNCTION_FRAGMENT } from '@/logic-functions/graphql/fragments/logicFunctionFragment';
export const FIND_MANY_LOGIC_FUNCTIONS = gql`
${LOGIC_FUNCTION_FRAGMENT}
query FindManyLogicFunctions {
findManyLogicFunctions {
...LogicFunctionFields
}
}
`;
@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { LOGIC_FUNCTION_FRAGMENT } from '@/logic-functions/graphql/fragments/logicFunctionFragment';
export const FIND_ONE_LOGIC_FUNCTION = gql`
${LOGIC_FUNCTION_FRAGMENT}
query FindOneLogicFunction($input: LogicFunctionIdInput!) {
findOneLogicFunction(input: $input) {
...LogicFunctionFields
}
}
`;
@@ -0,0 +1,49 @@
import { useLogicFunctionUpdateFormState } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
jest.mock('@/logic-functions/hooks/useGetOneLogicFunction', () => ({
useGetOneLogicFunction: jest.fn(),
}));
jest.mock('@/logic-functions/hooks/useGetLogicFunctionSourceCode', () => ({
useGetLogicFunctionSourceCode: jest.fn(),
}));
const mockCode = 'export const main = async (): Promise<void> => { return; }';
describe('useLogicFunctionUpdateFormState', () => {
test('should return a form', () => {
const logicFunctionId = 'logicFunctionId';
const useGetOneLogicFunctionMock = jest.requireMock(
'@/logic-functions/hooks/useGetOneLogicFunction',
);
const useGetLogicFunctionSourceCodeMock = jest.requireMock(
'@/logic-functions/hooks/useGetLogicFunctionSourceCode',
);
useGetOneLogicFunctionMock.useGetOneLogicFunction.mockReturnValue({
logicFunction: { name: 'name' },
loading: false,
});
useGetLogicFunctionSourceCodeMock.useGetLogicFunctionSourceCode.mockReturnValue(
{
code: mockCode,
loading: false,
},
);
const { result } = renderHook(
() => useLogicFunctionUpdateFormState({ logicFunctionId }),
{
wrapper: RecoilRoot,
},
);
const { formValues } = result.current;
expect(formValues).toEqual({
name: '',
description: '',
code: mockCode,
});
});
});
@@ -1,5 +1,4 @@
import { EXECUTE_ONE_LOGIC_FUNCTION } from '@/logic-functions/graphql/mutations/executeOneLogicFunction';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { logicFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/logicFunctionTestDataFamilyState';
import { useMutation } from '@apollo/client';
import { useState } from 'react';
@@ -11,7 +10,6 @@ import { sleep } from '~/utils/sleep';
type ExecuteOneLogicFunctionInput = {
id: string;
payload: object;
forceRebuild?: boolean;
};
type ExecuteOneLogicFunctionResult = {
@@ -34,21 +32,16 @@ export const useExecuteLogicFunction = ({
callback?: (result: object) => void;
}) => {
const [isExecuting, setIsExecuting] = useState(false);
const apolloMetadataClient = useApolloCoreClient();
const [executeOneLogicFunctionMutation] = useMutation<
{ executeOneLogicFunction: ExecuteOneLogicFunctionResult },
{ input: ExecuteOneLogicFunctionInput }
>(EXECUTE_ONE_LOGIC_FUNCTION, {
client: apolloMetadataClient,
});
>(EXECUTE_ONE_LOGIC_FUNCTION);
const [logicFunctionTestData, setLogicFunctionTestData] = useRecoilState(
logicFunctionTestDataFamilyState(logicFunctionId),
);
const executeLogicFunction = async ({
forceRebuild = false,
}: { forceRebuild?: boolean } = {}) => {
const executeLogicFunction = async () => {
try {
setIsExecuting(true);
await sleep(200); // Delay artificially to avoid flashing the UI
@@ -57,7 +50,6 @@ export const useExecuteLogicFunction = ({
input: {
id: logicFunctionId,
payload: logicFunctionTestData.input,
forceRebuild,
},
},
});
@@ -0,0 +1,21 @@
import { useQuery } from '@apollo/client';
import { FIND_MANY_AVAILABLE_PACKAGES } from '@/logic-functions/graphql/queries/findManyAvailablePackages';
import {
type FindManyAvailablePackagesQuery,
type FindManyAvailablePackagesQueryVariables,
type LogicFunctionIdInput,
} from '~/generated-metadata/graphql';
export const useGetAvailablePackages = (input: LogicFunctionIdInput) => {
const { data } = useQuery<
FindManyAvailablePackagesQuery,
FindManyAvailablePackagesQueryVariables
>(FIND_MANY_AVAILABLE_PACKAGES, {
variables: {
input,
},
});
return {
availablePackages: data?.getAvailablePackages || null,
};
};
@@ -1,7 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { GET_LOGIC_FUNCTION_SOURCE_CODE } from '@/logic-functions/graphql/queries/getLogicFunctionSourceCode';
import { useQuery } from '@apollo/client';
import { type Sources } from 'twenty-shared/types';
import {
type GetLogicFunctionSourceCodeQuery,
type GetLogicFunctionSourceCodeQueryVariables,
@@ -11,24 +9,16 @@ export const useGetLogicFunctionSourceCode = ({
logicFunctionId,
}: {
logicFunctionId: string;
}): { code: Sources | null; loading: boolean } => {
const apolloMetadataClient = useApolloCoreClient();
}) => {
const { data, loading } = useQuery<
GetLogicFunctionSourceCodeQuery,
GetLogicFunctionSourceCodeQueryVariables
>(GET_LOGIC_FUNCTION_SOURCE_CODE, {
client: apolloMetadataClient ?? undefined,
variables: {
input: { id: logicFunctionId },
},
skip: !logicFunctionId,
});
const raw = data?.getLogicFunctionSourceCode;
const code =
raw != null && typeof raw === 'object' && !Array.isArray(raw)
? (raw as Sources)
: null;
return { code, loading };
return { code: data?.getLogicFunctionSourceCode, loading };
};
@@ -0,0 +1,28 @@
import { FIND_ONE_LOGIC_FUNCTION } from '@/logic-functions/graphql/queries/findOneLogicFunction';
import { useQuery } from '@apollo/client';
import {
type FindOneLogicFunctionQuery,
type FindOneLogicFunctionQueryVariables,
type LogicFunctionIdInput,
} from '~/generated-metadata/graphql';
export const useGetOneLogicFunction = ({
id,
onCompleted,
}: LogicFunctionIdInput & {
onCompleted?: (data: FindOneLogicFunctionQuery) => void;
}) => {
const { data, loading } = useQuery<
FindOneLogicFunctionQuery,
FindOneLogicFunctionQueryVariables
>(FIND_ONE_LOGIC_FUNCTION, {
variables: {
input: { id },
},
onCompleted,
});
return {
logicFunction: data?.findOneLogicFunction || null,
loading,
};
};
@@ -0,0 +1,72 @@
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type FindOneLogicFunctionQuery,
type LogicFunction,
} from '~/generated-metadata/graphql';
import { useGetLogicFunctionSourceCode } from '@/logic-functions/hooks/useGetLogicFunctionSourceCode';
export type LogicFunctionNewFormValues = {
name: string;
description: string;
};
export type LogicFunctionFormValues = LogicFunctionNewFormValues & {
code: string;
};
type SetLogicFunctionFormValues = Dispatch<
SetStateAction<LogicFunctionFormValues>
>;
export const useLogicFunctionUpdateFormState = ({
logicFunctionId,
}: {
logicFunctionId: string;
}): {
formValues: LogicFunctionFormValues;
logicFunction: LogicFunction | null;
setFormValues: SetLogicFunctionFormValues;
loading: boolean;
} => {
const [formValues, setFormValues] = useState<LogicFunctionFormValues>({
name: '',
description: '',
code: '',
});
const { code: codeFromApi, loading: logicFunctionSourceCodeLoading } =
useGetLogicFunctionSourceCode({
logicFunctionId,
});
const { logicFunction, loading: logicFunctionLoading } =
useGetOneLogicFunction({
id: logicFunctionId,
onCompleted: (data: FindOneLogicFunctionQuery) => {
const fn = data?.findOneLogicFunction;
if (isDefined(fn)) {
setFormValues((prevState) => ({
...prevState,
name: fn.name || '',
description: fn.description || '',
}));
}
},
});
useEffect(() => {
if (isDefined(codeFromApi)) {
setFormValues((prev) => ({ ...prev, code: codeFromApi }));
}
}, [codeFromApi]);
return {
formValues,
setFormValues,
logicFunction,
loading: logicFunctionLoading || logicFunctionSourceCodeLoading,
};
};
@@ -1,65 +1,55 @@
import { useCallback } from 'react';
import { UPDATE_LOGIC_FUNCTION_SOURCE } from '@/logic-functions/graphql/mutations/updateLogicFunctionSource';
import { UPDATE_ONE_LOGIC_FUNCTION } from '@/logic-functions/graphql/mutations/updateOneLogicFunction';
import { GET_LOGIC_FUNCTION_SOURCE_CODE } from '@/logic-functions/graphql/queries/getLogicFunctionSourceCode';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { CREATE_DEFAULT_LOGIC_FUNCTION } from '@/settings/logic-functions/graphql/mutations/createDefaultLogicFunction';
import { DELETE_ONE_LOGIC_FUNCTION } from '@/settings/logic-functions/graphql/mutations/deleteOneLogicFunction';
import { FIND_MANY_LOGIC_FUNCTIONS } from '@/settings/logic-functions/graphql/queries/findManyLogicFunctions';
import { CREATE_ONE_LOGIC_FUNCTION } from '@/logic-functions/graphql/mutations/createOneLogicFunction';
import { DELETE_ONE_LOGIC_FUNCTION } from '@/logic-functions/graphql/mutations/deleteOneLogicFunction';
import { FIND_MANY_LOGIC_FUNCTIONS } from '@/logic-functions/graphql/queries/findManyLogicFunctions';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError, useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { t } from '@lingui/core/macro';
import { type Sources, CrudOperationType } from 'twenty-shared/types';
import { CrudOperationType } from 'twenty-shared/types';
import {
type CreateDefaultLogicFunctionItemMutation,
type CreateDefaultLogicFunctionItemMutationVariables,
type CreateOneLogicFunctionMutation,
type CreateOneLogicFunctionMutationVariables,
type DeleteOneLogicFunctionMutation,
type DeleteOneLogicFunctionMutationVariables,
type UpdateOneLogicFunctionMutation,
type UpdateOneLogicFunctionMutationVariables,
} from '~/generated-metadata/graphql';
type UpdateLogicFunctionSourceMutationVariables = {
input: { id: string; code: Sources };
};
export const usePersistLogicFunction = () => {
const apolloMetadataClient = useApolloCoreClient();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const [createDefaultLogicFunctionMutation] = useMutation<
CreateDefaultLogicFunctionItemMutation,
CreateDefaultLogicFunctionItemMutationVariables
>(CREATE_DEFAULT_LOGIC_FUNCTION, {
client: apolloMetadataClient,
});
const [createLogicFunctionMutation] = useMutation<
CreateOneLogicFunctionMutation,
CreateOneLogicFunctionMutationVariables
>(CREATE_ONE_LOGIC_FUNCTION);
const [deleteLogicFunctionMutation] = useMutation<
DeleteOneLogicFunctionMutation,
DeleteOneLogicFunctionMutationVariables
>(DELETE_ONE_LOGIC_FUNCTION, {
client: apolloMetadataClient,
});
>(DELETE_ONE_LOGIC_FUNCTION);
const [updateLogicFunctionSourceMutation] = useMutation<
{ updateLogicFunctionSource: boolean },
UpdateLogicFunctionSourceMutationVariables
>(UPDATE_LOGIC_FUNCTION_SOURCE, {
client: apolloMetadataClient,
});
UpdateOneLogicFunctionMutation,
UpdateOneLogicFunctionMutationVariables
>(UPDATE_ONE_LOGIC_FUNCTION);
const createLogicFunction = useCallback(
async (
variables: CreateDefaultLogicFunctionItemMutationVariables,
variables: CreateOneLogicFunctionMutationVariables,
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof createDefaultLogicFunctionMutation>>
Awaited<ReturnType<typeof createLogicFunctionMutation>>
>
> => {
try {
const result = await createDefaultLogicFunctionMutation({
const result = await createLogicFunctionMutation({
variables,
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_LOGIC_FUNCTIONS) ?? ''],
@@ -85,16 +75,12 @@ export const usePersistLogicFunction = () => {
};
}
},
[
createDefaultLogicFunctionMutation,
handleMetadataError,
enqueueErrorSnackBar,
],
[createLogicFunctionMutation, handleMetadataError, enqueueErrorSnackBar],
);
const updateLogicFunctionSource = useCallback(
const updateLogicFunction = useCallback(
async (
variables: UpdateLogicFunctionSourceMutationVariables,
variables: UpdateOneLogicFunctionMutationVariables,
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof updateLogicFunctionSourceMutation>>
@@ -174,7 +160,7 @@ export const usePersistLogicFunction = () => {
return {
createLogicFunction,
updateLogicFunctionSource,
updateLogicFunction,
deleteLogicFunction,
};
};
@@ -1,146 +0,0 @@
import { computeNewSources } from '@/logic-functions/utils/computeNewSources';
describe('computeNewSources', () => {
it('should compute new code input root 0', () => {
const previousCodeInput = {
'index.ts': 'export const toto = () => {}',
};
const filePath = 'index.ts';
const value = 'export const totoUpdated = () => {}';
const expectedResult = {
'index.ts': 'export const totoUpdated = () => {}',
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input root 0 file changed', () => {
const previousCodeInput = {
'.env': 'ENV=env',
'index.ts': 'export const toto = () => {}',
};
const filePath = '.env';
const value = 'ENV=env\nENV2=env2';
const expectedResult = {
'.env': 'ENV=env\nENV2=env2',
'index.ts': 'export const toto = () => {}',
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input root 0 with multiple files', () => {
const previousCodeInput = {
'index.ts': 'export const toto = () => {}',
'.env': 'ENV',
};
const filePath = 'index.ts';
const value = 'export const totoUpdated = () => {}';
const expectedResult = {
'index.ts': 'export const totoUpdated = () => {}',
'.env': 'ENV',
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input root 1', () => {
const previousCodeInput = {
src: { 'index.ts': 'export const toto = () => {}' },
};
const filePath = 'src/index.ts';
const value = 'export const totoUpdated = () => {}';
const expectedResult = {
src: { 'index.ts': 'export const totoUpdated = () => {}' },
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input root 1 with multiple files', () => {
const previousCodeInput = {
src: {
'index.ts': 'export const toto = () => {}',
'index2.ts': 'export const toto2 = () => {}',
},
};
const filePath = 'src/index.ts';
const value = 'export const totoUpdated = () => {}';
const expectedResult = {
src: {
'index.ts': 'export const totoUpdated = () => {}',
'index2.ts': 'export const toto2 = () => {}',
},
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input root 1 with added files', () => {
const previousCodeInput = {
src: {
'index.ts': 'export const toto = () => {}',
},
};
const filePath = 'src/index2.ts';
const value = 'export const toto2 = () => {}';
const expectedResult = {
src: {
'index.ts': 'export const toto = () => {}',
'index2.ts': 'export const toto2 = () => {}',
},
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
it('should compute new code input multiple roots', () => {
const previousCodeInput = {
'.env': 'ENV=env',
src: { 'index.ts': 'export const toto = () => {}' },
};
const filePath = 'src/index.ts';
const value = 'export const totoUpdated = () => {}';
const expectedResult = {
src: { 'index.ts': 'export const totoUpdated = () => {}' },
'.env': 'ENV=env',
};
expect(
computeNewSources({ previousCode: previousCodeInput, filePath, value }),
).toEqual(expectedResult);
});
});
@@ -1,59 +0,0 @@
// IA Generated
import { flattenSources } from '@/logic-functions/utils/flattenSources';
import { type Sources } from 'twenty-shared/types';
describe('flattenSources', () => {
it('flattens nested sources with root files', () => {
const input: Sources = {
'.env': 'KEY=VALUE',
src: {
'index.ts': 'export const a = 1',
lib: {
'util.ts': 'export const util = () => {}',
},
},
docs: {
'README.md': '# Hello',
},
};
const result = flattenSources(input);
expect(result).toEqual([
{ path: '.env', content: 'KEY=VALUE' },
{ path: 'docs/README.md', content: '# Hello' },
{ path: 'src/index.ts', content: 'export const a = 1' },
{ path: 'src/lib/util.ts', content: 'export const util = () => {}' },
]);
});
it('handles deep nesting and preserves file contents', () => {
const input: Sources = {
a: { b: { c: { d: { 'file.ts': 'content' } } } },
};
expect(flattenSources(input)).toEqual([
{ path: 'a/b/c/d/file.ts', content: 'content' },
]);
});
it('ignores empty folders and non-string leaves', () => {
const input: Sources = {
empty: {},
weird: {
oops: 42,
} as unknown as Sources,
file: 'ok',
};
const res = flattenSources(input);
expect(res).toEqual([{ path: 'file', content: 'ok' }]);
});
it('accepts a custom basePath prefix', () => {
const input: Sources = { src: { 'index.ts': 'x' } };
const res = flattenSources(input, 'pkg');
expect(res).toEqual([{ path: 'pkg/src/index.ts', content: 'x' }]);
});
});
@@ -1,48 +0,0 @@
import { type Sources } from 'twenty-shared/types';
export const computeNewSources = ({
previousCode,
filePath,
value,
}: {
previousCode: Sources;
filePath: string;
value: string;
}): Sources => {
const result = { ...previousCode };
const parts = filePath.split('/').filter(Boolean);
if (parts.length === 0) {
return result;
}
if (parts.length === 1) {
result[filePath] = value;
return result;
}
const [root, ...rest] = parts;
const newFilePath = rest.join('/');
if (
typeof result?.[root] === 'string' ||
typeof previousCode[root] === 'string'
) {
throw Error('Cannot compute new code input');
}
return {
...previousCode,
[root]: {
...previousCode[root],
...computeNewSources({
previousCode: result?.[root] ?? {},
filePath: newFilePath,
value,
}),
},
};
};
@@ -1,28 +0,0 @@
// IA Generated
import { type Sources } from 'twenty-shared/types';
type FlatSource = { path: string; content: string };
export const flattenSources = (
sources: Sources,
basePath = '',
): FlatSource[] => {
const out: FlatSource[] = [];
const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
const walk = (node: Sources, prefix: string) => {
for (const [name, value] of Object.entries(node)) {
if (typeof value === 'string') {
out.push({ path: join(prefix, name), content: value });
} else if (value && typeof value === 'object') {
walk(value as Sources, join(prefix, name));
}
}
};
walk(sources, basePath);
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
return out;
};