1750 extensibility twenty sdk v2 use twenty sdk to define an object (#15230)
We maintain jsonc object definition but will deprecate them pretty soon ## Before <img width="1512" height="575" alt="image" src="https://github.com/user-attachments/assets/d2fa6ca4-c456-4aa9-a1e3-845b61839718" /> ## After <img width="1260" height="555" alt="image" src="https://github.com/user-attachments/assets/ba72f4cf-d443-4967-913c-029bc71f3f48" />
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { getDecoratedClass } from '../../utils/get-decorated-class';
|
||||
|
||||
describe('getDecoratedClass', () => {
|
||||
it('should return properly formatted class', () => {
|
||||
const result = getDecoratedClass({
|
||||
data: { nameSingular: 'Name', namePlural: 'Names' },
|
||||
name: 'MyNewObject',
|
||||
});
|
||||
|
||||
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk';
|
||||
|
||||
@ObjectMetadata({
|
||||
nameSingular: 'Name',
|
||||
namePlural: 'Names',
|
||||
})
|
||||
export class MyNewObject {}
|
||||
`;
|
||||
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,12 @@ import * as path from 'path';
|
||||
import {
|
||||
AppManifest,
|
||||
CoreEntityManifest,
|
||||
ObjectManifest,
|
||||
PackageJson,
|
||||
} from '../types/config.types';
|
||||
import { validateSchema } from '../utils/schema-validator';
|
||||
import { parseJsoncFile } from './jsonc-parser';
|
||||
import { loadManifestFromDecorators } from '../utils/load-manifest-from-decorators';
|
||||
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
@@ -156,7 +158,7 @@ export const loadManifest = async (
|
||||
(manifest, path) => validateSchema('agent', manifest, path),
|
||||
);
|
||||
|
||||
const objects = await loadCoreEntity(
|
||||
const objectFromManifests = await loadCoreEntity(
|
||||
path.join(appPath, 'objects'),
|
||||
(manifest, path) => validateSchema('object', manifest, path),
|
||||
);
|
||||
@@ -166,6 +168,15 @@ export const loadManifest = async (
|
||||
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
|
||||
);
|
||||
|
||||
const { objects: objectsFromDecorators } = loadManifestFromDecorators();
|
||||
|
||||
const objects = (
|
||||
[...objectFromManifests, ...objectsFromDecorators] as ObjectManifest[]
|
||||
).map((object) => {
|
||||
object.standardId = object.universalIdentifier;
|
||||
return object;
|
||||
});
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
yarnLock: rawYarnLock,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import camelcase from 'lodash.camelcase';
|
||||
|
||||
export const getDecoratedClass = ({
|
||||
data,
|
||||
name,
|
||||
}: {
|
||||
data: object;
|
||||
name: string;
|
||||
}) => {
|
||||
const decoratorOptions = Object.entries(data)
|
||||
.map(([key, value]) => ` ${key}: '${value}',`)
|
||||
.join('\n');
|
||||
|
||||
const camelCaseName = camelcase(name);
|
||||
|
||||
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
|
||||
|
||||
return `import { ObjectMetadata } from 'twenty-sdk';
|
||||
|
||||
@ObjectMetadata({
|
||||
${decoratorOptions}
|
||||
})
|
||||
export class ${className} {}
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
sys,
|
||||
getDecorators,
|
||||
readConfigFile,
|
||||
parseJsonConfigFileContent,
|
||||
formatDiagnosticsWithColorAndContext,
|
||||
createProgram,
|
||||
Decorator,
|
||||
isPropertyAccessExpression,
|
||||
isNumericLiteral,
|
||||
SyntaxKind,
|
||||
isArrayLiteralExpression,
|
||||
Expression,
|
||||
isPropertyAssignment,
|
||||
isComputedPropertyName,
|
||||
isStringLiteralLike,
|
||||
isShorthandPropertyAssignment,
|
||||
isIdentifier,
|
||||
Program,
|
||||
Node,
|
||||
isClassDeclaration,
|
||||
isCallExpression,
|
||||
isObjectLiteralExpression,
|
||||
forEachChild,
|
||||
} from 'typescript';
|
||||
import { AppManifest, ObjectManifest } from '../types/config.types';
|
||||
|
||||
type JSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue[]
|
||||
| { [k: string]: JSONValue };
|
||||
|
||||
const getProgramFromTsconfig = (tsconfigPath = 'tsconfig.json') => {
|
||||
const basePath = process.cwd();
|
||||
const configFile = readConfigFile(tsconfigPath, sys.readFile);
|
||||
if (configFile.error)
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext([configFile.error], {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath);
|
||||
if (parsed.errors.length) {
|
||||
throw new Error(
|
||||
formatDiagnosticsWithColorAndContext(parsed.errors, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return createProgram(parsed.fileNames, parsed.options);
|
||||
};
|
||||
|
||||
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
|
||||
const expr = node.expression;
|
||||
if (isCallExpression(expr)) {
|
||||
if (isIdentifier(expr.expression)) return expr.expression.text === name;
|
||||
if (isPropertyAccessExpression(expr.expression))
|
||||
return expr.expression.name.text === name;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const exprToValue = (expr: Expression): JSONValue => {
|
||||
if (isStringLiteralLike(expr)) return expr.text;
|
||||
if (isNumericLiteral(expr)) return Number(expr.text);
|
||||
if (expr.kind === SyntaxKind.TrueKeyword) return true;
|
||||
if (expr.kind === SyntaxKind.FalseKeyword) return false;
|
||||
if (expr.kind === SyntaxKind.NullKeyword) return null;
|
||||
|
||||
if (isArrayLiteralExpression(expr)) {
|
||||
return expr.elements.map((e) =>
|
||||
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
|
||||
);
|
||||
}
|
||||
|
||||
if (isObjectLiteralExpression(expr)) {
|
||||
const obj: Record<string, JSONValue> = {};
|
||||
for (const prop of expr.properties) {
|
||||
if (isPropertyAssignment(prop)) {
|
||||
const key =
|
||||
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
|
||||
? prop.name.text
|
||||
: isComputedPropertyName(prop.name) &&
|
||||
isStringLiteralLike(prop.name.expression)
|
||||
? prop.name.expression.text
|
||||
: undefined;
|
||||
if (key) obj[key] = exprToValue(prop.initializer);
|
||||
} else if (isShorthandPropertyAssignment(prop)) {
|
||||
// Unsupported without a checker; skip to keep it "light".
|
||||
// Could resolve via typechecker if needed.
|
||||
}
|
||||
// getters/setters/methods are ignored intentionally
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
|
||||
// You can throw instead if you prefer to fail fast.
|
||||
return isIdentifier(expr)
|
||||
? expr.text
|
||||
: String((expr as any).getText?.() ?? '');
|
||||
};
|
||||
|
||||
const collectObjects = (program: Program) => {
|
||||
const manifest: ObjectManifest[] = [];
|
||||
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
if (sf.isDeclarationFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const visit = (node: Node) => {
|
||||
if (isClassDeclaration(node) && getDecorators(node)?.length) {
|
||||
const decorators = getDecorators(node);
|
||||
const objectDec = decorators?.find((d) =>
|
||||
isDecoratorNamed(d, 'ObjectMetadata'),
|
||||
);
|
||||
if (objectDec && isCallExpression(objectDec.expression)) {
|
||||
const [firstArg] = objectDec.expression.arguments;
|
||||
if (firstArg && isObjectLiteralExpression(firstArg)) {
|
||||
const config = exprToValue(firstArg);
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
!Array.isArray(config)
|
||||
) {
|
||||
manifest.push({
|
||||
...config,
|
||||
} as ObjectManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sf);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
};
|
||||
|
||||
const validateProgram = (program: Program) => {
|
||||
const diagnostics = [
|
||||
...program.getSyntacticDiagnostics(),
|
||||
...program.getSemanticDiagnostics(),
|
||||
...program.getGlobalDiagnostics(),
|
||||
];
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
getCurrentDirectory: sys.getCurrentDirectory,
|
||||
getNewLine: () => sys.newLine,
|
||||
});
|
||||
throw new Error(`TypeScript validation failed:\n${formatted}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const loadManifestFromDecorators = (): Pick<AppManifest, 'objects'> => {
|
||||
const program = getProgramFromTsconfig('tsconfig.json');
|
||||
|
||||
validateProgram(program);
|
||||
|
||||
const objects = collectObjects(program);
|
||||
|
||||
return { objects };
|
||||
};
|
||||
Reference in New Issue
Block a user