Update manifest structure (#17547)
Move all sync entities in an `entities` key. Rename functions to
logicFunctions
```json
{
application: {
...
},
entities: {
objects: [],
logicFunctions: [],
...
}
}
```
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { defineApplication } from '@/sdk';
|
||||
|
||||
describe('defineApplication', () => {
|
||||
it('should return successful validation result when valid', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
|
||||
displayName: 'My App',
|
||||
description: 'My app description',
|
||||
icon: 'IconWorld',
|
||||
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
|
||||
};
|
||||
|
||||
const result = defineApplication(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through all optional fields', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
|
||||
displayName: 'My App',
|
||||
description: 'My app description',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
API_KEY: {
|
||||
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
|
||||
description: 'API Key',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
|
||||
};
|
||||
|
||||
const result = defineApplication(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.config?.applicationVariables).toBeDefined();
|
||||
expect(result.config?.defaultRoleUniversalIdentifier).toBe(
|
||||
'68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is missing', () => {
|
||||
const config = {
|
||||
displayName: 'My App',
|
||||
};
|
||||
|
||||
const result = defineApplication(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Application must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is empty string', () => {
|
||||
const config = {
|
||||
universalIdentifier: '',
|
||||
displayName: 'My App',
|
||||
};
|
||||
|
||||
const result = defineApplication(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Application must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ApplicationManifest } from 'twenty-shared/application';
|
||||
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
|
||||
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
|
||||
export const defineApplication: DefineEntity<ApplicationManifest> = (
|
||||
config,
|
||||
) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('Application must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.defaultRoleUniversalIdentifier) {
|
||||
errors.push('Application must have a defaultRoleUniversalIdentifier');
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
type ApplicationManifest,
|
||||
type FieldManifest,
|
||||
type ObjectManifest,
|
||||
type RoleManifest,
|
||||
} from 'twenty-shared/application';
|
||||
import { type FrontComponentConfig } from '@/sdk/front-components/front-component-config';
|
||||
import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config';
|
||||
|
||||
export type ValidationResult<T> = {
|
||||
success: boolean;
|
||||
config: T;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type DefinableEntity =
|
||||
| ApplicationManifest
|
||||
| ObjectManifest
|
||||
| FieldManifest
|
||||
| FrontComponentConfig
|
||||
| LogicFunctionConfig
|
||||
| RoleManifest;
|
||||
|
||||
export type DefineEntity<C extends DefinableEntity = DefinableEntity> = <
|
||||
T extends C,
|
||||
>(
|
||||
config: T,
|
||||
) => ValidationResult<T>;
|
||||
@@ -0,0 +1 @@
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type ValidationResult } from '@/sdk/common/types/define-entity.type';
|
||||
|
||||
export const createValidationResult = <T>({
|
||||
config,
|
||||
errors = [],
|
||||
}: {
|
||||
config: T;
|
||||
errors: string[];
|
||||
}): ValidationResult<T> => ({
|
||||
success: errors.length === 0,
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { defineField } from '@/sdk';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type FieldManifest } from 'twenty-shared/application';
|
||||
|
||||
const validConfig: FieldManifest = {
|
||||
objectUniversalIdentifier: '45e8ae95-0ed8-4087-9f59-3ac85144f86d',
|
||||
universalIdentifier: '73068741-1638-4d40-b30c-3431a8733094',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'customNote',
|
||||
label: 'Custom Note',
|
||||
};
|
||||
|
||||
describe('defineField', () => {
|
||||
describe('valid configurations', () => {
|
||||
it('should return successful validation result when targeting by nameSingular', () => {
|
||||
const result = defineField(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(validConfig);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through optional field properties', () => {
|
||||
const config: FieldManifest = {
|
||||
...validConfig,
|
||||
|
||||
description: 'A health score from 0-100',
|
||||
icon: 'IconHeart',
|
||||
};
|
||||
|
||||
const result = defineField(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.description).toBe('A health score from 0-100');
|
||||
expect(result.config?.icon).toBe('IconHeart');
|
||||
});
|
||||
|
||||
it('should accept SELECT field with options', () => {
|
||||
const config: FieldManifest = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440003',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'churnRisk',
|
||||
label: 'Churn Risk',
|
||||
options: [
|
||||
{ value: 'low', label: 'Low', color: 'green', position: 0 },
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Medium',
|
||||
color: 'yellow',
|
||||
position: 1,
|
||||
},
|
||||
{ value: 'high', label: 'High', color: 'red', position: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineField(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.label).toBe('Churn Risk');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when objectUniversalIdentifier is missing', () => {
|
||||
const { objectUniversalIdentifier: _, ...validConfigWithoutTargetObject } =
|
||||
validConfig;
|
||||
|
||||
const result = defineField(validConfigWithoutTargetObject as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field must have an objectUniversalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
describe('fields validation', () => {
|
||||
it('should return error when field is missing label', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
name: 'healthScore',
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Field must have a label');
|
||||
});
|
||||
|
||||
it('should return error when field is missing name', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Health Score',
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Field "Health Score" must have a name');
|
||||
});
|
||||
|
||||
it('should return error when field is missing universalIdentifier', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
type: FieldMetadataType.NUMBER,
|
||||
name: 'healthScore',
|
||||
label: 'Health Score',
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Health Score" must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when SELECT field has no options', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when MULTI_SELECT field has no options', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
name: 'tags',
|
||||
label: 'Tags',
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Tags" is a SELECT/MULTI_SELECT type and must have options',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when SELECT field has empty options array', () => {
|
||||
const config = {
|
||||
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
|
||||
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
options: [],
|
||||
};
|
||||
|
||||
const result = defineField(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ActorMetadata as ActorField,
|
||||
AddressMetadata as AddressField,
|
||||
CurrencyMetadata as CurrencyField,
|
||||
EmailsMetadata as EmailsField,
|
||||
FullNameMetadata as FullNameField,
|
||||
LinksMetadata as LinksField,
|
||||
PhonesMetadata as PhonesField,
|
||||
RichTextV2Metadata as RichTextField,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type FieldManifest } from 'twenty-shared/application';
|
||||
import { validateFields } from '@/sdk/fields/validate-fields';
|
||||
|
||||
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
|
||||
|
||||
export const defineField: DefineEntity<FieldManifest> = (config) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.objectUniversalIdentifier) {
|
||||
errors.push('Field must have an objectUniversalIdentifier');
|
||||
}
|
||||
|
||||
const fieldErrors = validateFields([config]);
|
||||
|
||||
errors.push(...fieldErrors);
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { FieldMetadataType as FieldType } from 'twenty-shared/types';
|
||||
@@ -0,0 +1 @@
|
||||
export { RelationOnDeleteAction as OnDeleteAction } from 'twenty-shared/types';
|
||||
@@ -0,0 +1 @@
|
||||
export { RelationType } from 'twenty-shared/types';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type ObjectFieldManifest } from 'twenty-shared/application';
|
||||
|
||||
export const validateFields = (
|
||||
fields: ObjectFieldManifest[] | undefined,
|
||||
): string[] => {
|
||||
if (!fields) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (!isNonEmptyString(field.label)) {
|
||||
errors.push('Field must have a label');
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(field.name)) {
|
||||
errors.push(`Field "${field.label}" must have a name`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(field.universalIdentifier)) {
|
||||
errors.push(`Field "${field.label}" must have a universalIdentifier`);
|
||||
}
|
||||
|
||||
if (
|
||||
(field.type === FieldMetadataType.SELECT ||
|
||||
field.type === FieldMetadataType.MULTI_SELECT) &&
|
||||
(!Array.isArray(field.options) || field.options.length === 0)
|
||||
) {
|
||||
errors.push(
|
||||
`Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
// Mock component for testing
|
||||
const MockComponent = () => null;
|
||||
|
||||
describe('defineFrontComponent', () => {
|
||||
const validConfig = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'My Component',
|
||||
component: MockComponent,
|
||||
};
|
||||
|
||||
it('should return successful validation result when valid', () => {
|
||||
const result = defineFrontComponent(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(validConfig);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through optional fields', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
description: 'A sample front component',
|
||||
};
|
||||
|
||||
const result = defineFrontComponent(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.description).toBe('A sample front component');
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is missing', () => {
|
||||
const config = {
|
||||
name: 'My Component',
|
||||
component: MockComponent,
|
||||
};
|
||||
|
||||
const result = defineFrontComponent(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Front component must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when component is missing', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'My Component',
|
||||
};
|
||||
|
||||
const result = defineFrontComponent(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Front component must have a component');
|
||||
});
|
||||
|
||||
it('should return error when component is not a function', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'My Component',
|
||||
component: 'not-a-function',
|
||||
};
|
||||
|
||||
const result = defineFrontComponent(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Front component component must be a React component',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept config without name', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
component: MockComponent,
|
||||
};
|
||||
|
||||
const result = defineFrontComponent(config as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type FrontComponentConfig } from '@/sdk/front-components/front-component-config';
|
||||
import type { DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
import { createValidationResult } from '@/sdk';
|
||||
|
||||
export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
|
||||
config,
|
||||
) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('Front component must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.component) {
|
||||
errors.push('Front component must have a component');
|
||||
}
|
||||
|
||||
if (typeof config.component !== 'function') {
|
||||
errors.push('Front component component must be a React component');
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type FrontComponentManifest } from 'twenty-shared/application';
|
||||
|
||||
export type FrontComponentType = React.ComponentType<any>;
|
||||
|
||||
export type FrontComponentConfig = Omit<
|
||||
FrontComponentManifest,
|
||||
| 'sourceComponentPath'
|
||||
| 'builtComponentPath'
|
||||
| 'builtComponentChecksum'
|
||||
| 'componentName'
|
||||
> & {
|
||||
component: FrontComponentType;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { defineApplication } from './application/define-application';
|
||||
export type {
|
||||
ValidationResult,
|
||||
DefinableEntity,
|
||||
DefineEntity,
|
||||
} from './common/types/define-entity.type';
|
||||
export type { SyncableEntityOptions } from './common/types/syncable-entity-options.type';
|
||||
export { createValidationResult } from './common/utils/create-validation-result';
|
||||
export type {
|
||||
ActorField,
|
||||
AddressField,
|
||||
CurrencyField,
|
||||
EmailsField,
|
||||
FullNameField,
|
||||
LinksField,
|
||||
PhonesField,
|
||||
RichTextField,
|
||||
} from './fields/composite-fields';
|
||||
export { defineField } from './fields/define-field';
|
||||
export { FieldType } from './fields/field-type';
|
||||
export { OnDeleteAction } from './fields/on-delete-action';
|
||||
export { RelationType } from './fields/relation-type';
|
||||
export { validateFields } from './fields/validate-fields';
|
||||
export { defineFrontComponent } from './front-components/define-front-component';
|
||||
export type {
|
||||
FrontComponentType,
|
||||
FrontComponentConfig,
|
||||
} from './front-components/front-component-config';
|
||||
export { defineLogicFunction } from './logic-functions/define-logic-function';
|
||||
export type {
|
||||
LogicFunctionHandler,
|
||||
LogicFunctionConfig,
|
||||
} from './logic-functions/logic-function-config';
|
||||
export type { CronPayload } from './logic-functions/triggers/cron-payload-type';
|
||||
export type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
ObjectRecordEvent,
|
||||
ObjectRecordDeleteEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordBaseEvent,
|
||||
ObjectRecordRestoreEvent,
|
||||
ObjectRecordUpsertEvent,
|
||||
} from './logic-functions/triggers/database-event-payload-type';
|
||||
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
|
||||
export { defineObject } from './objects/define-object';
|
||||
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
|
||||
export { defineRole } from './roles/define-role';
|
||||
export { PermissionFlag } from './roles/permission-flag-type';
|
||||
@@ -0,0 +1,314 @@
|
||||
import { defineLogicFunction } from '@/sdk';
|
||||
|
||||
const mockHandler = async () => ({ success: true });
|
||||
|
||||
describe('defineLogicFunction', () => {
|
||||
const validRouteConfig = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route' as const,
|
||||
path: '/postcards/send',
|
||||
httpMethod: 'POST' as const,
|
||||
isAuthRequired: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should return the config when valid with route trigger', () => {
|
||||
const result = defineLogicFunction(validRouteConfig);
|
||||
|
||||
expect(result.config).toEqual(validRouteConfig);
|
||||
});
|
||||
|
||||
it('should accept cron trigger', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Daily Report',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron' as const,
|
||||
pattern: '0 9 * * *',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config);
|
||||
|
||||
expect(result.config.triggers[0].type).toBe('cron');
|
||||
});
|
||||
|
||||
it('should accept databaseEvent trigger', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'On Contact Created',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent' as const,
|
||||
eventName: 'contact.created',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config);
|
||||
|
||||
expect(result.config.triggers[0].type).toBe('databaseEvent');
|
||||
});
|
||||
|
||||
it('should accept multiple triggers', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Multi-trigger Function',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route' as const,
|
||||
path: '/sync',
|
||||
httpMethod: 'POST' as const,
|
||||
isAuthRequired: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron' as const,
|
||||
pattern: '0 * * * *',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config);
|
||||
|
||||
expect(result.config.triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should pass through optional fields', () => {
|
||||
const config = {
|
||||
...validRouteConfig,
|
||||
description: 'Send a postcard to a contact',
|
||||
timeoutSeconds: 30,
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config);
|
||||
|
||||
expect(result.config.description).toBe('Send a postcard to a contact');
|
||||
expect(result.config.timeoutSeconds).toBe(30);
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is missing', () => {
|
||||
const config = {
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: validRouteConfig.triggers,
|
||||
};
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Logic function must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when handler is missing', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
triggers: validRouteConfig.triggers,
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Logic function must have a handler');
|
||||
});
|
||||
|
||||
it('should return error when handler is not a function', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: 'not-a-function',
|
||||
triggers: validRouteConfig.triggers,
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Logic function handler must be a function',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept empty triggers array', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.config.triggers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accept missing triggers', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.config.triggers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return error when trigger is missing universalIdentifier', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
type: 'route' as const,
|
||||
path: '/postcards/send',
|
||||
httpMethod: 'POST' as const,
|
||||
isAuthRequired: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Each trigger must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when trigger is missing type', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
path: '/postcards/send',
|
||||
httpMethod: 'POST' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Each trigger must have a type');
|
||||
});
|
||||
|
||||
it('should return error when route trigger is missing path', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route' as const,
|
||||
httpMethod: 'POST' as const,
|
||||
isAuthRequired: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Route trigger must have a path');
|
||||
});
|
||||
|
||||
it('should return error when route trigger is missing httpMethod', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Send Postcard',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route' as const,
|
||||
path: '/postcards/send',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Route trigger must have an httpMethod');
|
||||
});
|
||||
|
||||
it('should return error when cron trigger is missing pattern', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Daily Report',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Cron trigger must have a pattern');
|
||||
});
|
||||
|
||||
it('should return error when databaseEvent trigger is missing eventName', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'On Contact Created',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Database event trigger must have an eventName',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error for unknown trigger type', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'Unknown Trigger',
|
||||
handler: mockHandler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'unknown' as any,
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = defineLogicFunction(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Unknown trigger type: unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config';
|
||||
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
|
||||
import type { DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
|
||||
export const defineLogicFunction: DefineEntity<LogicFunctionConfig> = (
|
||||
config,
|
||||
) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('Logic function must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.handler) {
|
||||
errors.push('Logic function must have a handler');
|
||||
}
|
||||
|
||||
if (typeof config.handler !== 'function') {
|
||||
errors.push('Logic function handler must be a function');
|
||||
}
|
||||
|
||||
for (const trigger of config.triggers ?? []) {
|
||||
if (!trigger.universalIdentifier) {
|
||||
errors.push('Each trigger must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!trigger.type) {
|
||||
errors.push('Each trigger must have a type');
|
||||
}
|
||||
|
||||
switch (trigger.type) {
|
||||
case 'route':
|
||||
if (!trigger.path) {
|
||||
errors.push('Route trigger must have a path');
|
||||
}
|
||||
if (!trigger.httpMethod) {
|
||||
errors.push('Route trigger must have an httpMethod');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cron':
|
||||
if (!trigger.pattern) {
|
||||
errors.push('Cron trigger must have a pattern');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'databaseEvent':
|
||||
if (!trigger.eventName) {
|
||||
errors.push('Database event trigger must have an eventName');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push(
|
||||
`Unknown trigger type: ${(trigger as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
type LogicFunctionManifest,
|
||||
type LogicFunctionTriggerManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export type LogicFunctionHandler = (...args: any[]) => any | Promise<any>;
|
||||
|
||||
export type LogicFunctionConfig = Omit<
|
||||
LogicFunctionManifest,
|
||||
| 'sourceHandlerPath'
|
||||
| 'builtHandlerPath'
|
||||
| 'builtHandlerChecksum'
|
||||
| 'handlerName'
|
||||
> & {
|
||||
handler: LogicFunctionHandler;
|
||||
triggers?: LogicFunctionTriggerManifest[];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type CronPayload = Record<string, never>;
|
||||
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
ObjectRecordEvent,
|
||||
ObjectRecordDeleteEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordBaseEvent,
|
||||
ObjectRecordRestoreEvent,
|
||||
ObjectRecordUpsertEvent,
|
||||
} from 'twenty-shared/database-events';
|
||||
@@ -0,0 +1 @@
|
||||
export type { LogicFunctionEvent as RoutePayload } from 'twenty-shared/types';
|
||||
@@ -0,0 +1,242 @@
|
||||
import { defineObject } from '@/sdk';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type ObjectManifest } from 'twenty-shared/application';
|
||||
|
||||
describe('defineObject', () => {
|
||||
const validConfig: ObjectManifest = {
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post Card',
|
||||
labelPlural: 'Post Cards',
|
||||
icon: 'IconMail',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'content',
|
||||
label: 'Content',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should return successful validation result when valid', () => {
|
||||
const result = defineObject(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(validConfig);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through all optional fields', () => {
|
||||
const config: ObjectManifest = {
|
||||
...validConfig,
|
||||
description: 'A post card object',
|
||||
};
|
||||
|
||||
const result = defineObject(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.description).toBe('A post card object');
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is missing', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
universalIdentifier: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Object must have a universalIdentifier');
|
||||
});
|
||||
|
||||
it('should return error when nameSingular is missing', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
nameSingular: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Object must have a nameSingular');
|
||||
});
|
||||
|
||||
it('should return error when namePlural is missing', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
namePlural: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Object must have a namePlural');
|
||||
});
|
||||
|
||||
it('should return error when labelSingular is missing', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
labelSingular: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Object must have a labelSingular');
|
||||
});
|
||||
|
||||
it('should return error when labelPlural is missing', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
labelPlural: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Object must have a labelPlural');
|
||||
});
|
||||
|
||||
it('should accept empty fields array', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [],
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.config.fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accept missing fields', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: undefined,
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.config.fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return error when field is missing label', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'content',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Field must have a label');
|
||||
});
|
||||
|
||||
it('should return error when field is missing name', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Content',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Field "Content" must have a name');
|
||||
});
|
||||
|
||||
it('should return error when field is missing universalIdentifier', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
type: FieldMetadataType.TEXT,
|
||||
name: 'content',
|
||||
label: 'Content',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Content" must have a universalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when SELECT field has no options', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Status',
|
||||
name: 'status',
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when MULTI_SELECT field has no options', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
label: 'Tags',
|
||||
name: 'tag',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field "Tags" is a SELECT/MULTI_SELECT type and must have options',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept SELECT field with options', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
options: [
|
||||
{ value: 'draft', label: 'Draft', color: 'gray', position: 0 },
|
||||
{ value: 'sent', label: 'Sent', color: 'green', position: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineObject(config as any);
|
||||
|
||||
expect(result.config.fields[0].options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type ObjectManifest } from 'twenty-shared/application';
|
||||
|
||||
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
|
||||
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
import { validateFields } from '@/sdk/fields/validate-fields';
|
||||
|
||||
export const defineObject: DefineEntity<ObjectManifest> = (config) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('Object must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.nameSingular) {
|
||||
errors.push('Object must have a nameSingular');
|
||||
}
|
||||
|
||||
if (!config.namePlural) {
|
||||
errors.push('Object must have a namePlural');
|
||||
}
|
||||
|
||||
if (!config.labelSingular) {
|
||||
errors.push('Object must have a labelSingular');
|
||||
}
|
||||
|
||||
if (!config.labelPlural) {
|
||||
errors.push('Object must have a labelPlural');
|
||||
}
|
||||
|
||||
const fieldErrors = validateFields(config.fields);
|
||||
|
||||
errors.push(...fieldErrors);
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { STANDARD_OBJECT_IDS as STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
|
||||
@@ -0,0 +1,124 @@
|
||||
import { defineRole } from '@/sdk';
|
||||
|
||||
describe('defineRole', () => {
|
||||
const validConfig = {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
label: 'App User',
|
||||
description: 'Standard user role',
|
||||
};
|
||||
|
||||
it('should return successful validation result when valid', () => {
|
||||
const result = defineRole(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toEqual(validConfig);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through all optional fields', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
icon: 'IconUser',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
};
|
||||
|
||||
const result = defineRole(config);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.icon).toBe('IconUser');
|
||||
expect(result.config?.canReadAllObjectRecords).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept permissionFlags', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
permissionFlags: ['UPLOAD_FILE', 'DOWNLOAD_FILE'],
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.permissionFlags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return error when universalIdentifier is missing', () => {
|
||||
const config = {
|
||||
label: 'App User',
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Role must have a universalIdentifier');
|
||||
});
|
||||
|
||||
it('should return error when label is missing', () => {
|
||||
const config = {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Role must have a label');
|
||||
});
|
||||
|
||||
it('should return error when objectPermission has no objectUniversalIdentifier', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
objectPermissions: [
|
||||
{
|
||||
canReadObjectRecords: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Object permission must have an objectUniversalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when fieldPermission has no objectUniversalIdentifier', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fieldPermissions: [
|
||||
{
|
||||
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
|
||||
canReadFieldValue: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field permission must have an objectUniversalIdentifier',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when fieldPermission has no fieldUniversalIdentifier', () => {
|
||||
const config = {
|
||||
...validConfig,
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
|
||||
canReadFieldValue: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = defineRole(config as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'Field permission must have a fieldUniversalIdentifier',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
|
||||
import { type RoleManifest } from 'twenty-shared/application';
|
||||
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
|
||||
|
||||
export const defineRole: DefineEntity<RoleManifest> = (config) => {
|
||||
const errors = [];
|
||||
|
||||
if (!config.universalIdentifier) {
|
||||
errors.push('Role must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.label) {
|
||||
errors.push('Role must have a label');
|
||||
}
|
||||
|
||||
if (config.objectPermissions) {
|
||||
for (const permission of config.objectPermissions) {
|
||||
if (!permission.objectUniversalIdentifier) {
|
||||
errors.push('Object permission must have an objectUniversalIdentifier');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fieldPermissions) {
|
||||
for (const permission of config.fieldPermissions) {
|
||||
if (!permission.objectUniversalIdentifier) {
|
||||
errors.push('Field permission must have an objectUniversalIdentifier');
|
||||
}
|
||||
|
||||
if (!permission.fieldUniversalIdentifier) {
|
||||
errors.push('Field permission must have a fieldUniversalIdentifier');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createValidationResult({ config, errors });
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PermissionFlagType as PermissionFlag } from 'twenty-shared/constants';
|
||||
Reference in New Issue
Block a user