Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/twenty-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"preact": "^10.28.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-morph": "^25.0.0",
"typescript": "^5.9.2",
"uuid": "^13.0.0",
"vite": "^7.0.0",
Expand All @@ -95,7 +96,6 @@
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.56.1",
"storybook": "^10.1.11",
"ts-morph": "^25.0.0",
"tsx": "^4.7.0",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,43 +1,160 @@
const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN =
/import\s*\{\s*defineFrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g;
import { Node, Project, type SourceFile, ts } from 'ts-morph';

const DEFINE_FRONT_COMPONENT_EXPORT_PATTERN =
/export\s+default\s+defineFrontComponent\s*\(\s*\{[^}]*component\s*:\s*(\w+)[^}]*\}\s*\)\s*;?/s;
const project = new Project({
useInMemoryFileSystem: true,
compilerOptions: { jsx: ts.JsxEmit.ReactJSX },
});

const removeDefineFrontComponentFromImports = (
sourceFile: SourceFile,
): void => {
for (const importDeclaration of sourceFile.getImportDeclarations()) {
const defineFrontComponentSpecifier = importDeclaration
.getNamedImports()
.find((specifier) => specifier.getName() === 'defineFrontComponent');

if (!defineFrontComponentSpecifier) {
continue;
}

defineFrontComponentSpecifier.remove();

if (importDeclaration.getNamedImports().length === 0) {
importDeclaration.remove();
}
}
};

const extractComponentNameFromDefaultExport = (
sourceFile: SourceFile,
): string | null => {
for (const statement of sourceFile.getStatements()) {
if (!Node.isExportAssignment(statement) || statement.isExportEquals()) {
continue;
}

const expression = statement.getExpression();

if (
!Node.isCallExpression(expression) ||
expression.getExpression().getText() !== 'defineFrontComponent'
) {
continue;
}

const [configArg] = expression.getArguments();

if (!configArg || !Node.isObjectLiteralExpression(configArg)) {
throw new Error(
'defineFrontComponent must be called with an object literal argument',
);
}

const componentProperty = configArg.getProperty('component');

if (!componentProperty) {
throw new Error(
'defineFrontComponent config must include a "component" property',
);
}

if (Node.isShorthandPropertyAssignment(componentProperty)) {
return componentProperty.getName();
}

if (!Node.isPropertyAssignment(componentProperty)) {
throw new Error(
'Unexpected syntax for "component" property in defineFrontComponent config',
);
}

const initializer = componentProperty.getInitializer();

if (!initializer) {
throw new Error('"component" property must have a value');
}

return initializer.getText();
}

return null;
};

const removeExportFromComponentDeclaration = (
sourceFile: SourceFile,
componentName: string,
): void => {
for (const statement of sourceFile.getStatements()) {
if (
Node.isVariableStatement(statement) &&
statement.isExported() &&
statement
.getDeclarationList()
.getDeclarations()
.some((declaration) => declaration.getName() === componentName)
) {
statement.setIsExported(false);

return;
}

if (
Node.isFunctionDeclaration(statement) &&
statement.isExported() &&
!statement.isDefaultExport() &&
statement.getName() === componentName
) {
statement.setIsExported(false);

return;
}
}
};

const replaceDefaultExportWithRenderFunction = (
sourceFile: SourceFile,
componentName: string,
): void => {
for (const statement of sourceFile.getStatements()) {
if (!Node.isExportAssignment(statement) || statement.isExportEquals()) {
continue;
}

statement.replaceWithText(
`export default function __renderFrontComponent(__container) {` +
` __createRoot(__container).render(` +
`__frontComponentJsx(${componentName}, {})); }`,
);

return;
}
};

export const unwrapDefineFrontComponentToDirectExport = (
sourceCode: string,
): string => {
let transformedSource = sourceCode.replace(
DEFINE_FRONT_COMPONENT_IMPORT_PATTERN,
'',
);
const existingFile = project.getSourceFile('component.tsx');

const defineFrontComponentMatch = transformedSource.match(
DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
);
if (existingFile) {
project.removeSourceFile(existingFile);
}

if (defineFrontComponentMatch) {
const wrappedComponentName = defineFrontComponentMatch[1];
const sourceFile = project.createSourceFile('component.tsx', sourceCode);

const exportedComponentDeclarationPattern = new RegExp(
`export\\s+(const|function)\\s+${wrappedComponentName}\\b`,
);
const componentName = extractComponentNameFromDefaultExport(sourceFile);

transformedSource = transformedSource.replace(
exportedComponentDeclarationPattern,
`$1 ${wrappedComponentName}`,
);
if (!componentName) {
return sourceCode;
}

transformedSource =
`import { createRoot as __createRoot } from 'react-dom/client';\n` +
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n` +
transformedSource;
removeDefineFrontComponentFromImports(sourceFile);
removeExportFromComponentDeclaration(sourceFile, componentName);
replaceDefaultExportWithRenderFunction(sourceFile, componentName);

transformedSource = transformedSource.replace(
DEFINE_FRONT_COMPONENT_EXPORT_PATTERN,
`export default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${wrappedComponentName}, {})); }`,
);
}
sourceFile.insertStatements(0, [
`import { createRoot as __createRoot } from 'react-dom/client';`,
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';`,
]);

return transformedSource;
return sourceFile.getFullText();
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING,
} from '@/cli/utilities/build/manifest/manifest-extract-config';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { injectDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/inject-default-fields-in-object-fields';
import {
type ApplicationConfig,
type FrontComponentConfig,
Expand All @@ -20,6 +21,7 @@ import {
type ApplicationManifest,
type AssetManifest,
ASSETS_DIR,
type CommandMenuItemManifest,
type FieldManifest,
type FrontComponentManifest,
type LogicFunctionManifest,
Expand All @@ -32,7 +34,6 @@ import {
} from 'twenty-shared/application';
import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { injectDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/inject-default-fields-in-object-fields';

const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
Expand Down Expand Up @@ -70,6 +71,7 @@ export const buildManifest = async (
const views: ViewManifest[] = [];
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const commandMenuItems: CommandMenuItemManifest[] = [];

const applicationFilePaths: string[] = [];
const objectsFilePaths: string[] = [];
Expand Down Expand Up @@ -202,7 +204,7 @@ export const buildManifest = async (

errors.push(...extract.errors);

const { component, ...rest } = extract.config;
const { component, command, ...rest } = extract.config;

const relativeFilePath = relative(appPath, filePath);

Expand All @@ -216,6 +218,14 @@ export const buildManifest = async (

frontComponents.push(config);
frontComponentsFilePaths.push(relativePath);

if (command) {
commandMenuItems.push({
...command,
frontComponentUniversalIdentifier: rest.universalIdentifier,
});
}

break;
}
case ManifestEntityKey.Views: {
Expand Down Expand Up @@ -300,6 +310,7 @@ export const buildManifest = async (
views,
navigationMenuItems,
pageLayouts,
commandMenuItems,
};

const entityFilePaths: EntityFilePaths = {
Expand Down
10 changes: 10 additions & 0 deletions packages/twenty-sdk/src/sdk/define-front-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
errors.push('Front component component must be a React component');
}

if (config.command) {
if (!config.command.universalIdentifier) {
errors.push('Command must have a universalIdentifier');
}

if (!config.command.label) {
errors.push('Command must have a label');
}
}

return createValidationResult({
config,
errors,
Expand Down
11 changes: 10 additions & 1 deletion packages/twenty-sdk/src/sdk/front-component-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { type FrontComponentManifest } from 'twenty-shared/application';
import {
type CommandMenuItemManifest,
type FrontComponentManifest,
} from 'twenty-shared/application';

export type FrontComponentType = React.ComponentType<any>;

export type FrontComponentCommandConfig = Omit<
CommandMenuItemManifest,
'frontComponentUniversalIdentifier'
>;

export type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
Expand All @@ -10,4 +18,5 @@ export type FrontComponentConfig = Omit<
| 'componentName'
> & {
component: FrontComponentType;
command?: FrontComponentCommandConfig;
};
3 changes: 2 additions & 1 deletion packages/twenty-sdk/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { OnDeleteAction } from './fields/on-delete-action';
export { RelationType } from './fields/relation-type';
export { validateFields } from './fields/validate-fields';
export type {
FrontComponentCommandConfig,
FrontComponentConfig,
FrontComponentType,
} from './front-component-config';
Expand All @@ -47,9 +48,9 @@ export type {
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
export { defineNavigationMenuItem } from './navigation-menu-items/define-navigation-menu-item';
export { defineObject } from './objects/define-object';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { definePageLayout } from './page-layouts/define-page-layout';
export type { PageLayoutConfig } from './page-layouts/page-layout-config';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { defineRole } from './roles/define-role';
export { PermissionFlag } from './roles/permission-flag-type';
export { defineView } from './views/define-view';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [
'pageLayout',
'pageLayoutTab',
'pageLayoutWidget',
'commandMenuItem',
] as const satisfies AllMetadataName[];

export type ApplicationManifestMetadataName =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Manifest } from 'twenty-shared/application';

import { type ApplicationManifestMetadataName } from 'src/engine/core-modules/application/constants/application-manifest-metadata-names.constant';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/utils/from-field-manifest-to-universal-flat-field-metadata.util';
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/utils/from-front-component-manifest-to-universal-flat-front-component.util';
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util';
Expand Down Expand Up @@ -289,5 +290,20 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}

for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) {
addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow(
{
metadataName: 'commandMenuItem',
universalFlatEntity:
fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({
commandMenuItemManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps,
},
);
}

return allUniversalFlatEntityMaps;
};
Loading
Loading