Baptiste Devessier
2026-04-02 13:20:08 +02:00
committed by GitHub
parent 268543a08e
commit 885ab8b444
20 changed files with 1087 additions and 275 deletions
@@ -33,7 +33,7 @@ export const fromCreateFrontComponentInputToFlatFrontComponentToCreate = ({
builtComponentPath: createFrontComponentInput.builtComponentPath,
componentName: createFrontComponentInput.componentName,
builtComponentChecksum: createFrontComponentInput.builtComponentChecksum,
isHeadless: false,
isHeadless: createFrontComponentInput.isHeadless ?? false,
usesSdkClient: false,
workspaceId,
createdAt: now,
@@ -0,0 +1,120 @@
import {
defineFrontComponent,
useFrontComponentExecutionContext,
useUserId,
} from 'twenty-sdk';
import { useState } from 'react';
const HelloWorld = () => {
const [renderCount, setRenderCount] = useState(0);
const userId = useUserId();
const fullContext = useFrontComponentExecutionContext((context) => context);
return (
<div
style={{
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
fontFamily: 'system-ui, sans-serif',
background: '#f0f9ff',
borderRadius: 12,
border: '2px solid #38bdf8',
maxWidth: 400,
}}
>
<h2
style={{
color: '#0369a1',
fontWeight: 700,
fontSize: 18,
margin: 0,
}}
>
Hello World
</h2>
<div>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: 1,
}}
>
User ID
</p>
<p
style={{
fontSize: 16,
fontWeight: 700,
color: '#0c4a6e',
wordBreak: 'break-all',
}}
>
{userId ?? 'null'}
</p>
</div>
<div>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: 1,
}}
>
Execution Context
</p>
<pre
style={{
fontSize: 13,
fontWeight: 700,
color: '#0c4a6e',
background: '#e0f2fe',
padding: 12,
borderRadius: 8,
overflow: 'auto',
wordBreak: 'break-all',
}}
>
{JSON.stringify(fullContext, null, 2) ?? 'undefined'}
</pre>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => setRenderCount((c) => c + 1)}
style={{
padding: '8px 16px',
backgroundColor: '#0284c7',
color: 'white',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
}}
>
Re-render
</button>
<span style={{ fontSize: 14, color: '#64748b' }}>
Render count: {renderCount}
</span>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'seed-front-component-hello-world',
name: 'Hello World',
description:
'A sample visual front component that displays execution context',
component: HelloWorld,
});
@@ -0,0 +1,32 @@
import {
defineFrontComponent,
enqueueSnackbar,
unmountFrontComponent,
} from 'twenty-sdk';
import { useEffect } from 'react';
const ShowNotification = () => {
useEffect(() => {
const run = async () => {
await enqueueSnackbar({
message: 'Hello from a headless front component!',
variant: 'success',
duration: 5000,
});
await unmountFrontComponent();
};
run();
}, []);
return null;
};
export default defineFrontComponent({
universalIdentifier: 'seed-front-component-show-notification',
name: 'Show Notification',
description: 'A sample headless front component that displays a notification',
isHeadless: true,
component: ShowNotification,
});
@@ -1,6 +1,12 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -16,6 +22,11 @@ export class CreateFrontComponentInput {
@HideField()
universalIdentifier?: string;
@IsBoolean()
@IsOptional()
@HideField()
isHeadless?: boolean;
@IsString()
@IsOptional()
@Field()
@@ -0,0 +1,46 @@
import fs from 'fs/promises';
import path from 'path';
import { ASSET_PATH } from 'src/constants/assets-path';
export type FrontComponentSeedProjectFile = {
name: string;
path: string;
content: Buffer;
};
const getAllFiles = async (
rootDir: string,
dir: string = rootDir,
files: FrontComponentSeedProjectFile[] = [],
): Promise<FrontComponentSeedProjectFile[]> => {
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of dirEntries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await getAllFiles(rootDir, fullPath, files);
} else {
files.push({
path: path.relative(rootDir, dir),
name: entry.name,
content: await fs.readFile(fullPath),
});
}
}
return files;
};
export const getFrontComponentSeedProjectFiles = async (
subdirectory: string,
): Promise<FrontComponentSeedProjectFile[]> => {
const seedProjectPath = path.join(
ASSET_PATH,
'engine/metadata-modules/front-component/constants/seed-project',
subdirectory,
);
return getAllFiles(seedProjectPath);
};
@@ -5,7 +5,7 @@ import { readFile } from 'fs/promises';
import { join } from 'path';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { DataSource } from 'typeorm';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -56,10 +56,6 @@ import {
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
import {
MESSAGE_FOLDER_DATA_SEED_COLUMNS,
MESSAGE_FOLDER_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
import {
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEED_COLUMNS,
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEEDS,
@@ -68,6 +64,10 @@ import {
MESSAGE_DATA_SEED_COLUMNS,
MESSAGE_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-data-seeds.constant';
import {
MESSAGE_FOLDER_DATA_SEED_COLUMNS,
MESSAGE_FOLDER_DATA_SEEDS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-folder-data-seeds.constant';
import {
getMessageParticipantDataSeeds,
MESSAGE_PARTICIPANT_DATA_SEED_COLUMNS,
@@ -121,10 +121,13 @@ import {
WORKSPACE_MEMBER_DATA_SEED_COLUMNS,
} from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
import { TimelineActivitySeederService } from 'src/engine/workspace-manager/dev-seeder/data/services/timeline-activity-seeder.service';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-command-menu-items.util';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
import { PrefillFrontComponentService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { prefillFrontComponentCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-command-menu-items.util';
import { getSeedFrontComponentDefinitions } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
import { prefillWorkflowCommandMenuItems } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-command-menu-items.util';
import { prefillWorkflows } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflows.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
type RecordSeedConfig = {
@@ -307,6 +310,7 @@ export class DevSeederDataService {
private readonly fileStorageService: FileStorageService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly prefillLogicFunctionService: PrefillLogicFunctionService,
private readonly prefillFrontComponentService: PrefillFrontComponentService,
) {}
public async seed({
@@ -342,6 +346,11 @@ export class DevSeederDataService {
),
});
await this.prefillFrontComponentService.ensureSeeded({
workspaceId,
definitions: getSeedFrontComponentDefinitions(workspaceId),
});
await this.coreDataSource.transaction(
async (entityManager: WorkspaceEntityManager) => {
await this.seedRecordsInBatches({
@@ -377,6 +386,8 @@ export class DevSeederDataService {
);
await prefillWorkflowCommandMenuItems(entityManager, workspaceId);
await prefillFrontComponentCommandMenuItems(entityManager, workspaceId);
},
);
}
@@ -0,0 +1,129 @@
import crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service';
import {
type FrontComponentSeedProjectFile,
getFrontComponentSeedProjectFiles,
} from 'src/engine/metadata-modules/front-component/utils/get-front-component-seed-project-files.util';
import { type SeedFrontComponentDefinition } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util';
@Injectable()
export class PrefillFrontComponentService {
constructor(
private readonly frontComponentService: FrontComponentService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
) {}
async ensureSeeded({
workspaceId,
definitions,
}: {
workspaceId: string;
definitions: SeedFrontComponentDefinition[];
}) {
const { flatFrontComponentMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFrontComponentMaps'],
},
);
const { workspaceCustomFlatApplication: ownerFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
for (const definition of definitions) {
const existingFrontComponent = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: definition.id,
flatEntityMaps: flatFrontComponentMaps,
});
if (isDefined(existingFrontComponent)) {
continue;
}
const seedFiles = await getFrontComponentSeedProjectFiles(
definition.seedProjectSubdir,
);
const sourceFile = seedFiles.find((file: FrontComponentSeedProjectFile) =>
file.name.endsWith('index.tsx'),
);
const builtFile = seedFiles.find((file: FrontComponentSeedProjectFile) =>
file.name.endsWith('index.mjs'),
);
if (!isDefined(sourceFile) || !isDefined(builtFile)) {
throw new Error(
`Seed project for front component "${definition.name}" must have an index.tsx and an index.mjs file`,
);
}
const sourceComponentPath = `${definition.id}/index.tsx`;
const builtComponentPath = `${definition.id}/index.mjs`;
await this.fileStorageService.writeFile({
workspaceId,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
fileFolder: FileFolder.Source,
resourcePath: sourceComponentPath,
sourceFile: sourceFile.content,
mimeType: 'application/typescript',
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await this.fileStorageService.writeFile({
workspaceId,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
fileFolder: FileFolder.BuiltFrontComponent,
resourcePath: builtComponentPath,
sourceFile: builtFile.content,
mimeType: 'application/javascript',
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
const checksum = crypto
.createHash('md5')
.update(builtFile.content)
.digest('hex');
await this.frontComponentService.createOne({
workspaceId,
ownerFlatApplication,
input: {
id: definition.id,
universalIdentifier: definition.universalIdentifier,
name: definition.name,
description: definition.description,
componentName: definition.componentName,
sourceComponentPath,
builtComponentPath,
builtComponentChecksum: checksum,
isHeadless: definition.isHeadless,
},
});
}
}
}
@@ -1,12 +1,22 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { PrefillFrontComponentService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
@Module({
imports: [LogicFunctionModule, WorkspaceManyOrAllFlatEntityMapsCacheModule],
providers: [PrefillLogicFunctionService],
exports: [PrefillLogicFunctionService],
imports: [
LogicFunctionModule,
FrontComponentModule,
FileStorageModule,
ApplicationModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [PrefillLogicFunctionService, PrefillFrontComponentService],
exports: [PrefillLogicFunctionService, PrefillFrontComponentService],
})
export class StandardObjectsPrefillModule {}
@@ -0,0 +1,72 @@
import { type EntityManager } from 'typeorm';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import {
getSeedFrontComponentCommandMenuItemDefinitions,
getSeedFrontComponentIds,
} from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-front-component-definitions.util';
export const prefillFrontComponentCommandMenuItems = async (
entityManager: EntityManager,
workspaceId: string,
) => {
const { helloWorldId } = getSeedFrontComponentIds(workspaceId);
const frontComponentRow = await entityManager
.createQueryBuilder()
.select('fc.applicationId', 'applicationId')
.from('core.frontComponent', 'fc')
.where('fc.id = :id', { id: helloWorldId })
.andWhere('fc.workspaceId = :workspaceId', { workspaceId })
.getRawOne();
if (!frontComponentRow) {
return;
}
const definitions =
getSeedFrontComponentCommandMenuItemDefinitions(workspaceId);
await entityManager
.createQueryBuilder()
.insert()
.into('core.commandMenuItem', [
'workspaceId',
'universalIdentifier',
'applicationId',
'workflowVersionId',
'frontComponentId',
'engineComponentKey',
'label',
'icon',
'shortLabel',
'position',
'isPinned',
'availabilityType',
'conditionalAvailabilityExpression',
'availabilityObjectMetadataId',
'hotKeys',
])
.values(
definitions.map((definition) => ({
workspaceId,
universalIdentifier: definition.universalIdentifier,
applicationId: frontComponentRow.applicationId,
workflowVersionId: null,
frontComponentId: definition.frontComponentId,
engineComponentKey: EngineComponentKey.FRONT_COMPONENT_RENDERER,
label: definition.label,
icon: definition.icon,
shortLabel: definition.label,
position: definition.position,
isPinned: false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataId: null,
hotKeys: null,
})),
)
.orIgnore()
.execute();
};
@@ -0,0 +1,102 @@
import { v5 as uuidv5 } from 'uuid';
const SEED_FRONT_COMPONENT_ID_NAMESPACE =
'e7a3b1c4-f5d6-4e8a-9b2c-3d4e5f6a7b8c';
export type SeedFrontComponentDefinition = {
id: string;
universalIdentifier: string;
name: string;
description: string;
componentName: string;
isHeadless: boolean;
usesSdkClient: boolean;
seedProjectSubdir: string;
};
export type SeedFrontComponentCommandMenuItemDefinition = {
universalIdentifier: string;
frontComponentId: string;
label: string;
icon: string;
position: number;
};
export const getSeedFrontComponentIds = (workspaceId: string) => ({
helloWorldId: uuidv5(
`${workspaceId}:seed-front-component:hello-world`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
showNotificationId: uuidv5(
`${workspaceId}:seed-front-component:show-notification`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
});
export const getSeedFrontComponentDefinitions = (
workspaceId: string,
): SeedFrontComponentDefinition[] => {
const { helloWorldId, showNotificationId } =
getSeedFrontComponentIds(workspaceId);
return [
{
id: helloWorldId,
universalIdentifier: uuidv5(
`${workspaceId}:seed-front-component-uid:hello-world`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
name: 'Hello World',
description:
'A sample visual front component that displays execution context',
componentName: 'HelloWorld',
isHeadless: false,
usesSdkClient: false,
seedProjectSubdir: 'hello-world',
},
{
id: showNotificationId,
universalIdentifier: uuidv5(
`${workspaceId}:seed-front-component-uid:show-notification`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
name: 'Show Notification',
description:
'A sample headless front component that displays a notification',
componentName: 'ShowNotification',
isHeadless: true,
usesSdkClient: false,
seedProjectSubdir: 'show-notification',
},
];
};
export const getSeedFrontComponentCommandMenuItemDefinitions = (
workspaceId: string,
): SeedFrontComponentCommandMenuItemDefinition[] => {
const { helloWorldId, showNotificationId } =
getSeedFrontComponentIds(workspaceId);
return [
{
universalIdentifier: uuidv5(
`${workspaceId}:seed-front-component-command:hello-world`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
frontComponentId: helloWorldId,
label: 'Hello World',
icon: 'IconAppWindow',
position: 200,
},
{
universalIdentifier: uuidv5(
`${workspaceId}:seed-front-component-command:show-notification`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
frontComponentId: showNotificationId,
label: 'Show Notification',
icon: 'IconBell',
position: 201,
},
];
};