mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
Support define is tool logic function (#17926)
- supports isTool and timeout settings in defineLogicFunction in apps and in setting tabs definition - compute for all toolInputSchema for logic funciton, in settings and in code steps <img width="991" height="802" alt="image" src="https://github.com/user-attachments/assets/05dc1221-cac9-45a3-87b0-3b13161446fd" />
This commit is contained in:
parent
f694bb99b3
commit
da064d5e88
138 changed files with 4839 additions and 367 deletions
|
|
@ -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_<name>` (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.
|
||||
|
||||
<Note>
|
||||
**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.
|
||||
</Note>
|
||||
|
||||
### 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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -43,7 +43,13 @@ describe('useLogicFunctionUpdateFormState', () => {
|
|||
expect(formValues).toEqual({
|
||||
name: '',
|
||||
description: '',
|
||||
code: mockCode,
|
||||
sourceHandlerCode: '',
|
||||
isTool: false,
|
||||
timeoutSeconds: 300,
|
||||
toolInputSchema: {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ export const useGetLogicFunctionSourceCode = ({
|
|||
skip: !logicFunctionId,
|
||||
});
|
||||
|
||||
return { code: data?.getLogicFunctionSourceCode, loading };
|
||||
return { sourceHandlerCode: data?.getLogicFunctionSourceCode, loading };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = <TKey extends keyof LogicFunctionFormValues>(key: TKey) => {
|
||||
return async (
|
||||
value: LogicFunctionFormValues[TKey],
|
||||
): Promise<InputJsonSchema | undefined> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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<LogicFunctionFormValues>({
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<FunctionInput> => {
|
||||
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;
|
||||
};
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getToolInputSchemaFromSourceCode = async (
|
||||
sourceCode: string,
|
||||
): Promise<object | null> => {
|
||||
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;
|
||||
};
|
||||
|
|
@ -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`] = `
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: <TKey extends keyof LogicFunctionFormValues>(
|
||||
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}
|
||||
/>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconTool}
|
||||
title={t`Available as tool`}
|
||||
description={t`When enabled, AI agents and workflow automations can discover and call this function`}
|
||||
checked={formValues.isTool}
|
||||
onChange={onChange('isTool')}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</Card>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentCounter
|
||||
Icon={IconClockHour8}
|
||||
title={t`Timeout`}
|
||||
description={t`Maximum execution time in seconds (1-900)`}
|
||||
value={formValues.timeoutSeconds}
|
||||
onChange={onChange('timeoutSeconds')}
|
||||
minValue={1}
|
||||
maxValue={900}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</Card>
|
||||
</StyledInputsContainer>
|
||||
</Section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<SettingsLogicFunctionCodeEditor
|
||||
files={files}
|
||||
currentFilePath={activeTabId}
|
||||
onChange={(newCodeValue: string) =>
|
||||
onChange(activeTabId, newCodeValue)
|
||||
}
|
||||
onChange={(newCodeValue: string) => onChange(newCodeValue)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ export const SettingsLogicFunctionSettingsTab = ({
|
|||
onChange,
|
||||
}: {
|
||||
formValues: LogicFunctionFormValues;
|
||||
onChange: (key: string) => (value: string) => void;
|
||||
onChange: <TKey extends keyof LogicFunctionFormValues>(
|
||||
key: TKey,
|
||||
) => (value: LogicFunctionFormValues[TKey]) => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = ({
|
|||
<StyledFullScreenCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={formValues.code}
|
||||
value={formValues.sourceHandlerCode}
|
||||
language="typescript"
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
|
|
@ -417,7 +398,7 @@ export const WorkflowEditActionCode = ({
|
|||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
<WorkflowCodeEditor
|
||||
value={formValues.code}
|
||||
value={formValues.sourceHandlerCode}
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
|
|
@ -469,7 +450,7 @@ export const WorkflowEditActionCode = ({
|
|||
? [
|
||||
<CmdEnterActionButton
|
||||
title={t`Test`}
|
||||
onClick={handleRunFunction}
|
||||
onClick={handleTestFunction}
|
||||
disabled={isExecuting}
|
||||
/>,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<StyledCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height={343}
|
||||
value={code ?? undefined}
|
||||
value={sourceHandlerCode ?? undefined}
|
||||
language="typescript"
|
||||
onMount={handleEditorDidMount}
|
||||
setMarkers={getWrongExportedFunctionMarkers}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type InputSchema } from '@/workflow/types/InputSchema';
|
||||
import { getDefaultFunctionInputFromInputSchema } from '@/logic-functions/utils/getDefaultFunctionInputFromInputSchema';
|
||||
import { getFunctionInputFromInputSchema } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getFunctionInputFromInputSchema';
|
||||
|
||||
describe('getDefaultFunctionInputFromInputSchema', () => {
|
||||
it('should init function input properly', () => {
|
||||
|
|
@ -37,7 +37,7 @@ describe('getDefaultFunctionInputFromInputSchema', () => {
|
|||
e: {},
|
||||
},
|
||||
];
|
||||
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
|
||||
expect(getFunctionInputFromInputSchema(inputSchema)).toEqual(
|
||||
expectedResult,
|
||||
);
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 ?? {};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<SettingsLogicFunctionCodeEditorTab
|
||||
files={files}
|
||||
handleExecute={handleTestFunction}
|
||||
onChange={onCodeChange}
|
||||
onChange={onChange('sourceHandlerCode')}
|
||||
isTesting={isExecuting}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -215,7 +160,7 @@ export const SettingsLogicFunctionDetail = () => {
|
|||
)}
|
||||
{isTestTab && (
|
||||
<SettingsLogicFunctionTestTab
|
||||
handleExecute={handleExecute}
|
||||
handleExecute={executeLogicFunction}
|
||||
logicFunctionId={logicFunctionId}
|
||||
isTesting={isExecuting}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
const persisted = { name: 'Alice' } as Record<string, any>;
|
||||
|
||||
const result = getDirtyFields(draft, persisted);
|
||||
|
||||
expect(result).toEqual({ age: 30 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
|||
sourceHandlerPath: 'my.function.ts',
|
||||
builtHandlerPath: 'my.function.mjs',
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
toolInputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
frontComponents: [
|
||||
|
|
|
|||
|
|
@ -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<string[]> => {
|
||||
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'),
|
||||
|
|
|
|||
|
|
@ -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<any>;
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ export type LogicFunctionConfig = Omit<
|
|||
| 'builtHandlerPath'
|
||||
| 'builtHandlerChecksum'
|
||||
| 'handlerName'
|
||||
| 'toolInputSchema'
|
||||
> & {
|
||||
handler: LogicFunctionHandler;
|
||||
toolInputSchema?: InputJsonSchema;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export const SEED_LOGIC_FUNCTION_INPUT_SCHEMA = {
|
||||
a: null,
|
||||
b: null,
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export const DEFAULT_TOOL_INPUT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'string' },
|
||||
b: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<object> | null;
|
||||
toolInputSchema: JsonbProperty<InputJsonSchema> | null;
|
||||
|
||||
@Column({ nullable: false, default: false })
|
||||
isTool: boolean;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<typeof logicFunctionRepository.update>[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export type {
|
|||
} from './fieldManifestType';
|
||||
export type { FrontComponentManifest } from './frontComponentManifestType';
|
||||
export type {
|
||||
InputJsonSchema,
|
||||
LogicFunctionManifest,
|
||||
CronTriggerSettings,
|
||||
DatabaseEventTriggerSettings,
|
||||
|
|
|
|||
|
|
@ -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<string, InputJsonSchema>;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type InputJsonSchema } from '@/logic-function';
|
||||
|
||||
export const DEFAULT_TOOL_INPUT_SCHEMA: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
};
|
||||
|
|
@ -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' },
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<InputJsonSchema> => {
|
||||
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;
|
||||
};
|
||||
|
|
@ -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] = {
|
||||
14
packages/twenty-shared/src/logic-function/index.ts
Normal file
14
packages/twenty-shared/src/logic-function/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export type InputJsonSchema = {
|
||||
type?:
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'integer'
|
||||
| 'null';
|
||||
description?: string;
|
||||
enum?: unknown[];
|
||||
items?: InputJsonSchema;
|
||||
properties?: Record<string, InputJsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | InputJsonSchema;
|
||||
};
|
||||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue