1630 extensibility twenty cli ability to create edit and delete fields (#15501)
As title - adds decorators in twenty-sdk - update twenty-cli load-manifest to it gets @FieldMetadata infos + testing - update twenty-server so it CRUD fields properly, using universalIdentifier - Fix UI so we can update managed objects records - move FieldMetadata items from twenty-server to twenty-shared
This commit is contained in:
@@ -20,6 +20,7 @@ const tsLibMock = `declare module 'tslib' {
|
||||
export const __spreadArray: any;
|
||||
export const __assign: any;
|
||||
}`;
|
||||
|
||||
const twentySdkTypesMock = `
|
||||
declare module 'twenty-sdk/application' {
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
@@ -76,6 +77,18 @@ declare module 'twenty-sdk/application' {
|
||||
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
|
||||
return () => {};
|
||||
};
|
||||
|
||||
export class BaseObjectMetadata {}
|
||||
|
||||
export enum FieldMetadataType {
|
||||
TEXT = 'TEXT',
|
||||
FULL_NAME = 'FULL_NAME',
|
||||
ADDRESS = 'ADDRESS',
|
||||
SELECT = 'SELECT',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
}
|
||||
|
||||
export const FieldMetadata: (_: any) => PropertyDecorator;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -111,7 +124,19 @@ export const config: ServerlessFunctionConfig = {
|
||||
]
|
||||
};`;
|
||||
|
||||
const objectMock = `import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
const objectMock = `import {
|
||||
ObjectMetadata,
|
||||
BaseObjectMetadata,
|
||||
FieldMetadata,
|
||||
FieldMetadataType
|
||||
} from 'twenty-sdk/application';
|
||||
|
||||
enum PostCardStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
SENT = 'SENT',
|
||||
DELIVERED = 'DELIVERED',
|
||||
RETURNED = 'RETURNED',
|
||||
}
|
||||
|
||||
@ObjectMetadata({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
@@ -122,7 +147,72 @@ const objectMock = `import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
export class PostCard {}
|
||||
export class PostCard extends BaseObjectMetadata {
|
||||
@FieldMetadata({
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
})
|
||||
content: string;
|
||||
|
||||
@FieldMetadata({
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
label: 'Recipient name',
|
||||
})
|
||||
recipientName: string;
|
||||
|
||||
@FieldMetadata({
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
label: 'Recipient address',
|
||||
})
|
||||
recipientAddress: string;
|
||||
|
||||
@FieldMetadata({
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Status',
|
||||
defaultValue: \`'\${PostCardStatus.DRAFT}'\`,
|
||||
options: [
|
||||
{
|
||||
value: PostCardStatus.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
})
|
||||
status: 'draft' | 'sent' | 'delivered' | 'returned';
|
||||
|
||||
@FieldMetadata({
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
label: 'Delivered at',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
})
|
||||
deliveredAt?: Date;
|
||||
}
|
||||
`;
|
||||
|
||||
describe('loadManifest (integration)', () => {
|
||||
@@ -178,10 +268,11 @@ describe('loadManifest (integration)', () => {
|
||||
description: 'My app description',
|
||||
});
|
||||
|
||||
// objects collected from @ObjectMetadata
|
||||
expect(manifest.objects.length).toBe(1);
|
||||
|
||||
for (const object of manifest.objects) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { universalIdentifier: _, ...otherInfo } = object;
|
||||
const { universalIdentifier: _, fields, ...otherInfo } = object;
|
||||
expect(otherInfo).toEqual({
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
@@ -190,6 +281,61 @@ describe('loadManifest (integration)', () => {
|
||||
namePlural: 'postCards',
|
||||
nameSingular: 'postCard',
|
||||
});
|
||||
|
||||
expect(Array.isArray(fields)).toBe(true);
|
||||
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: 'TEXT',
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
name: 'content',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: 'FULL_NAME',
|
||||
label: 'Recipient name',
|
||||
name: 'recipientName',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: 'ADDRESS',
|
||||
label: 'Recipient address',
|
||||
name: 'recipientAddress',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
defaultValue: "'DRAFT'",
|
||||
options: [
|
||||
{ value: 'DRAFT', label: 'Draft', position: 0, color: 'gray' },
|
||||
{ value: 'SENT', label: 'Sent', position: 1, color: 'orange' },
|
||||
{
|
||||
value: 'DELIVERED',
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'RETURNED',
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
name: 'status',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: 'DATE_TIME',
|
||||
label: 'Delivered at',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
name: 'deliveredAt',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// serverless functions
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
isFunctionExpression,
|
||||
isExportAssignment,
|
||||
Modifier,
|
||||
isPropertyDeclaration,
|
||||
isNoSubstitutionTemplateLiteral,
|
||||
isTemplateExpression,
|
||||
} from 'typescript';
|
||||
import {
|
||||
AppManifest,
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
PackageJson,
|
||||
ServerlessFunctionManifest,
|
||||
Sources,
|
||||
FieldMetadata,
|
||||
} from '../types/config.types';
|
||||
import { posix, relative, sep, resolve, join } from 'path';
|
||||
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
|
||||
@@ -96,6 +100,25 @@ const exprToValue = (expr: Expression): JSONValue => {
|
||||
if (expr.kind === SyntaxKind.FalseKeyword) return false;
|
||||
if (expr.kind === SyntaxKind.NullKeyword) return null;
|
||||
|
||||
if (isPropertyAccessExpression(expr)) {
|
||||
if (isIdentifier(expr.expression) && isIdentifier(expr.name)) {
|
||||
return expr.name.text;
|
||||
}
|
||||
return String(expr.getText());
|
||||
}
|
||||
|
||||
if (isNoSubstitutionTemplateLiteral(expr)) {
|
||||
return expr.text;
|
||||
}
|
||||
if (isTemplateExpression(expr)) {
|
||||
let out = expr.head.text;
|
||||
for (const span of expr.templateSpans) {
|
||||
const v = exprToValue(span.expression);
|
||||
out += String(v) + span.literal.text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (isArrayLiteralExpression(expr)) {
|
||||
return expr.elements.map((e) =>
|
||||
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
|
||||
@@ -155,7 +178,42 @@ const collectObjects = (program: Program) => {
|
||||
if (objectDec) {
|
||||
const cfg = getFirstArgObject(objectDec);
|
||||
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
|
||||
manifest.push({ ...(cfg as any) } as ObjectManifest);
|
||||
const fields: Array<Record<string, JSONValue>> = [];
|
||||
|
||||
for (const member of node.members) {
|
||||
if (!isPropertyDeclaration(member)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldDec = getDecorators(member)?.find((d) =>
|
||||
isDecoratorNamed(d, 'FieldMetadata'),
|
||||
);
|
||||
|
||||
if (!fieldDec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldCfg = getFirstArgObject(fieldDec);
|
||||
|
||||
if (!fieldCfg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to attach the TypeScript property name as "name"
|
||||
let name: string | undefined;
|
||||
if (member.name && isIdentifier(member.name)) {
|
||||
name = member.name.text;
|
||||
} else {
|
||||
// fallback to AST text if not a simple identifier
|
||||
name = member.name?.getText?.() ?? undefined;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
...(fieldCfg as FieldMetadata),
|
||||
...(name ? { name } : {}),
|
||||
});
|
||||
}
|
||||
manifest.push({ ...(cfg as any), fields } as ObjectManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user