Add dashboard rollout commands (#15567)

## Tests

### makeSureDashboardNamingAvailableCommand

Case 1: no dashboard custom object
Case 2: with dashboard custom object

### SeedDashboardViewCommand

Case 1: no existing view
Case 2: with existing view
This commit is contained in:
Charles Bochet
2025-11-03 18:35:57 +01:00
committed by GitHub
parent 5bc876e4b4
commit 7ff91a61c6
8 changed files with 222 additions and 197 deletions
@@ -0,0 +1,81 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade:1-10:make-sure-dashboard-naming-available',
description: 'Make sure the dashboard naming is available',
})
export class MakeSureDashboardNamingAvailableCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const potentialCustomDashboardObjectMetadata =
await this.objectMetadataRepository.findOne({
where: {
nameSingular: 'dashboard',
workspaceId,
isCustom: true,
},
});
if (!isDefined(potentialCustomDashboardObjectMetadata)) {
this.logger.log(
`No custom dashboard object metadata found for workspace ${workspaceId}. Skipping...`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have updated the dashboard object metadata for workspace ${workspaceId}. Skipping...`,
);
return;
}
this.logger.log(
`Updating the dashboard object metadata for workspace ${workspaceId}...`,
);
await this.objectMetadataServiceV2.updateOne({
workspaceId,
updateObjectInput: {
id: potentialCustomDashboardObjectMetadata.id,
update: {
nameSingular: 'myDashboard',
namePlural: 'myDashboards',
labelSingular: 'My Dashboard',
labelPlural: 'My Dashboards',
},
},
});
this.logger.log(
`Updated the dashboard object metadata for workspace ${workspaceId}...`,
);
}
}
@@ -0,0 +1,115 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { ViewKey } from 'src/engine/metadata-modules/view/enums/view-key.enum';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { createCoreViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-core-views';
import { prefillWorkspaceFavorites } from 'src/engine/workspace-manager/standard-objects-prefill-data/prefill-workspace-favorites';
import { dashboardsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
@Command({
name: 'upgrade:1-10:seed-dashboard-view',
description: 'Seed the dashboard view',
})
export class SeedDashboardViewCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectDataSource()
private readonly coreDataSource: DataSource,
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(DataSourceEntity)
private readonly dataSourceRepository: Repository<DataSourceEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const [dashboardObjectMetadata] = await this.objectMetadataRepository.find({
where: {
workspaceId,
standardId: STANDARD_OBJECT_IDS.dashboard,
},
relations: ['fields'],
});
if (!isDefined(dashboardObjectMetadata)) {
throw new Error(
`Dashboard object metadata not found for workspace ${workspaceId}`,
);
}
const views = [dashboardsAllView([dashboardObjectMetadata], true)];
const schema = await this.dataSourceRepository.findOne({
where: {
workspaceId,
},
});
if (!isDefined(schema)) {
throw new Error(`Schema not found for workspace ${workspaceId}`);
}
const existingViews = await this.viewRepository.find({
where: {
workspaceId,
objectMetadataId: dashboardObjectMetadata.id,
key: ViewKey.INDEX,
},
});
if (existingViews.length > 0) {
this.logger.log(
`Dashboard view already exists for workspace ${workspaceId}. Skipping...`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have seeded dashboard view for workspace ${workspaceId}. Skipping...`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
const createdViews = await createCoreViews(queryRunner, workspaceId, views);
await prefillWorkspaceFavorites(
createdViews.map((view) => view.id),
queryRunner.manager as WorkspaceEntityManager,
schema.schema,
);
await queryRunner.release();
this.logger.log(
`Successfully seeded dashboard view for workspace ${workspaceId}: ${createdViews.map((view) => view.name).join(', ')}`,
);
}
}
@@ -4,14 +4,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AddWorkflowRunStopStatusesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-add-workflow-run-stop-statuses.command';
import { CleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-clean-orphaned-kanban-aggregate-operation-field-metadata-id.command';
import { CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-create-view-kanban-field-metadata-id-foreign-key-migration.command';
import { MakeSureDashboardNamingAvailableCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-make-sure-dashboard-naming-available.command';
import { MigrateAttachmentAuthorToCreatedByCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-author-to-created-by.command';
import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-type-to-file-category.command';
import { MigrateChannelPartialFullSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-channel-partial-full-sync-stages.command';
import { RegenerateSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-search-vectors.command';
import { SeedDashboardViewCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-seed-dashboard-view.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
@@ -23,8 +27,10 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
FieldMetadataEntity,
IndexMetadataEntity,
ViewEntity,
DataSourceEntity,
]),
WorkspaceSchemaManagerModule,
ObjectMetadataModule,
],
providers: [
MigrateChannelPartialFullSyncStagesCommand,
@@ -33,6 +39,8 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
RegenerateSearchVectorsCommand,
AddWorkflowRunStopStatusesCommand,
CleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand,
MakeSureDashboardNamingAvailableCommand,
SeedDashboardViewCommand,
CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
],
exports: [
@@ -42,6 +50,8 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
AddWorkflowRunStopStatusesCommand,
CleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand,
MigrateChannelPartialFullSyncStagesCommand,
MakeSureDashboardNamingAvailableCommand,
SeedDashboardViewCommand,
CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
],
})
@@ -12,10 +12,12 @@ import {
import { AddWorkflowRunStopStatusesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-add-workflow-run-stop-statuses.command';
import { CleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-clean-orphaned-kanban-aggregate-operation-field-metadata-id.command';
import { CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-create-view-kanban-field-metadata-id-foreign-key-migration.command';
import { MakeSureDashboardNamingAvailableCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-make-sure-dashboard-naming-available.command';
import { MigrateAttachmentAuthorToCreatedByCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-author-to-created-by.command';
import { MigrateAttachmentTypeToFileCategoryCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-attachment-type-to-file-category.command';
import { MigrateChannelPartialFullSyncStagesCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-migrate-channel-partial-full-sync-stages.command';
import { RegenerateSearchVectorsCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-regenerate-search-vectors.command';
import { SeedDashboardViewCommand } from 'src/database/commands/upgrade-version-command/1-10/1-10-seed-dashboard-view.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { DeduplicateUniqueFieldsCommand } from 'src/database/commands/upgrade-version-command/1-8/1-8-deduplicate-unique-fields.command';
@@ -62,6 +64,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly addWorkflowRunStopStatusesCommand: AddWorkflowRunStopStatusesCommand,
protected readonly cleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand: CleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand,
protected readonly migrateChannelPartialFullSyncStagesCommand: MigrateChannelPartialFullSyncStagesCommand,
protected readonly makeSureDashboardNamingAvailableCommand: MakeSureDashboardNamingAvailableCommand,
protected readonly seedDashboardViewCommand: SeedDashboardViewCommand,
protected readonly createViewKanbanFieldMetadataIdForeignKeyMigrationCommand: CreateViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
) {
super(
@@ -101,10 +105,12 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.cleanOrphanedKanbanAggregateOperationFieldMetadataIdCommand,
this.createViewKanbanFieldMetadataIdForeignKeyMigrationCommand,
this.migrateChannelPartialFullSyncStagesCommand,
this.makeSureDashboardNamingAvailableCommand,
],
afterSyncMetadata: [
this.migrateAttachmentAuthorToCreatedByCommand,
this.migrateAttachmentTypeToFileCategoryCommand,
this.seedDashboardViewCommand,
],
};
@@ -28,6 +28,12 @@ const coreObjectNames = [
'jobs',
'keyValuePair',
'keyValuePairs',
'pageLayout',
'pageLayouts',
'pageLayoutTab',
'pageLayoutTabs',
'pageLayoutWidget',
'pageLayoutWidgets',
'postgresCredential',
'postgresCredentials',
'twoFactorMethod',
@@ -54,10 +54,9 @@ export const prefillCoreViews = async ({
workflowsAllView(objectMetadataItems, true),
workflowVersionsAllView(objectMetadataItems, true),
workflowRunsAllView(objectMetadataItems, true),
dashboardsAllView(objectMetadataItems, true),
];
views.push(dashboardsAllView(objectMetadataItems, true));
const queryRunner = coreDataSource.createQueryRunner();
await queryRunner.connect();
@@ -100,7 +99,7 @@ export const prefillCoreViews = async ({
}
};
const createCoreViews = async (
export const createCoreViews = async (
queryRunner: QueryRunner,
workspaceId: string,
viewDefinitions: ViewDefinition[],
@@ -1,192 +0,0 @@
import { type EntityManager } from 'typeorm';
import { v4 } from 'uuid';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type ViewDefinition } from 'src/engine/workspace-manager/standard-objects-prefill-data/types/view-definition.interface';
import { companiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view';
import { customAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/custom-all.view';
import { dashboardsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view';
import { notesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view';
import { opportunitiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/opportunities-all.view';
import { opportunitiesByStageView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/opportunity-by-stage.view';
import { peopleAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/people-all.view';
import { tasksAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-all.view';
import { tasksAssignedToMeView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-assigned-to-me';
import { tasksByStatusView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/tasks-by-status.view';
import { workflowRunsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-runs-all.view';
import { workflowVersionsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-versions-all.view';
import { workflowsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflows-all.view';
export const prefillViews = async (
entityManager: EntityManager,
schemaName: string,
objectMetadataItems: ObjectMetadataEntity[],
_featureFlags?: Record<string, boolean>,
) => {
const customObjectMetadataItems = objectMetadataItems.filter(
(item) => item.isCustom,
);
const customViews = customObjectMetadataItems.map((item) =>
customAllView(item),
);
const views = [
companiesAllView(objectMetadataItems),
peopleAllView(objectMetadataItems),
opportunitiesAllView(objectMetadataItems),
opportunitiesByStageView(objectMetadataItems),
notesAllView(objectMetadataItems),
tasksAllView(objectMetadataItems),
tasksAssignedToMeView(objectMetadataItems),
tasksByStatusView(objectMetadataItems),
workflowsAllView(objectMetadataItems),
workflowVersionsAllView(objectMetadataItems),
workflowRunsAllView(objectMetadataItems),
...customViews,
];
views.push(dashboardsAllView(objectMetadataItems));
return createWorkspaceViews(entityManager, schemaName, views);
};
const createWorkspaceViews = async (
entityManager: EntityManager,
schemaName: string,
viewDefinitions: ViewDefinition[],
) => {
const viewDefinitionsWithId = viewDefinitions.map((viewDefinition) => ({
...viewDefinition,
id: v4(),
}));
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.view`, [
'id',
'name',
'objectMetadataId',
'type',
'key',
'position',
'icon',
'openRecordIn',
'kanbanFieldMetadataId',
'kanbanAggregateOperation',
'kanbanAggregateOperationFieldMetadataId',
])
.values(
viewDefinitionsWithId.map(
({
id,
name,
objectMetadataId,
type,
key,
position,
icon,
openRecordIn,
kanbanFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
}) => ({
id,
name: name as string,
objectMetadataId,
type,
key,
position,
icon,
openRecordIn,
kanbanFieldMetadataId,
kanbanAggregateOperation,
kanbanAggregateOperationFieldMetadataId,
}),
),
)
.returning('*')
.execute();
for (const viewDefinition of viewDefinitionsWithId) {
if (viewDefinition.fields && viewDefinition.fields.length > 0) {
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.viewField`, [
'fieldMetadataId',
'position',
'isVisible',
'size',
'viewId',
'aggregateOperation',
])
.values(
viewDefinition.fields.map((field) => ({
fieldMetadataId: field.fieldMetadataId,
position: field.position,
isVisible: field.isVisible,
size: field.size,
viewId: viewDefinition.id,
aggregateOperation: field.aggregateOperation,
})),
)
.execute();
}
if (viewDefinition.filters && viewDefinition.filters.length > 0) {
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.viewFilter`, [
'fieldMetadataId',
'displayValue',
'operand',
'value',
'viewId',
])
.values(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
viewDefinition.filters.map((filter: any) => ({
fieldMetadataId: filter.fieldMetadataId,
displayValue: filter.displayValue,
operand: filter.operand,
value: filter.value,
viewId: viewDefinition.id,
})),
)
.execute();
}
if (
'groups' in viewDefinition &&
viewDefinition.groups &&
viewDefinition.groups.length > 0
) {
await entityManager
.createQueryBuilder()
.insert()
.into(`${schemaName}.viewGroup`, [
'fieldMetadataId',
'isVisible',
'fieldValue',
'position',
'viewId',
])
.values(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
viewDefinition.groups.map((group: any) => ({
fieldMetadataId: group.fieldMetadataId,
isVisible: group.isVisible,
fieldValue: group.fieldValue,
position: group.position,
viewId: viewDefinition.id,
})),
)
.execute();
}
}
return viewDefinitionsWithId;
};
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { DataSource } from 'typeorm';
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@@ -12,8 +12,8 @@ import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-contex
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
@Injectable()
@WorkspaceQueryHook(`dashboard.createOne`)