Allow users to create Fields and Field widget (#18801)

- Allow users to create a Fields widget or a Field widget; **this PR is
focused on Fields widgets as Field widget can't be configured yet**
- Automatically create a filled view when the user creates a draft
Fields widget in edit mode


https://github.com/user-attachments/assets/b2dbba52-c614-44cd-bf6c-095ce9d4ec26

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Baptiste Devessier
2026-03-23 00:44:49 +01:00
committed by GitHub
parent dc00701448
commit e9aa6f47e5
19 changed files with 1921 additions and 771 deletions
@@ -16,6 +16,7 @@ import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-l
import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@@ -37,6 +38,7 @@ import { DashboardSyncModule } from 'src/modules/dashboard-sync/dashboard-sync.m
FlatPageLayoutWidgetModule,
ApplicationModule,
DashboardSyncModule,
ViewModule,
],
controllers: [PageLayoutController],
providers: [
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
@@ -21,6 +21,7 @@ import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layou
import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata-modules/flat-page-layout/utils/reconstruct-flat-page-layout-with-tabs-and-widgets.util';
import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules/page-layout-tab/dtos/inputs/update-page-layout-tab-with-widgets.input';
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget-with-id.input';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import {
@@ -30,6 +31,7 @@ import {
generatePageLayoutExceptionMessage,
} from 'src/engine/metadata-modules/page-layout/exceptions/page-layout.exception';
import { fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto } from 'src/engine/metadata-modules/page-layout/utils/from-flat-page-layout-with-tabs-and-widgets-to-page-layout-dto.util';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
@@ -42,11 +44,14 @@ type UpdatePageLayoutWithTabsParams = {
@Injectable()
export class PageLayoutUpdateService {
private readonly logger = new Logger(PageLayoutUpdateService.name);
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly dashboardSyncService: DashboardSyncService,
private readonly viewService: ViewService,
) {}
async updatePageLayoutWithTabs({
@@ -161,6 +166,12 @@ export class PageLayoutUpdateService {
flatViewMaps,
});
const orphanedViewIds = this.collectOrphanedViewIdsFromDeletedWidgets({
widgetsToUpdate,
tabsToUpdate,
flatPageLayoutWidgetMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -223,6 +234,11 @@ export class PageLayoutUpdateService {
},
);
await this.destroyOrphanedFieldsWidgetViews({
viewIds: orphanedViewIds,
workspaceId,
});
return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto(
reconstructFlatPageLayoutWithTabsAndWidgets({
layout: flatLayout,
@@ -617,4 +633,108 @@ export class PageLayoutUpdateService {
],
};
}
private collectOrphanedViewIdsFromDeletedWidgets({
widgetsToUpdate,
tabsToUpdate,
flatPageLayoutWidgetMaps,
}: {
widgetsToUpdate: FlatPageLayoutWidget[];
tabsToUpdate: FlatPageLayoutTab[];
flatPageLayoutWidgetMaps: Pick<
AllFlatEntityMaps,
'flatPageLayoutWidgetMaps'
>['flatPageLayoutWidgetMaps'];
}): string[] {
const viewIdsToDelete = new Set<string>();
const directlyDeletedWidgetIds = new Set<string>();
// Collect viewIds from directly deleted FIELDS widgets
for (const widget of widgetsToUpdate) {
if (isDefined(widget.deletedAt)) {
directlyDeletedWidgetIds.add(widget.id);
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.add(viewId);
}
}
}
// Collect viewIds from FIELDS widgets in deleted tabs
const deletedTabIds = new Set(
tabsToUpdate
.filter((tab) => isDefined(tab.deletedAt))
.map((tab) => tab.id),
);
const allExistingWidgets = Object.values(
flatPageLayoutWidgetMaps.byUniversalIdentifier,
).filter(isDefined);
for (const widget of allExistingWidgets) {
if (
!isDefined(widget.deletedAt) &&
deletedTabIds.has(widget.pageLayoutTabId)
) {
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.add(viewId);
}
}
}
// Filter out viewIds still referenced by surviving widgets
for (const widget of allExistingWidgets) {
if (
!isDefined(widget.deletedAt) &&
!directlyDeletedWidgetIds.has(widget.id) &&
!deletedTabIds.has(widget.pageLayoutTabId)
) {
const viewId = this.getViewIdFromFieldsWidget(widget);
if (isDefined(viewId)) {
viewIdsToDelete.delete(viewId);
}
}
}
return [...viewIdsToDelete];
}
private getViewIdFromFieldsWidget(
widget: FlatPageLayoutWidget,
): string | undefined {
if (
widget.configuration.configurationType !== WidgetConfigurationType.FIELDS
) {
return undefined;
}
const viewId = (widget.configuration as { viewId?: string | null }).viewId;
return typeof viewId === 'string' ? viewId : undefined;
}
private async destroyOrphanedFieldsWidgetViews({
viewIds,
workspaceId,
}: {
viewIds: string[];
workspaceId: string;
}): Promise<void> {
for (const viewId of viewIds) {
try {
await this.viewService.destroyOne({
destroyViewInput: { id: viewId },
workspaceId,
});
} catch (error) {
this.logger.warn(
`Failed to destroy view ${viewId} after Fields widget deletion: ${error}`,
);
}
}
}
}