Pre hook for dashboard creation + updates on the entity (#14354)

Closes https://github.com/twentyhq/core-team-issues/issues/1417

- The pre-hook creates a page-layout and links it to the dashboard
- Added `position`, `createdBy`, `attachments`, `searchVector`,
`favorites` to the dashboard entity
- Updated the view seed
- Updated the page layout services to work within a transaction
This commit is contained in:
Raphaël Bosi
2025-09-09 14:55:22 +02:00
committed by GitHub
parent 861cb1c222
commit 3d2d14eb71
19 changed files with 657 additions and 115 deletions
@@ -8,6 +8,7 @@ import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-quer
import { BlocklistQueryHookModule } from 'src/modules/blocklist/query-hooks/blocklist-query-hook.module';
import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module';
import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module';
import { DashboardQueryHookModule } from 'src/modules/dashboard/query-hooks/dashboard-query-hook.module';
import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module';
import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module';
@@ -16,6 +17,7 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que
MessagingQueryHookModule,
CalendarQueryHookModule,
ConnectedAccountQueryHookModule,
DashboardQueryHookModule,
BlocklistQueryHookModule,
WorkspaceMemberQueryHookModule,
DiscoveryModule,
@@ -29,5 +29,5 @@ export class CreatePageLayoutInput {
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string;
objectMetadataId?: string | null;
}
@@ -20,5 +20,5 @@ export class UpdatePageLayoutInput {
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string;
objectMetadataId?: string | null;
}
@@ -13,6 +13,7 @@ import { PageLayoutResolver } from 'src/engine/core-modules/page-layout/resolver
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutWidgetService } from 'src/engine/core-modules/page-layout/services/page-layout-widget.service';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@Module({
imports: [
@@ -21,6 +22,7 @@ import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/
PageLayoutTabEntity,
PageLayoutWidgetEntity,
]),
TwentyORMModule,
],
controllers: [
PageLayoutController,
@@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import {
PageLayoutTabException,
@@ -26,11 +27,22 @@ export class PageLayoutTabService {
private readonly pageLayoutService: PageLayoutService,
) {}
private getPageLayoutTabRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutTabEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutTabEntity)
: this.pageLayoutTabRepository;
}
async findByPageLayoutId(
workspaceId: string,
pageLayoutId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity[]> {
return this.pageLayoutTabRepository.find({
const repository = this.getPageLayoutTabRepository(transactionManager);
return repository.find({
where: {
pageLayoutId,
pageLayout: { workspaceId },
@@ -44,8 +56,11 @@ export class PageLayoutTabService {
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.pageLayoutTabRepository.findOne({
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
@@ -68,8 +83,9 @@ export class PageLayoutTabService {
}
async create(
pageLayoutTabData: Partial<PageLayoutTabEntity>,
pageLayoutTabData: CreatePageLayoutTabInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
if (!isDefined(pageLayoutTabData.title)) {
throw new PageLayoutTabException(
@@ -93,14 +109,21 @@ export class PageLayoutTabService {
await this.pageLayoutService.findByIdOrThrow(
pageLayoutTabData.pageLayoutId,
workspaceId,
transactionManager,
);
const pageLayoutTab = this.pageLayoutTabRepository.create({
const repository = this.getPageLayoutTabRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutTabData,
workspaceId,
});
return this.pageLayoutTabRepository.save(pageLayoutTab);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutException &&
@@ -121,8 +144,11 @@ export class PageLayoutTabService {
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutTabEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const existingTab = await this.pageLayoutTabRepository.findOne({
const repository = this.getPageLayoutTabRepository(transactionManager);
const existingTab = await repository.findOne({
where: {
id,
workspaceId,
@@ -140,21 +166,37 @@ export class PageLayoutTabService {
);
}
await this.pageLayoutTabRepository.update({ id }, updateData);
await repository.update({ id }, updateData);
return this.findByIdOrThrow(id, workspaceId);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
}
async delete(id: string, workspaceId: string): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.findByIdOrThrow(id, workspaceId);
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
await this.pageLayoutTabRepository.softDelete(id);
const repository = this.getPageLayoutTabRepository(transactionManager);
await repository.softDelete(id);
return pageLayoutTab;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const pageLayoutTab = await this.pageLayoutTabRepository.findOne({
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
@@ -172,13 +214,19 @@ export class PageLayoutTabService {
);
}
await this.pageLayoutTabRepository.delete(id);
await repository.delete(id);
return true;
}
async restore(id: string, workspaceId: string): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.pageLayoutTabRepository.findOne({
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
select: {
id: true,
deletedAt: true,
@@ -214,6 +262,7 @@ export class PageLayoutTabService {
await this.pageLayoutService.findByIdOrThrow(
pageLayoutTab.pageLayoutId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
@@ -230,9 +279,13 @@ export class PageLayoutTabService {
throw error;
}
await this.pageLayoutTabRepository.restore(id);
await repository.restore(id);
const restoredPageLayoutTab = await this.findByIdOrThrow(id, workspaceId);
const restoredPageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return restoredPageLayoutTab;
}
@@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { CreatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget.input';
import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import {
@@ -27,11 +28,22 @@ export class PageLayoutWidgetService {
private readonly pageLayoutTabService: PageLayoutTabService,
) {}
private getPageLayoutWidgetRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutWidgetEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutWidgetEntity)
: this.pageLayoutWidgetRepository;
}
async findByPageLayoutTabId(
workspaceId: string,
pageLayoutTabId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity[]> {
return this.pageLayoutWidgetRepository.find({
const repository = this.getPageLayoutWidgetRepository(transactionManager);
return repository.find({
where: {
pageLayoutTabId,
workspaceId,
@@ -44,8 +56,11 @@ export class PageLayoutWidgetService {
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
@@ -67,8 +82,9 @@ export class PageLayoutWidgetService {
}
async create(
pageLayoutWidgetData: Partial<PageLayoutWidgetEntity>,
pageLayoutWidgetData: CreatePageLayoutWidgetInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
if (!isDefined(pageLayoutWidgetData.title)) {
throw new PageLayoutWidgetException(
@@ -101,14 +117,21 @@ export class PageLayoutWidgetService {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidgetData.pageLayoutTabId,
workspaceId,
transactionManager,
);
const pageLayoutWidget = this.pageLayoutWidgetRepository.create({
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutWidgetData,
workspaceId,
});
} as QueryDeepPartialEntity<PageLayoutWidgetEntity>);
return this.pageLayoutWidgetRepository.save(pageLayoutWidget);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutTabException &&
@@ -129,8 +152,11 @@ export class PageLayoutWidgetService {
id: string,
workspaceId: string,
updateData: UpdatePageLayoutWidgetInput,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const existingWidget = await this.pageLayoutWidgetRepository.findOne({
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const existingWidget = await repository.findOne({
where: {
id,
workspaceId,
@@ -148,27 +174,40 @@ export class PageLayoutWidgetService {
);
}
await this.pageLayoutWidgetRepository.update(
await repository.update(
{ id },
updateData as QueryDeepPartialEntity<PageLayoutWidgetEntity>,
);
return this.findByIdOrThrow(id, workspaceId);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const pageLayoutWidget = await this.findByIdOrThrow(id, workspaceId);
const pageLayoutWidget = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
await this.pageLayoutWidgetRepository.softDelete(id);
const repository = this.getPageLayoutWidgetRepository(transactionManager);
await repository.softDelete(id);
return pageLayoutWidget;
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
@@ -186,7 +225,7 @@ export class PageLayoutWidgetService {
);
}
await this.pageLayoutWidgetRepository.delete(id);
await repository.delete(id);
return true;
}
@@ -194,8 +233,11 @@ export class PageLayoutWidgetService {
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
select: {
id: true,
deletedAt: true,
@@ -231,6 +273,7 @@ export class PageLayoutWidgetService {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidget.pageLayoutTabId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
@@ -247,11 +290,12 @@ export class PageLayoutWidgetService {
throw error;
}
await this.pageLayoutWidgetRepository.restore(id);
await repository.restore(id);
const restoredPageLayoutWidget = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return restoredPageLayoutWidget;
@@ -1,27 +1,46 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class PageLayoutService {
private readonly logger = new Logger(PageLayoutService.name);
constructor(
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async findByWorkspaceId(workspaceId: string): Promise<PageLayoutEntity[]> {
return this.pageLayoutRepository.find({
private getPageLayoutRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutEntity)
: this.pageLayoutRepository;
}
async findByWorkspaceId(
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
const repository = this.getPageLayoutRepository(transactionManager);
return repository.find({
where: {
workspaceId,
deletedAt: IsNull(),
@@ -33,8 +52,11 @@ export class PageLayoutService {
async findByObjectMetadataId(
workspaceId: string,
objectMetadataId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
return this.pageLayoutRepository.find({
const repository = this.getPageLayoutRepository(transactionManager);
return repository.find({
where: {
workspaceId,
objectMetadataId,
@@ -47,8 +69,11 @@ export class PageLayoutService {
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
const repository = this.getPageLayoutRepository(transactionManager);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
@@ -71,8 +96,9 @@ export class PageLayoutService {
}
async create(
pageLayoutData: Partial<PageLayoutEntity>,
pageLayoutData: CreatePageLayoutInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
if (!isDefined(pageLayoutData.name)) {
throw new PageLayoutException(
@@ -83,36 +109,65 @@ export class PageLayoutService {
);
}
const pageLayout = this.pageLayoutRepository.create({
const repository = this.getPageLayoutRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutData,
workspaceId,
});
return this.pageLayoutRepository.save(pageLayout);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
await this.pageLayoutRepository.update({ id, workspaceId }, updateData);
const repository = this.getPageLayoutRepository(transactionManager);
const updatedPageLayout = await this.findByIdOrThrow(id, workspaceId);
await repository.update({ id, workspaceId }, updateData);
const updatedPageLayout = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return updatedPageLayout;
}
async delete(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.findByIdOrThrow(id, workspaceId);
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const pageLayout = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
await this.pageLayoutRepository.softDelete(id);
const repository = this.getPageLayoutRepository(transactionManager);
await repository.softDelete(id);
return pageLayout;
}
async destroy(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
@@ -130,11 +185,43 @@ export class PageLayoutService {
);
}
await this.pageLayoutRepository.delete(id);
if (pageLayout.type === PageLayoutType.DASHBOARD) {
await this.destroyAssociatedDashboards(id, workspaceId);
}
await repository.delete(id);
return pageLayout;
}
private async destroyAssociatedDashboards(
pageLayoutId: string,
workspaceId: string,
): Promise<void> {
try {
const dashboardRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
const dashboards = await dashboardRepository.find({
where: {
pageLayoutId,
},
});
for (const dashboard of dashboards) {
await dashboardRepository.delete(dashboard.id);
}
} catch (error) {
this.logger.error(
`Failed to destroy associated dashboards for page layout ${pageLayoutId}: ${error}`,
);
}
}
async restore(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
select: {
@@ -51,15 +51,6 @@ describe('PageLayoutTabService', () => {
deletedAt: null,
} as PageLayoutWidgetEntity;
const mockPageLayout = {
id: 'page-layout-id',
workspaceId: 'workspace-id',
title: 'Test Layout',
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -77,6 +68,7 @@ describe('PageLayoutTabService', () => {
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
@@ -240,20 +232,21 @@ describe('PageLayoutTabService', () => {
it('should create a new page layout tab successfully', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
id: 'page-layout-tab-id',
title: 'New Tab',
pageLayoutId: 'page-layout-id',
position: 1,
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout as any);
jest
.spyOn(pageLayoutTabRepository, 'create')
.mockReturnValue(mockPageLayoutTab);
jest
.spyOn(pageLayoutTabRepository, 'save')
.mockResolvedValue(mockPageLayoutTab);
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab as any);
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
const result = await pageLayoutTabService.create(
pageLayoutTabData,
@@ -263,14 +256,12 @@ describe('PageLayoutTabService', () => {
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
pageLayoutTabData.pageLayoutId,
workspaceId,
undefined,
);
expect(pageLayoutTabRepository.create).toHaveBeenCalledWith({
expect(pageLayoutTabRepository.insert).toHaveBeenCalledWith({
...pageLayoutTabData,
workspaceId,
});
expect(pageLayoutTabRepository.save).toHaveBeenCalledWith(
mockPageLayoutTab,
);
expect(result).toEqual(mockPageLayoutTab);
});
@@ -281,9 +272,11 @@ describe('PageLayoutTabService', () => {
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toHaveProperty(
'code',
@@ -298,6 +291,11 @@ describe('PageLayoutTabService', () => {
pageLayoutId: 'non-existent-page-layout-id',
};
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
@@ -586,6 +584,7 @@ describe('PageLayoutTabService', () => {
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-page-layout-id',
workspaceId,
undefined,
);
});
});
@@ -38,18 +38,6 @@ describe('PageLayoutWidgetService', () => {
deletedAt: null,
} as PageLayoutWidgetEntity;
const mockPageLayoutTab = {
id: 'page-layout-tab-id',
title: 'Test Tab',
position: 0,
pageLayoutId: 'page-layout-id',
pageLayout: { workspaceId: 'workspace-id' },
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
};
beforeEach(async () => {
jest.clearAllMocks();
@@ -63,6 +51,7 @@ describe('PageLayoutWidgetService', () => {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
@@ -177,6 +166,7 @@ describe('PageLayoutWidgetService', () => {
describe('create', () => {
const validPageLayoutWidgetData = {
id: 'page-layout-widget-id',
title: 'New Widget',
pageLayoutTabId: 'page-layout-tab-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
@@ -186,14 +176,13 @@ describe('PageLayoutWidgetService', () => {
it('should create a new page layout widget successfully', async () => {
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-widget-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab as any);
jest
.spyOn(pageLayoutWidgetRepository, 'create')
.mockReturnValue(mockPageLayoutWidget);
jest
.spyOn(pageLayoutWidgetRepository, 'save')
.spyOn(pageLayoutWidgetService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutWidget);
const result = await pageLayoutWidgetService.create(
@@ -201,17 +190,10 @@ describe('PageLayoutWidgetService', () => {
workspaceId,
);
expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith(
validPageLayoutWidgetData.pageLayoutTabId,
workspaceId,
);
expect(pageLayoutWidgetRepository.create).toHaveBeenCalledWith({
expect(pageLayoutWidgetRepository.insert).toHaveBeenCalledWith({
...validPageLayoutWidgetData,
workspaceId,
});
expect(pageLayoutWidgetRepository.save).toHaveBeenCalledWith(
mockPageLayoutWidget,
);
expect(result).toEqual(mockPageLayoutWidget);
});
@@ -223,9 +205,11 @@ describe('PageLayoutWidgetService', () => {
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
@@ -241,9 +225,11 @@ describe('PageLayoutWidgetService', () => {
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
@@ -259,9 +245,11 @@ describe('PageLayoutWidgetService', () => {
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
@@ -578,6 +566,7 @@ describe('PageLayoutWidgetService', () => {
expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-tab-id',
workspaceId,
undefined,
);
});
});
@@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { type CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import {
@@ -12,10 +13,12 @@ import {
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
describe('PageLayoutService', () => {
let pageLayoutService: PageLayoutService;
let pageLayoutRepository: Repository<PageLayoutEntity>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
const mockPageLayout = {
id: 'page-layout-id',
@@ -46,6 +49,13 @@ describe('PageLayoutService', () => {
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
],
@@ -55,6 +65,9 @@ describe('PageLayoutService', () => {
pageLayoutRepository = module.get<Repository<PageLayoutEntity>>(
getRepositoryToken(PageLayoutEntity),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
});
describe('findByWorkspaceId', () => {
@@ -142,17 +155,20 @@ describe('PageLayoutService', () => {
describe('create', () => {
const validPageLayoutData = {
id: 'page-layout-id',
name: 'Test Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
};
it('should create a page layout successfully', async () => {
jest.spyOn(pageLayoutRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutRepository, 'create')
.mockReturnValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'save')
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.create(
@@ -160,11 +176,10 @@ describe('PageLayoutService', () => {
'workspace-id',
);
expect(pageLayoutRepository.create).toHaveBeenCalledWith({
expect(pageLayoutRepository.insert).toHaveBeenCalledWith({
...validPageLayoutData,
workspaceId: 'workspace-id',
});
expect(pageLayoutRepository.save).toHaveBeenCalledWith(mockPageLayout);
expect(result).toEqual(mockPageLayout);
});
@@ -173,7 +188,10 @@ describe('PageLayoutService', () => {
const workspaceId = 'workspace-id';
await expect(
pageLayoutService.create(invalidData, workspaceId),
pageLayoutService.create(
invalidData as unknown as CreatePageLayoutInput,
workspaceId,
),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
@@ -210,6 +228,7 @@ describe('PageLayoutService', () => {
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(result).toEqual(updatedPageLayout);
});
@@ -262,6 +281,7 @@ describe('PageLayoutService', () => {
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
@@ -344,6 +364,51 @@ describe('PageLayoutService', () => {
),
);
});
it('should destroy associated dashboards when page layout is a dashboard', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const mockDashboardRepository = {
find: jest.fn(),
delete: jest.fn(),
};
const mockDashboards = [{ id: 'dashboard', pageLayoutId: id }];
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(mockDashboardRepository as any);
jest
.spyOn(mockDashboardRepository, 'find')
.mockResolvedValue(mockDashboards);
jest
.spyOn(mockDashboardRepository, 'delete')
.mockResolvedValue({} as any);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(
twentyORMGlobalManager.getRepositoryForWorkspace,
).toHaveBeenCalledWith(workspaceId, 'dashboard', {
shouldBypassPermissionChecks: true,
});
expect(mockDashboardRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId: id,
},
});
expect(mockDashboardRepository.delete).toHaveBeenCalledWith('dashboard');
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
});
});
describe('restore', () => {
@@ -6,6 +6,7 @@ import {
DASHBOARD_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewOpenRecordInType } from 'src/modules/view/standard-objects/view.workspace-entity';
export const dashboardsAllView = (
objectMetadataItems: ObjectMetadataEntity[],
@@ -27,6 +28,7 @@ export const dashboardsAllView = (
position: 0,
icon: 'IconLayoutDashboard',
kanbanFieldMetadataId: '',
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
filters: [],
fields: [
{
@@ -42,7 +44,7 @@ export const dashboardsAllView = (
fieldMetadataId:
dashboardObjectMetadata.fields.find(
(field) =>
field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.createdAt,
field.standardId === DASHBOARD_STANDARD_FIELD_IDS.createdBy,
)?.id ?? '',
position: 1,
isVisible: true,
@@ -52,12 +54,22 @@ export const dashboardsAllView = (
fieldMetadataId:
dashboardObjectMetadata.fields.find(
(field) =>
field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt,
field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.createdAt,
)?.id ?? '',
position: 2,
isVisible: true,
size: 150,
},
{
fieldMetadataId:
dashboardObjectMetadata.fields.find(
(field) =>
field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt,
)?.id ?? '',
position: 3,
isVisible: true,
size: 150,
},
],
};
};
@@ -40,6 +40,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = {
person: '20202020-0158-4aa2-965c-5cdafe21ffa2',
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
opportunity: '20202020-7374-499d-bea3-9354890755b5',
dashboard: '20202020-5324-43f3-9dbf-1a33e7de0ce6',
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
} as const;
@@ -193,6 +194,7 @@ export const TIMELINE_ACTIVITY_STANDARD_FIELD_IDS = {
workflow: '20202020-616c-4ad3-a2e9-c477c341e295',
workflowVersion: '20202020-74f1-4711-a129-e14ca0ecd744',
workflowRun: '20202020-96f0-401b-9186-a3a0759225ac',
dashboard: '20202020-7864-48f5-af7c-9e4b60140948',
custom: '20202020-4a71-41b0-9f83-9cdcca3f8b14',
linkedRecordCachedName: '20202020-cfdb-4bef-bbce-a29f41230934',
linkedRecordId: '20202020-2e0e-48c0-b445-ee6c1e61687d',
@@ -213,6 +215,7 @@ export const FAVORITE_STANDARD_FIELD_IDS = {
view: '20202020-5a93-4fa9-acce-e73481a0bbdf',
custom: '20202020-855a-4bc8-9861-79deef37011f',
favoriteFolder: '20202020-f658-4d12-8b4d-248356aa4bd9',
dashboard: '20202020-6ef9-45e4-b440-cc986f687c91',
} as const;
export const FAVORITE_FOLDER_STANDARD_FIELD_IDS = {
@@ -541,7 +544,13 @@ export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = {
export const DASHBOARD_STANDARD_FIELD_IDS = {
title: '20202020-20ee-4091-95dc-44b57eda3a89',
position: '20202020-38af-409b-95f0-7f08aa5f420f',
pageLayoutId: '20202020-bb53-4648-aa36-1d9d54e6f7f2',
createdBy: '20202020-ff32-4fa1-b7ad-407cc6aa0734',
timelineActivities: '20202020-9b0c-5d6e-7f8a-9b0c1d2e3f4a',
favorites: '20202020-f032-478f-88fa-6426ff6f1e4c',
attachments: '20202020-bf6f-4220-8c55-2764f1175870',
searchVector: '20202020-0bcc-47a4-8360-2e35a7133f7a',
} as const;
export const STANDARD_OBJECT_FIELD_IDS = {
@@ -1,9 +1,9 @@
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
@@ -18,6 +18,7 @@ import { ATTACHMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/work
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@@ -158,6 +159,22 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceJoinColumn('opportunity')
opportunityId: string | null;
@WorkspaceRelation({
standardId: ATTACHMENT_STANDARD_FIELD_IDS.dashboard,
type: RelationType.MANY_TO_ONE,
label: msg`Dashboard`,
description: msg`Attachment dashboard`,
icon: 'IconLayout',
inverseSideTarget: () => DashboardWorkspaceEntity,
inverseSideFieldKey: 'attachments',
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
dashboard: Relation<DashboardWorkspaceEntity> | null;
@WorkspaceJoinColumn('dashboard')
dashboardId: string | null;
@WorkspaceDynamicRelation({
type: RelationType.MANY_TO_ONE,
argsFactory: (oppositeObjectMetadata) => ({
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
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';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
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 { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
@Injectable()
@WorkspaceQueryHook(`dashboard.createOne`)
export class DashboardCreateOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor(
private readonly pageLayoutService: PageLayoutService,
private readonly pageLayoutTabService: PageLayoutTabService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {}
async execute(
authContext: AuthContext,
_objectName: string,
payload: CreateOneResolverArgs<DashboardWorkspaceEntity>,
): Promise<CreateOneResolverArgs<DashboardWorkspaceEntity>> {
const workspace = authContext.workspace;
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.coreDataSource.transaction(async (manager) => {
const pageLayout = await this.pageLayoutService.create(
{
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
name: 'Dashboard Layout',
},
workspace.id,
manager,
);
await this.pageLayoutTabService.create(
{
title: 'Tab 1',
pageLayoutId: pageLayout.id,
},
workspace.id,
manager,
);
payload.data.pageLayoutId = pageLayout.id;
return payload;
});
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PageLayoutModule } from 'src/engine/core-modules/page-layout/page-layout.module';
import { DashboardCreateOnePreQueryHook } from 'src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook';
@Module({
imports: [PageLayoutModule],
providers: [DashboardCreateOnePreQueryHook],
})
export class DashboardQueryHookModule {}
@@ -1,14 +1,30 @@
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIsFieldUIReadOnly } from 'src/engine/twenty-orm/decorators/workspace-is-field-ui-readonly.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSearchable } from 'src/engine/twenty-orm/decorators/workspace-is-searchable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { DASHBOARD_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.dashboard,
@@ -22,6 +38,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
@WorkspaceGate({
featureFlag: FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED,
})
@WorkspaceIsSearchable()
export class DashboardWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceField({
standardId: DASHBOARD_STANDARD_FIELD_IDS.title,
@@ -39,5 +56,77 @@ export class DashboardWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Dashboard page layout`,
icon: 'IconLayout',
})
pageLayoutId: string;
@WorkspaceIsNullable()
@WorkspaceIsFieldUIReadOnly()
pageLayoutId: string | null;
@WorkspaceField({
standardId: DASHBOARD_STANDARD_FIELD_IDS.position,
type: FieldMetadataType.POSITION,
label: msg`Position`,
description: msg`Dashboard record Position`,
icon: 'IconHierarchy2',
defaultValue: 0,
})
@WorkspaceIsSystem()
position: number;
@WorkspaceField({
standardId: DASHBOARD_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
label: msg`Created by`,
icon: 'IconCreativeCommonsSa',
description: msg`The creator of the record`,
})
@WorkspaceIsFieldUIReadOnly()
createdBy: ActorMetadata;
@WorkspaceRelation({
standardId: DASHBOARD_STANDARD_FIELD_IDS.timelineActivities,
type: RelationType.ONE_TO_MANY,
label: msg`Timeline Activities`,
description: msg`Timeline activities linked to the dashboard`,
inverseSideTarget: () => TimelineActivityWorkspaceEntity,
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: DASHBOARD_STANDARD_FIELD_IDS.favorites,
type: RelationType.ONE_TO_MANY,
label: msg`Favorites`,
description: msg`Favorites linked to the dashboard`,
inverseSideTarget: () => FavoriteWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsSystem()
favorites: Relation<FavoriteWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: DASHBOARD_STANDARD_FIELD_IDS.attachments,
type: RelationType.ONE_TO_MANY,
label: msg`Attachments`,
description: msg`Attachments linked to the dashboard`,
inverseSideTarget: () => AttachmentWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
attachments: Relation<AttachmentWorkspaceEntity[]>;
@WorkspaceField({
standardId: DASHBOARD_STANDARD_FIELD_IDS.searchVector,
type: FieldMetadataType.TS_VECTOR,
label: SEARCH_VECTOR_FIELD.label,
description: SEARCH_VECTOR_FIELD.description,
icon: 'IconUser',
generatedType: 'STORED',
asExpression: `to_tsvector('english', title)`,
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
searchVector: string;
}
@@ -18,6 +18,7 @@ import { FAVORITE_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/worksp
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
@@ -210,6 +211,22 @@ export class FavoriteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceJoinColumn('note')
noteId: string;
@WorkspaceRelation({
standardId: FAVORITE_STANDARD_FIELD_IDS.dashboard,
type: RelationType.MANY_TO_ONE,
label: msg`Dashboard`,
description: msg`Favorite dashboard`,
icon: 'IconLayoutDashboard',
inverseSideTarget: () => DashboardWorkspaceEntity,
inverseSideFieldKey: 'favorites',
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
dashboard: Relation<DashboardWorkspaceEntity> | null;
@WorkspaceJoinColumn('dashboard')
dashboardId: string;
@WorkspaceField({
standardId: FAVORITE_STANDARD_FIELD_IDS.view,
type: FieldMetadataType.UUID,
@@ -1,9 +1,9 @@
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity';
@@ -19,6 +19,7 @@ import { TIMELINE_ACTIVITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manag
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
@@ -243,6 +244,22 @@ export class TimelineActivityWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceJoinColumn('workflowRun')
workflowRunId: string | null;
@WorkspaceRelation({
standardId: TIMELINE_ACTIVITY_STANDARD_FIELD_IDS.dashboard,
type: RelationType.MANY_TO_ONE,
label: msg`Dashboard`,
description: msg`Event dashboard`,
icon: 'IconTargetArrow',
inverseSideTarget: () => DashboardWorkspaceEntity,
inverseSideFieldKey: 'timelineActivities',
onDelete: RelationOnDeleteAction.SET_NULL,
})
@WorkspaceIsNullable()
dashboard: Relation<DashboardWorkspaceEntity> | null;
@WorkspaceJoinColumn('dashboard')
dashboardId: string | null;
@WorkspaceDynamicRelation({
type: RelationType.MANY_TO_ONE,
argsFactory: (oppositeObjectMetadata) => ({
@@ -1,3 +1,4 @@
import gql from 'graphql-tag';
import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants';
import { createPageLayoutOperationFactory } from 'test/integration/graphql/utils/create-page-layout-operation-factory.util';
import { deletePageLayoutOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-operation-factory.util';
@@ -367,6 +368,72 @@ describe('Page Layout Resolver', () => {
),
);
});
it('should destroy all associated dashboards when page layout is of type dashboard', async () => {
const dashboardId = '20202020-304c-44f2-ba7b-070762ff0e8a';
const pageLayout = await createTestPageLayoutWithGraphQL({
name: 'Page Layout to Destroy',
type: PageLayoutType.DASHBOARD,
});
const findOneDashboardOperation = {
query: gql`
query Dashboard($filter: DashboardFilterInput!) {
dashboard(filter: $filter) {
id
}
}
`,
variables: {
filter: { id: { eq: dashboardId } },
},
};
await makeGraphqlAPIRequest({
query: gql`
mutation CreateDashboard($input: CreateDashboardInput!) {
createDashboard(input: $input) {
pageLayoutId
id
title
}
}
`,
variables: {
input: {
id: dashboardId,
name: 'Dashboard to Destroy',
pageLayoutId: pageLayout.id,
},
},
});
const findOneDashboardResponseBeforeDestroy = await makeGraphqlAPIRequest(
findOneDashboardOperation,
);
expect(
findOneDashboardResponseBeforeDestroy.body.data.dashboard,
).toBeDefined();
const destroyOperation = destroyPageLayoutOperationFactory({
pageLayoutId: pageLayout.id,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyOperation);
const findOneDashboardResponseAfterDestroy = await makeGraphqlAPIRequest(
findOneDashboardOperation,
);
assertGraphQLSuccessfulResponse(destroyResponse);
expect(destroyResponse.body.data.destroyPageLayout).toBe(true);
expect(findOneDashboardResponseAfterDestroy.body.errors).toBeDefined();
expect(
findOneDashboardResponseAfterDestroy.body.errors[0].extensions.code,
).toBe(ErrorCode.NOT_FOUND);
});
});
describe('restorePageLayout', () => {