diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 4db69e61df4..07f10389b38 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -544,6 +544,71 @@ You can create new functions in two ways: - **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config. - **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern. +### Marking a logic function as a tool + +Logic functions can be exposed as **tools** for AI agents and workflows. When a function is marked as a tool, it becomes discoverable by Twenty's AI features and can be selected as a step in workflow automations. + +To mark a logic function as a tool, set `isTool: true` and provide a `toolInputSchema` describing the expected input parameters using [JSON Schema](https://json-schema.org/): + +```typescript +// src/logic-functions/enrich-company.logic-function.ts +import { defineLogicFunction } from 'twenty-sdk'; +import Twenty from '~/generated'; + +const handler = async (params: { companyName: string; domain?: string }) => { + const client = new Twenty(); + + const result = await client.mutation({ + createTask: { + __args: { + data: { + title: `Enrich data for ${params.companyName}`, + body: `Domain: ${params.domain ?? 'unknown'}`, + }, + }, + id: true, + }, + }); + + return { taskId: result.createTask.id }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + name: 'enrich-company', + description: 'Enrich a company record with external data', + timeoutSeconds: 10, + handler, + isTool: true, + toolInputSchema: { + type: 'object', + properties: { + companyName: { + type: 'string', + description: 'The name of the company to enrich', + }, + domain: { + type: 'string', + description: 'The company website domain (optional)', + }, + }, + required: ['companyName'], + }, +}); +``` + +Key points: + +- **`isTool`** (`boolean`, default: `false`): When set to `true`, the function is registered as a tool and becomes available to AI agents and workflow automations. +- **`toolInputSchema`** (`object`, optional): A JSON Schema object that describes the parameters your function accepts. AI agents use this schema to understand what inputs the tool expects and to validate calls. If omitted, the schema defaults to `{ type: 'object', properties: {} }` (no parameters). +- Functions with `isTool: false` (or unset) are **not** exposed as tools. They can still be executed directly or called by other functions, but will not appear in tool discovery. +- **Tool naming**: When exposed as a tool, the function name is automatically normalized to `logic_function_` (lowercased, non-alphanumeric characters replaced with underscores). For example, `enrich-company` becomes `logic_function_enrich_company`. +- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events (cron, database events, routes) at the same time. + + +**Write a good `description`.** AI agents rely on the function's `description` field to decide when to use the tool. Be specific about what the tool does and when it should be called. + + ### Front components Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation: diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts new file mode 100644 index 00000000000..e6b980ded09 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorMessage.test.ts @@ -0,0 +1,42 @@ +import { extractErrorMessage } from '@/ai/utils/extractErrorMessage'; + +describe('extractErrorMessage', () => { + it('should return the string directly when error is a string', () => { + expect(extractErrorMessage('Something went wrong')).toBe( + 'Something went wrong', + ); + }); + + it('should extract message from object with message property', () => { + expect(extractErrorMessage({ message: 'Error occurred' })).toBe( + 'Error occurred', + ); + }); + + it('should extract message from nested error object', () => { + expect(extractErrorMessage({ error: { message: 'Nested error' } })).toBe( + 'Nested error', + ); + }); + + it('should extract message from deeply nested error object', () => { + expect( + extractErrorMessage({ + data: { error: { message: 'Deep nested error' } }, + }), + ).toBe('Deep nested error'); + }); + + it('should return fallback message for unknown error shapes', () => { + const result = extractErrorMessage(42); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return fallback message for null', () => { + const result = extractErrorMessage(null); + + expect(typeof result).toBe('string'); + }); +}); diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/__tests__/useLogicFunctionUpdateFormState.test.ts b/packages/twenty-front/src/modules/logic-functions/hooks/__tests__/useLogicFunctionUpdateFormState.test.ts index 910d32f5ecc..b6e7944b310 100644 --- a/packages/twenty-front/src/modules/logic-functions/hooks/__tests__/useLogicFunctionUpdateFormState.test.ts +++ b/packages/twenty-front/src/modules/logic-functions/hooks/__tests__/useLogicFunctionUpdateFormState.test.ts @@ -43,7 +43,13 @@ describe('useLogicFunctionUpdateFormState', () => { expect(formValues).toEqual({ name: '', description: '', - code: mockCode, + sourceHandlerCode: '', + isTool: false, + timeoutSeconds: 300, + toolInputSchema: { + properties: {}, + type: 'object', + }, }); }); }); diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/useExecuteLogicFunction.ts b/packages/twenty-front/src/modules/logic-functions/hooks/useExecuteLogicFunction.ts index fc945fa23b7..52ac8c56d54 100644 --- a/packages/twenty-front/src/modules/logic-functions/hooks/useExecuteLogicFunction.ts +++ b/packages/twenty-front/src/modules/logic-functions/hooks/useExecuteLogicFunction.ts @@ -42,6 +42,10 @@ export const useExecuteLogicFunction = ({ ); const executeLogicFunction = async () => { + if (isExecuting) { + return; + } + try { setIsExecuting(true); await sleep(200); // Delay artificially to avoid flashing the UI diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/useGetLogicFunctionSourceCode.ts b/packages/twenty-front/src/modules/logic-functions/hooks/useGetLogicFunctionSourceCode.ts index 557fa5e37d3..8e23bb14602 100644 --- a/packages/twenty-front/src/modules/logic-functions/hooks/useGetLogicFunctionSourceCode.ts +++ b/packages/twenty-front/src/modules/logic-functions/hooks/useGetLogicFunctionSourceCode.ts @@ -20,5 +20,5 @@ export const useGetLogicFunctionSourceCode = ({ skip: !logicFunctionId, }); - return { code: data?.getLogicFunctionSourceCode, loading }; + return { sourceHandlerCode: data?.getLogicFunctionSourceCode, loading }; }; diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionEditor.ts b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionEditor.ts new file mode 100644 index 00000000000..dcd2b55dbd7 --- /dev/null +++ b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionEditor.ts @@ -0,0 +1,80 @@ +import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction'; +import { + type LogicFunctionFormValues, + useLogicFunctionUpdateFormState, +} from '@/logic-functions/hooks/useLogicFunctionUpdateFormState'; +import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction'; +import { + getInputSchemaFromSourceCode, + type InputJsonSchema, +} from 'twenty-shared/logic-function'; +import { useDebouncedCallback } from 'use-debounce'; + +export const useLogicFunctionEditor = ({ + logicFunctionId, + executeCallback, +}: { + logicFunctionId: string; + executeCallback?: (result: object) => void; +}) => { + const { updateLogicFunction } = usePersistLogicFunction(); + + const { formValues, setFormValues, logicFunction, loading } = + useLogicFunctionUpdateFormState({ logicFunctionId }); + + const { executeLogicFunction, isExecuting } = useExecuteLogicFunction({ + logicFunctionId, + callback: executeCallback, + }); + + const handleSave = useDebouncedCallback(async () => { + await updateLogicFunction({ + input: { + id: logicFunctionId, + update: formValues, + }, + }); + }, 500); + + const onChange = (key: TKey) => { + return async ( + value: LogicFunctionFormValues[TKey], + ): Promise => { + if (key === 'sourceHandlerCode') { + const toolInputSchema = await getInputSchemaFromSourceCode( + value as LogicFunctionFormValues['sourceHandlerCode'], + ); + + setFormValues((prevState: LogicFunctionFormValues) => ({ + ...prevState, + sourceHandlerCode: value as string, + toolInputSchema, + })); + + await handleSave(); + + return toolInputSchema; + } + + setFormValues((prevState: LogicFunctionFormValues) => ({ + ...prevState, + [key]: value, + })); + + await handleSave(); + + return undefined; + }; + }; + + return { + formValues, + setFormValues, + logicFunction, + loading, + handleSave, + onChange, + executeLogicFunction, + isExecuting, + }; +}; diff --git a/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionUpdateFormState.ts b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionUpdateFormState.ts index 3c90fb50e31..48fec892c8d 100644 --- a/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionUpdateFormState.ts +++ b/packages/twenty-front/src/modules/logic-functions/hooks/useLogicFunctionUpdateFormState.ts @@ -6,14 +6,15 @@ import { type LogicFunction, } from '~/generated-metadata/graphql'; import { useGetLogicFunctionSourceCode } from '@/logic-functions/hooks/useGetLogicFunctionSourceCode'; +import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; -export type LogicFunctionNewFormValues = { +export type LogicFunctionFormValues = { name: string; description: string; -}; - -export type LogicFunctionFormValues = LogicFunctionNewFormValues & { - code: string; + isTool: boolean; + timeoutSeconds: number; + sourceHandlerCode: string; + toolInputSchema?: object; }; type SetLogicFunctionFormValues = Dispatch< @@ -33,10 +34,13 @@ export const useLogicFunctionUpdateFormState = ({ const [formValues, setFormValues] = useState({ name: '', description: '', - code: '', + isTool: false, + sourceHandlerCode: '', + timeoutSeconds: 300, + toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA, }); - const { code: codeFromApi, loading: logicFunctionSourceCodeLoading } = + const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } = useGetLogicFunctionSourceCode({ logicFunctionId, }); @@ -52,16 +56,22 @@ export const useLogicFunctionUpdateFormState = ({ ...prevState, name: fn.name || '', description: fn.description || '', + isTool: fn.isTool ?? false, + timeoutSeconds: fn.timeoutSeconds ?? 300, + toolInputSchema: fn.toolInputSchema || DEFAULT_TOOL_INPUT_SCHEMA, })); } }, }); useEffect(() => { - if (isDefined(codeFromApi)) { - setFormValues((prev) => ({ ...prev, code: codeFromApi })); + if (isDefined(sourceHandlerCode)) { + setFormValues((prev) => ({ + ...prev, + sourceHandlerCode, + })); } - }, [codeFromApi]); + }, [sourceHandlerCode]); return { formValues, diff --git a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputFromSourceCode.test.ts b/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputFromSourceCode.test.ts deleted file mode 100644 index 2e618d10aab..00000000000 --- a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputFromSourceCode.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getFunctionInputFromSourceCode } from '@/logic-functions/utils/getFunctionInputFromSourceCode'; - -describe('getFunctionInputFromSourceCode', () => { - it('should return empty input if not parameter', async () => { - const fileContent = 'function testFunction() { return }'; - const result = await getFunctionInputFromSourceCode(fileContent); - expect(result).toEqual({}); - }); - it('should return first input if multiple parameters', async () => { - const fileContent = - 'function testFunction(params1: {}, params2: {}) { return }'; - const result = await getFunctionInputFromSourceCode(fileContent); - expect(result).toEqual({}); - }); - it('should return empty input if wrong parameter', async () => { - const fileContent = 'function testFunction(params: string) { return }'; - const result = await getFunctionInputFromSourceCode(fileContent); - expect(result).toEqual({}); - }); - it('should return input from source code', async () => { - 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 = await getFunctionInputFromSourceCode(fileContent); - expect(result).toEqual({ - param1: null, - param2: null, - param3: null, - param4: {}, - param5: { - subParam1: null, - }, - param6: 'my', - param7: [], - }); - }); -}); diff --git a/packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputFromSourceCode.ts b/packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputFromSourceCode.ts deleted file mode 100644 index bb542848faf..00000000000 --- a/packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputFromSourceCode.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getDefaultFunctionInputFromInputSchema } from '@/logic-functions/utils/getDefaultFunctionInputFromInputSchema'; -import { type FunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/types/FunctionInput'; -import { isObject } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; - -export const getFunctionInputFromSourceCode = async ( - sourceCode?: string, -): Promise => { - if (!isDefined(sourceCode)) { - throw new Error('Source code is not defined'); - } - - const { getFunctionInputSchema } = await import( - '@/logic-functions/utils/getFunctionInputSchema' - ); - const functionInputSchema = getFunctionInputSchema(sourceCode); - - const result = getDefaultFunctionInputFromInputSchema(functionInputSchema)[0]; - - if (!isObject(result)) { - return {}; - } - - return result; -}; diff --git a/packages/twenty-front/src/modules/logic-functions/utils/getToolInputSchemaFromSourceCode.ts b/packages/twenty-front/src/modules/logic-functions/utils/getToolInputSchemaFromSourceCode.ts deleted file mode 100644 index 90f5ab3f6a3..00000000000 --- a/packages/twenty-front/src/modules/logic-functions/utils/getToolInputSchemaFromSourceCode.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; - -export const getToolInputSchemaFromSourceCode = async ( - sourceCode: string, -): Promise => { - const { getFunctionInputSchema } = await import('./getFunctionInputSchema'); - const inputSchema = getFunctionInputSchema(sourceCode); - - // Logic functions take a single params object - const firstParam = inputSchema[0]; - - if (firstParam?.type === 'object' && isDefined(firstParam.properties)) { - return { - type: 'object', - properties: firstParam.properties, - }; - } - - return null; -}; diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap index 38e7e695141..87f1191b7da 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/__tests__/__snapshots__/generateDepthRecordGqlFieldsFromObject.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = ` { diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldAddressValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldAddressValue.test.ts new file mode 100644 index 00000000000..9e0e1e891ea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldAddressValue.test.ts @@ -0,0 +1,62 @@ +import { + isFieldAddressValue, + addressSchema, +} from '@/object-record/record-field/ui/types/guards/isFieldAddressValue'; + +describe('isFieldAddressValue', () => { + it('should return true for valid address values', () => { + expect( + isFieldAddressValue({ + addressStreet1: '123 Main St', + addressStreet2: null, + addressCity: 'Paris', + addressState: null, + addressPostcode: '75001', + addressCountry: 'France', + addressLat: 48.8566, + addressLng: 2.3522, + }), + ).toBe(true); + }); + + it('should return true for minimal address with all nullable fields null', () => { + expect( + isFieldAddressValue({ + addressStreet1: '', + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, + addressCountry: null, + addressLat: null, + addressLng: null, + }), + ).toBe(true); + }); + + it('should return false for incomplete address', () => { + expect(isFieldAddressValue({ addressStreet1: '123 Main St' })).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(isFieldAddressValue(null)).toBe(false); + expect(isFieldAddressValue('address')).toBe(false); + }); +}); + +describe('addressSchema', () => { + it('should parse a valid address', () => { + const result = addressSchema.safeParse({ + addressStreet1: '123 Main St', + addressStreet2: null, + addressCity: 'Paris', + addressState: null, + addressPostcode: '75001', + addressCountry: 'France', + addressLat: null, + addressLng: null, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldArrayValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldArrayValue.test.ts new file mode 100644 index 00000000000..1a5f188433d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldArrayValue.test.ts @@ -0,0 +1,22 @@ +import { isFieldArrayValue } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue'; + +describe('isFieldArrayValue', () => { + it('should return true for string arrays', () => { + expect(isFieldArrayValue(['a', 'b', 'c'])).toBe(true); + expect(isFieldArrayValue([])).toBe(true); + }); + + it('should return true for null', () => { + expect(isFieldArrayValue(null)).toBe(true); + }); + + it('should return false for non-array values', () => { + expect(isFieldArrayValue('string')).toBe(false); + expect(isFieldArrayValue(42)).toBe(false); + expect(isFieldArrayValue(undefined)).toBe(false); + }); + + it('should return false for arrays of non-strings', () => { + expect(isFieldArrayValue([1, 2, 3])).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldBooleanValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldBooleanValue.test.ts new file mode 100644 index 00000000000..aacd44ceb92 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldBooleanValue.test.ts @@ -0,0 +1,15 @@ +import { isFieldBooleanValue } from '@/object-record/record-field/ui/types/guards/isFieldBooleanValue'; + +describe('isFieldBooleanValue', () => { + it('should return true for boolean values', () => { + expect(isFieldBooleanValue(true)).toBe(true); + expect(isFieldBooleanValue(false)).toBe(true); + }); + + it('should return false for non-boolean values', () => { + expect(isFieldBooleanValue('true')).toBe(false); + expect(isFieldBooleanValue(1)).toBe(false); + expect(isFieldBooleanValue(null)).toBe(false); + expect(isFieldBooleanValue(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldCurrencyValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldCurrencyValue.test.ts new file mode 100644 index 00000000000..d21584f2c90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldCurrencyValue.test.ts @@ -0,0 +1,21 @@ +import { isFieldCurrencyValue } from '@/object-record/record-field/ui/types/guards/isFieldCurrencyValue'; + +describe('isFieldCurrencyValue', () => { + it('should return true for valid currency values', () => { + expect( + isFieldCurrencyValue({ currencyCode: 'USD', amountMicros: 1000000 }), + ).toBe(true); + expect( + isFieldCurrencyValue({ currencyCode: null, amountMicros: null }), + ).toBe(true); + expect(isFieldCurrencyValue({ currencyCode: '', amountMicros: null })).toBe( + true, + ); + }); + + it('should return false for invalid currency values', () => { + expect(isFieldCurrencyValue({ currencyCode: 'INVALID' })).toBe(false); + expect(isFieldCurrencyValue(null)).toBe(false); + expect(isFieldCurrencyValue('USD')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldDateValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldDateValue.test.ts new file mode 100644 index 00000000000..4d8bb97113e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldDateValue.test.ts @@ -0,0 +1,21 @@ +import { isFieldDateValue } from '@/object-record/record-field/ui/types/guards/isFieldDateValue'; + +describe('isFieldDateValue', () => { + it('should return true for valid date strings', () => { + expect(isFieldDateValue('2024-01-15')).toBe(true); + expect(isFieldDateValue('2024-01-15T10:30:00Z')).toBe(true); + }); + + it('should return true for null', () => { + expect(isFieldDateValue(null)).toBe(true); + }); + + it('should return false for invalid date strings', () => { + expect(isFieldDateValue('not-a-date')).toBe(false); + }); + + it('should return false for non-string values', () => { + expect(isFieldDateValue(42)).toBe(false); + expect(isFieldDateValue(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldFullNameValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldFullNameValue.test.ts new file mode 100644 index 00000000000..01e274b7d5a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldFullNameValue.test.ts @@ -0,0 +1,21 @@ +import { isFieldFullNameValue } from '@/object-record/record-field/ui/types/guards/isFieldFullNameValue'; + +describe('isFieldFullNameValue', () => { + it('should return true for valid full name objects', () => { + expect(isFieldFullNameValue({ firstName: 'John', lastName: 'Doe' })).toBe( + true, + ); + expect(isFieldFullNameValue({ firstName: '', lastName: '' })).toBe(true); + }); + + it('should return false for incomplete objects', () => { + expect(isFieldFullNameValue({ firstName: 'John' })).toBe(false); + expect(isFieldFullNameValue({ lastName: 'Doe' })).toBe(false); + }); + + it('should return false for non-object values', () => { + expect(isFieldFullNameValue('John Doe')).toBe(false); + expect(isFieldFullNameValue(null)).toBe(false); + expect(isFieldFullNameValue(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldNumberValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldNumberValue.test.ts new file mode 100644 index 00000000000..350ff6d11fa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldNumberValue.test.ts @@ -0,0 +1,19 @@ +import { isFieldNumberValue } from '@/object-record/record-field/ui/types/guards/isFieldNumberValue'; + +describe('isFieldNumberValue', () => { + it('should return true for numbers', () => { + expect(isFieldNumberValue(42)).toBe(true); + expect(isFieldNumberValue(0)).toBe(true); + expect(isFieldNumberValue(-1.5)).toBe(true); + }); + + it('should return true for null', () => { + expect(isFieldNumberValue(null)).toBe(true); + }); + + it('should return false for non-number values', () => { + expect(isFieldNumberValue('42')).toBe(false); + expect(isFieldNumberValue(undefined)).toBe(false); + expect(isFieldNumberValue({})).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldRawJsonValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldRawJsonValue.test.ts new file mode 100644 index 00000000000..951ceccc9a0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldRawJsonValue.test.ts @@ -0,0 +1,23 @@ +import { isFieldRawJsonValue } from '@/object-record/record-field/ui/types/guards/isFieldRawJsonValue'; + +describe('isFieldRawJsonValue', () => { + it('should return true for JSON objects', () => { + expect(isFieldRawJsonValue({ key: 'value' })).toBe(true); + expect(isFieldRawJsonValue({ nested: { deep: true } })).toBe(true); + }); + + it('should return true for JSON arrays', () => { + expect(isFieldRawJsonValue([1, 2, 3])).toBe(true); + expect(isFieldRawJsonValue([])).toBe(true); + }); + + it('should return true for null', () => { + expect(isFieldRawJsonValue(null)).toBe(true); + }); + + it('should return false for literal values other than null', () => { + expect(isFieldRawJsonValue('string')).toBe(false); + expect(isFieldRawJsonValue(42)).toBe(false); + expect(isFieldRawJsonValue(true)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldTextValue.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldTextValue.test.ts new file mode 100644 index 00000000000..6ffc3432f4e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/guards/__tests__/isFieldTextValue.test.ts @@ -0,0 +1,15 @@ +import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue'; + +describe('isFieldTextValue', () => { + it('should return true for strings', () => { + expect(isFieldTextValue('hello')).toBe(true); + expect(isFieldTextValue('')).toBe(true); + }); + + it('should return false for non-string values', () => { + expect(isFieldTextValue(42)).toBe(false); + expect(isFieldTextValue(null)).toBe(false); + expect(isFieldTextValue(undefined)).toBe(false); + expect(isFieldTextValue(true)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-group/utils/__tests__/sortRecordGroupDefinitions.test.ts b/packages/twenty-front/src/modules/object-record/record-group/utils/__tests__/sortRecordGroupDefinitions.test.ts new file mode 100644 index 00000000000..4d1c974d67b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/utils/__tests__/sortRecordGroupDefinitions.test.ts @@ -0,0 +1,67 @@ +import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { sortRecordGroupDefinitions } from '@/object-record/record-group/utils/sortRecordGroupDefinitions'; + +const createGroup = ( + overrides: Partial<{ + id: string; + title: string; + position: number; + isVisible: boolean; + }>, +) => ({ + id: overrides.id ?? '1', + type: RecordGroupDefinitionType.Value, + title: overrides.title ?? 'Group', + value: 'value', + color: 'green' as const, + position: overrides.position ?? 0, + isVisible: overrides.isVisible ?? true, +}); + +describe('sortRecordGroupDefinitions', () => { + const groups = [ + createGroup({ id: '1', title: 'Charlie', position: 2 }), + createGroup({ id: '2', title: 'Alpha', position: 0 }), + createGroup({ id: '3', title: 'Bravo', position: 1 }), + ]; + + it('should sort alphabetically', () => { + const result = sortRecordGroupDefinitions( + groups, + RecordGroupSort.Alphabetical, + ); + + expect(result.map((g) => g.title)).toEqual(['Alpha', 'Bravo', 'Charlie']); + }); + + it('should sort reverse alphabetically', () => { + const result = sortRecordGroupDefinitions( + groups, + RecordGroupSort.ReverseAlphabetical, + ); + + expect(result.map((g) => g.title)).toEqual(['Charlie', 'Bravo', 'Alpha']); + }); + + it('should sort by position for Manual sort', () => { + const result = sortRecordGroupDefinitions(groups, RecordGroupSort.Manual); + + expect(result.map((g) => g.title)).toEqual(['Alpha', 'Bravo', 'Charlie']); + }); + + it('should filter out hidden groups', () => { + const groupsWithHidden = [ + ...groups, + createGroup({ id: '4', title: 'Delta', position: 3, isVisible: false }), + ]; + + const result = sortRecordGroupDefinitions( + groupsWithHidden, + RecordGroupSort.Manual, + ); + + expect(result).toHaveLength(3); + expect(result.find((g) => g.title === 'Delta')).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfDraggedRecord.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfDraggedRecord.test.ts new file mode 100644 index 00000000000..7705bfbb2b5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfDraggedRecord.test.ts @@ -0,0 +1,98 @@ +import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; + +describe('computeNewPositionOfDraggedRecord', () => { + const records = [ + { id: 'a', position: 1 }, + { id: 'b', position: 2 }, + { id: 'c', position: 3 }, + { id: 'd', position: 4 }, + ]; + + it('should return same position when dragging to itself', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'b', + idOfTargetItem: 'b', + isDroppedAfterList: false, + }); + + expect(result).toBe(2); + }); + + it('should return target position + 1 when dropped after list', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'a', + idOfTargetItem: 'd', + isDroppedAfterList: true, + }); + + expect(result).toBe(5); + }); + + it('should return target position - 1 when moving to first position', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'c', + idOfTargetItem: 'a', + isDroppedAfterList: false, + }); + + expect(result).toBe(0); + }); + + it('should compute intermediary position when moving after target', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'a', + idOfTargetItem: 'b', + isDroppedAfterList: false, + }); + + expect(result).toBe(2.5); + }); + + it('should compute intermediary position when moving before target', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'd', + idOfTargetItem: 'c', + isDroppedAfterList: false, + }); + + expect(result).toBe(2.5); + }); + + it('should throw when target item is not found', () => { + expect(() => + computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'a', + idOfTargetItem: 'unknown', + isDroppedAfterList: false, + }), + ).toThrow('Cannot find item to move for id : unknown'); + }); + + it('should handle item not in table moved before target', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'new-item', + idOfTargetItem: 'c', + isDroppedAfterList: false, + }); + + expect(result).toBe(2.5); + }); + + it('should return target + 1 when moving after last item', () => { + const result = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: records, + idOfItemToMove: 'a', + idOfTargetItem: 'd', + isDroppedAfterList: false, + }); + + expect(result).toBe(4 + 1); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/filterAvailableTableColumns.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/filterAvailableTableColumns.test.ts new file mode 100644 index 00000000000..0a3b93df899 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/filterAvailableTableColumns.test.ts @@ -0,0 +1,32 @@ +import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns'; + +describe('filterAvailableTableColumns', () => { + const createColumnDefinition = (fieldName: string) => + ({ + metadata: { fieldName }, + }) as any; + + it('should return false for denied column names', () => { + expect( + filterAvailableTableColumns(createColumnDefinition('attachments')), + ).toBe(false); + expect( + filterAvailableTableColumns(createColumnDefinition('activities')), + ).toBe(false); + expect( + filterAvailableTableColumns(createColumnDefinition('timelineActivities')), + ).toBe(false); + }); + + it('should return true for allowed column names', () => { + expect(filterAvailableTableColumns(createColumnDefinition('name'))).toBe( + true, + ); + expect(filterAvailableTableColumns(createColumnDefinition('email'))).toBe( + true, + ); + expect(filterAvailableTableColumns(createColumnDefinition('phone'))).toBe( + true, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getDropdownFocusIdForRecordField.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getDropdownFocusIdForRecordField.test.ts new file mode 100644 index 00000000000..b6d310f8d95 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getDropdownFocusIdForRecordField.test.ts @@ -0,0 +1,29 @@ +import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; + +describe('getDropdownFocusIdForRecordField', () => { + it('should generate correct dropdown focus id for table-cell', () => { + const result = getDropdownFocusIdForRecordField({ + recordId: 'record-123', + fieldMetadataId: 'field-456', + componentType: 'table-cell', + instanceId: 'instance-789', + }); + + expect(result).toBe( + 'dropdown-instance-789-table-cell-record-record-123-field-field-456', + ); + }); + + it('should generate correct dropdown focus id for inline-cell', () => { + const result = getDropdownFocusIdForRecordField({ + recordId: 'record-abc', + fieldMetadataId: 'field-def', + componentType: 'inline-cell', + instanceId: 'instance-ghi', + }); + + expect(result).toBe( + 'dropdown-instance-ghi-inline-cell-record-record-abc-field-field-def', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getQueryIdentifier.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getQueryIdentifier.test.ts new file mode 100644 index 00000000000..1c627d2accf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getQueryIdentifier.test.ts @@ -0,0 +1,58 @@ +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; + +describe('getQueryIdentifier', () => { + it('should create identifier from object name and variables', () => { + const result = getQueryIdentifier({ + objectNameSingular: 'person', + filter: { name: { eq: 'Alice' } }, + orderBy: [{ name: 'AscNullsFirst' }], + limit: 10, + }); + + expect(result).toContain('person'); + expect(result).toContain('Alice'); + expect(result).toContain('10'); + }); + + it('should include cursor filter when present', () => { + const result = getQueryIdentifier({ + objectNameSingular: 'company', + filter: {}, + orderBy: [], + limit: 20, + cursorFilter: { cursor: 'cursor-123', cursorDirection: 'before' }, + }); + + expect(result).toContain('cursor-123'); + }); + + it('should include groupBy when present', () => { + const result = getQueryIdentifier({ + objectNameSingular: 'task', + filter: {}, + orderBy: [], + limit: 10, + groupBy: [{ status: 'Done' }], + }); + + expect(result).toContain('Done'); + }); + + it('should produce different identifiers for different filters', () => { + const resultA = getQueryIdentifier({ + objectNameSingular: 'person', + filter: { name: { eq: 'Alice' } }, + orderBy: [], + limit: 10, + }); + + const resultB = getQueryIdentifier({ + objectNameSingular: 'person', + filter: { name: { eq: 'Bob' } }, + orderBy: [], + limit: 10, + }); + + expect(resultA).not.toBe(resultB); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdateOneRecordMutationResponseField.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdateOneRecordMutationResponseField.test.ts new file mode 100644 index 00000000000..f20fa7f3e42 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdateOneRecordMutationResponseField.test.ts @@ -0,0 +1,21 @@ +import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; + +describe('getUpdateOneRecordMutationResponseField', () => { + it('should capitalize and prefix with "update"', () => { + expect(getUpdateOneRecordMutationResponseField('person')).toBe( + 'updatePerson', + ); + }); + + it('should handle multi-word object names', () => { + expect(getUpdateOneRecordMutationResponseField('company')).toBe( + 'updateCompany', + ); + }); + + it('should handle already capitalized names', () => { + expect(getUpdateOneRecordMutationResponseField('Person')).toBe( + 'updatePerson', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdatedFieldsFromRecordInput.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdatedFieldsFromRecordInput.test.ts new file mode 100644 index 00000000000..446aaa0cdc8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/getUpdatedFieldsFromRecordInput.test.ts @@ -0,0 +1,33 @@ +import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput'; + +describe('getUpdatedFieldsFromRecordInput', () => { + it('should return field entries excluding id', () => { + const input = { id: '123', name: 'Alice', age: 30 }; + + const result = getUpdatedFieldsFromRecordInput(input); + + expect(result).toEqual([{ name: 'Alice' }, { age: 30 }]); + }); + + it('should return empty array when only id is present', () => { + const input = { id: '123' }; + + const result = getUpdatedFieldsFromRecordInput(input); + + expect(result).toEqual([]); + }); + + it('should handle inputs without id', () => { + const input = { name: 'Bob', email: 'bob@test.com' }; + + const result = getUpdatedFieldsFromRecordInput(input); + + expect(result).toEqual([{ name: 'Bob' }, { email: 'bob@test.com' }]); + }); + + it('should handle empty input', () => { + const result = getUpdatedFieldsFromRecordInput({}); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/isSystemSearchVectorField.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/isSystemSearchVectorField.test.ts new file mode 100644 index 00000000000..3e0de1c963a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/isSystemSearchVectorField.test.ts @@ -0,0 +1,14 @@ +import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField'; + +describe('isSystemSearchVectorField', () => { + it('should return true for searchVector field', () => { + expect(isSystemSearchVectorField('searchVector')).toBe(true); + }); + + it('should return false for other field names', () => { + expect(isSystemSearchVectorField('name')).toBe(false); + expect(isSystemSearchVectorField('id')).toBe(false); + expect(isSystemSearchVectorField('position')).toBe(false); + expect(isSystemSearchVectorField('')).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/sortRecordsByPosition.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/sortRecordsByPosition.test.ts new file mode 100644 index 00000000000..18aac5b36af --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/sortRecordsByPosition.test.ts @@ -0,0 +1,47 @@ +import { sortRecordsByPosition } from '@/object-record/utils/sortRecordsByPosition'; + +describe('sortRecordsByPosition', () => { + it('should sort by numeric position', () => { + const record1 = { id: '1', __typename: 'Record', position: 1 }; + const record2 = { id: '2', __typename: 'Record', position: 2 }; + + expect(sortRecordsByPosition(record1, record2)).toBeLessThan(0); + expect(sortRecordsByPosition(record2, record1)).toBeGreaterThan(0); + }); + + it('should return 0 for equal numeric positions', () => { + const record1 = { id: '1', __typename: 'Record', position: 5 }; + const record2 = { id: '2', __typename: 'Record', position: 5 }; + + expect(sortRecordsByPosition(record1, record2)).toBe(0); + }); + + it('should place "first" before other positions', () => { + const record1 = { id: '1', __typename: 'Record', position: 'first' }; + const record2 = { id: '2', __typename: 'Record', position: 'last' }; + + expect(sortRecordsByPosition(record1 as any, record2 as any)).toBe(-1); + }); + + it('should place "last" after other positions', () => { + const record1 = { id: '1', __typename: 'Record', position: 'last' }; + const record2 = { id: '2', __typename: 'Record', position: 'first' }; + + expect(sortRecordsByPosition(record1 as any, record2 as any)).toBe(1); + }); + + it('should return 0 for unknown position types', () => { + const record1 = { + id: '1', + __typename: 'Record', + position: undefined as any, + }; + const record2 = { + id: '2', + __typename: 'Record', + position: undefined as any, + }; + + expect(sortRecordsByPosition(record1, record2)).toBe(0); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx index d29bdbe14e5..0fe5974fd3a 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionCodeEditor.tsx @@ -39,6 +39,7 @@ export const SettingsLogicFunctionCodeEditor = ({ if (files.length > 1) { files.forEach((file) => { const model = monaco.editor.getModel(monaco.Uri.file(file.path)); + if (!isDefined(model)) { monaco.editor.createModel( file.content, diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionNewForm.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionNewForm.tsx index 6c545a1513c..19b642473b1 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionNewForm.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/SettingsLogicFunctionNewForm.tsx @@ -1,10 +1,12 @@ -import { type LogicFunctionNewFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState'; +import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState'; +import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter'; +import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { TextArea } from '@/ui/input/components/TextArea'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; -import { H2Title } from 'twenty-ui/display'; -import { Section } from 'twenty-ui/layout'; +import { H2Title, IconClockHour8, IconTool } from 'twenty-ui/display'; +import { Card, Section } from 'twenty-ui/layout'; const StyledInputsContainer = styled.div` display: flex; @@ -17,8 +19,10 @@ export const SettingsLogicFunctionNewForm = ({ onChange, readonly = false, }: { - formValues: LogicFunctionNewFormValues; - onChange: (key: string) => (value: string) => void; + formValues: LogicFunctionFormValues; + onChange: ( + key: TKey, + ) => (value: LogicFunctionFormValues[TKey]) => void; readonly?: boolean; }) => { const descriptionTextAreaId = `${formValues.name}-description`; @@ -48,6 +52,28 @@ export const SettingsLogicFunctionNewForm = ({ onChange={onChange('description')} readOnly={readonly} /> + + + + + + ); diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx index 19fd3fce97d..18e09f939e5 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab.tsx @@ -24,7 +24,7 @@ export const SettingsLogicFunctionCodeEditorTab = ({ }: { files: File[]; handleExecute: () => void; - onChange: (filePath: string, value: string) => void; + onChange: (value: string) => void; isTesting?: boolean; }) => { const activeTabId = useRecoilComponentValue( @@ -63,9 +63,7 @@ export const SettingsLogicFunctionCodeEditorTab = ({ - onChange(activeTabId, newCodeValue) - } + onChange={(newCodeValue: string) => onChange(newCodeValue)} /> )} diff --git a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionSettingsTab.tsx b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionSettingsTab.tsx index 7e7398537ad..c4fc2dd0a7d 100644 --- a/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionSettingsTab.tsx +++ b/packages/twenty-front/src/modules/settings/logic-functions/components/tabs/SettingsLogicFunctionSettingsTab.tsx @@ -7,7 +7,9 @@ export const SettingsLogicFunctionSettingsTab = ({ onChange, }: { formValues: LogicFunctionFormValues; - onChange: (key: string) => (value: string) => void; + onChange: ( + key: TKey, + ) => (value: LogicFunctionFormValues[TKey]) => void; }) => { return ( <> diff --git a/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/__tests__/build-role-maps.test.ts b/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/__tests__/build-role-maps.test.ts new file mode 100644 index 00000000000..430f7f2fa02 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-assignment/utils/__tests__/build-role-maps.test.ts @@ -0,0 +1,88 @@ +import { buildRoleMaps } from '@/settings/roles/role-assignment/utils/build-role-maps'; + +describe('buildRoleMaps', () => { + it('should build member map from roles', () => { + const roles = [ + { + id: 'role-1', + label: 'Admin', + workspaceMembers: [{ id: 'member-1' }, { id: 'member-2' }], + }, + ] as any; + + const result = buildRoleMaps(roles); + + expect(result.member.get('member-1')).toEqual({ + id: 'role-1', + label: 'Admin', + }); + expect(result.member.get('member-2')).toEqual({ + id: 'role-1', + label: 'Admin', + }); + }); + + it('should build agent map from roles', () => { + const roles = [ + { + id: 'role-1', + label: 'Admin', + workspaceMembers: [], + agents: [{ id: 'agent-1' }], + }, + ] as any; + + const result = buildRoleMaps(roles); + + expect(result.agent.get('agent-1')).toEqual({ + id: 'role-1', + label: 'Admin', + }); + }); + + it('should build apiKey map from roles', () => { + const roles = [ + { + id: 'role-1', + label: 'Admin', + workspaceMembers: [], + apiKeys: [{ id: 'key-1' }], + }, + ] as any; + + const result = buildRoleMaps(roles); + + expect(result.apiKey.get('key-1')).toEqual({ + id: 'role-1', + label: 'Admin', + }); + }); + + it('should return empty maps for empty roles', () => { + const result = buildRoleMaps([]); + + expect(result.member.size).toBe(0); + expect(result.agent.size).toBe(0); + expect(result.apiKey.size).toBe(0); + }); + + it('should handle multiple roles correctly', () => { + const roles = [ + { + id: 'role-1', + label: 'Admin', + workspaceMembers: [{ id: 'member-1' }], + }, + { + id: 'role-2', + label: 'Viewer', + workspaceMembers: [{ id: 'member-2' }], + }, + ] as any; + + const result = buildRoleMaps(roles); + + expect(result.member.get('member-1')?.label).toBe('Admin'); + expect(result.member.get('member-2')?.label).toBe('Viewer'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx index 891a840c30e..77d94c5904b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCode.tsx @@ -1,8 +1,5 @@ import { useGetAvailablePackages } from '@/logic-functions/hooks/useGetAvailablePackages'; -import { - type LogicFunctionFormValues, - useLogicFunctionUpdateFormState, -} from '@/logic-functions/hooks/useLogicFunctionUpdateFormState'; +import { useLogicFunctionEditor } from '@/logic-functions/hooks/useLogicFunctionEditor'; import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal'; import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; @@ -13,8 +10,7 @@ import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code- import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton'; import { LogicFunctionExecutionResult } from '@/logic-functions/components/LogicFunctionExecutionResult'; -import { getFunctionInputFromSourceCode } from '@/logic-functions/utils/getFunctionInputFromSourceCode'; -import { mergeDefaultFunctionInputAndFunctionInput } from '@/logic-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; +import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { TextArea } from '@/ui/input/components/TextArea'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; @@ -36,8 +32,6 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; -import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction'; -import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction'; import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; import { CODE_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CodeAction'; import { type Monaco } from '@monaco-editor/react'; @@ -47,11 +41,15 @@ import { useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; import { Key } from 'ts-key-enum'; import { isDefined } from 'twenty-shared/utils'; -import { buildOutputSchemaFromValue } from 'twenty-shared/workflow'; +import { + getOutputSchemaFromValue, + type InputJsonSchema, +} from 'twenty-shared/logic-function'; import { IconCode, IconPlayerPlay } from 'twenty-ui/display'; import { CodeEditor } from 'twenty-ui/input'; import { useIsMobile } from 'twenty-ui/utilities'; import { useDebouncedCallback } from 'use-debounce'; +import { getFunctionInputFromInputSchema } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema'; const CODE_EDITOR_MIN_HEIGHT = 343; @@ -103,7 +101,6 @@ export const WorkflowEditActionCode = ({ activeTabIdComponentState, WORKFLOW_LOGIC_FUNCTION_TAB_LIST_COMPONENT_ID, ); - const { updateLogicFunction } = usePersistLogicFunction(); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersionOrThrow(); @@ -111,6 +108,24 @@ export const WorkflowEditActionCode = ({ workflowVisualizerWorkflowIdComponentState, ); const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); + + const updateOutputSchemaFromTestResult = async (testResult: object) => { + if (actionOptions.readonly === true) { + return; + } + const newOutputSchema = getOutputSchemaFromValue(testResult); + updateAction({ + ...action, + settings: { ...action.settings, outputSchema: newOutputSchema }, + }); + }; + + const { formValues, loading, executeLogicFunction, onChange, isExecuting } = + useLogicFunctionEditor({ + logicFunctionId, + executeCallback: updateOutputSchemaFromTestResult, + }); + const { availablePackages } = useGetAvailablePackages({ id: logicFunctionId, }); @@ -125,52 +140,8 @@ export const WorkflowEditActionCode = ({ action.settings.input.logicFunctionInput, ); - const { formValues, setFormValues, loading } = - useLogicFunctionUpdateFormState({ logicFunctionId }); - - const updateOutputSchemaFromTestResult = async (testResult: object) => { - if (actionOptions.readonly === true) { - return; - } - const newOutputSchema = buildOutputSchemaFromValue(testResult); - updateAction({ - ...action, - settings: { ...action.settings, outputSchema: newOutputSchema }, - }); - }; - - const { executeLogicFunction, isExecuting } = useExecuteLogicFunction({ - logicFunctionId, - callback: updateOutputSchemaFromTestResult, - }); - - const handleSave = useDebouncedCallback(async () => { - await updateLogicFunction({ - input: { - id: logicFunctionId, - update: { - sourceHandlerCode: formValues.code, - }, - }, - }); - }, 500); - - const onCodeChange = async (newCode: string) => { - if (actionOptions.readonly === true) { - return; - } - setFormValues((prevState: LogicFunctionFormValues) => { - return { - ...prevState, - code: newCode, - }; - }); - await handleSave(); - await handleUpdateFunctionInputSchema(newCode); - }; - const handleUpdateFunctionInputSchema = useDebouncedCallback( - async (sourceCode: string) => { + async (sourceCode: string, toolInputSchema: InputJsonSchema) => { if (actionOptions.readonly === true) { return; } @@ -179,7 +150,12 @@ export const WorkflowEditActionCode = ({ return; } - const newFunctionInput = await getFunctionInputFromSourceCode(sourceCode); + const schemaArray = Array.isArray(toolInputSchema) + ? toolInputSchema + : [toolInputSchema]; + + const newFunctionInput = getFunctionInputFromInputSchema(schemaArray)[0]; + const newMergedInput = mergeDefaultFunctionInputAndFunctionInput({ newInput: newFunctionInput, oldInput: action.settings.input.logicFunctionInput, @@ -190,6 +166,7 @@ export const WorkflowEditActionCode = ({ }); setFunctionInput(newMergedInput); + setLogicFunctionTestData((prev) => ({ ...prev, input: newMergedTestInput, @@ -251,14 +228,12 @@ export const WorkflowEditActionCode = ({ })); }; - const handleRunFunction = async () => { + const handleTestFunction = async () => { if (actionOptions.readonly === true) { return; } - if (!isExecuting) { - await executeLogicFunction(); - } + await executeLogicFunction(); }; const handleEditorDidMount = async ( @@ -288,12 +263,18 @@ export const WorkflowEditActionCode = ({ 500, ); - const handleCodeChange = async (value: string) => { + const handleCodeChange = async (newCode: string) => { if (actionOptions.readonly === true || !isDefined(workflow)) { return; } + + const toolInputSchema = await onChange('sourceHandlerCode')(newCode); + await getUpdatableWorkflowVersion(); - await onCodeChange(value); + + if (isDefined(toolInputSchema)) { + await handleUpdateFunctionInputSchema(newCode, toolInputSchema); + } }; const tabs = [ @@ -382,7 +363,7 @@ export const WorkflowEditActionCode = ({ , ] diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowReadonlyActionCode.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowReadonlyActionCode.tsx index d2d51d97060..492fafc1ec4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowReadonlyActionCode.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowReadonlyActionCode.tsx @@ -29,7 +29,7 @@ export const WorkflowReadonlyActionCode = ({ id: logicFunctionId, }); - const { code, loading } = useGetLogicFunctionSourceCode({ + const { sourceHandlerCode, loading } = useGetLogicFunctionSourceCode({ logicFunctionId, }); @@ -60,7 +60,7 @@ export const WorkflowReadonlyActionCode = ({ { it('should init function input properly', () => { @@ -37,7 +37,7 @@ describe('getDefaultFunctionInputFromInputSchema', () => { e: {}, }, ]; - expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual( + expect(getFunctionInputFromInputSchema(inputSchema)).toEqual( expectedResult, ); }); diff --git a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts similarity index 78% rename from packages/twenty-front/src/modules/logic-functions/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts index a9f902f0b2f..a3720eb539b 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts @@ -1,4 +1,4 @@ -import { mergeDefaultFunctionInputAndFunctionInput } from '@/logic-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; +import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput'; describe('mergeDefaultFunctionInputAndFunctionInput', () => { it('should merge properly', () => { diff --git a/packages/twenty-front/src/modules/logic-functions/utils/getDefaultFunctionInputFromInputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema.ts similarity index 67% rename from packages/twenty-front/src/modules/logic-functions/utils/getDefaultFunctionInputFromInputSchema.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema.ts index e62189c435b..693366c2f57 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/getDefaultFunctionInputFromInputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema.ts @@ -1,18 +1,22 @@ import { type InputSchema } from '@/workflow/types/InputSchema'; import { type FunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/types/FunctionInput'; import { isDefined } from 'twenty-shared/utils'; +import type { InputJsonSchema } from 'twenty-shared/logic-function'; -export const getDefaultFunctionInputFromInputSchema = ( - inputSchema: InputSchema, +export const getFunctionInputFromInputSchema = ( + inputSchema: InputSchema | InputJsonSchema[], ): FunctionInput => { return inputSchema.map((param) => { - if (['string', 'number', 'boolean'].includes(param.type)) { + if ( + isDefined(param.type) && + ['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]; + result[key] = getFunctionInputFromInputSchema([val])[0]; }); } return result; diff --git a/packages/twenty-front/src/modules/logic-functions/utils/mergeDefaultFunctionInputAndFunctionInput.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput.ts similarity index 100% rename from packages/twenty-front/src/modules/logic-functions/utils/mergeDefaultFunctionInputAndFunctionInput.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx index 296ba672e70..ab2ac93f70b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/logic-function-action/components/WorkflowEditActionLogicFunction.tsx @@ -1,5 +1,5 @@ -import { getDefaultFunctionInputFromInputSchema } from '@/logic-functions/utils/getDefaultFunctionInputFromInputSchema'; -import { mergeDefaultFunctionInputAndFunctionInput } from '@/logic-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; +import { getFunctionInputFromInputSchema } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema'; +import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput'; import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction'; import { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; @@ -56,7 +56,7 @@ export const WorkflowEditActionLogicFunction = ({ ? toolInputSchema : [toolInputSchema]; - const defaultInput = getDefaultFunctionInputFromInputSchema(schemaArray)[0]; + const defaultInput = getFunctionInputFromInputSchema(schemaArray)[0]; if (!isObject(defaultInput)) { return action.settings.input.logicFunctionInput ?? {}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx index 5f4e20cb6d4..7b1ac6cbec4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx @@ -17,10 +17,8 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -import { - buildOutputSchemaFromValue, - TRIGGER_STEP_ID, -} from 'twenty-shared/workflow'; +import { getOutputSchemaFromValue } from 'twenty-shared/logic-function'; +import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; import { t } from '@lingui/core/macro'; import { IconCopy } from 'twenty-ui/display'; @@ -153,9 +151,7 @@ export const WorkflowEditTriggerWebhookForm = ({ expectedBody: undefined, })); - const outputSchema = buildOutputSchemaFromValue( - parsingResult.data, - ); + const outputSchema = getOutputSchemaFromValue(parsingResult.data); triggerOptions.onTriggerUpdate( { diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx index c53493c91f5..8b9b3ffdfc2 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsToolsTable.tsx @@ -54,11 +54,6 @@ const StyledFooterContainer = styled.div` margin-top: ${({ theme }) => theme.spacing(4)}; `; -const DEFAULT_TOOL_INPUT_SCHEMA = { - type: 'object', - properties: {}, -}; - export const SettingsToolsTable = () => { const logicFunctions = useRecoilValue(logicFunctionsState); const { toolIndex, loading: toolIndexLoading } = useGetToolIndex(); @@ -126,7 +121,6 @@ export const SettingsToolsTable = () => { input: { name: 'new-tool', isTool: true, - toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA, }, }); diff --git a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx index e3f1d42139e..46d074516d9 100644 --- a/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx +++ b/packages/twenty-front/src/pages/settings/logic-functions/SettingsLogicFunctionDetail.tsx @@ -1,16 +1,11 @@ -import { getToolInputSchemaFromSourceCode } from '@/logic-functions/utils/getToolInputSchemaFromSourceCode'; import { useNavigate, useParams } from 'react-router-dom'; -import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction'; +import { useLogicFunctionEditor } from '@/logic-functions/hooks/useLogicFunctionEditor'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsLogicFunctionLabelContainer } from '@/settings/logic-functions/components/SettingsLogicFunctionLabelContainer'; import { SettingsLogicFunctionSettingsTab } from '@/settings/logic-functions/components/tabs/SettingsLogicFunctionSettingsTab'; import { SettingsLogicFunctionTestTab } from '@/settings/logic-functions/components/tabs/SettingsLogicFunctionTestTab'; import { SettingsLogicFunctionTriggersTab } from '@/settings/logic-functions/components/tabs/SettingsLogicFunctionTriggersTab'; -import { - type LogicFunctionFormValues, - useLogicFunctionUpdateFormState, -} from '@/logic-functions/hooks/useLogicFunctionUpdateFormState'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; @@ -27,8 +22,6 @@ import { import { useFindOneApplicationQuery } from '~/generated-metadata/graphql'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useRecoilValue } from 'recoil'; -import { useDebouncedCallback } from 'use-debounce'; -import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction'; import { SettingsLogicFunctionCodeEditorTab } from '@/settings/logic-functions/components/tabs/SettingsLogicFunctionCodeEditorTab'; const LOGIC_FUNCTION_DETAIL_ID = 'logic-function-detail'; @@ -58,62 +51,14 @@ export const SettingsLogicFunctionDetail = () => { instanceId, ); - const { formValues, setFormValues, logicFunction, loading } = - useLogicFunctionUpdateFormState({ logicFunctionId }); - - const { updateLogicFunction } = usePersistLogicFunction(); - - const { executeLogicFunction, isExecuting } = useExecuteLogicFunction({ - logicFunctionId, - }); - - const handleExecute = async () => { - await executeLogicFunction(); - }; - - const handleSave = useDebouncedCallback( - async (toolInputSchema?: object | null) => { - await updateLogicFunction({ - input: { - id: logicFunctionId, - update: { - name: formValues.name, - description: formValues.description, - sourceHandlerCode: formValues.code, - ...(toolInputSchema !== undefined && { toolInputSchema }), - }, - }, - }); - }, - 500, - ); - - const onChange = (key: string) => { - return (value: string) => { - setFormValues((prevState: LogicFunctionFormValues) => ({ - ...prevState, - [key]: value, - })); - }; - }; - - const onCodeChange = async (filePath: string, value: string) => { - setFormValues((prevState: LogicFunctionFormValues) => { - return { - ...prevState, - code: value, - }; - }); - - // Parse and save schema if editing the handler file - let toolInputSchema: object | null | undefined; - - if (filePath === logicFunction?.sourceHandlerPath) { - toolInputSchema = await getToolInputSchemaFromSourceCode(value); - } - - await handleSave(toolInputSchema); - }; + const { + formValues, + logicFunction, + loading, + onChange, + executeLogicFunction, + isExecuting, + } = useLogicFunctionEditor({ logicFunctionId }); const handleTestFunction = async () => { navigate('#test'); @@ -177,7 +122,7 @@ export const SettingsLogicFunctionDetail = () => { const files = [ { path: 'index.ts', - content: formValues.code, + content: formValues.sourceHandlerCode, language: 'typescript', }, ]; @@ -200,7 +145,7 @@ export const SettingsLogicFunctionDetail = () => { )} @@ -215,7 +160,7 @@ export const SettingsLogicFunctionDetail = () => { )} {isTestTab && ( diff --git a/packages/twenty-front/src/utils/__tests__/getDirtyFields.test.ts b/packages/twenty-front/src/utils/__tests__/getDirtyFields.test.ts new file mode 100644 index 00000000000..a12ed32db44 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/getDirtyFields.test.ts @@ -0,0 +1,55 @@ +import { getDirtyFields } from '~/utils/getDirtyFields'; + +describe('getDirtyFields', () => { + it('should return all defined fields when persisted is null', () => { + const draft = { name: 'Alice', age: 30, email: undefined }; + + const result = getDirtyFields(draft, null); + + expect(result).toEqual({ name: 'Alice', age: 30 }); + }); + + it('should return all defined fields when persisted is undefined', () => { + const draft = { name: 'Bob' }; + + const result = getDirtyFields(draft, undefined); + + expect(result).toEqual({ name: 'Bob' }); + }); + + it('should return only changed fields', () => { + const draft = { name: 'Alice', age: 31 }; + const persisted = { name: 'Alice', age: 30 }; + + const result = getDirtyFields(draft, persisted); + + expect(result).toEqual({ age: 31 }); + }); + + it('should return empty object when nothing changed', () => { + const draft = { name: 'Alice', age: 30 }; + const persisted = { name: 'Alice', age: 30 }; + + const result = getDirtyFields(draft, persisted); + + expect(result).toEqual({}); + }); + + it('should detect deeply nested changes', () => { + const draft = { address: { city: 'Paris' } }; + const persisted = { address: { city: 'London' } }; + + const result = getDirtyFields(draft, persisted); + + expect(result).toEqual({ address: { city: 'Paris' } }); + }); + + it('should detect new keys in draft', () => { + const draft = { name: 'Alice', age: 30 } as Record; + const persisted = { name: 'Alice' } as Record; + + const result = getDirtyFields(draft, persisted); + + expect(result).toEqual({ age: 30 }); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/getIsDevelopmentEnvironment.test.ts b/packages/twenty-front/src/utils/__tests__/getIsDevelopmentEnvironment.test.ts new file mode 100644 index 00000000000..a9e4bcdf4d5 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/getIsDevelopmentEnvironment.test.ts @@ -0,0 +1,31 @@ +import { getIsDevelopmentEnvironment } from '~/utils/getIsDevelopmentEnvironment'; + +describe('getIsDevelopmentEnvironment', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true when IS_DEV_ENV is "true"', () => { + process.env.IS_DEV_ENV = 'true'; + + expect(getIsDevelopmentEnvironment()).toBe(true); + }); + + it('should return false when IS_DEV_ENV is "false"', () => { + process.env.IS_DEV_ENV = 'false'; + + expect(getIsDevelopmentEnvironment()).toBe(false); + }); + + it('should return false when IS_DEV_ENV is not set', () => { + delete process.env.IS_DEV_ENV; + + expect(getIsDevelopmentEnvironment()).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/isDeeplyEqual.test.ts b/packages/twenty-front/src/utils/__tests__/isDeeplyEqual.test.ts new file mode 100644 index 00000000000..ccfd1c6e63c --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/isDeeplyEqual.test.ts @@ -0,0 +1,37 @@ +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +describe('isDeeplyEqual', () => { + it('should return true for equal primitives', () => { + expect(isDeeplyEqual(1, 1)).toBe(true); + expect(isDeeplyEqual('hello', 'hello')).toBe(true); + expect(isDeeplyEqual(true, true)).toBe(true); + expect(isDeeplyEqual(null, null)).toBe(true); + expect(isDeeplyEqual(undefined, undefined)).toBe(true); + }); + + it('should return false for different primitives', () => { + expect(isDeeplyEqual(1, 2)).toBe(false); + expect(isDeeplyEqual('hello', 'world')).toBe(false); + expect(isDeeplyEqual(true, false)).toBe(false); + }); + + it('should return true for deeply equal objects', () => { + expect(isDeeplyEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe( + true, + ); + }); + + it('should return false for different objects', () => { + expect(isDeeplyEqual({ a: 1 }, { a: 2 })).toBe(false); + expect(isDeeplyEqual({ a: 1 }, { b: 1 })).toBe(false); + }); + + it('should return true for deeply equal arrays', () => { + expect(isDeeplyEqual([1, 2, [3]], [1, 2, [3]])).toBe(true); + }); + + it('should return false for different arrays', () => { + expect(isDeeplyEqual([1, 2], [1, 3])).toBe(false); + expect(isDeeplyEqual([1, 2], [1, 2, 3])).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/isUndefinedOrNull.test.ts b/packages/twenty-front/src/utils/__tests__/isUndefinedOrNull.test.ts new file mode 100644 index 00000000000..3c1f2f6111d --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/isUndefinedOrNull.test.ts @@ -0,0 +1,31 @@ +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +describe('isUndefinedOrNull', () => { + it('should return true for null', () => { + expect(isUndefinedOrNull(null)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isUndefinedOrNull(undefined)).toBe(true); + }); + + it('should return false for a number', () => { + expect(isUndefinedOrNull(0)).toBe(false); + expect(isUndefinedOrNull(42)).toBe(false); + }); + + it('should return false for a string', () => { + expect(isUndefinedOrNull('')).toBe(false); + expect(isUndefinedOrNull('hello')).toBe(false); + }); + + it('should return false for an object', () => { + expect(isUndefinedOrNull({})).toBe(false); + expect(isUndefinedOrNull([])).toBe(false); + }); + + it('should return false for a boolean', () => { + expect(isUndefinedOrNull(false)).toBe(false); + expect(isUndefinedOrNull(true)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/normalizeGQLField.test.ts b/packages/twenty-front/src/utils/__tests__/normalizeGQLField.test.ts new file mode 100644 index 00000000000..5b030bb018b --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/normalizeGQLField.test.ts @@ -0,0 +1,23 @@ +import { normalizeGQLField } from '~/utils/normalizeGQLField'; + +describe('normalizeGQLField', () => { + it('should produce consistent output for the same fields', () => { + const resultA = normalizeGQLField('id name'); + const resultB = normalizeGQLField('id name'); + + expect(resultA).toBe(resultB); + }); + + it('should produce the same output regardless of whitespace', () => { + const resultA = normalizeGQLField('id name email'); + const resultB = normalizeGQLField('id name email'); + + expect(resultA).toBe(resultB); + }); + + it('should return a string', () => { + const result = normalizeGQLField('id name'); + + expect(typeof result).toBe('string'); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/normalizeGQLQuery.test.ts b/packages/twenty-front/src/utils/__tests__/normalizeGQLQuery.test.ts new file mode 100644 index 00000000000..6f9c6d289e2 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/normalizeGQLQuery.test.ts @@ -0,0 +1,25 @@ +import { normalizeGQLQuery } from '~/utils/normalizeGQLQuery'; + +describe('normalizeGQLQuery', () => { + it('should produce consistent output for the same query', () => { + const query = 'query { users { id name } }'; + + const resultA = normalizeGQLQuery(query); + const resultB = normalizeGQLQuery(query); + + expect(resultA).toBe(resultB); + }); + + it('should produce the same output regardless of whitespace', () => { + const queryA = 'query { users { id name } }'; + const queryB = 'query{users{id name}}'; + + expect(normalizeGQLQuery(queryA)).toBe(normalizeGQLQuery(queryB)); + }); + + it('should return a string', () => { + const result = normalizeGQLQuery('query { users { id } }'); + + expect(typeof result).toBe('string'); + }); +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index 19f89f27d73..69b28f10ca4 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -259,6 +259,7 @@ export const EXPECTED_MANIFEST: Manifest = { handlerName: 'default.config.handler', name: 'root-function', sourceHandlerPath: 'src/root.function.ts', + toolInputSchema: { type: 'object', properties: {} }, timeoutSeconds: 5, httpRouteTriggerSettings: { httpMethod: 'GET', @@ -273,6 +274,7 @@ export const EXPECTED_MANIFEST: Manifest = { handlerName: 'default.config.handler', name: 'greeting-function', sourceHandlerPath: 'src/logic-functions/greeting.function.ts', + toolInputSchema: { type: 'object', properties: {} }, timeoutSeconds: 5, httpRouteTriggerSettings: { httpMethod: 'GET', @@ -287,6 +289,7 @@ export const EXPECTED_MANIFEST: Manifest = { handlerName: 'default.config.handler', name: 'test-function-2', sourceHandlerPath: 'src/logic-functions/test-function-2.function.ts', + toolInputSchema: { type: 'object', properties: {} }, timeoutSeconds: 2, cronTriggerSettings: { pattern: '0 0 1 1 *', @@ -299,6 +302,7 @@ export const EXPECTED_MANIFEST: Manifest = { handlerName: 'default.config.handler', name: 'test-function', sourceHandlerPath: 'src/logic-functions/test-function.function.ts', + toolInputSchema: { type: 'object', properties: {} }, timeoutSeconds: 2, httpRouteTriggerSettings: { forwardedRequestHeaders: ['signature'], diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts index 47e955f1683..e056aeb02fe 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest.ts @@ -46,6 +46,7 @@ export const EXPECTED_MANIFEST: Manifest = { sourceHandlerPath: 'my.function.ts', builtHandlerPath: 'my.function.mjs', builtHandlerChecksum: '[checksum]', + toolInputSchema: { type: 'object', properties: {} }, }, ], frontComponents: [ diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 069780b1e59..7730d35f003 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -25,6 +25,7 @@ import { type RoleManifest, } from 'twenty-shared/application'; import { assertUnreachable } from 'twenty-shared/utils'; +import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function'; const loadSources = async (appPath: string): Promise => { return await glob(['**/*.ts', '**/*.tsx'], { @@ -138,8 +139,13 @@ export const buildManifest = async ( const relativeFilePath = relative(appPath, filePath); + const toolInputSchema = + rest.toolInputSchema ?? + (await getInputSchemaFromSourceCode(fileContent)); + const config: LogicFunctionManifest = { ...rest, + toolInputSchema, handlerName: 'default.config.handler', sourceHandlerPath: relativeFilePath, builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'), diff --git a/packages/twenty-sdk/src/sdk/logic-functions/logic-function-config.ts b/packages/twenty-sdk/src/sdk/logic-functions/logic-function-config.ts index cfb0ccaa595..99855409481 100644 --- a/packages/twenty-sdk/src/sdk/logic-functions/logic-function-config.ts +++ b/packages/twenty-sdk/src/sdk/logic-functions/logic-function-config.ts @@ -1,4 +1,5 @@ import { type LogicFunctionManifest } from 'twenty-shared/application'; +import { type InputJsonSchema } from 'twenty-shared/logic-function'; export type LogicFunctionHandler = (...args: any[]) => any | Promise; @@ -8,6 +9,8 @@ export type LogicFunctionConfig = Omit< | 'builtHandlerPath' | 'builtHandlerChecksum' | 'handlerName' + | 'toolInputSchema' > & { handler: LogicFunctionHandler; + toolInputSchema?: InputJsonSchema; }; diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts index 7ad0032f49f..627bfb8da96 100644 --- a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts @@ -1,5 +1,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; + import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const'; import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller'; import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc'; @@ -152,7 +154,7 @@ describe('McpCoreController', () => { { name: 'testTool', description: 'A test tool', - inputSchema: { type: 'object', properties: {} }, + inputSchema: DEFAULT_TOOL_INPUT_SCHEMA, }, ], }, diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts index 37cd9dad8ef..4ad465c52ba 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts @@ -3,6 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { jsonSchema } from 'ai'; +import { type JSONSchema7 } from 'json-schema'; +import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const'; import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc'; @@ -264,7 +266,7 @@ describe('McpProtocolService', () => { const mockTool = { description: 'Test tool', - inputSchema: jsonSchema({ type: 'object', properties: {} }), + inputSchema: jsonSchema(DEFAULT_TOOL_INPUT_SCHEMA as JSONSchema7), execute: jest.fn().mockResolvedValue({ result: 'success' }), }; @@ -319,7 +321,7 @@ describe('McpProtocolService', () => { const mockTool = { description: 'Test tool', - inputSchema: jsonSchema({ type: 'object', properties: {} }), + inputSchema: jsonSchema(DEFAULT_TOOL_INPUT_SCHEMA as JSONSchema7), execute: jest.fn().mockResolvedValue({ result: 'success' }), }; @@ -375,7 +377,7 @@ describe('McpProtocolService', () => { const mockToolsMap = { testTool: { description: 'Test tool', - inputSchema: jsonSchema({ type: 'object', properties: {} }), + inputSchema: jsonSchema(DEFAULT_TOOL_INPUT_SCHEMA as JSONSchema7), }, }; @@ -393,7 +395,7 @@ describe('McpProtocolService', () => { { name: 'testTool', description: 'Test tool', - inputSchema: { type: 'object', properties: {} }, + inputSchema: DEFAULT_TOOL_INPUT_SCHEMA, }, ], }), diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util.ts index bde3afd51ed..7e0fc7db56a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util.ts @@ -28,7 +28,7 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({ builtHandlerPath: logicFunctionManifest.builtHandlerPath, handlerName: logicFunctionManifest.handlerName, checksum: logicFunctionManifest.builtHandlerChecksum, - toolInputSchema: logicFunctionManifest.toolInputSchema ?? null, + toolInputSchema: logicFunctionManifest.toolInputSchema, isTool: logicFunctionManifest.isTool ?? false, cronTriggerSettings: logicFunctionManifest.cronTriggerSettings ?? null, databaseEventTriggerSettings: diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/constants/seed-logic-function-input-schema.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/constants/seed-logic-function-input-schema.ts deleted file mode 100644 index 90905d9ca18..00000000000 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/constants/seed-logic-function-input-schema.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const SEED_LOGIC_FUNCTION_INPUT_SCHEMA = { - a: null, - b: null, -}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index 18e39932b49..a82202e4786 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; +import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; import { type GenerateDescriptorOptions, @@ -68,10 +69,9 @@ export class LogicFunctionToolProvider implements ToolProvider { if (includeSchemas) { // Logic functions already store JSON Schema -- use it directly - const inputSchema = (logicFunction.toolInputSchema as object) ?? { - type: 'object', - properties: {}, - }; + const inputSchema = + (logicFunction.toolInputSchema as object) ?? + DEFAULT_TOOL_INPUT_SCHEMA; descriptors.push({ ...base, inputSchema }); } else { diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/default-tool-input-schema.constant.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/default-tool-input-schema.constant.ts deleted file mode 100644 index 23f1e5d3d33..00000000000 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/default-tool-input-schema.constant.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const DEFAULT_TOOL_INPUT_SCHEMA = { - type: 'object', - properties: { - a: { type: 'string' }, - b: { type: 'number' }, - }, -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function.input.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function.input.ts index 94c0ce6cd5f..0e5f097b0e6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function.input.ts @@ -18,6 +18,8 @@ import { HttpRouteTriggerSettings, } from 'twenty-shared/application'; +import type { InputJsonSchema } from 'twenty-shared/logic-function'; + import type { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -52,7 +54,7 @@ export class CreateLogicFunction { @Field(() => graphqlTypeJson, { nullable: false }) @IsObject() - toolInputSchema: object; + toolInputSchema: InputJsonSchema; @IsBoolean() @Field({ nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/logic-function.dto.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/logic-function.dto.ts index c19150e8c69..73cc39b16ba 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/logic-function.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/logic-function.dto.ts @@ -22,6 +22,8 @@ import { HttpRouteTriggerSettings, } from 'twenty-shared/application'; +import type { InputJsonSchema } from 'twenty-shared/logic-function'; + import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @ObjectType('LogicFunction') @@ -69,7 +71,7 @@ export class LogicFunctionDTO { @IsObject() @IsOptional() @Field(() => graphqlTypeJson, { nullable: true }) - toolInputSchema?: object; + toolInputSchema?: InputJsonSchema; @IsBoolean() @Field() diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts index 286582e094e..d30ce70d16d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts @@ -13,6 +13,7 @@ import { DatabaseEventTriggerSettings, HttpRouteTriggerSettings, } from 'twenty-shared/application'; +import { type InputJsonSchema } from 'twenty-shared/logic-function'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type'; @@ -59,7 +60,7 @@ export class LogicFunctionEntity checksum: string | null; @Column({ nullable: true, type: 'jsonb' }) - toolInputSchema: JsonbProperty | null; + toolInputSchema: JsonbProperty | null; @Column({ nullable: false, default: false }) isTool: boolean; diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts index c711323a090..649cb27831a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/services/logic-function-from-source.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { join } from 'path'; import { v4 } from 'uuid'; +import { SEED_LOGIC_FUNCTION_INPUT_SCHEMA } from 'twenty-shared/logic-function'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; @@ -14,7 +15,6 @@ import { LogicFunctionMetadataService } from 'src/engine/metadata-modules/logic- import { findFlatLogicFunctionOrThrow } from 'src/engine/metadata-modules/logic-function/utils/find-flat-logic-function-or-throw.util'; import { fromFlatLogicFunctionToLogicFunctionDto } from 'src/engine/metadata-modules/logic-function/utils/from-flat-logic-function-to-logic-function-dto.util'; import { CreateLogicFunctionFromSourceInput } from 'src/engine/metadata-modules/logic-function/dtos/create-logic-function-from-source.input'; -import { SEED_LOGIC_FUNCTION_INPUT_SCHEMA } from 'src/engine/core-modules/logic-function/logic-function-resource/constants/seed-logic-function-input-schema'; import { UpdateLogicFunctionFromSourceInput } from 'src/engine/metadata-modules/logic-function/dtos/update-logic-function-from-source.input'; import { getLogicFunctionSubfolderForFromSource } from 'src/engine/metadata-modules/logic-function/utils/get-logic-function-subfolder-for-from-source'; import { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts index 0da5d3f0827..ae41dc74d12 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts @@ -54,6 +54,9 @@ export class UpdateLogicFunctionActionHandlerService extends WorkspaceMigrationR LogicFunctionEntity, ); - await logicFunctionRepository.update({ id: entityId, workspaceId }, update); + await logicFunctionRepository.update( + { id: entityId, workspaceId }, + update as Parameters[1], + ); } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema.ts index 83aaebd59d9..bdc23ee3bfd 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/infer-array-item-schema.ts @@ -6,12 +6,8 @@ import { isString, } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { - buildOutputSchemaFromValue, - type Leaf, - type LeafType, - type Node, -} from 'twenty-shared/workflow'; +import { getOutputSchemaFromValue } from 'twenty-shared/logic-function'; +import { type Leaf, type LeafType, type Node } from 'twenty-shared/workflow'; import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const'; @@ -37,7 +33,7 @@ export const inferArrayItemSchema = ({ } if (isObject(firstItem)) { - const itemSchema = buildOutputSchemaFromValue(firstItem); + const itemSchema = getOutputSchemaFromValue(firstItem); return { isLeaf: false, diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/successful-agent-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/agent/successful-agent-update.integration-spec.ts index 3dd4248b7b6..440af5096ce 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/successful-agent-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/successful-agent-update.integration-spec.ts @@ -1,3 +1,5 @@ +import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; +import { type AgentResponseSchema } from 'twenty-shared/ai'; import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util'; import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util'; import { updateOneAgent } from 'test/integration/metadata/suites/agent/utils/update-one-agent.util'; @@ -148,7 +150,7 @@ describe('Agent update should succeed', () => { id: testAgentId, responseFormat: { type: 'json', - schema: { type: 'object', properties: {} }, + schema: DEFAULT_TOOL_INPUT_SCHEMA as AgentResponseSchema, }, }, }); @@ -227,7 +229,7 @@ describe('Agent update should succeed', () => { modelId: 'gpt-4o-mini', responseFormat: { type: 'json', - schema: { type: 'object', properties: {} }, + schema: DEFAULT_TOOL_INPUT_SCHEMA as AgentResponseSchema, }, evaluationInputs: ['eval 1', 'eval 2'], }, diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index f77079ee625..469350840dd 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -23,6 +23,7 @@ "babel-plugin-module-resolver": "^5.0.2", "glob": "^11.1.0", "tsx": "^4.19.3", + "typescript": "^5.9.2", "vite": "^7.0.0", "vite-plugin-dts": "3.8.1", "vite-tsconfig-paths": "^4.2.1" @@ -64,6 +65,11 @@ "import": "./dist/database-events.mjs", "require": "./dist/database-events.cjs" }, + "./logic-function": { + "types": "./dist/logic-function/index.d.ts", + "import": "./dist/logic-function.mjs", + "require": "./dist/logic-function.cjs" + }, "./metadata": { "types": "./dist/metadata/index.d.ts", "import": "./dist/metadata.mjs", @@ -106,6 +112,7 @@ "application", "constants", "database-events", + "logic-function", "metadata", "testing", "translations", @@ -128,6 +135,9 @@ "database-events": [ "dist/database-events/index.d.ts" ], + "logic-function": [ + "dist/logic-function/index.d.ts" + ], "metadata": [ "dist/metadata/index.d.ts" ], diff --git a/packages/twenty-shared/project.json b/packages/twenty-shared/project.json index 2c9860e7c51..f4d41461d3a 100644 --- a/packages/twenty-shared/project.json +++ b/packages/twenty-shared/project.json @@ -28,6 +28,8 @@ "{projectRoot}/constants/dist", "{projectRoot}/database-events/package.json", "{projectRoot}/database-events/dist", + "{projectRoot}/logic-function/package.json", + "{projectRoot}/logic-function/dist", "{projectRoot}/metadata/package.json", "{projectRoot}/metadata/dist", "{projectRoot}/testing/package.json", diff --git a/packages/twenty-shared/scripts/generateBarrels.ts b/packages/twenty-shared/scripts/generateBarrels.ts index 28aac3dc551..65b93794954 100644 --- a/packages/twenty-shared/scripts/generateBarrels.ts +++ b/packages/twenty-shared/scripts/generateBarrels.ts @@ -271,15 +271,20 @@ const EXCLUDED_DIRECTORIES = [ '**/__stories__/**', '**/internal/**', ] as const; -function getTypeScriptFiles( +const EXCLUDED_FILES = ['**/get-function-input-schema.ts'] as const; +const getTypeScriptFiles = ( directoryPath: string, includeIndex: boolean = false, -): string[] { +): string[] => { const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}')); const files = globSync(pattern, { cwd: SRC_PATH, nodir: true, - ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES], + ignore: [ + ...EXCLUDED_EXTENSIONS, + ...EXCLUDED_DIRECTORIES, + ...EXCLUDED_FILES, + ], }); return files.filter( @@ -287,7 +292,7 @@ function getTypeScriptFiles( !file.endsWith('.d.ts') && (includeIndex ? true : !file.endsWith('index.ts')), ); -} +}; const getKind = ( node: ts.VariableStatement, @@ -305,10 +310,10 @@ const getKind = ( return 'var'; }; -function extractExportsFromSourceFile(sourceFile: ts.SourceFile) { +const extractExportsFromSourceFile = (sourceFile: ts.SourceFile) => { const exports: DeclarationOccurrence[] = []; - function visit(node: ts.Node) { + const visit = (node: ts.Node): void => { if (!ts.canHaveModifiers(node)) { return ts.forEachChild(node, visit); } @@ -409,11 +414,11 @@ function extractExportsFromSourceFile(sourceFile: ts.SourceFile) { break; } return ts.forEachChild(node, visit); - } + }; visit(sourceFile); return exports; -} +}; type ExportKind = | 'type' @@ -430,7 +435,7 @@ type FileExports = Array<{ exports: DeclarationOccurrence[]; }>; -function findAllExports(directoryPath: string): FileExports { +const findAllExports = (directoryPath: string): FileExports => { const results: FileExports = []; const files = getTypeScriptFiles(directoryPath); @@ -453,7 +458,7 @@ function findAllExports(directoryPath: string): FileExports { } return results; -} +}; type ExportByBarrel = { barrel: { diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index df772e694f3..4635a4b49f3 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -27,7 +27,6 @@ export type { } from './fieldManifestType'; export type { FrontComponentManifest } from './frontComponentManifestType'; export type { - InputJsonSchema, LogicFunctionManifest, CronTriggerSettings, DatabaseEventTriggerSettings, diff --git a/packages/twenty-shared/src/application/logicFunctionManifestType.ts b/packages/twenty-shared/src/application/logicFunctionManifestType.ts index 0634e518fc4..9aa5663859d 100644 --- a/packages/twenty-shared/src/application/logicFunctionManifestType.ts +++ b/packages/twenty-shared/src/application/logicFunctionManifestType.ts @@ -1,23 +1,6 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; import { type HTTPMethod } from '@/types'; - -// Standard JSON Schema type for tool input/output definitions -export type InputJsonSchema = { - type?: - | 'string' - | 'number' - | 'boolean' - | 'object' - | 'array' - | 'integer' - | 'null'; - description?: string; - enum?: unknown[]; - items?: InputJsonSchema; - properties?: Record; - required?: string[]; - additionalProperties?: boolean | InputJsonSchema; -}; +import { type InputJsonSchema } from '@/logic-function/input-json-schema.type'; export type LogicFunctionManifest = SyncableEntityOptions & { name?: string; @@ -30,7 +13,7 @@ export type LogicFunctionManifest = SyncableEntityOptions & { builtHandlerPath: string; builtHandlerChecksum: string; handlerName: string; - toolInputSchema?: InputJsonSchema; + toolInputSchema: InputJsonSchema; isTool?: boolean; }; diff --git a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputSchema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts similarity index 95% rename from packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputSchema.test.ts rename to packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts index a023ed62d7c..79e411bba08 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/__tests__/getFunctionInputSchema.test.ts +++ b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts @@ -1,4 +1,4 @@ -import { getFunctionInputSchema } from '@/logic-functions/utils/getFunctionInputSchema'; +import { getFunctionInputSchema } from '@/logic-function/get-function-input-schema'; describe('getFunctionInputSchema', () => { it('should analyze a simple function correctly', () => { diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts new file mode 100644 index 00000000000..fc0d82e0b34 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts @@ -0,0 +1,75 @@ +import { getInputSchemaFromSourceCode } from '@/logic-function/get-input-schema-from-source-code'; +import { DEFAULT_TOOL_INPUT_SCHEMA } from '@/logic-function/constants/default-tool-input-schema'; + +describe('getInputSchemaFromSourceCode', () => { + it('should return empty input if not parameter', async () => { + const fileContent = 'function testFunction() { return }'; + const result = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA); + }); + it('should return first input if multiple parameters', async () => { + const fileContent = + 'function testFunction(params1: {}, params2: {}) { return }'; + const result = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA); + }); + it('should return empty input if wrong parameter', async () => { + const fileContent = 'function testFunction(params: string) { return }'; + const result = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA); + }); + it('should return input from source code', async () => { + 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 = await getInputSchemaFromSourceCode(fileContent); + expect(result).toEqual({ + properties: { + param1: { + type: 'string', + }, + param2: { + type: 'number', + }, + param3: { + type: 'boolean', + }, + param4: { + type: 'object', + }, + param5: { + properties: { + subParam1: { + type: 'string', + }, + }, + type: 'object', + }, + param6: { + enum: ['my', 'enum'], + type: 'string', + }, + param7: { + items: { + type: 'string', + }, + type: 'array', + }, + }, + type: 'object', + }); + }); +}); diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/buildOutputSchemaFromValue.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-output-schema-from-value.test.ts similarity index 85% rename from packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/buildOutputSchemaFromValue.test.ts rename to packages/twenty-shared/src/logic-function/__tests__/get-output-schema-from-value.test.ts index 631e477538f..98fe3718561 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/__tests__/buildOutputSchemaFromValue.test.ts +++ b/packages/twenty-shared/src/logic-function/__tests__/get-output-schema-from-value.test.ts @@ -1,6 +1,6 @@ -import { buildOutputSchemaFromValue } from '../buildOutputSchemaFromValue'; +import { getOutputSchemaFromValue } from '@/logic-function/get-output-schema-from-value'; -describe('buildOutputSchemaFromValue', () => { +describe('getOutputSchemaFromValue', () => { it('should compute outputSchema properly for mixed types', () => { const testResult = { a: null, @@ -48,9 +48,7 @@ describe('buildOutputSchemaFromValue', () => { label: 'e', }, }; - expect(buildOutputSchemaFromValue(testResult)).toEqual( - expectedOutputSchema, - ); + expect(getOutputSchemaFromValue(testResult)).toEqual(expectedOutputSchema); }); it('should handle nested objects', () => { @@ -64,7 +62,7 @@ describe('buildOutputSchemaFromValue', () => { }, }; - const result = buildOutputSchemaFromValue(testResult); + const result = getOutputSchemaFromValue(testResult); expect(result.user).toEqual({ isLeaf: false, @@ -101,6 +99,6 @@ describe('buildOutputSchemaFromValue', () => { }); it('should return empty object for empty input', () => { - expect(buildOutputSchemaFromValue({})).toEqual({}); + expect(getOutputSchemaFromValue({})).toEqual({}); }); }); diff --git a/packages/twenty-shared/src/logic-function/constants/default-tool-input-schema.ts b/packages/twenty-shared/src/logic-function/constants/default-tool-input-schema.ts new file mode 100644 index 00000000000..1206410e33d --- /dev/null +++ b/packages/twenty-shared/src/logic-function/constants/default-tool-input-schema.ts @@ -0,0 +1,6 @@ +import { type InputJsonSchema } from '@/logic-function'; + +export const DEFAULT_TOOL_INPUT_SCHEMA: InputJsonSchema = { + type: 'object', + properties: {}, +}; diff --git a/packages/twenty-shared/src/logic-function/constants/seed-logic-function-input-schema.ts b/packages/twenty-shared/src/logic-function/constants/seed-logic-function-input-schema.ts new file mode 100644 index 00000000000..9622e8db99e --- /dev/null +++ b/packages/twenty-shared/src/logic-function/constants/seed-logic-function-input-schema.ts @@ -0,0 +1,9 @@ +import { type InputJsonSchema } from '@/logic-function'; + +export const SEED_LOGIC_FUNCTION_INPUT_SCHEMA: InputJsonSchema = { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' }, + }, +}; diff --git a/packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputSchema.ts b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts similarity index 85% rename from packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputSchema.ts rename to packages/twenty-shared/src/logic-function/get-function-input-schema.ts index 6486ee0ac92..215be75a1bb 100644 --- a/packages/twenty-front/src/modules/logic-functions/utils/getFunctionInputSchema.ts +++ b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts @@ -1,7 +1,3 @@ -import { - type InputSchema, - type InputSchemaProperty, -} from '@/workflow/types/InputSchema'; import { type ArrayTypeNode, type ArrowFunction, @@ -18,9 +14,11 @@ import { type UnionTypeNode, type VariableStatement, } from 'typescript'; -import { isDefined } from 'twenty-shared/utils'; -const getTypeString = (typeNode: TypeNode): InputSchemaProperty => { +import { type InputJsonSchema } from '@/logic-function'; +import { isDefined } from '@/utils/validation/isDefined'; + +const getTypeString = (typeNode: TypeNode): InputJsonSchema => { switch (typeNode.kind) { case SyntaxKind.NumberKeyword: return { type: 'number' }; @@ -36,7 +34,7 @@ const getTypeString = (typeNode: TypeNode): InputSchemaProperty => { case SyntaxKind.ObjectKeyword: return { type: 'object' }; case SyntaxKind.TypeLiteral: { - const properties: InputSchemaProperty['properties'] = {}; + const properties: InputJsonSchema['properties'] = {}; (typeNode as any).members.forEach((member: PropertySignature) => { if (isDefined(member.name) && isDefined(member.type)) { @@ -72,17 +70,17 @@ const getTypeString = (typeNode: TypeNode): InputSchemaProperty => { return { type: 'string', enum: enumValues }; } - return { type: 'unknown' }; + return {}; } default: - return { type: 'unknown' }; + return {}; } }; const computeFunctionParameters = ( funcNode: FunctionDeclaration | FunctionLikeDeclaration | ArrowFunction, - schema: InputSchema, -): InputSchema => { + schema: InputJsonSchema[], +): InputJsonSchema[] => { const params = funcNode.parameters; return params.reduce((updatedSchema, param) => { @@ -91,7 +89,7 @@ const computeFunctionParameters = ( if (isDefined(typeNode)) { return [...updatedSchema, getTypeString(typeNode)]; } else { - return [...updatedSchema, { type: 'unknown' }]; + return [...updatedSchema, {}]; } }, schema); }; @@ -103,6 +101,7 @@ const extractFunctions = (node: Node): FunctionLikeDeclaration[] => { if (node.kind === SyntaxKind.VariableStatement) { const varStatement = node as VariableStatement; + return varStatement.declarationList.declarations .filter( (declaration) => @@ -115,14 +114,16 @@ const extractFunctions = (node: Node): FunctionLikeDeclaration[] => { return []; }; -export const getFunctionInputSchema = (fileContent: string): InputSchema => { +export const getFunctionInputSchema = ( + fileContent: string, +): InputJsonSchema[] => { const sourceFile = createSourceFile( 'temp.ts', fileContent, ScriptTarget.ESNext, true, ); - let schema: InputSchema = []; + let schema: InputJsonSchema[] = []; sourceFile.forEachChild((node) => { if ( @@ -130,6 +131,7 @@ export const getFunctionInputSchema = (fileContent: string): InputSchema => { node.kind === SyntaxKind.VariableStatement ) { const functions = extractFunctions(node); + functions.forEach((func) => { schema = computeFunctionParameters(func, schema); }); diff --git a/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts new file mode 100644 index 00000000000..5c25d49f286 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts @@ -0,0 +1,26 @@ +import { isDefined } from '@/utils/validation/isDefined'; +import { + DEFAULT_TOOL_INPUT_SCHEMA, + type InputJsonSchema, +} from '@/logic-function'; + +export const getInputSchemaFromSourceCode = async ( + sourceCode: string, +): Promise => { + const { getFunctionInputSchema } = await import( + './get-function-input-schema' + ); + const inputSchema = getFunctionInputSchema(sourceCode); + + // Logic functions take a single params object + const firstParam = inputSchema[0]; + + if (firstParam?.type === 'object' && isDefined(firstParam.properties)) { + return { + type: 'object', + properties: firstParam.properties, + }; + } + + return DEFAULT_TOOL_INPUT_SCHEMA; +}; diff --git a/packages/twenty-shared/src/workflow/workflow-schema/utils/buildOutputSchemaFromValue.ts b/packages/twenty-shared/src/logic-function/get-output-schema-from-value.ts similarity index 92% rename from packages/twenty-shared/src/workflow/workflow-schema/utils/buildOutputSchemaFromValue.ts rename to packages/twenty-shared/src/logic-function/get-output-schema-from-value.ts index 132cf8cbc6d..8c2f2f7863e 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/utils/buildOutputSchemaFromValue.ts +++ b/packages/twenty-shared/src/logic-function/get-output-schema-from-value.ts @@ -24,7 +24,7 @@ const getValueType = (value: any): LeafType => { return 'unknown'; }; -export const buildOutputSchemaFromValue = ( +export const getOutputSchemaFromValue = ( testResult: object, ): BaseOutputSchemaV2 => { return testResult @@ -35,7 +35,7 @@ export const buildOutputSchemaFromValue = ( isLeaf: false, type: 'object', label: key, - value: buildOutputSchemaFromValue(value), + value: getOutputSchemaFromValue(value), }; } else { acc[key] = { diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts new file mode 100644 index 00000000000..23f5e1b2925 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/index.ts @@ -0,0 +1,14 @@ +/* + * _____ _ + *|_ _|_ _____ _ __ | |_ _ _ + * | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file + * | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden + * |_| \_/\_/ \___|_| |_|\__|\__, | + * |___/ + */ + +export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/default-tool-input-schema'; +export { SEED_LOGIC_FUNCTION_INPUT_SCHEMA } from './constants/seed-logic-function-input-schema'; +export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code'; +export { getOutputSchemaFromValue } from './get-output-schema-from-value'; +export type { InputJsonSchema } from './input-json-schema.type'; diff --git a/packages/twenty-shared/src/logic-function/input-json-schema.type.ts b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts new file mode 100644 index 00000000000..4a40447a972 --- /dev/null +++ b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts @@ -0,0 +1,16 @@ +export type InputJsonSchema = { + type?: + | 'string' + | 'number' + | 'boolean' + | 'object' + | 'array' + | 'integer' + | 'null'; + description?: string; + enum?: unknown[]; + items?: InputJsonSchema; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | InputJsonSchema; +}; diff --git a/packages/twenty-shared/src/metadata/__tests__/addCustomSuffixIfReserved.test.ts b/packages/twenty-shared/src/metadata/__tests__/addCustomSuffixIfReserved.test.ts new file mode 100644 index 00000000000..090d1f0d3d6 --- /dev/null +++ b/packages/twenty-shared/src/metadata/__tests__/addCustomSuffixIfReserved.test.ts @@ -0,0 +1,19 @@ +import { addCustomSuffixIfIsReserved } from '@/metadata/add-custom-suffix-if-reserved.util'; + +describe('addCustomSuffixIfIsReserved', () => { + it('should add Custom suffix for reserved name "event"', () => { + expect(addCustomSuffixIfIsReserved('event')).toBe('eventCustom'); + }); + + it('should not modify non-reserved names', () => { + expect(addCustomSuffixIfIsReserved('myField')).toBe('myField'); + }); + + it('should add Custom suffix for reserved name "type"', () => { + expect(addCustomSuffixIfIsReserved('type')).toBe('typeCustom'); + }); + + it('should return empty string for empty input', () => { + expect(addCustomSuffixIfIsReserved('')).toBe(''); + }); +}); diff --git a/packages/twenty-shared/src/metadata/__tests__/computeMetadataNameFromLabel.test.ts b/packages/twenty-shared/src/metadata/__tests__/computeMetadataNameFromLabel.test.ts new file mode 100644 index 00000000000..db84d1c35a2 --- /dev/null +++ b/packages/twenty-shared/src/metadata/__tests__/computeMetadataNameFromLabel.test.ts @@ -0,0 +1,35 @@ +import { computeMetadataNameFromLabel } from '@/metadata/compute-metadata-name-from-label.util'; + +describe('computeMetadataNameFromLabel', () => { + it('should convert a label to camelCase', () => { + expect(computeMetadataNameFromLabel({ label: 'My Custom Field' })).toBe( + 'myCustomField', + ); + }); + + it('should return empty string for empty label', () => { + expect(computeMetadataNameFromLabel({ label: '' })).toBe(''); + }); + + it('should prefix numeric labels with n', () => { + expect(computeMetadataNameFromLabel({ label: '123 Field' })).toBe( + 'n123Field', + ); + }); + + it('should add Custom suffix for reserved words', () => { + const result = computeMetadataNameFromLabel({ label: 'Name' }); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should skip custom suffix when applyCustomSuffix is false', () => { + const result = computeMetadataNameFromLabel({ + label: 'My Field', + applyCustomSuffix: false, + }); + + expect(result).toBe('myField'); + }); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/fromArrayToValuesByKeyRecord.test.ts b/packages/twenty-shared/src/utils/__tests__/fromArrayToValuesByKeyRecord.test.ts new file mode 100644 index 00000000000..5b616279232 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/fromArrayToValuesByKeyRecord.test.ts @@ -0,0 +1,44 @@ +import { fromArrayToValuesByKeyRecord } from '@/utils/fromArrayToValuesByKeyRecord.util'; + +describe('fromArrayToValuesByKeyRecord', () => { + it('should group items by the specified key', () => { + const array = [ + { name: 'Alice', role: 'admin' }, + { name: 'Bob', role: 'user' }, + { name: 'Charlie', role: 'admin' }, + ]; + + const result = fromArrayToValuesByKeyRecord({ array, key: 'role' }); + + expect(result).toEqual({ + admin: [ + { name: 'Alice', role: 'admin' }, + { name: 'Charlie', role: 'admin' }, + ], + user: [{ name: 'Bob', role: 'user' }], + }); + }); + + it('should return an empty object for an empty array', () => { + const result = fromArrayToValuesByKeyRecord({ + array: [] as { name: string }[], + key: 'name', + }); + + expect(result).toEqual({}); + }); + + it('should handle unique keys', () => { + const array = [ + { name: 'Alice', role: 'admin' }, + { name: 'Bob', role: 'user' }, + ]; + + const result = fromArrayToValuesByKeyRecord({ array, key: 'name' }); + + expect(result).toEqual({ + Alice: [{ name: 'Alice', role: 'admin' }], + Bob: [{ name: 'Bob', role: 'user' }], + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/getURLSafely.test.ts b/packages/twenty-shared/src/utils/__tests__/getURLSafely.test.ts new file mode 100644 index 00000000000..23af1528cff --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/getURLSafely.test.ts @@ -0,0 +1,16 @@ +import { getURLSafely } from '@/utils/getURLSafely'; + +describe('getURLSafely', () => { + it('should return a URL object for a valid URL', () => { + const result = getURLSafely('https://example.com'); + + expect(result).toBeInstanceOf(URL); + expect(result?.hostname).toBe('example.com'); + }); + + it('should return null for an invalid URL', () => { + const result = getURLSafely('not-a-url'); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/removePropertiesFromRecord.test.ts b/packages/twenty-shared/src/utils/__tests__/removePropertiesFromRecord.test.ts new file mode 100644 index 00000000000..423d44a1b87 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/removePropertiesFromRecord.test.ts @@ -0,0 +1,36 @@ +import { removePropertiesFromRecord } from '@/utils/removePropertiesFromRecord'; + +describe('removePropertiesFromRecord', () => { + it('should remove specified keys from the record', () => { + const record = { a: 1, b: 2, c: 3 }; + + const result = removePropertiesFromRecord(record, ['b']); + + expect(result).toEqual({ a: 1, c: 3 }); + expect('b' in result).toBe(false); + }); + + it('should remove multiple keys', () => { + const record = { a: 1, b: 2, c: 3 }; + + const result = removePropertiesFromRecord(record, ['a', 'c']); + + expect(result).toEqual({ b: 2 }); + }); + + it('should not mutate the original record', () => { + const record = { a: 1, b: 2 }; + + removePropertiesFromRecord(record, ['a']); + + expect(record).toEqual({ a: 1, b: 2 }); + }); + + it('should return the same shape when no keys are removed', () => { + const record = { a: 1, b: 2 }; + + const result = removePropertiesFromRecord(record, []); + + expect(result).toEqual({ a: 1, b: 2 }); + }); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/uuidToBase36.test.ts b/packages/twenty-shared/src/utils/__tests__/uuidToBase36.test.ts new file mode 100644 index 00000000000..c807b82db8d --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/uuidToBase36.test.ts @@ -0,0 +1,25 @@ +import { uuidToBase36 } from '@/utils/uuidToBase36'; + +describe('uuidToBase36', () => { + it('should convert a UUID to base36', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + const result = uuidToBase36(uuid); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // base36 only contains alphanumeric characters + expect(result).toMatch(/^[0-9a-z]+$/); + }); + + it('should produce consistent results', () => { + const uuid = '00000000-0000-0000-0000-000000000001'; + + expect(uuidToBase36(uuid)).toBe(uuidToBase36(uuid)); + }); + + it('should convert the zero UUID', () => { + const uuid = '00000000-0000-0000-0000-000000000000'; + + expect(uuidToBase36(uuid)).toBe('0'); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/filterOutByProperty.test.ts b/packages/twenty-shared/src/utils/array/__tests__/filterOutByProperty.test.ts new file mode 100644 index 00000000000..d1b072b587e --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/filterOutByProperty.test.ts @@ -0,0 +1,40 @@ +import { filterOutByProperty } from '@/utils/array/filterOutByProperty'; + +describe('filterOutByProperty', () => { + it('should filter out items matching the excluded value', () => { + const items = [ + { id: '1', status: 'active' }, + { id: '2', status: 'inactive' }, + { id: '3', status: 'active' }, + ]; + + const result = items.filter(filterOutByProperty('status', 'inactive')); + + expect(result).toEqual([ + { id: '1', status: 'active' }, + { id: '3', status: 'active' }, + ]); + }); + + it('should keep all items when no match exists', () => { + const items = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + + const result = items.filter(filterOutByProperty('name', 'Charlie')); + + expect(result).toEqual(items); + }); + + it('should handle null exclusion value', () => { + const items = [ + { id: '1', name: null as string | null }, + { id: '2', name: 'Bob' }, + ]; + + const result = items.filter(filterOutByProperty('name', null)); + + expect(result).toEqual([{ id: '2', name: 'Bob' }]); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/findByProperty.test.ts b/packages/twenty-shared/src/utils/array/__tests__/findByProperty.test.ts new file mode 100644 index 00000000000..a2c1e08e629 --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/findByProperty.test.ts @@ -0,0 +1,33 @@ +import { findByProperty } from '@/utils/array/findByProperty'; + +describe('findByProperty', () => { + it('should find item matching the value', () => { + const items = [ + { id: '1', status: 'active' }, + { id: '2', status: 'inactive' }, + ]; + + const result = items.find(findByProperty('status', 'inactive')); + + expect(result).toEqual({ id: '2', status: 'inactive' }); + }); + + it('should return undefined when no match exists', () => { + const items = [{ id: '1', name: 'Alice' }]; + + const result = items.find(findByProperty('name', 'Bob')); + + expect(result).toBeUndefined(); + }); + + it('should handle null match value', () => { + const items = [ + { id: '1', name: null as string | null }, + { id: '2', name: 'Bob' }, + ]; + + const result = items.find(findByProperty('name', null)); + + expect(result).toEqual({ id: '1', name: null }); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/getContiguousIncrementalValues.test.ts b/packages/twenty-shared/src/utils/array/__tests__/getContiguousIncrementalValues.test.ts new file mode 100644 index 00000000000..333b660f963 --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/getContiguousIncrementalValues.test.ts @@ -0,0 +1,19 @@ +import { getContiguousIncrementalValues } from '@/utils/array/getContiguousIncrementalValues'; + +describe('getContiguousIncrementalValues', () => { + it('should return incremental values starting from 0 by default', () => { + expect(getContiguousIncrementalValues(5)).toEqual([0, 1, 2, 3, 4]); + }); + + it('should return incremental values starting from a custom value', () => { + expect(getContiguousIncrementalValues(3, 10)).toEqual([10, 11, 12]); + }); + + it('should return an empty array for 0 values', () => { + expect(getContiguousIncrementalValues(0)).toEqual([]); + }); + + it('should handle negative starting values', () => { + expect(getContiguousIncrementalValues(3, -2)).toEqual([-2, -1, 0]); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/mapById.test.ts b/packages/twenty-shared/src/utils/array/__tests__/mapById.test.ts new file mode 100644 index 00000000000..4fbda23bb50 --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/mapById.test.ts @@ -0,0 +1,13 @@ +import { mapById } from '@/utils/array/mapById'; + +describe('mapById', () => { + it('should extract the id from an item', () => { + expect(mapById({ id: 'abc-123' })).toBe('abc-123'); + }); + + it('should work with Array.map', () => { + const items = [{ id: '1' }, { id: '2' }, { id: '3' }]; + + expect(items.map(mapById)).toEqual(['1', '2', '3']); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/mapByProperty.test.ts b/packages/twenty-shared/src/utils/array/__tests__/mapByProperty.test.ts new file mode 100644 index 00000000000..ed54b96c301 --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/mapByProperty.test.ts @@ -0,0 +1,18 @@ +import { mapByProperty } from '@/utils/array/mapByProperty'; + +describe('mapByProperty', () => { + it('should extract the specified property from an item', () => { + const items = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ]; + + expect(items.map(mapByProperty('name'))).toEqual(['Alice', 'Bob']); + }); + + it('should extract the id property', () => { + const items = [{ id: 'a' }, { id: 'b' }]; + + expect(items.map(mapByProperty('id'))).toEqual(['a', 'b']); + }); +}); diff --git a/packages/twenty-shared/src/utils/array/__tests__/sumByProperty.test.ts b/packages/twenty-shared/src/utils/array/__tests__/sumByProperty.test.ts new file mode 100644 index 00000000000..06b2a28cc9d --- /dev/null +++ b/packages/twenty-shared/src/utils/array/__tests__/sumByProperty.test.ts @@ -0,0 +1,29 @@ +import { sumByProperty } from '@/utils/array/sumByProperty'; + +describe('sumByProperty', () => { + it('should sum numeric property values', () => { + const items = [ + { id: '1', amount: 10 }, + { id: '2', amount: 20 }, + { id: '3', amount: 30 }, + ]; + + expect(items.reduce(sumByProperty('amount'), 0)).toBe(60); + }); + + it('should skip non-numeric values', () => { + const items = [ + { id: '1', amount: 10 }, + { id: '2', amount: 'not-a-number' as unknown as number }, + { id: '3', amount: 30 }, + ]; + + expect(items.reduce(sumByProperty('amount'), 0)).toBe(40); + }); + + it('should handle an empty array', () => { + const items: { id: string; amount: number }[] = []; + + expect(items.reduce(sumByProperty('amount'), 0)).toBe(0); + }); +}); diff --git a/packages/twenty-shared/src/utils/date/__tests__/dateConversions.test.ts b/packages/twenty-shared/src/utils/date/__tests__/dateConversions.test.ts new file mode 100644 index 00000000000..83ba8f04d26 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/dateConversions.test.ts @@ -0,0 +1,67 @@ +import { Temporal } from 'temporal-polyfill'; + +import { parseToPlainDateOrThrow } from '@/utils/date/parseToPlainDateOrThrow'; +import { turnJSDateToPlainDate } from '@/utils/date/turnJSDateToPlainDate'; +import { turnPlainDateIntoUserTimeZoneInstantString } from '@/utils/date/turnPlainDateIntoUserTimeZoneInstantString'; +import { turnPlainDateToShiftedDateInSystemTimeZone } from '@/utils/date/turnPlainDateToShiftedDateInSystemTimeZone'; + +describe('parseToPlainDateOrThrow', () => { + it('should parse an ISO instant string', () => { + const result = parseToPlainDateOrThrow('2024-03-15T10:00:00Z'); + + expect(result.year).toBe(2024); + expect(result.month).toBe(3); + expect(result.day).toBe(15); + }); + + it('should parse a plain date string', () => { + const result = parseToPlainDateOrThrow('2024-03-15'); + + expect(result.year).toBe(2024); + expect(result.month).toBe(3); + expect(result.day).toBe(15); + }); + + it('should throw for an invalid date string', () => { + expect(() => parseToPlainDateOrThrow('not-a-date')).toThrow( + 'Cannot parse date string as PlainDate', + ); + }); +}); + +describe('turnJSDateToPlainDate', () => { + it('should convert a JS Date to a PlainDate', () => { + const jsDate = new Date(2024, 2, 15); // March 15, 2024 + + const result = turnJSDateToPlainDate(jsDate); + + expect(result.year).toBe(2024); + expect(result.month).toBe(3); + expect(result.day).toBe(15); + }); +}); + +describe('turnPlainDateIntoUserTimeZoneInstantString', () => { + it('should convert a PlainDate to an instant string in the given timezone', () => { + const plainDate = Temporal.PlainDate.from('2024-03-15'); + const result = turnPlainDateIntoUserTimeZoneInstantString( + plainDate, + 'UTC', + ); + + expect(result).toBe('2024-03-15T00:00:00Z'); + }); +}); + +describe('turnPlainDateToShiftedDateInSystemTimeZone', () => { + it('should return a JS Date for the given PlainDate', () => { + const plainDate = Temporal.PlainDate.from('2024-03-15'); + + const result = turnPlainDateToShiftedDateInSystemTimeZone(plainDate); + + expect(result).toBeInstanceOf(Date); + expect(result.getFullYear()).toBe(2024); + expect(result.getMonth()).toBe(2); // 0-indexed + expect(result.getDate()).toBe(15); + }); +}); diff --git a/packages/twenty-shared/src/utils/date/__tests__/plainDateComparisons.test.ts b/packages/twenty-shared/src/utils/date/__tests__/plainDateComparisons.test.ts new file mode 100644 index 00000000000..5f8f438abe5 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/plainDateComparisons.test.ts @@ -0,0 +1,132 @@ +import { Temporal } from 'temporal-polyfill'; + +import { isPlainDateAfter } from '@/utils/date/isPlainDateAfter'; +import { isPlainDateBefore } from '@/utils/date/isPlainDateBefore'; +import { isPlainDateBeforeOrEqual } from '@/utils/date/isPlainDateBeforeOrEqual'; +import { isPlainDateInSameMonth } from '@/utils/date/isPlainDateInSameMonth'; +import { isPlainDateInWeekend } from '@/utils/date/isPlainDateInWeekend'; +import { isSamePlainDate } from '@/utils/date/isSamePlainDate'; + +describe('isPlainDateAfter', () => { + it('should return true when first date is after second', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-10'); + + expect(isPlainDateAfter(a, b)).toBe(true); + }); + + it('should return false when first date is before second', () => { + const a = Temporal.PlainDate.from('2024-03-10'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateAfter(a, b)).toBe(false); + }); + + it('should return false when dates are equal', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateAfter(a, b)).toBe(false); + }); +}); + +describe('isPlainDateBefore', () => { + it('should return true when first date is before second', () => { + const a = Temporal.PlainDate.from('2024-03-10'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateBefore(a, b)).toBe(true); + }); + + it('should return false when first date is after second', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-10'); + + expect(isPlainDateBefore(a, b)).toBe(false); + }); +}); + +describe('isPlainDateBeforeOrEqual', () => { + it('should return true when first date is before second', () => { + const a = Temporal.PlainDate.from('2024-03-10'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateBeforeOrEqual(a, b)).toBe(true); + }); + + it('should return true when dates are equal', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateBeforeOrEqual(a, b)).toBe(true); + }); + + it('should return false when first date is after second', () => { + const a = Temporal.PlainDate.from('2024-03-20'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isPlainDateBeforeOrEqual(a, b)).toBe(false); + }); +}); + +describe('isPlainDateInSameMonth', () => { + it('should return true for dates in same month and year', () => { + const a = Temporal.PlainDate.from('2024-03-01'); + const b = Temporal.PlainDate.from('2024-03-31'); + + expect(isPlainDateInSameMonth(a, b)).toBe(true); + }); + + it('should return false for dates in different months', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-04-15'); + + expect(isPlainDateInSameMonth(a, b)).toBe(false); + }); + + it('should return false for same month but different year', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2025-03-15'); + + expect(isPlainDateInSameMonth(a, b)).toBe(false); + }); +}); + +describe('isPlainDateInWeekend', () => { + it('should return true for Saturday', () => { + // 2024-03-16 is a Saturday + const saturday = Temporal.PlainDate.from('2024-03-16'); + + expect(isPlainDateInWeekend(saturday)).toBe(true); + }); + + it('should return true for Sunday', () => { + // 2024-03-17 is a Sunday + const sunday = Temporal.PlainDate.from('2024-03-17'); + + expect(isPlainDateInWeekend(sunday)).toBe(true); + }); + + it('should return false for a weekday', () => { + // 2024-03-18 is a Monday + const monday = Temporal.PlainDate.from('2024-03-18'); + + expect(isPlainDateInWeekend(monday)).toBe(false); + }); +}); + +describe('isSamePlainDate', () => { + it('should return true for equal dates', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-15'); + + expect(isSamePlainDate(a, b)).toBe(true); + }); + + it('should return false for different dates', () => { + const a = Temporal.PlainDate.from('2024-03-15'); + const b = Temporal.PlainDate.from('2024-03-16'); + + expect(isSamePlainDate(a, b)).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/errors/__tests__/CustomError.test.ts b/packages/twenty-shared/src/utils/errors/__tests__/CustomError.test.ts new file mode 100644 index 00000000000..3a917f85da3 --- /dev/null +++ b/packages/twenty-shared/src/utils/errors/__tests__/CustomError.test.ts @@ -0,0 +1,18 @@ +import { CustomError } from '@/utils/errors/CustomError'; + +describe('CustomError', () => { + it('should create an error with a message', () => { + const error = new CustomError('Something went wrong'); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Something went wrong'); + expect(error.code).toBeUndefined(); + }); + + it('should create an error with a message and code', () => { + const error = new CustomError('Not found', 'NOT_FOUND'); + + expect(error.message).toBe('Not found'); + expect(error.code).toBe('NOT_FOUND'); + }); +}); diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/computeMorphRelationFieldName.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/computeMorphRelationFieldName.test.ts new file mode 100644 index 00000000000..c16b815b1a9 --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/computeMorphRelationFieldName.test.ts @@ -0,0 +1,36 @@ +import { computeMorphRelationFieldName } from '@/utils/fieldMetadata/compute-morph-relation-field-name'; + +describe('computeMorphRelationFieldName', () => { + it('should return singular-based name for MANY_TO_ONE', () => { + const result = computeMorphRelationFieldName({ + fieldName: 'assigned', + relationType: 'MANY_TO_ONE' as any, + targetObjectMetadataNameSingular: 'person', + targetObjectMetadataNamePlural: 'people', + }); + + expect(result).toBe('assignedPerson'); + }); + + it('should return plural-based name for ONE_TO_MANY', () => { + const result = computeMorphRelationFieldName({ + fieldName: 'assigned', + relationType: 'ONE_TO_MANY' as any, + targetObjectMetadataNameSingular: 'person', + targetObjectMetadataNamePlural: 'people', + }); + + expect(result).toBe('assignedPeople'); + }); + + it('should throw for invalid relation type', () => { + expect(() => + computeMorphRelationFieldName({ + fieldName: 'assigned', + relationType: 'INVALID' as any, + targetObjectMetadataNameSingular: 'person', + targetObjectMetadataNamePlural: 'people', + }), + ).toThrow(); + }); +}); diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataDateKind.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataDateKind.test.ts new file mode 100644 index 00000000000..7b161ec058c --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataDateKind.test.ts @@ -0,0 +1,20 @@ +import { FieldMetadataType } from '@/types'; +import { isFieldMetadataDateKind } from '@/utils/fieldMetadata/isFieldMetadataDateKind'; + +describe('isFieldMetadataDateKind', () => { + it('should return true for DATE type', () => { + expect(isFieldMetadataDateKind(FieldMetadataType.DATE)).toBe(true); + }); + + it('should return true for DATE_TIME type', () => { + expect(isFieldMetadataDateKind(FieldMetadataType.DATE_TIME)).toBe(true); + }); + + it('should return false for TEXT type', () => { + expect(isFieldMetadataDateKind(FieldMetadataType.TEXT)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isFieldMetadataDateKind(undefined)).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForEmails.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForEmails.test.ts new file mode 100644 index 00000000000..750c5e72c4f --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForEmails.test.ts @@ -0,0 +1,52 @@ +import { FieldMetadataType, ViewFilterOperand } from '@/types'; +import { computeEmptyGqlOperationFilterForEmails } from '@/utils/filter/computeEmptyGqlOperationFilterForEmails'; + +describe('computeEmptyGqlOperationFilterForEmails', () => { + const baseFilter = { + id: '1', + fieldMetadataId: 'f1', + value: '', + type: 'EMAILS' as const, + operand: ViewFilterOperand.IS_EMPTY, + }; + + const field = { name: 'email', type: FieldMetadataType.EMAILS }; + + it('should compute filter for primaryEmail subfield', () => { + const result = computeEmptyGqlOperationFilterForEmails({ + recordFilter: { ...baseFilter, subFieldName: 'primaryEmail' }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('or'); + expect((result as any).or).toHaveLength(2); + }); + + it('should compute filter for additionalEmails subfield', () => { + const result = computeEmptyGqlOperationFilterForEmails({ + recordFilter: { ...baseFilter, subFieldName: 'additionalEmails' }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('or'); + }); + + it('should throw for unknown subfield', () => { + expect(() => + computeEmptyGqlOperationFilterForEmails({ + recordFilter: { ...baseFilter, subFieldName: 'unknown' as any }, + correspondingFieldMetadataItem: field, + }), + ).toThrow('Unknown subfield name'); + }); + + it('should compute combined filter when no subfield is specified', () => { + const result = computeEmptyGqlOperationFilterForEmails({ + recordFilter: { ...baseFilter, subFieldName: undefined }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('and'); + expect((result as any).and).toHaveLength(2); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForLinks.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForLinks.test.ts new file mode 100644 index 00000000000..67f0655ad57 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/__tests__/computeEmptyGqlOperationFilterForLinks.test.ts @@ -0,0 +1,60 @@ +import { ViewFilterOperand } from '@/types'; +import { computeEmptyGqlOperationFilterForLinks } from '@/utils/filter/computeEmptyGqlOperationFilterForLinks'; + +describe('computeEmptyGqlOperationFilterForLinks', () => { + const baseFilter = { + id: '1', + fieldMetadataId: 'f1', + value: '', + type: 'LINKS' as const, + operand: ViewFilterOperand.IS_EMPTY, + }; + + const field = { name: 'links' }; + + it('should compute filter for primaryLinkLabel subfield', () => { + const result = computeEmptyGqlOperationFilterForLinks({ + recordFilter: { ...baseFilter, subFieldName: 'primaryLinkLabel' }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('or'); + }); + + it('should compute filter for primaryLinkUrl subfield', () => { + const result = computeEmptyGqlOperationFilterForLinks({ + recordFilter: { ...baseFilter, subFieldName: 'primaryLinkUrl' }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('or'); + }); + + it('should compute filter for secondaryLinks subfield', () => { + const result = computeEmptyGqlOperationFilterForLinks({ + recordFilter: { ...baseFilter, subFieldName: 'secondaryLinks' }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('or'); + }); + + it('should throw for unknown subfield', () => { + expect(() => + computeEmptyGqlOperationFilterForLinks({ + recordFilter: { ...baseFilter, subFieldName: 'unknown' as any }, + correspondingFieldMetadataItem: field, + }), + ).toThrow('Unknown subfield name'); + }); + + it('should compute combined filter when no subfield is specified', () => { + const result = computeEmptyGqlOperationFilterForLinks({ + recordFilter: { ...baseFilter, subFieldName: undefined }, + correspondingFieldMetadataItem: field, + }); + + expect(result).toHaveProperty('and'); + expect((result as any).and).toHaveLength(3); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterGroupIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterGroupIntoGqlOperationFilter.test.ts new file mode 100644 index 00000000000..a2d4c77fd1b --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterGroupIntoGqlOperationFilter.test.ts @@ -0,0 +1,121 @@ +import { + FieldMetadataType, + RecordFilterGroupLogicalOperator, + ViewFilterOperand, +} from '@/types'; +import { turnRecordFilterGroupsIntoGqlOperationFilter } from '@/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter'; + +describe('turnRecordFilterGroupsIntoGqlOperationFilter', () => { + const fields = [ + { + id: 'f1', + name: 'name', + type: FieldMetadataType.TEXT, + label: 'Name', + }, + { + id: 'f2', + name: 'age', + type: FieldMetadataType.NUMBER, + label: 'Age', + }, + ]; + + it('should return undefined when group is not found', () => { + const result = turnRecordFilterGroupsIntoGqlOperationFilter({ + filterValueDependencies: {}, + filters: [], + fields, + recordFilterGroups: [], + currentRecordFilterGroupId: 'nonexistent', + }); + + expect(result).toBeUndefined(); + }); + + it('should return AND filter for AND logical operator', () => { + const result = turnRecordFilterGroupsIntoGqlOperationFilter({ + filterValueDependencies: {}, + filters: [ + { + id: 'filter1', + fieldMetadataId: 'f1', + value: 'test', + type: 'TEXT', + operand: ViewFilterOperand.CONTAINS, + recordFilterGroupId: 'group1', + }, + ], + fields, + recordFilterGroups: [ + { + id: 'group1', + logicalOperator: RecordFilterGroupLogicalOperator.AND, + }, + ], + currentRecordFilterGroupId: 'group1', + }); + + expect(result).toHaveProperty('and'); + }); + + it('should return OR filter for OR logical operator', () => { + const result = turnRecordFilterGroupsIntoGqlOperationFilter({ + filterValueDependencies: {}, + filters: [ + { + id: 'filter1', + fieldMetadataId: 'f1', + value: 'test', + type: 'TEXT', + operand: ViewFilterOperand.CONTAINS, + recordFilterGroupId: 'group1', + }, + ], + fields, + recordFilterGroups: [ + { + id: 'group1', + logicalOperator: RecordFilterGroupLogicalOperator.OR, + }, + ], + currentRecordFilterGroupId: 'group1', + }); + + expect(result).toHaveProperty('or'); + }); + + it('should handle nested groups', () => { + const result = turnRecordFilterGroupsIntoGqlOperationFilter({ + filterValueDependencies: {}, + filters: [ + { + id: 'filter1', + fieldMetadataId: 'f1', + value: 'test', + type: 'TEXT', + operand: ViewFilterOperand.CONTAINS, + recordFilterGroupId: 'subgroup1', + }, + ], + fields, + recordFilterGroups: [ + { + id: 'group1', + logicalOperator: RecordFilterGroupLogicalOperator.AND, + }, + { + id: 'subgroup1', + parentRecordFilterGroupId: 'group1', + logicalOperator: RecordFilterGroupLogicalOperator.OR, + }, + ], + currentRecordFilterGroupId: 'group1', + }); + + expect(result).toHaveProperty('and'); + const andFilters = (result as any).and; + + expect(andFilters.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts new file mode 100644 index 00000000000..fe7ce1ef469 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts @@ -0,0 +1,826 @@ +import { + FieldMetadataType, + ViewFilterOperand as RecordFilterOperand, +} from '@/types'; +import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter'; +import { type RecordFilter } from '@/utils'; + +const fields = [ + { id: 'f-text', name: 'name', type: FieldMetadataType.TEXT, label: 'Name' }, + { + id: 'f-number', + name: 'amount', + type: FieldMetadataType.NUMBER, + label: 'Amount', + }, + { + id: 'f-date', + name: 'createdAt', + type: FieldMetadataType.DATE, + label: 'Created At', + }, + { + id: 'f-datetime', + name: 'updatedAt', + type: FieldMetadataType.DATE_TIME, + label: 'Updated At', + }, + { + id: 'f-select', + name: 'status', + type: FieldMetadataType.SELECT, + label: 'Status', + }, + { + id: 'f-multiselect', + name: 'tags', + type: FieldMetadataType.MULTI_SELECT, + label: 'Tags', + }, + { + id: 'f-rating', + name: 'rating', + type: FieldMetadataType.RATING, + label: 'Rating', + }, + { + id: 'f-relation', + name: 'company', + type: FieldMetadataType.RELATION, + label: 'Company', + }, + { + id: 'f-bool', + name: 'isActive', + type: FieldMetadataType.BOOLEAN, + label: 'Is Active', + }, + { + id: 'f-rawjson', + name: 'metadata', + type: FieldMetadataType.RAW_JSON, + label: 'Metadata', + }, + { + id: 'f-files', + name: 'attachments', + type: FieldMetadataType.FILES, + label: 'Attachments', + }, + { + id: 'f-tsvector', + name: 'search', + type: FieldMetadataType.TS_VECTOR, + label: 'Search', + }, + { + id: 'f-currency', + name: 'revenue', + type: FieldMetadataType.CURRENCY, + label: 'Revenue', + }, + { + id: 'f-fullname', + name: 'fullName', + type: FieldMetadataType.FULL_NAME, + label: 'Full Name', + }, + { + id: 'f-address', + name: 'location', + type: FieldMetadataType.ADDRESS, + label: 'Location', + }, + { + id: 'f-actor', + name: 'actor', + type: FieldMetadataType.ACTOR, + label: 'Actor', + }, + { + id: 'f-phones', + name: 'phone', + type: FieldMetadataType.PHONES, + label: 'Phone', + }, + { + id: 'f-emails', + name: 'email', + type: FieldMetadataType.EMAILS, + label: 'Email', + }, + { + id: 'f-links', + name: 'website', + type: FieldMetadataType.LINKS, + label: 'Website', + }, + { + id: 'f-array', + name: 'items', + type: FieldMetadataType.ARRAY, + label: 'Items', + }, + { + id: 'f-uuid', + name: 'recordId', + type: FieldMetadataType.UUID, + label: 'Record ID', + }, +]; + +const filterValueDependencies = { timeZone: 'UTC' }; + +const makeFilter = ( + fieldMetadataId: string, + operand: RecordFilterOperand, + value: string, + type: string = 'TEXT', + subFieldName?: string, +) => + ({ + id: 'test-filter', + fieldMetadataId, + value, + type: type as any, + operand, + subFieldName, + }) as RecordFilter; + +describe('turnRecordFilterIntoRecordGqlOperationFilter', () => { + it('should return undefined when field metadata is not found', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'nonexistent', + RecordFilterOperand.CONTAINS, + 'x', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when value is empty for non-emptiness operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-text', RecordFilterOperand.CONTAINS, ''), + fieldMetadataItems: fields, + }); + + expect(result).toBeUndefined(); + }); + + describe('TEXT filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-text', + RecordFilterOperand.CONTAINS, + 'test', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ name: { ilike: '%test%' } }); + }); + + it('should handle DOES_NOT_CONTAIN operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-text', + RecordFilterOperand.DOES_NOT_CONTAIN, + 'test', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ not: { name: { ilike: '%test%' } } }); + }); + }); + + describe('NUMBER filter', () => { + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-number', RecordFilterOperand.IS, '42'), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ amount: { eq: 42 } }); + }); + + it('should handle IS_NOT operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-number', RecordFilterOperand.IS_NOT, '42'), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ not: { amount: { eq: 42 } } }); + }); + + it('should handle GREATER_THAN_OR_EQUAL operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-number', + RecordFilterOperand.GREATER_THAN_OR_EQUAL, + '10', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ amount: { gte: 10 } }); + }); + + it('should handle LESS_THAN_OR_EQUAL operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-number', + RecordFilterOperand.LESS_THAN_OR_EQUAL, + '100', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ amount: { lte: 100 } }); + }); + }); + + describe('DATE filter', () => { + it('should handle IS_AFTER operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-date', + RecordFilterOperand.IS_AFTER, + '2024-03-15', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ createdAt: { gte: '2024-03-15' } }); + }); + + it('should handle IS_BEFORE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-date', + RecordFilterOperand.IS_BEFORE, + '2024-03-15', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ createdAt: { lt: '2024-03-15' } }); + }); + + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-date', + RecordFilterOperand.IS, + '2024-03-15', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ createdAt: { eq: '2024-03-15' } }); + }); + + it('should handle IS_IN_PAST operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-date', RecordFilterOperand.IS_IN_PAST, ''), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('createdAt.lt'); + }); + + it('should handle IS_IN_FUTURE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-date', + RecordFilterOperand.IS_IN_FUTURE, + '', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('createdAt.gte'); + }); + + it('should handle IS_TODAY operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-date', RecordFilterOperand.IS_TODAY, ''), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('createdAt.eq'); + }); + + it('should handle IS_RELATIVE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-date', + RecordFilterOperand.IS_RELATIVE, + 'PAST_7_DAY', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('and'); + }); + }); + + describe('DATE_TIME filter', () => { + it('should handle IS_AFTER operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_AFTER, + '2024-03-15T10:00:00Z', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('updatedAt.gte'); + }); + + it('should handle IS_BEFORE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_BEFORE, + '2024-03-15T10:00:00Z', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('updatedAt.lt'); + }); + + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS, + '2024-03-15T10:00:00Z', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('and'); + }); + + it('should handle IS_IN_PAST operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_IN_PAST, + '', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('updatedAt.lt'); + }); + + it('should handle IS_IN_FUTURE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_IN_FUTURE, + '', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('updatedAt.gt'); + }); + + it('should handle IS_TODAY operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_TODAY, + '', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('and'); + }); + + it('should handle IS_RELATIVE operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-datetime', + RecordFilterOperand.IS_RELATIVE, + `PAST_7_DAY;;UTC;;`, + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('and'); + }); + }); + + describe('RATING filter', () => { + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-rating', RecordFilterOperand.IS, '3'), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('rating.eq'); + }); + + it('should handle GREATER_THAN_OR_EQUAL operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-rating', + RecordFilterOperand.GREATER_THAN_OR_EQUAL, + '3', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('rating.in'); + }); + + it('should handle LESS_THAN_OR_EQUAL operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-rating', + RecordFilterOperand.LESS_THAN_OR_EQUAL, + '3', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('rating.in'); + }); + }); + + describe('BOOLEAN filter', () => { + it('should handle IS operand with true', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-bool', RecordFilterOperand.IS, 'true'), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ isActive: { eq: true } }); + }); + + it('should handle IS operand with false', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-bool', RecordFilterOperand.IS, 'false'), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ isActive: { eq: false } }); + }); + }); + + describe('SELECT filter', () => { + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-select', + RecordFilterOperand.IS, + '["ACTIVE","PENDING"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('status.in'); + }); + + it('should handle IS_NOT operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-select', + RecordFilterOperand.IS_NOT, + '["ACTIVE"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('not'); + }); + }); + + describe('MULTI_SELECT filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-multiselect', + RecordFilterOperand.CONTAINS, + '["TAG1","TAG2"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('tags.containsAny'); + }); + + it('should handle DOES_NOT_CONTAIN operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-multiselect', + RecordFilterOperand.DOES_NOT_CONTAIN, + '["TAG1"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('or'); + }); + }); + + describe('RELATION filter', () => { + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-relation', + RecordFilterOperand.IS, + '["550e8400-e29b-41d4-a716-446655440000"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('companyId.in'); + }); + + it('should handle IS_NOT operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-relation', + RecordFilterOperand.IS_NOT, + '["550e8400-e29b-41d4-a716-446655440000"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('or'); + }); + }); + + describe('RAW_JSON filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-rawjson', + RecordFilterOperand.CONTAINS, + 'test', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ metadata: { like: '%test%' } }); + }); + + it('should handle DOES_NOT_CONTAIN operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-rawjson', + RecordFilterOperand.DOES_NOT_CONTAIN, + 'test', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ not: { metadata: { like: '%test%' } } }); + }); + }); + + describe('FILES filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-files', + RecordFilterOperand.CONTAINS, + 'doc', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ attachments: { like: '%doc%' } }); + }); + }); + + describe('TS_VECTOR filter', () => { + it('should handle VECTOR_SEARCH operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-tsvector', + RecordFilterOperand.VECTOR_SEARCH, + 'hello world', + ), + fieldMetadataItems: fields, + }); + + expect(result).toEqual({ search: { search: 'hello world' } }); + }); + }); + + describe('CURRENCY filter', () => { + it('should handle GREATER_THAN_OR_EQUAL on amountMicros', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-currency', + RecordFilterOperand.GREATER_THAN_OR_EQUAL, + '1000', + 'CURRENCY', + 'amountMicros', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('revenue'); + }); + }); + + describe('FULL_NAME filter', () => { + it('should handle CONTAINS operand without subfield', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-fullname', + RecordFilterOperand.CONTAINS, + 'John', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('or'); + }); + }); + + describe('ADDRESS filter', () => { + it('should handle CONTAINS operand without subfield', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-address', + RecordFilterOperand.CONTAINS, + 'Paris', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('or'); + }); + }); + + describe('ACTOR filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-actor', + RecordFilterOperand.CONTAINS, + 'Admin', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeDefined(); + }); + }); + + describe('PHONES filter', () => { + it('should handle CONTAINS on primaryPhoneNumber', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-phones', + RecordFilterOperand.CONTAINS, + '555', + 'PHONES', + 'primaryPhoneNumber', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeDefined(); + }); + }); + + describe('EMAILS filter', () => { + it('should handle CONTAINS on primaryEmail', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-emails', + RecordFilterOperand.CONTAINS, + 'test@example.com', + 'EMAILS', + 'primaryEmail', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeDefined(); + }); + }); + + describe('LINKS filter', () => { + it('should handle CONTAINS on primaryLinkUrl', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-links', + RecordFilterOperand.CONTAINS, + 'example.com', + 'LINKS', + 'primaryLinkUrl', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeDefined(); + }); + }); + + describe('ARRAY filter', () => { + it('should handle CONTAINS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-array', + RecordFilterOperand.CONTAINS, + '["item1"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toBeDefined(); + }); + + it('should handle DOES_NOT_CONTAIN operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-array', + RecordFilterOperand.DOES_NOT_CONTAIN, + '["item1"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('not'); + }); + }); + + describe('UUID filter', () => { + it('should handle IS operand', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-uuid', + RecordFilterOperand.IS, + '["550e8400-e29b-41d4-a716-446655440000"]', + ), + fieldMetadataItems: fields, + }); + + expect(result).toHaveProperty('recordId.in'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToDateTime.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToDateTime.test.ts new file mode 100644 index 00000000000..2f119af4b17 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToDateTime.test.ts @@ -0,0 +1,47 @@ +import { addUnitToDateTime } from '@/utils/filter/dates/utils/addUnitToDateTime'; + +describe('addUnitToDateTime', () => { + const baseDate = new Date('2024-03-15T12:00:00Z'); + + it('should add seconds', () => { + const result = addUnitToDateTime(baseDate, 30, 'SECOND'); + + expect(result.getTime() - baseDate.getTime()).toBe(30_000); + }); + + it('should add minutes', () => { + const result = addUnitToDateTime(baseDate, 5, 'MINUTE'); + + expect(result.getTime() - baseDate.getTime()).toBe(5 * 60_000); + }); + + it('should add hours', () => { + const result = addUnitToDateTime(baseDate, 2, 'HOUR'); + + expect(result.getTime() - baseDate.getTime()).toBe(2 * 3_600_000); + }); + + it('should add days', () => { + const result = addUnitToDateTime(baseDate, 3, 'DAY'); + + expect(result.getDate()).toBe(18); + }); + + it('should add weeks', () => { + const result = addUnitToDateTime(baseDate, 1, 'WEEK'); + + expect(result.getDate()).toBe(22); + }); + + it('should add months', () => { + const result = addUnitToDateTime(baseDate, 2, 'MONTH'); + + expect(result.getMonth()).toBe(4); // May (0-indexed) + }); + + it('should add years', () => { + const result = addUnitToDateTime(baseDate, 1, 'YEAR'); + + expect(result.getFullYear()).toBe(2025); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToZonedDateTime.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToZonedDateTime.test.ts new file mode 100644 index 00000000000..4fb022ef263 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/addUnitToZonedDateTime.test.ts @@ -0,0 +1,57 @@ +import { Temporal } from 'temporal-polyfill'; + +import { addUnitToZonedDateTime } from '@/utils/filter/dates/utils/addUnitToZonedDateTime'; + +describe('addUnitToZonedDateTime', () => { + const baseZdt = Temporal.ZonedDateTime.from( + '2024-03-15T12:00:00[UTC]', + ); + + it('should add days', () => { + const result = addUnitToZonedDateTime(baseZdt, 'DAY', 3); + + expect(result.day).toBe(18); + }); + + it('should add weeks', () => { + const result = addUnitToZonedDateTime(baseZdt, 'WEEK', 1); + + expect(result.day).toBe(22); + }); + + it('should add months', () => { + const result = addUnitToZonedDateTime(baseZdt, 'MONTH', 2); + + expect(result.month).toBe(5); + }); + + it('should add quarters', () => { + const result = addUnitToZonedDateTime(baseZdt, 'QUARTER', 1); + + expect(result.month).toBe(6); + }); + + it('should add years', () => { + const result = addUnitToZonedDateTime(baseZdt, 'YEAR', 1); + + expect(result.year).toBe(2025); + }); + + it('should add seconds', () => { + const result = addUnitToZonedDateTime(baseZdt, 'SECOND', 30); + + expect(result.second).toBe(30); + }); + + it('should add minutes', () => { + const result = addUnitToZonedDateTime(baseZdt, 'MINUTE', 15); + + expect(result.minute).toBe(15); + }); + + it('should add hours', () => { + const result = addUnitToZonedDateTime(baseZdt, 'HOUR', 3); + + expect(result.hour).toBe(15); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/calendarStartDayConversions.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/calendarStartDayConversions.test.ts new file mode 100644 index 00000000000..f9013bf8cae --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/calendarStartDayConversions.test.ts @@ -0,0 +1,89 @@ +import { CalendarStartDay } from '@/constants'; +import { FirstDayOfTheWeek } from '@/types'; +import { convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek } from '@/utils/filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek'; +import { convertFirstDayOfTheWeekToCalendarStartDayNumber } from '@/utils/filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber'; +import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS'; + +describe('convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek', () => { + it('should convert MONDAY', () => { + expect( + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + CalendarStartDay.MONDAY, + FirstDayOfTheWeek.SUNDAY, + ), + ).toBe(FirstDayOfTheWeek.MONDAY); + }); + + it('should convert SATURDAY', () => { + expect( + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + CalendarStartDay.SATURDAY, + FirstDayOfTheWeek.SUNDAY, + ), + ).toBe(FirstDayOfTheWeek.SATURDAY); + }); + + it('should convert SUNDAY', () => { + expect( + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + CalendarStartDay.SUNDAY, + FirstDayOfTheWeek.MONDAY, + ), + ).toBe(FirstDayOfTheWeek.SUNDAY); + }); + + it('should use system default for SYSTEM', () => { + expect( + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + CalendarStartDay.SYSTEM, + FirstDayOfTheWeek.SATURDAY, + ), + ).toBe(FirstDayOfTheWeek.SATURDAY); + }); +}); + +describe('convertFirstDayOfTheWeekToCalendarStartDayNumber', () => { + it('should convert MONDAY', () => { + expect( + convertFirstDayOfTheWeekToCalendarStartDayNumber( + FirstDayOfTheWeek.MONDAY, + ), + ).toBe(CalendarStartDay.MONDAY); + }); + + it('should convert SATURDAY', () => { + expect( + convertFirstDayOfTheWeekToCalendarStartDayNumber( + FirstDayOfTheWeek.SATURDAY, + ), + ).toBe(CalendarStartDay.SATURDAY); + }); + + it('should convert SUNDAY', () => { + expect( + convertFirstDayOfTheWeekToCalendarStartDayNumber( + FirstDayOfTheWeek.SUNDAY, + ), + ).toBe(CalendarStartDay.SUNDAY); + }); +}); + +describe('getFirstDayOfTheWeekAsANumberForDateFNS', () => { + it('should return 1 for MONDAY', () => { + expect( + getFirstDayOfTheWeekAsANumberForDateFNS(FirstDayOfTheWeek.MONDAY), + ).toBe(1); + }); + + it('should return 6 for SATURDAY', () => { + expect( + getFirstDayOfTheWeekAsANumberForDateFNS(FirstDayOfTheWeek.SATURDAY), + ).toBe(6); + }); + + it('should return 0 for SUNDAY', () => { + expect( + getFirstDayOfTheWeekAsANumberForDateFNS(FirstDayOfTheWeek.SUNDAY), + ).toBe(0); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/relativeDateFilterStringifiedSchema.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/relativeDateFilterStringifiedSchema.test.ts new file mode 100644 index 00000000000..4ed0b337f22 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/relativeDateFilterStringifiedSchema.test.ts @@ -0,0 +1,69 @@ +import { relativeDateFilterStringifiedSchema } from '@/utils/filter/dates/utils/relativeDateFilterStringifiedSchema'; + +describe('relativeDateFilterStringifiedSchema', () => { + it('should parse a valid PAST filter string', () => { + const result = relativeDateFilterStringifiedSchema.safeParse('PAST_7_DAY'); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.direction).toBe('PAST'); + expect(result.data.amount).toBe(7); + expect(result.data.unit).toBe('DAY'); + } + }); + + it('should parse a valid NEXT filter string', () => { + const result = relativeDateFilterStringifiedSchema.safeParse('NEXT_30_MINUTE'); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.direction).toBe('NEXT'); + expect(result.data.amount).toBe(30); + expect(result.data.unit).toBe('MINUTE'); + } + }); + + it('should parse a THIS filter string', () => { + const result = relativeDateFilterStringifiedSchema.safeParse('THIS_1_MONTH'); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.direction).toBe('THIS'); + expect(result.data.unit).toBe('MONTH'); + } + }); + + it('should parse a filter string with timezone', () => { + const result = relativeDateFilterStringifiedSchema.safeParse( + 'PAST_7_DAY;;America/New_York;;', + ); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.timezone).toBe('America/New_York'); + } + }); + + it('should parse a filter string with firstDayOfTheWeek', () => { + const result = relativeDateFilterStringifiedSchema.safeParse( + 'THIS_1_WEEK;;UTC;;MONDAY;;', + ); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.firstDayOfTheWeek).toBe('MONDAY'); + } + }); + + it('should fail for invalid strings', () => { + const result = + relativeDateFilterStringifiedSchema.safeParse('INVALID_STRING'); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveDateFilter.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveDateFilter.test.ts new file mode 100644 index 00000000000..6e06eae1272 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveDateFilter.test.ts @@ -0,0 +1,62 @@ +import { ViewFilterOperand } from '@/types'; +import { resolveDateFilter } from '@/utils/filter/dates/utils/resolveDateFilter'; +import { resolveDateTimeFilter } from '@/utils/filter/dates/utils/resolveDateTimeFilter'; + +describe('resolveDateFilter', () => { + it('should return null for empty value', () => { + expect( + resolveDateFilter({ value: '', operand: ViewFilterOperand.IS_AFTER }), + ).toBeNull(); + }); + + it('should return the value directly for non-relative operands', () => { + expect( + resolveDateFilter({ + value: '2024-03-15', + operand: ViewFilterOperand.IS_AFTER, + }), + ).toBe('2024-03-15'); + }); + + it('should resolve relative date filter for IS_RELATIVE operand', () => { + const result = resolveDateFilter({ + value: 'PAST_7_DAY', + operand: ViewFilterOperand.IS_RELATIVE, + }); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty('start'); + expect(result).toHaveProperty('end'); + }); +}); + +describe('resolveDateTimeFilter', () => { + it('should return null for empty value', () => { + expect( + resolveDateTimeFilter({ + value: '', + operand: ViewFilterOperand.IS_AFTER, + }), + ).toBeNull(); + }); + + it('should return the value directly for non-relative operands', () => { + expect( + resolveDateTimeFilter({ + value: '2024-03-15T10:00:00Z', + operand: ViewFilterOperand.IS_AFTER, + }), + ).toBe('2024-03-15T10:00:00Z'); + }); + + it('should resolve relative date-time filter for IS_RELATIVE operand', () => { + const result = resolveDateTimeFilter({ + value: 'PAST_7_DAY', + operand: ViewFilterOperand.IS_RELATIVE, + }); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty('start'); + expect(result).toHaveProperty('end'); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts new file mode 100644 index 00000000000..87955f7e7ed --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts @@ -0,0 +1,73 @@ +import { Temporal } from 'temporal-polyfill'; + +import { resolveRelativeDateFilter } from '@/utils/filter/dates/utils/resolveRelativeDateFilter'; + +describe('resolveRelativeDateFilter', () => { + const referenceZdt = Temporal.ZonedDateTime.from( + '2024-03-15T12:00:00[UTC]', + ); + + describe('NEXT direction', () => { + it('should compute start and end for NEXT 7 DAY', () => { + const result = resolveRelativeDateFilter( + { direction: 'NEXT', amount: 7, unit: 'DAY' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-16'); + expect(result.end).toBe('2024-03-23'); + }); + + it('should throw if amount is undefined', () => { + expect(() => + resolveRelativeDateFilter( + { direction: 'NEXT', amount: undefined as any, unit: 'DAY' }, + referenceZdt, + ), + ).toThrow('Amount is required'); + }); + }); + + describe('PAST direction', () => { + it('should compute start and end for PAST 7 DAY', () => { + const result = resolveRelativeDateFilter( + { direction: 'PAST', amount: 7, unit: 'DAY' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-08'); + expect(result.end).toBe('2024-03-15'); + }); + + it('should throw if amount is undefined', () => { + expect(() => + resolveRelativeDateFilter( + { direction: 'PAST', amount: undefined as any, unit: 'DAY' }, + referenceZdt, + ), + ).toThrow('Amount is required'); + }); + }); + + describe('THIS direction', () => { + it('should compute start and end for THIS MONTH', () => { + const result = resolveRelativeDateFilter( + { direction: 'THIS', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-01'); + expect(result.end).toBe('2024-04-01'); + }); + + it('should compute start and end for THIS YEAR', () => { + const result = resolveRelativeDateFilter( + { direction: 'THIS', amount: 1, unit: 'YEAR' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-01-01'); + expect(result.end).toBe('2025-01-01'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilterStringified.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilterStringified.test.ts new file mode 100644 index 00000000000..adae5e31575 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilterStringified.test.ts @@ -0,0 +1,59 @@ +import { resolveRelativeDateFilterStringified } from '@/utils/filter/dates/utils/resolveRelativeDateFilterStringified'; +import { resolveRelativeDateTimeFilterStringified } from '@/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified'; + +describe('resolveRelativeDateFilterStringified', () => { + it('should return null for empty string', () => { + expect(resolveRelativeDateFilterStringified('')).toBeNull(); + }); + + it('should return null for null', () => { + expect(resolveRelativeDateFilterStringified(null)).toBeNull(); + }); + + it('should return null for undefined', () => { + expect(resolveRelativeDateFilterStringified(undefined)).toBeNull(); + }); + + it('should return null for invalid filter string', () => { + expect(resolveRelativeDateFilterStringified('INVALID')).toBeNull(); + }); + + it('should resolve a valid PAST filter', () => { + const result = resolveRelativeDateFilterStringified('PAST_7_DAY'); + + expect(result).not.toBeNull(); + expect(result?.direction).toBe('PAST'); + expect(result?.start).toBeDefined(); + expect(result?.end).toBeDefined(); + }); + + it('should resolve a valid NEXT filter', () => { + const result = resolveRelativeDateFilterStringified('NEXT_3_MONTH'); + + expect(result).not.toBeNull(); + expect(result?.direction).toBe('NEXT'); + }); +}); + +describe('resolveRelativeDateTimeFilterStringified', () => { + it('should return null for empty string', () => { + expect(resolveRelativeDateTimeFilterStringified('')).toBeNull(); + }); + + it('should return null for null', () => { + expect(resolveRelativeDateTimeFilterStringified(null)).toBeNull(); + }); + + it('should return null for invalid filter string', () => { + expect(resolveRelativeDateTimeFilterStringified('INVALID')).toBeNull(); + }); + + it('should resolve a valid PAST filter', () => { + const result = resolveRelativeDateTimeFilterStringified('PAST_24_HOUR'); + + expect(result).not.toBeNull(); + expect(result?.direction).toBe('PAST'); + expect(result?.start).toBeDefined(); + expect(result?.end).toBeDefined(); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts new file mode 100644 index 00000000000..2658624a213 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts @@ -0,0 +1,84 @@ +import { Temporal } from 'temporal-polyfill'; + +import { resolveRelativeDateTimeFilter } from '@/utils/filter/dates/utils/resolveRelativeDateTimeFilter'; + +describe('resolveRelativeDateTimeFilter', () => { + const referenceZdt = Temporal.ZonedDateTime.from( + '2024-03-15T12:00:00[UTC]', + ); + + describe('NEXT direction', () => { + it('should compute for sub-day unit (HOUR)', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'NEXT', amount: 3, unit: 'HOUR' }, + referenceZdt, + ); + + expect(result.start).toEqual(referenceZdt); + expect(result.end?.hour).toBe(15); + }); + + it('should compute for DAY unit', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'NEXT', amount: 7, unit: 'DAY' }, + referenceZdt, + ); + + expect(result.start?.day).toBe(16); + expect(result.end?.day).toBe(23); + }); + + it('should throw if amount is undefined', () => { + expect(() => + resolveRelativeDateTimeFilter( + { direction: 'NEXT', amount: undefined as any, unit: 'DAY' }, + referenceZdt, + ), + ).toThrow('Amount is required'); + }); + }); + + describe('PAST direction', () => { + it('should compute for sub-day unit (MINUTE)', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: 30, unit: 'MINUTE' }, + referenceZdt, + ); + + expect(result.end).toEqual(referenceZdt); + expect(result.start?.minute).toBe(30); + expect(result.start?.hour).toBe(11); + }); + + it('should compute for DAY unit', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: 3, unit: 'DAY' }, + referenceZdt, + ); + + expect(result.end?.hour).toBe(0); + expect(result.start?.day).toBe(12); + }); + + it('should throw if amount is undefined', () => { + expect(() => + resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: undefined as any, unit: 'MONTH' }, + referenceZdt, + ), + ).toThrow('Amount is required'); + }); + }); + + describe('THIS direction', () => { + it('should compute for THIS MONTH', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'THIS', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start?.day).toBe(1); + expect(result.end?.month).toBe(4); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromDateTime.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromDateTime.test.ts new file mode 100644 index 00000000000..6957c2272bc --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromDateTime.test.ts @@ -0,0 +1,47 @@ +import { subUnitFromDateTime } from '@/utils/filter/dates/utils/subUnitFromDateTime'; + +describe('subUnitFromDateTime', () => { + const baseDate = new Date('2024-03-15T12:00:00Z'); + + it('should subtract seconds', () => { + const result = subUnitFromDateTime(baseDate, 30, 'SECOND'); + + expect(baseDate.getTime() - result.getTime()).toBe(30_000); + }); + + it('should subtract minutes', () => { + const result = subUnitFromDateTime(baseDate, 5, 'MINUTE'); + + expect(baseDate.getTime() - result.getTime()).toBe(5 * 60_000); + }); + + it('should subtract hours', () => { + const result = subUnitFromDateTime(baseDate, 2, 'HOUR'); + + expect(baseDate.getTime() - result.getTime()).toBe(2 * 3_600_000); + }); + + it('should subtract days', () => { + const result = subUnitFromDateTime(baseDate, 3, 'DAY'); + + expect(result.getDate()).toBe(12); + }); + + it('should subtract weeks', () => { + const result = subUnitFromDateTime(baseDate, 1, 'WEEK'); + + expect(result.getDate()).toBe(8); + }); + + it('should subtract months', () => { + const result = subUnitFromDateTime(baseDate, 2, 'MONTH'); + + expect(result.getMonth()).toBe(0); // January + }); + + it('should subtract years', () => { + const result = subUnitFromDateTime(baseDate, 1, 'YEAR'); + + expect(result.getFullYear()).toBe(2023); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromZonedDateTime.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromZonedDateTime.test.ts new file mode 100644 index 00000000000..8007f56d964 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/subUnitFromZonedDateTime.test.ts @@ -0,0 +1,60 @@ +import { Temporal } from 'temporal-polyfill'; + +import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFromZonedDateTime'; + +describe('subUnitFromZonedDateTime', () => { + const baseZdt = Temporal.ZonedDateTime.from( + '2024-03-15T12:00:00[UTC]', + ); + + it('should subtract days', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'DAY', 3); + + expect(result.day).toBe(12); + }); + + it('should subtract weeks', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'WEEK', 1); + + expect(result.day).toBe(8); + }); + + it('should subtract months', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'MONTH', 2); + + expect(result.month).toBe(1); + }); + + it('should subtract quarters', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'QUARTER', 1); + + expect(result.month).toBe(12); + expect(result.year).toBe(2023); + }); + + it('should subtract years', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'YEAR', 1); + + expect(result.year).toBe(2023); + }); + + it('should subtract seconds', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'SECOND', 30); + + expect(result.second).toBe(30); + expect(result.minute).toBe(59); + expect(result.hour).toBe(11); + }); + + it('should subtract minutes', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'MINUTE', 15); + + expect(result.minute).toBe(45); + }); + + it('should subtract hours', () => { + const result = subUnitFromZonedDateTime(baseZdt, 'HOUR', 3); + + expect(result.hour).toBe(9); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/combineFilters.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/combineFilters.test.ts new file mode 100644 index 00000000000..50ea919501d --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/combineFilters.test.ts @@ -0,0 +1,26 @@ +import { combineFilters } from '@/utils/filter/utils/combineFilters'; + +describe('combineFilters', () => { + it('should return empty object when all filters are empty', () => { + expect(combineFilters([{}, {}, {}])).toEqual({}); + }); + + it('should return single filter when only one non-empty filter exists', () => { + const filter = { name: { eq: 'test' } }; + + expect(combineFilters([{}, filter, {}])).toEqual(filter); + }); + + it('should combine multiple non-empty filters with and', () => { + const filter1 = { name: { eq: 'test' } }; + const filter2 = { age: { gt: 18 } }; + + expect(combineFilters([filter1, filter2])).toEqual({ + and: [filter1, filter2], + }); + }); + + it('should return empty object for empty array', () => { + expect(combineFilters([])).toEqual({}); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/convertViewFilterValueToString.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/convertViewFilterValueToString.test.ts new file mode 100644 index 00000000000..258365235fd --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/convertViewFilterValueToString.test.ts @@ -0,0 +1,25 @@ +import { convertViewFilterValueToString } from '@/utils/filter/utils/convertViewFilterValueToString'; + +describe('convertViewFilterValueToString', () => { + it('should return string values as-is', () => { + expect(convertViewFilterValueToString('hello')).toBe('hello'); + }); + + it('should stringify non-string values', () => { + expect(convertViewFilterValueToString(42)).toBe('42'); + }); + + it('should stringify objects', () => { + expect(convertViewFilterValueToString({ key: 'value' })).toBe( + '{"key":"value"}', + ); + }); + + it('should stringify null as empty string', () => { + expect(convertViewFilterValueToString(null)).toBe('""'); + }); + + it('should stringify undefined as empty string', () => { + expect(convertViewFilterValueToString(undefined)).toBe('""'); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts new file mode 100644 index 00000000000..9ded6c3aed7 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts @@ -0,0 +1,239 @@ +import { FieldMetadataType, ViewFilterOperand } from '@/types'; +import { getEmptyRecordGqlOperationFilter } from '@/utils/filter/utils/getEmptyRecordGqlOperationFilter'; +import { type RecordFilter } from '@/utils'; + +const makeParams = ( + fieldType: FieldMetadataType, + operand: ViewFilterOperand = ViewFilterOperand.IS_EMPTY, + subFieldName?: string, +) => ({ + operand, + correspondingField: { id: 'f1', name: 'testField', type: fieldType }, + recordFilter: { + id: '1', + fieldMetadataId: 'f1', + value: '', + type: 'TEXT' as any, + operand, + subFieldName, + } as RecordFilter, +}); + +describe('getEmptyRecordGqlOperationFilter', () => { + describe('IS_EMPTY operand', () => { + it('should handle TEXT type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.TEXT), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle NUMBER type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.NUMBER), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + + it('should handle DATE type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.DATE), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + + it('should handle DATE_TIME type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.DATE_TIME), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + + it('should handle SELECT type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.SELECT), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + + it('should handle MULTI_SELECT type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.MULTI_SELECT), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle RATING type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.RATING), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + + it('should handle RELATION type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.RELATION), + ); + + expect(result).toEqual({ + testFieldId: { is: 'NULL' }, + }); + }); + + it('should handle CURRENCY type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.CURRENCY), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle ACTOR type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.ACTOR), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle ARRAY type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.ARRAY), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle RAW_JSON type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.RAW_JSON), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle FILES type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.FILES), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle FULL_NAME type without subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.FULL_NAME), + ); + + expect(result).toHaveProperty('and'); + }); + + it('should handle FULL_NAME type with subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams( + FieldMetadataType.FULL_NAME, + ViewFilterOperand.IS_EMPTY, + 'firstName', + ), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle PHONES type without subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.PHONES), + ); + + expect(result).toHaveProperty('and'); + }); + + it('should handle PHONES type with primaryPhoneNumber subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams( + FieldMetadataType.PHONES, + ViewFilterOperand.IS_EMPTY, + 'primaryPhoneNumber', + ), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle PHONES type with additionalPhones subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams( + FieldMetadataType.PHONES, + ViewFilterOperand.IS_EMPTY, + 'additionalPhones', + ), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle ADDRESS type without subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.ADDRESS), + ); + + expect(result).toHaveProperty('and'); + expect((result as any).and).toHaveLength(6); + }); + + it('should handle ADDRESS type with subfield', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams( + FieldMetadataType.ADDRESS, + ViewFilterOperand.IS_EMPTY, + 'addressCity', + ), + ); + + expect(result).toHaveProperty('or'); + }); + + it('should handle LINKS type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.LINKS), + ); + + expect(result).toHaveProperty('and'); + }); + + it('should handle EMAILS type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.EMAILS), + ); + + expect(result).toHaveProperty('and'); + }); + }); + + describe('IS_NOT_EMPTY operand', () => { + it('should wrap filter in not for IS_NOT_EMPTY', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.TEXT, ViewFilterOperand.IS_NOT_EMPTY), + ); + + expect(result).toHaveProperty('not'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingMultiSelectFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingMultiSelectFilter.test.ts new file mode 100644 index 00000000000..4c72a4e1699 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingMultiSelectFilter.test.ts @@ -0,0 +1,83 @@ +import { isMatchingMultiSelectFilter } from '@/utils/filter/utils/isMatchingMultiSelectFilter'; + +describe('isMatchingMultiSelectFilter', () => { + describe('containsAny', () => { + it('should return true when value contains all filter items', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { containsAny: ['A', 'B'] }, + value: ['A', 'B', 'C'], + }), + ).toBe(true); + }); + + it('should return false when value does not contain all filter items', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { containsAny: ['A', 'D'] }, + value: ['A', 'B', 'C'], + }), + ).toBe(false); + }); + + it('should return false for null value', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { containsAny: ['A'] }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('isEmptyArray', () => { + it('should return true for empty array', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { isEmptyArray: true }, + value: [], + }), + ).toBe(true); + }); + + it('should return false for non-empty array', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { isEmptyArray: true }, + value: ['A'], + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('should match NULL check', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { is: 'NULL' }, + value: null, + }), + ).toBe(true); + }); + + it('should match NOT_NULL check', () => { + expect( + isMatchingMultiSelectFilter({ + multiSelectFilter: { is: 'NOT_NULL' }, + value: ['A'], + }), + ).toBe(true); + }); + }); + + describe('default', () => { + it('should throw for unexpected filter', () => { + expect(() => + isMatchingMultiSelectFilter({ + multiSelectFilter: {} as any, + value: ['A'], + }), + ).toThrow('Unexpected value for multi-select filter'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRatingFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRatingFilter.test.ts new file mode 100644 index 00000000000..9ec9dba2291 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRatingFilter.test.ts @@ -0,0 +1,74 @@ +import { isMatchingRatingFilter } from '@/utils/filter/utils/isMatchingRatingFilter'; + +describe('isMatchingRatingFilter', () => { + describe('eq', () => { + it('should return true when value equals', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { eq: 'RATING_3' }, + value: 'RATING_3', + }), + ).toBe(true); + }); + + it('should return false when value does not equal', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { eq: 'RATING_3' }, + value: 'RATING_1', + }), + ).toBe(false); + }); + }); + + describe('in', () => { + it('should return true when value is in list', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { in: ['RATING_3', 'RATING_5'] }, + value: 'RATING_3', + }), + ).toBe(true); + }); + + it('should return false for null value', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { in: ['RATING_3'] }, + value: null, + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('should match NULL check', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { is: 'NULL' }, + value: null, + }), + ).toBe(true); + }); + + it('should match NOT_NULL check', () => { + expect( + isMatchingRatingFilter({ + ratingFilter: { is: 'NOT_NULL' }, + value: 'RATING_3', + }), + ).toBe(true); + }); + }); + + describe('default', () => { + it('should throw for unexpected filter', () => { + expect(() => + isMatchingRatingFilter({ + ratingFilter: {} as any, + value: 'RATING_3', + }), + ).toThrow('Unexpected value for rating filter'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRawJsonFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRawJsonFilter.test.ts new file mode 100644 index 00000000000..ba6294d493c --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRawJsonFilter.test.ts @@ -0,0 +1,54 @@ +import { isMatchingRawJsonFilter } from '@/utils/filter/utils/isMatchingRawJsonFilter'; + +describe('isMatchingRawJsonFilter', () => { + describe('like', () => { + it('should match using wildcard pattern', () => { + expect( + isMatchingRawJsonFilter({ + rawJsonFilter: { like: '%test%' }, + value: 'some test value', + }), + ).toBe(true); + }); + + it('should not match when pattern does not match', () => { + expect( + isMatchingRawJsonFilter({ + rawJsonFilter: { like: '%xyz%' }, + value: 'some test value', + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('should match NULL check', () => { + expect( + isMatchingRawJsonFilter({ + rawJsonFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('should match NOT_NULL check', () => { + expect( + isMatchingRawJsonFilter({ + rawJsonFilter: { is: 'NOT_NULL' }, + value: '{"key": "val"}', + }), + ).toBe(true); + }); + }); + + describe('default', () => { + it('should throw for unexpected filter', () => { + expect(() => + isMatchingRawJsonFilter({ + rawJsonFilter: {} as any, + value: 'test', + }), + ).toThrow('Unexpected value for string filter'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRichTextV2Filter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRichTextV2Filter.test.ts new file mode 100644 index 00000000000..768f3b1544b --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingRichTextV2Filter.test.ts @@ -0,0 +1,43 @@ +import { isMatchingRichTextV2Filter } from '@/utils/filter/utils/isMatchingRichTextV2Filter'; + +describe('isMatchingRichTextV2Filter', () => { + describe('markdown ilike', () => { + it('should match with wildcard pattern', () => { + expect( + isMatchingRichTextV2Filter({ + richTextV2Filter: { markdown: { ilike: '%hello%' } }, + value: 'say hello world', + }), + ).toBe(true); + }); + + it('should not match when pattern does not match', () => { + expect( + isMatchingRichTextV2Filter({ + richTextV2Filter: { markdown: { ilike: '%goodbye%' } }, + value: 'say hello world', + }), + ).toBe(false); + }); + + it('should be case insensitive', () => { + expect( + isMatchingRichTextV2Filter({ + richTextV2Filter: { markdown: { ilike: '%HELLO%' } }, + value: 'say hello world', + }), + ).toBe(true); + }); + }); + + describe('default', () => { + it('should throw for unexpected filter', () => { + expect(() => + isMatchingRichTextV2Filter({ + richTextV2Filter: {} as any, + value: 'test', + }), + ).toThrow('Unexpected value for RICH_TEXT_V2 filter'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingSelectFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingSelectFilter.test.ts new file mode 100644 index 00000000000..112fee91081 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/isMatchingSelectFilter.test.ts @@ -0,0 +1,85 @@ +import { isMatchingSelectFilter } from '@/utils/filter/utils/isMatchingSelectFilter'; + +describe('isMatchingSelectFilter', () => { + describe('in', () => { + it('should return true when value is in the list', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { in: ['ACTIVE', 'PENDING'] }, + value: 'ACTIVE', + }), + ).toBe(true); + }); + + it('should return false when value is not in the list', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { in: ['ACTIVE', 'PENDING'] }, + value: 'CLOSED', + }), + ).toBe(false); + }); + }); + + describe('is', () => { + it('should match NULL check', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { is: 'NULL' }, + value: null as any, + }), + ).toBe(true); + }); + + it('should match NOT_NULL check', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { is: 'NOT_NULL' }, + value: 'ACTIVE', + }), + ).toBe(true); + }); + }); + + describe('eq', () => { + it('should return true when value equals', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { eq: 'ACTIVE' }, + value: 'ACTIVE', + }), + ).toBe(true); + }); + + it('should return false when value does not equal', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { eq: 'ACTIVE' }, + value: 'CLOSED', + }), + ).toBe(false); + }); + }); + + describe('neq', () => { + it('should return true when value does not equal', () => { + expect( + isMatchingSelectFilter({ + selectFilter: { neq: 'ACTIVE' }, + value: 'CLOSED', + }), + ).toBe(true); + }); + }); + + describe('default', () => { + it('should throw for unexpected filter', () => { + expect(() => + isMatchingSelectFilter({ + selectFilter: {} as any, + value: 'ACTIVE', + }), + ).toThrow('Unexpected value for select filter'); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/sentry/__tests__/getGenericOperationName.test.ts b/packages/twenty-shared/src/utils/sentry/__tests__/getGenericOperationName.test.ts new file mode 100644 index 00000000000..45a3f4012b4 --- /dev/null +++ b/packages/twenty-shared/src/utils/sentry/__tests__/getGenericOperationName.test.ts @@ -0,0 +1,19 @@ +import { getGenericOperationName } from '@/utils/sentry/getGenericOperationName'; + +describe('getGenericOperationName', () => { + it('should extract the first word from a PascalCase name', () => { + expect(getGenericOperationName('FindOnePerson')).toBe('Find'); + }); + + it('should extract the first word from another PascalCase name', () => { + expect(getGenericOperationName('AggregateCompanies')).toBe('Aggregate'); + }); + + it('should return undefined for undefined input', () => { + expect(getGenericOperationName(undefined)).toBeUndefined(); + }); + + it('should handle single word', () => { + expect(getGenericOperationName('Create')).toBe('Create'); + }); +}); diff --git a/packages/twenty-shared/src/utils/sentry/__tests__/getHumanReadableNameFromCode.test.ts b/packages/twenty-shared/src/utils/sentry/__tests__/getHumanReadableNameFromCode.test.ts new file mode 100644 index 00000000000..b2249a2b2f5 --- /dev/null +++ b/packages/twenty-shared/src/utils/sentry/__tests__/getHumanReadableNameFromCode.test.ts @@ -0,0 +1,19 @@ +import { getHumanReadableNameFromCode } from '@/utils/sentry/getHumanReadableNameFromCode'; + +describe('getHumanReadableNameFromCode', () => { + it('should convert snake_case to Title Case', () => { + expect(getHumanReadableNameFromCode('hello_world')).toBe('Hello World'); + }); + + it('should handle single word', () => { + expect(getHumanReadableNameFromCode('hello')).toBe('Hello'); + }); + + it('should handle uppercase code', () => { + expect(getHumanReadableNameFromCode('NOT_FOUND')).toBe('Not Found'); + }); + + it('should handle multiple underscores', () => { + expect(getHumanReadableNameFromCode('a_b_c_d')).toBe('A B C D'); + }); +}); diff --git a/packages/twenty-shared/src/utils/typeguard/__tests__/isPlainObject.test.ts b/packages/twenty-shared/src/utils/typeguard/__tests__/isPlainObject.test.ts new file mode 100644 index 00000000000..e47ab50e953 --- /dev/null +++ b/packages/twenty-shared/src/utils/typeguard/__tests__/isPlainObject.test.ts @@ -0,0 +1,24 @@ +import { isPlainObject } from '@/utils/typeguard/isPlainObject'; + +describe('isPlainObject', () => { + it('should return true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ key: 'value' })).toBe(true); + }); + + it('should return false for arrays', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject([1, 2, 3])).toBe(false); + }); + + it('should return false for null', () => { + expect(isPlainObject(null)).toBe(false); + }); + + it('should return false for primitives', () => { + expect(isPlainObject(42)).toBe(false); + expect(isPlainObject('string')).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject(true)).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/typeguard/__tests__/throwIfNotDefined.test.ts b/packages/twenty-shared/src/utils/typeguard/__tests__/throwIfNotDefined.test.ts new file mode 100644 index 00000000000..9d151dd7a51 --- /dev/null +++ b/packages/twenty-shared/src/utils/typeguard/__tests__/throwIfNotDefined.test.ts @@ -0,0 +1,22 @@ +import { throwIfNotDefined } from '@/utils/typeguard/throwIfNotDefined'; + +describe('throwIfNotDefined', () => { + it('should not throw for defined values', () => { + expect(() => throwIfNotDefined('hello', 'myVar')).not.toThrow(); + expect(() => throwIfNotDefined(0, 'myVar')).not.toThrow(); + expect(() => throwIfNotDefined(false, 'myVar')).not.toThrow(); + expect(() => throwIfNotDefined('', 'myVar')).not.toThrow(); + }); + + it('should throw for null', () => { + expect(() => throwIfNotDefined(null, 'myVar')).toThrow( + 'Value must be defined for variable myVar', + ); + }); + + it('should throw for undefined', () => { + expect(() => throwIfNotDefined(undefined, 'myVar')).toThrow( + 'Value must be defined for variable myVar', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts b/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts new file mode 100644 index 00000000000..c22d5a859ae --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/getAbsoluteUrl.test.ts @@ -0,0 +1,23 @@ +import { getAbsoluteUrl } from '@/utils/url/getAbsoluteUrl'; + +describe('getAbsoluteUrl', () => { + it('should return https URL as-is', () => { + expect(getAbsoluteUrl('https://example.com')).toBe('https://example.com'); + }); + + it('should return http URL as-is', () => { + expect(getAbsoluteUrl('http://example.com')).toBe('http://example.com'); + }); + + it('should return HTTPS URL as-is', () => { + expect(getAbsoluteUrl('HTTPS://example.com')).toBe('HTTPS://example.com'); + }); + + it('should return HTTP URL as-is', () => { + expect(getAbsoluteUrl('HTTP://example.com')).toBe('HTTP://example.com'); + }); + + it('should prepend https:// to bare domains', () => { + expect(getAbsoluteUrl('example.com')).toBe('https://example.com'); + }); +}); diff --git a/packages/twenty-shared/src/utils/url/__tests__/safeDecodeURIComponent.test.ts b/packages/twenty-shared/src/utils/url/__tests__/safeDecodeURIComponent.test.ts new file mode 100644 index 00000000000..ea8fc387ee3 --- /dev/null +++ b/packages/twenty-shared/src/utils/url/__tests__/safeDecodeURIComponent.test.ts @@ -0,0 +1,15 @@ +import { safeDecodeURIComponent } from '@/utils/url/safeDecodeURIComponent'; + +describe('safeDecodeURIComponent', () => { + it('should decode valid encoded components', () => { + expect(safeDecodeURIComponent('hello%20world')).toBe('hello world'); + }); + + it('should return malformed input as-is', () => { + expect(safeDecodeURIComponent('%E0%A4%A')).toBe('%E0%A4%A'); + }); + + it('should return plain text as-is', () => { + expect(safeDecodeURIComponent('hello')).toBe('hello'); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/__tests__/isValidVariable.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/isValidVariable.test.ts new file mode 100644 index 00000000000..f147a82cd9d --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/__tests__/isValidVariable.test.ts @@ -0,0 +1,23 @@ +import { isValidVariable } from '@/utils/validation/isValidVariable'; + +describe('isValidVariable', () => { + it('should return true for valid variable syntax', () => { + expect(isValidVariable('{{step.output}}')).toBe(true); + }); + + it('should return true for nested variable', () => { + expect(isValidVariable('{{trigger.output.name}}')).toBe(true); + }); + + it('should return false for string without brackets', () => { + expect(isValidVariable('no brackets')).toBe(false); + }); + + it('should return false for partial brackets', () => { + expect(isValidVariable('{{incomplete')).toBe(false); + }); + + it('should return false for empty brackets', () => { + expect(isValidVariable('{{}}')).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/phones-value/__tests__/getCountryCodesForCallingCode.test.ts b/packages/twenty-shared/src/utils/validation/phones-value/__tests__/getCountryCodesForCallingCode.test.ts new file mode 100644 index 00000000000..66b36427cb7 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/phones-value/__tests__/getCountryCodesForCallingCode.test.ts @@ -0,0 +1,20 @@ +import { getCountryCodesForCallingCode } from '@/utils/validation/phones-value/getCountryCodesForCallingCode'; + +describe('getCountryCodesForCallingCode', () => { + it('should return country codes for calling code 1 (US/CA)', () => { + const result = getCountryCodesForCallingCode('1'); + + expect(result).toContain('US'); + expect(result).toContain('CA'); + }); + + it('should handle + prefix', () => { + const result = getCountryCodesForCallingCode('+33'); + + expect(result).toContain('FR'); + }); + + it('should return empty array for invalid calling code', () => { + expect(getCountryCodesForCallingCode('99999')).toEqual([]); + }); +}); diff --git a/packages/twenty-shared/src/utils/validation/phones-value/__tests__/isValidCountryCode.test.ts b/packages/twenty-shared/src/utils/validation/phones-value/__tests__/isValidCountryCode.test.ts new file mode 100644 index 00000000000..bfd32542ec2 --- /dev/null +++ b/packages/twenty-shared/src/utils/validation/phones-value/__tests__/isValidCountryCode.test.ts @@ -0,0 +1,19 @@ +import { isValidCountryCode } from '@/utils/validation/phones-value/isValidCountryCode'; + +describe('isValidCountryCode', () => { + it('should return true for valid country code US', () => { + expect(isValidCountryCode('US')).toBe(true); + }); + + it('should return true for valid country code FR', () => { + expect(isValidCountryCode('FR')).toBe(true); + }); + + it('should return false for invalid country code', () => { + expect(isValidCountryCode('XX')).toBe(false); + }); + + it('should return false for lowercase', () => { + expect(isValidCountryCode('us')).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 02bceeceff5..3d2aa040308 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -92,7 +92,6 @@ export type { Node, BaseOutputSchemaV2, } from './workflow-schema/types/base-output-schema.type'; -export { buildOutputSchemaFromValue } from './workflow-schema/utils/buildOutputSchemaFromValue'; export { navigateOutputSchemaProperty } from './workflow-schema/utils/navigateOutputSchemaProperty'; export type { GlobalAvailability, diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/canObjectBeManagedByWorkflow.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/canObjectBeManagedByWorkflow.test.ts new file mode 100644 index 00000000000..bfd6032c192 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/canObjectBeManagedByWorkflow.test.ts @@ -0,0 +1,57 @@ +import { canObjectBeManagedByWorkflow } from '@/workflow/utils/canObjectBeManagedByWorkflow'; + +describe('canObjectBeManagedByWorkflow', () => { + it('should return true for non-system, non-excluded objects', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'company', + isSystem: false, + }), + ).toBe(true); + }); + + it('should return false for system objects', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'company', + isSystem: true, + }), + ).toBe(false); + }); + + it('should return false for workflow object', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'workflow', + isSystem: false, + }), + ).toBe(false); + }); + + it('should return false for workflowVersion object', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'workflowVersion', + isSystem: false, + }), + ).toBe(false); + }); + + it('should return false for workflowRun object', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'workflowRun', + isSystem: false, + }), + ).toBe(false); + }); + + it('should return false for dashboard object', () => { + expect( + canObjectBeManagedByWorkflow({ + nameSingular: 'dashboard', + isSystem: false, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/extractRawVariableNameParts.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/extractRawVariableNameParts.test.ts new file mode 100644 index 00000000000..a0bb80a1a10 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/extractRawVariableNameParts.test.ts @@ -0,0 +1,30 @@ +import { extractRawVariableNamePart } from '@/workflow/utils/extractRawVariableNameParts'; + +describe('extractRawVariableNamePart', () => { + it('should extract stepId from a variable name', () => { + const result = extractRawVariableNamePart({ + rawVariableName: '{{step1.output.field}}', + part: 'stepId', + }); + + expect(result).toBe('step1'); + }); + + it('should extract selectedField from a variable name', () => { + const result = extractRawVariableNamePart({ + rawVariableName: '{{step1.output.field}}', + part: 'selectedField', + }); + + expect(result).toBe('field'); + }); + + it('should handle deeply nested variable names', () => { + const result = extractRawVariableNamePart({ + rawVariableName: '{{trigger.output.nested.deep}}', + part: 'selectedField', + }); + + expect(result).toBe('deep'); + }); +}); diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/parseBooleanFromStringValue.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/parseBooleanFromStringValue.test.ts new file mode 100644 index 00000000000..cb4ea217659 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/parseBooleanFromStringValue.test.ts @@ -0,0 +1,23 @@ +import { parseBooleanFromStringValue } from '@/workflow/utils/parseBooleanFromStringValue'; + +describe('parseBooleanFromStringValue', () => { + it('should return true for "true" string', () => { + expect(parseBooleanFromStringValue('true')).toBe(true); + }); + + it('should return false for "false" string', () => { + expect(parseBooleanFromStringValue('false')).toBe(false); + }); + + it('should return the original value for non-boolean strings', () => { + expect(parseBooleanFromStringValue('hello')).toBe('hello'); + }); + + it('should return the original value for numbers', () => { + expect(parseBooleanFromStringValue(42)).toBe(42); + }); + + it('should return the original value for null', () => { + expect(parseBooleanFromStringValue(null)).toBeNull(); + }); +}); diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/parseDataFromContentType.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/parseDataFromContentType.test.ts new file mode 100644 index 00000000000..e01e3852205 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/parseDataFromContentType.test.ts @@ -0,0 +1,116 @@ +import { parseDataFromContentType } from '@/workflow/utils/parseDataFromContentType'; + +describe('parseDataFromContentType', () => { + describe('application/json', () => { + it('should stringify object data', () => { + const result = parseDataFromContentType( + { key: 'value' }, + 'application/json', + ); + + expect(result).toBe('{"key":"value"}'); + }); + + it('should return string data as-is', () => { + const result = parseDataFromContentType( + '{"key":"value"}', + 'application/json', + ); + + expect(result).toBe('{"key":"value"}'); + }); + }); + + describe('text/plain', () => { + it('should return string data as-is', () => { + const result = parseDataFromContentType('hello', 'text/plain'); + + expect(result).toBe('hello'); + }); + + it('should convert object to key=val format', () => { + const result = parseDataFromContentType( + { name: 'Alice', age: '30' }, + 'text/plain', + ); + + expect(result).toContain('name=Alice'); + expect(result).toContain('age=30'); + }); + }); + + describe('application/x-www-form-urlencoded', () => { + it('should convert object to URL-encoded format', () => { + const result = parseDataFromContentType( + { key: 'value', foo: 'bar' }, + 'application/x-www-form-urlencoded', + ); + + expect(result).toContain('key=value'); + expect(result).toContain('foo=bar'); + }); + + it('should parse JSON string data', () => { + const result = parseDataFromContentType( + '{"key":"value"}', + 'application/x-www-form-urlencoded', + ); + + expect(result).toContain('key=value'); + }); + + it('should handle non-JSON string data', () => { + const result = parseDataFromContentType( + 'raw-string', + 'application/x-www-form-urlencoded', + ); + + expect(typeof result).toBe('string'); + }); + }); + + describe('multipart/form-data', () => { + it('should return FormData for object data', () => { + const result = parseDataFromContentType( + { key: 'value' }, + 'multipart/form-data', + ); + + expect(result).toBeInstanceOf(FormData); + }); + + it('should parse JSON string into FormData', () => { + const result = parseDataFromContentType( + '{"key":"value"}', + 'multipart/form-data', + ); + + expect(result).toBeInstanceOf(FormData); + }); + + it('should throw for invalid JSON string', () => { + expect(() => + parseDataFromContentType('not-json', 'multipart/form-data'), + ).toThrow('String data for FormData must be valid JSON'); + }); + }); + + describe('default (no content type)', () => { + it('should default to JSON parsing when content type is undefined', () => { + const result = parseDataFromContentType({ key: 'value' }); + + expect(result).toBe('{"key":"value"}'); + }); + }); + + describe('unknown content type', () => { + it('should default to JSON parsing', () => { + const result = parseDataFromContentType( + { key: 'value' }, + 'application/xml', + ); + + expect(result).toBe('{"key":"value"}'); + }); + }); +}); diff --git a/packages/twenty-shared/src/workflow/workflow-schema/index.ts b/packages/twenty-shared/src/workflow/workflow-schema/index.ts index 45ea747da7f..14955426536 100644 --- a/packages/twenty-shared/src/workflow/workflow-schema/index.ts +++ b/packages/twenty-shared/src/workflow/workflow-schema/index.ts @@ -5,5 +5,4 @@ export type { Node, NodeType, } from './types/base-output-schema.type'; -export { buildOutputSchemaFromValue } from './utils/buildOutputSchemaFromValue'; export { navigateOutputSchemaProperty } from './utils/navigateOutputSchemaProperty'; diff --git a/packages/twenty-shared/vite.config.ts b/packages/twenty-shared/vite.config.ts index b99eeb3fc36..3d238f6b6c3 100644 --- a/packages/twenty-shared/vite.config.ts +++ b/packages/twenty-shared/vite.config.ts @@ -53,7 +53,10 @@ export default defineConfig(() => { outDir: 'dist', lib: { entry: entries, name: 'twenty-shared' }, rollupOptions: { - external: Object.keys((packageJson as any).dependencies || {}), + external: [ + ...Object.keys((packageJson as any).dependencies || {}), + 'typescript', + ], output: [ { format: 'es', diff --git a/yarn.lock b/yarn.lock index 6943b3da4cc..d1886f64a2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58621,6 +58621,7 @@ __metadata: react-router-dom: "npm:^6.4.4" transliteration: "npm:^2.3.5" tsx: "npm:^4.19.3" + typescript: "npm:^5.9.2" vite: "npm:^7.0.0" vite-plugin-dts: "npm:3.8.1" vite-tsconfig-paths: "npm:^4.2.1"