diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
index 9d3593265e..2dbbeb1b56 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx
@@ -4,8 +4,8 @@ import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/r
import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell';
import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn';
import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn';
+import { RecordTableHeaderFirstCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell';
import { RecordTableHeaderFirstScrollableCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell';
-import { RecordTableHeaderLabelIdentifierCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell';
import { RecordTableHeaderLastEmptyColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastEmptyColumn';
import { useResizeTableHeader } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader';
import { filterOutByProperty } from 'twenty-shared/utils';
@@ -29,7 +29,7 @@ export const RecordTableHeader = () => {
<>
-
+
{recordFieldsWithoutLabelIdentifierAndFirstOne.map(
(recordField, index) => (
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell.tsx
similarity index 90%
rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx
rename to packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell.tsx
index 57511d7a39..1876559b9b 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCell.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell.tsx
@@ -1,6 +1,5 @@
import styled from '@emotion/styled';
-import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown';
import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler';
@@ -9,6 +8,7 @@ import { RecordTableHeaderCellContainer } from '@/object-record/record-table/rec
import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector';
import { RecordTableHeaderLabelIdentifierCellPlusButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLabelIdentifierCellPlusButton';
+import { getVisibleFieldWithLowestPosition } from '@/object-record/record-table/record-table-header/utils/getVisibleFieldWithLowestPosition.util';
import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState';
import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState';
import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState';
@@ -19,7 +19,7 @@ import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-st
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { cx } from '@linaria/core';
import { useState } from 'react';
-import { findByProperty, isDefined } from 'twenty-shared/utils';
+import { isDefined } from 'twenty-shared/utils';
const StyledColumnHeadContainer = styled.div`
cursor: pointer;
@@ -30,7 +30,7 @@ const StyledColumnHeadContainer = styled.div`
overflow: hidden;
`;
-export const RecordTableHeaderLabelIdentifierCell = () => {
+export const RecordTableHeaderFirstCell = () => {
const { objectMetadataItem, visibleRecordFields } =
useRecordTableContextOrThrow();
@@ -46,11 +46,7 @@ export const RecordTableHeaderLabelIdentifierCell = () => {
0,
);
- const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
-
- const recordField = visibleRecordFields.find(
- findByProperty('fieldMetadataItemId', labelIdentifierFieldMetadataItem?.id),
- );
+ const recordField = getVisibleFieldWithLowestPosition(visibleRecordFields);
const isScrolledVertically = useRecoilComponentValue(
isRecordTableScrolledVerticallyComponentState,
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/utils/getVisibleFieldWithLowestPosition.util.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/utils/getVisibleFieldWithLowestPosition.util.ts
new file mode 100644
index 0000000000..e4999a045e
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/utils/getVisibleFieldWithLowestPosition.util.ts
@@ -0,0 +1,15 @@
+import { type RecordField } from '@/object-record/record-field/types/RecordField';
+
+export const getVisibleFieldWithLowestPosition = (
+ visibleRecordFields: RecordField[],
+) => {
+ if (visibleRecordFields.length === 0) {
+ return undefined;
+ }
+ return visibleRecordFields.reduce((lowestPositionField, currentField) => {
+ if (currentField?.position < lowestPositionField?.position) {
+ return currentField;
+ }
+ return lowestPositionField;
+ }, visibleRecordFields[0]);
+};
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command.ts
new file mode 100644
index 0000000000..077a43f373
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command.ts
@@ -0,0 +1,132 @@
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { Command } from 'nest-commander';
+import { Repository } from 'typeorm';
+
+import {
+ ActiveOrSuspendedWorkspacesMigrationCommandRunner,
+ type RunOnWorkspaceArgs,
+} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
+import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
+import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
+import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+
+@Command({
+ name: 'upgrade:1-6:fix-label-identifier-position-and-visibility',
+ description:
+ 'Fix label identifier position to ensure it has the minimal position in each view',
+})
+export class FixLabelIdentifierPositionAndVisibilityCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
+ constructor(
+ @InjectRepository(Workspace)
+ protected readonly workspaceRepository: Repository,
+ protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ @InjectRepository(ViewEntity)
+ private readonly viewRepository: Repository,
+ @InjectRepository(ViewFieldEntity)
+ private readonly viewFieldRepository: Repository,
+ ) {
+ super(workspaceRepository, twentyORMGlobalManager);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ }: RunOnWorkspaceArgs): Promise {
+ this.logger.log(
+ `Checking label identifiers position and visibility for workspace ${workspaceId}`,
+ );
+
+ const views = await this.viewRepository.find({
+ where: { workspaceId },
+ relations: {
+ objectMetadata: true,
+ viewFields: true,
+ },
+ });
+
+ for (const view of views) {
+ const { objectMetadata, viewFields } = view;
+
+ if (!objectMetadata.labelIdentifierFieldMetadataId) {
+ continue;
+ }
+
+ const labelIdentifierViewField = viewFields.find(
+ (viewField: ViewFieldEntity) =>
+ viewField.fieldMetadataId ===
+ objectMetadata.labelIdentifierFieldMetadataId,
+ );
+
+ if (!labelIdentifierViewField) {
+ continue;
+ }
+
+ // Find minimum position and count fields at that position in single pass
+ const minPositionData = viewFields.reduce(
+ (acc, viewField: ViewFieldEntity) => {
+ if (viewField.position < acc.minPosition) {
+ return { minPosition: viewField.position, count: 1 };
+ }
+ if (viewField.position === acc.minPosition) {
+ return { ...acc, count: acc.count + 1 };
+ }
+
+ return acc;
+ },
+ { minPosition: Number.MAX_SAFE_INTEGER, count: 0 },
+ );
+
+ const minPosition = minPositionData.minPosition;
+ const numberOfViewFieldsAtTheMinimalPosition = minPositionData.count;
+
+ const labelIdentifierPositionIsAlreadyTheMinimalPosition =
+ labelIdentifierViewField.position === minPosition &&
+ numberOfViewFieldsAtTheMinimalPosition === 1;
+
+ const labelIdentifierIsAlreadyVisible =
+ labelIdentifierViewField.isVisible;
+
+ if (
+ labelIdentifierPositionIsAlreadyTheMinimalPosition &&
+ labelIdentifierIsAlreadyVisible
+ ) {
+ continue;
+ }
+
+ if (!labelIdentifierPositionIsAlreadyTheMinimalPosition) {
+ // Update the label identifier position to be the minimal one
+ const newPosition = minPosition - 1;
+
+ if (!options.dryRun) {
+ await this.viewFieldRepository.update(
+ { id: labelIdentifierViewField.id },
+ { position: newPosition },
+ );
+
+ this.logger.log(
+ `Fixed label identifier position for view ${view.id} in workspace ${workspaceId}: ${labelIdentifierViewField.position} -> ${newPosition}`,
+ );
+ } else {
+ this.logger.log(
+ `Would fix label identifier position for view ${view.id} in workspace ${workspaceId}: ${labelIdentifierViewField.position} -> ${newPosition}`,
+ );
+ }
+ }
+
+ if (!labelIdentifierIsAlreadyVisible) {
+ if (!options.dryRun) {
+ await this.viewFieldRepository.update(
+ { id: labelIdentifierViewField.id },
+ { isVisible: true },
+ );
+ }
+
+ this.logger.log(
+ `Fixed label identifier visibility for view ${view.id} in workspace ${workspaceId}: ${labelIdentifierViewField.isVisible} -> true`,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module.ts
index 92fb5610a5..a943af9e1e 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-6/1-6-upgrade-version-command.module.ts
@@ -1,6 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
+import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
+import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -14,12 +17,14 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
Workspace,
FieldMetadataEntity,
ObjectMetadataEntity,
+ ViewEntity,
+ ViewFieldEntity,
]),
WorkspaceDataSourceModule,
WorkspaceSchemaManagerModule,
WorkspaceMetadataVersionModule,
],
- providers: [],
- exports: [],
+ providers: [FixLabelIdentifierPositionAndVisibilityCommand],
+ exports: [FixLabelIdentifierPositionAndVisibilityCommand],
})
export class V1_6_UpgradeVersionCommandModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts
index 7bf501b16b..c3e83958de 100644
--- a/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view-field.service.spec.ts
@@ -1,9 +1,11 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
+import { UserInputError } from 'apollo-server-core';
import { type Repository } from 'typeorm';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
+import { type ViewEntity } from 'src/engine/core-modules/view/entities/view.entity';
import {
ViewFieldException,
ViewFieldExceptionCode,
@@ -12,10 +14,12 @@ import {
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/core-modules/view/exceptions/view-field.exception';
import { ViewFieldService } from 'src/engine/core-modules/view/services/view-field.service';
+import { ViewService } from 'src/engine/core-modules/view/services/view.service';
describe('ViewFieldService', () => {
let viewFieldService: ViewFieldService;
let viewFieldRepository: Repository;
+ let viewService: ViewService;
const mockViewField = {
id: 'view-field-id',
@@ -45,6 +49,12 @@ describe('ViewFieldService', () => {
delete: jest.fn(),
},
},
+ {
+ provide: ViewService,
+ useValue: {
+ findByIdWithRelatedObjectMetadata: jest.fn(),
+ },
+ },
],
}).compile();
@@ -52,6 +62,7 @@ describe('ViewFieldService', () => {
viewFieldRepository = module.get>(
getRepositoryToken(ViewFieldEntity),
);
+ viewService = module.get(ViewService);
});
it('should be defined', () => {
@@ -203,6 +214,54 @@ describe('ViewFieldService', () => {
),
);
});
+
+ it('should throw exception if position is lower than label metadata identifier', async () => {
+ const labelIdentifierFieldMetadataId =
+ 'label-identifier-field-matadata-id';
+ const labelIdentifierViewFieldId =
+ 'view-field-for-label-metadata-identifier-id';
+
+ const labelIdentifierViewField = {
+ ...mockViewField,
+ id: labelIdentifierViewFieldId,
+ fieldMetadataId: labelIdentifierFieldMetadataId,
+ position: 0,
+ };
+
+ const mockView = {
+ id: 'view-id',
+ objectMetadata: {
+ labelIdentifierFieldMetadataId,
+ },
+ viewFields: [
+ labelIdentifierViewField,
+ { ...mockViewField, position: 1 },
+ ],
+ } as ViewEntity;
+
+ jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
+ if (id === mockViewField.id) {
+ return Promise.resolve(mockViewField);
+ }
+
+ return Promise.resolve(null);
+ });
+ jest
+ .spyOn(viewService, 'findByIdWithRelatedObjectMetadata')
+ .mockResolvedValue(mockView);
+
+ const invalidData = { ...validViewFieldData, position: -1 };
+
+ await expect(viewFieldService.create(invalidData)).rejects.toThrow(
+ new UserInputError(
+ 'Label metadata identifier must keep the minimal position in the view.',
+ {
+ userFriendlyMessage:
+ 'Record text must be in first position of the view.',
+ },
+ ),
+ );
+ });
});
describe('update', () => {
@@ -212,10 +271,21 @@ describe('ViewFieldService', () => {
const updateData = { position: 1 };
const updatedViewField = { ...mockViewField, ...updateData };
+ const mockView = {
+ id: 'view-id',
+ objectMetadata: {
+ labelIdentifierFieldMetadataId: mockViewField.fieldMetadataId,
+ },
+ viewFields: [mockViewField],
+ } as ViewEntity;
+
jest.spyOn(viewFieldService, 'findById').mockResolvedValue(mockViewField);
jest
.spyOn(viewFieldRepository, 'save')
.mockResolvedValue(updatedViewField);
+ jest
+ .spyOn(viewService, 'findByIdWithRelatedObjectMetadata')
+ .mockResolvedValue(mockView);
const result = await viewFieldService.update(id, workspaceId, updateData);
@@ -246,6 +316,160 @@ describe('ViewFieldService', () => {
),
);
});
+
+ it('should throw exception when label metadata identifier is not in first position (label metadata identifier field update case)', async () => {
+ const workspaceId = 'workspace-id';
+ const updateData = { position: 2 };
+ const labelIdentifierFieldMetadataId =
+ 'label-identifier-field-matadata-id';
+ const labelIdentifierViewFieldId =
+ 'view-field-for-label-metadata-identifier-id';
+
+ const labelIdentifierViewField = {
+ ...mockViewField,
+ id: labelIdentifierViewFieldId,
+ fieldMetadataId: labelIdentifierFieldMetadataId,
+ position: 0,
+ };
+
+ const mockView = {
+ id: 'view-id',
+ objectMetadata: {
+ labelIdentifierFieldMetadataId,
+ },
+ viewFields: [
+ labelIdentifierViewField,
+ { ...mockViewField, position: 1 },
+ ],
+ } as ViewEntity;
+
+ jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
+ if (id === labelIdentifierViewFieldId) {
+ return Promise.resolve(labelIdentifierViewField);
+ }
+
+ return Promise.resolve(null);
+ });
+ jest
+ .spyOn(viewService, 'findByIdWithRelatedObjectMetadata')
+ .mockResolvedValue(mockView);
+
+ await expect(
+ viewFieldService.update(
+ labelIdentifierViewFieldId,
+ workspaceId,
+ updateData,
+ ),
+ ).rejects.toThrow(
+ new UserInputError(
+ 'Label metadata identifier must keep the minimal position in the view.',
+ {
+ userFriendlyMessage:
+ 'Record text must be in first position of the view.',
+ },
+ ),
+ );
+ });
+
+ it('should throw exception when label metadata identifier is not in first position (regular field update case)', async () => {
+ const workspaceId = 'workspace-id';
+ const updateData = { position: -1 };
+ const labelIdentifierFieldMetadataId =
+ 'label-identifier-field-matadata-id';
+ const labelIdentifierViewFieldId =
+ 'view-field-for-label-metadata-identifier-id';
+
+ const labelIdentifierViewField = {
+ ...mockViewField,
+ id: labelIdentifierViewFieldId,
+ fieldMetadataId: labelIdentifierFieldMetadataId,
+ position: 0,
+ };
+
+ const mockView = {
+ id: 'view-id',
+ objectMetadata: {
+ labelIdentifierFieldMetadataId,
+ },
+ viewFields: [
+ labelIdentifierViewField,
+ { ...mockViewField, position: 1 },
+ ],
+ } as ViewEntity;
+
+ jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
+ if (id === mockViewField.id) {
+ return Promise.resolve(mockViewField);
+ }
+
+ return Promise.resolve(null);
+ });
+ jest
+ .spyOn(viewService, 'findByIdWithRelatedObjectMetadata')
+ .mockResolvedValue(mockView);
+
+ await expect(
+ viewFieldService.update(mockViewField.id, workspaceId, updateData),
+ ).rejects.toThrow(
+ new UserInputError(
+ 'Label metadata identifier must keep the minimal position in the view.',
+ {
+ userFriendlyMessage:
+ 'Record text must be in first position of the view.',
+ },
+ ),
+ );
+ });
+
+ it('should throw exception when attempting to make label metadata identifier invisible', async () => {
+ const workspaceId = 'workspace-id';
+ const updateData = { isVisible: false };
+ const labelIdentifierFieldMetadataId =
+ 'label-identifier-field-matadata-id';
+ const labelIdentifierViewFieldId =
+ 'view-field-for-label-metadata-identifier-id';
+
+ const labelIdentifierViewField = {
+ ...mockViewField,
+ id: labelIdentifierViewFieldId,
+ fieldMetadataId: labelIdentifierFieldMetadataId,
+ position: 0,
+ };
+
+ const mockView = {
+ id: 'view-id',
+ objectMetadata: {
+ labelIdentifierFieldMetadataId,
+ },
+ viewFields: [labelIdentifierViewField, mockViewField],
+ } as ViewEntity;
+
+ jest.spyOn(viewFieldService, 'findById').mockImplementation((id) => {
+ if (id === labelIdentifierViewFieldId) {
+ return Promise.resolve(labelIdentifierViewField);
+ }
+
+ return Promise.resolve(null);
+ });
+ jest
+ .spyOn(
+ viewFieldService['viewService'],
+ 'findByIdWithRelatedObjectMetadata',
+ )
+ .mockResolvedValue(mockView);
+
+ await expect(
+ viewFieldService.update(
+ labelIdentifierViewField.id,
+ workspaceId,
+ updateData,
+ ),
+ ).rejects.toThrow(
+ new UserInputError('Label metadata identifier must stay visible.', {
+ userFriendlyMessage: 'Record text must stay visible.',
+ }),
+ );
+ });
});
describe('delete', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts
index 39e4077fff..8621938b20 100644
--- a/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/view/services/view-field.service.ts
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
+import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ViewFieldEntity } from 'src/engine/core-modules/view/entities/view-field.entity';
import {
ViewFieldException,
@@ -12,12 +13,14 @@ import {
generateViewFieldExceptionMessage,
generateViewFieldUserFriendlyExceptionMessage,
} from 'src/engine/core-modules/view/exceptions/view-field.exception';
+import { ViewService } from 'src/engine/core-modules/view/services/view.service';
@Injectable()
export class ViewFieldService {
constructor(
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository,
+ private readonly viewService: ViewService,
) {}
async findByWorkspaceId(workspaceId: string): Promise {
@@ -65,35 +68,105 @@ export class ViewFieldService {
async create(
viewFieldData: Partial,
): Promise {
- if (!isDefined(viewFieldData.workspaceId)) {
- throw new ViewFieldException(
- generateViewFieldExceptionMessage(
- ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
- ),
- ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
- {
- userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
+ if (this.hasRequiredFields(viewFieldData)) {
+ try {
+ if (isDefined(viewFieldData.position)) {
+ const viewFieldDataWithPosition =
+ viewFieldData as typeof viewFieldData & { position: number };
+
+ await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
+ viewFieldDataWithPosition,
+ viewFieldData.workspaceId,
+ );
+ }
+
+ await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
+ viewFieldData,
+ viewFieldData.workspaceId,
+ );
+
+ const viewFieldDataWithPosition = await this.formatViewFieldData(
+ viewFieldData,
+ viewFieldData.workspaceId,
+ );
+
+ const viewField = this.viewFieldRepository.create(
+ viewFieldDataWithPosition,
+ );
+
+ const savedViewField = await this.viewFieldRepository.save(viewField);
+ const createdViewField = await this.findById(
+ savedViewField.id,
+ viewFieldData.workspaceId,
+ );
+
+ if (!isDefined(createdViewField)) {
+ throw new ViewFieldException(
+ generateViewFieldExceptionMessage(
+ ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
+ ),
+ ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
+ {
+ userFriendlyMessage:
+ generateViewFieldUserFriendlyExceptionMessage(
+ ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
+ ),
+ },
+ );
+ }
+
+ return createdViewField;
+ } catch (error) {
+ if (
+ error.message.includes(
+ 'duplicate key value violates unique constraint',
+ )
+ ) {
+ throw new ViewFieldException(
+ generateViewFieldExceptionMessage(
+ ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
+ ),
+ ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
+ {
+ userFriendlyMessage:
+ generateViewFieldUserFriendlyExceptionMessage(
+ ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
+ ),
+ },
+ );
+ }
+
+ throw error;
+ }
+ } else {
+ if (!isDefined(viewFieldData.workspaceId)) {
+ throw new ViewFieldException(
+ generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
),
- },
- );
- }
+ ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
+ {
+ userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
+ ViewFieldExceptionMessageKey.WORKSPACE_ID_REQUIRED,
+ ),
+ },
+ );
+ }
- if (!isDefined(viewFieldData.viewId)) {
- throw new ViewFieldException(
- generateViewFieldExceptionMessage(
- ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
- ),
- ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
- {
- userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
+ if (!isDefined(viewFieldData.viewId)) {
+ throw new ViewFieldException(
+ generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
),
- },
- );
- }
+ ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
+ {
+ userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
+ ViewFieldExceptionMessageKey.VIEW_ID_REQUIRED,
+ ),
+ },
+ );
+ }
- if (!isDefined(viewFieldData.fieldMetadataId)) {
throw new ViewFieldException(
generateViewFieldExceptionMessage(
ViewFieldExceptionMessageKey.FIELD_METADATA_ID_REQUIRED,
@@ -106,50 +179,6 @@ export class ViewFieldService {
},
);
}
-
- try {
- const viewField = this.viewFieldRepository.create(viewFieldData);
-
- const savedViewField = await this.viewFieldRepository.save(viewField);
- const createdViewField = await this.findById(
- savedViewField.id,
- viewFieldData.workspaceId,
- );
-
- if (!isDefined(createdViewField)) {
- throw new ViewFieldException(
- generateViewFieldExceptionMessage(
- ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
- ),
- ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
- {
- userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
- ViewFieldExceptionMessageKey.VIEW_FIELD_NOT_FOUND,
- ),
- },
- );
- }
-
- return createdViewField;
- } catch (error) {
- if (
- error.message.includes('duplicate key value violates unique constraint')
- ) {
- throw new ViewFieldException(
- generateViewFieldExceptionMessage(
- ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
- ),
- ViewFieldExceptionCode.INVALID_VIEW_FIELD_DATA,
- {
- userFriendlyMessage: generateViewFieldUserFriendlyExceptionMessage(
- ViewFieldExceptionMessageKey.VIEW_FIELD_ALREADY_EXISTS,
- ),
- },
- );
- }
-
- throw error;
- }
}
async update(
@@ -168,6 +197,26 @@ export class ViewFieldService {
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
+ const viewId = existingViewField.viewId;
+
+ if (this.updatesPosition(updateData)) {
+ await this.verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
+ {
+ ...updateData,
+ viewId,
+ id,
+ fieldMetadataId: existingViewField.fieldMetadataId,
+ },
+ workspaceId,
+ );
+ }
+
+ if (this.disablesVisibility(updateData)) {
+ await this.verifyLabelMetadataIdentifierIsVisibleOrThrow(
+ { ...updateData, viewId, id },
+ workspaceId,
+ );
+ }
const updatedViewField = await this.viewFieldRepository.save({
id,
@@ -212,4 +261,166 @@ export class ViewFieldService {
return viewField;
}
+
+ private updatesPosition(
+ data: Partial,
+ ): data is Partial & { position: number } {
+ return isDefined(data.position);
+ }
+
+ private disablesVisibility(
+ data: Partial,
+ ): data is Partial & { isVisible: boolean } {
+ return data.isVisible === false;
+ }
+
+ private async verifyLabelMetadataIdentifierIsVisibleOrThrow(
+ newOrUpdatedViewField: Partial & {
+ viewId: string;
+ },
+ workspaceId: string,
+ ) {
+ const view = await this.viewService.findByIdWithRelatedObjectMetadata(
+ newOrUpdatedViewField.viewId,
+ workspaceId,
+ );
+
+ if (!isDefined(view)) {
+ throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
+ }
+
+ const labelMetadataIdentifierFieldMetadataId =
+ view.objectMetadata.labelIdentifierFieldMetadataId;
+
+ const labelMetadataIdentifierViewField = view.viewFields.find(
+ (viewField) =>
+ viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
+ );
+
+ if (
+ !isDefined(labelMetadataIdentifierViewField) ||
+ labelMetadataIdentifierViewField.id !== newOrUpdatedViewField.id
+ ) {
+ return;
+ }
+
+ if (newOrUpdatedViewField.isVisible === false) {
+ throw new UserInputError('Label metadata identifier must stay visible.', {
+ userFriendlyMessage: 'Record text must stay visible.',
+ });
+ }
+ }
+
+ private async verifyLabelMetadataIdentifierIsInFirstPositionOrThrow(
+ newOrUpdatedViewField: Partial & { viewId: string } & {
+ position: number;
+ },
+ workspaceId: string,
+ ) {
+ const view = await this.viewService.findByIdWithRelatedObjectMetadata(
+ newOrUpdatedViewField.viewId,
+ workspaceId,
+ );
+
+ if (!isDefined(view)) {
+ throw new Error(`View not found: ${newOrUpdatedViewField.viewId}`);
+ }
+
+ const viewFieldsWithoutUpdatedViewField = view.viewFields.filter(
+ (viewField) => viewField.id !== newOrUpdatedViewField?.id,
+ );
+
+ if (viewFieldsWithoutUpdatedViewField.length === 0) {
+ return;
+ }
+
+ const labelMetadataIdentifierFieldMetadataId =
+ view.objectMetadata.labelIdentifierFieldMetadataId;
+
+ if (
+ labelMetadataIdentifierFieldMetadataId ===
+ newOrUpdatedViewField.fieldMetadataId
+ ) {
+ const minPositionInViewWithoutUpdatedViewField =
+ viewFieldsWithoutUpdatedViewField.reduce(
+ (minViewField, viewField) =>
+ viewField.position < minViewField.position
+ ? viewField
+ : minViewField,
+ viewFieldsWithoutUpdatedViewField[0],
+ ).position;
+
+ if (
+ newOrUpdatedViewField.position >=
+ minPositionInViewWithoutUpdatedViewField
+ ) {
+ throw new UserInputError(
+ 'Label metadata identifier must keep the minimal position in the view.',
+ {
+ userFriendlyMessage:
+ 'Record text must be in first position of the view.',
+ },
+ );
+ }
+ } else {
+ const labelMetadataIdentifierViewFieldPosition = view.viewFields.find(
+ (viewField) =>
+ viewField.fieldMetadataId === labelMetadataIdentifierFieldMetadataId,
+ )?.position;
+
+ if (
+ isDefined(labelMetadataIdentifierViewFieldPosition) &&
+ newOrUpdatedViewField.position <=
+ labelMetadataIdentifierViewFieldPosition
+ ) {
+ throw new UserInputError(
+ 'Label metadata identifier must keep the minimal position in the view.',
+ {
+ userFriendlyMessage:
+ 'Record text must be in first position of the view.',
+ },
+ );
+ }
+ }
+ }
+
+ private async formatViewFieldData(
+ viewFieldData: Partial & { viewId: string },
+ workspaceId: string,
+ ): Promise> {
+ if (!isDefined(viewFieldData.position)) {
+ const view = await this.viewService.findByIdWithRelatedObjectMetadata(
+ viewFieldData.viewId,
+ workspaceId,
+ );
+
+ if (!isDefined(view)) {
+ throw new Error(`View not found: ${viewFieldData.viewId}`);
+ }
+
+ const highestPositionInView = view.viewFields.reduce(
+ (maxViewField, viewField) =>
+ viewField.position > maxViewField.position ? viewField : maxViewField,
+ view.viewFields[0],
+ );
+
+ return { ...viewFieldData, position: highestPositionInView.position + 1 };
+ } else {
+ return viewFieldData;
+ }
+ }
+
+ private hasRequiredFields(
+ data: Partial,
+ ): data is Partial & {
+ viewId: string;
+ fieldMetadataId: string;
+ workspaceId: string;
+ } {
+ return (
+ isDefined(data.viewId) &&
+ isDefined(data.fieldMetadataId) &&
+ isDefined(data.workspaceId)
+ );
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts
index 35cb861391..9b83c4ff7b 100644
--- a/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts
@@ -84,6 +84,22 @@ export class ViewService {
return view || null;
}
+ async findByIdWithRelatedObjectMetadata(
+ id: string,
+ workspaceId: string,
+ ): Promise {
+ const view = await this.viewRepository.findOne({
+ where: {
+ id,
+ workspaceId,
+ deletedAt: IsNull(),
+ },
+ relations: ['workspace', 'objectMetadata', 'viewFields'],
+ });
+
+ return view || null;
+ }
+
async create(viewData: Partial): Promise {
if (!isDefined(viewData.workspaceId)) {
throw new ViewException(
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts
index 7b451f448a..160668f096 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts
@@ -22,6 +22,7 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util';
import { type SelectOrMultiSelectFieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/utils/is-select-or-multi-select-field-metadata.util';
+import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
type Differences = {
created: T[];
@@ -376,7 +377,7 @@ export class FieldMetadataRelatedRecordsService {
fieldMetadataId: createdFieldMetadata.id,
position: lastPosition + 1,
isVisible,
- size: 180,
+ size: DEFAULT_VIEW_FIELD_SIZE,
viewId: indexView.id,
workspaceId: createdFieldMetadata.workspaceId,
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
index 228e00e44d..fdaa3bfdd9 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
@@ -401,6 +401,7 @@ export class ObjectMetadataService extends TypeOrmQueryService
+ viewField.fieldMetadataId ===
+ newLabelMetadataIdentifierFieldMetadata.id,
+ );
+
+ const currentMinPositionAmongViewFields = this.getViewFieldsMinPosition(
+ view.viewFields,
+ );
+
+ if (!labelMetadataIdentifierViewField) {
+ await this.viewFieldService.create({
+ fieldMetadataId: newLabelMetadataIdentifierFieldMetadata.id,
+ position: currentMinPositionAmongViewFields - 1,
+ isVisible: true,
+ size: DEFAULT_VIEW_FIELD_SIZE,
+ viewId: view.id,
+ workspaceId: newLabelMetadataIdentifierFieldMetadata.workspaceId,
+ });
+ continue;
+ }
+
+ await this.viewFieldService.update(
+ labelMetadataIdentifierViewField.id,
+ newLabelMetadataIdentifierFieldMetadata.workspaceId,
+ {
+ position: currentMinPositionAmongViewFields - 1,
+ isVisible: true,
+ },
+ );
+ }
+ }
+
+ private getViewFieldsMinPosition(viewFields: ViewFieldEntity[]): number {
+ if (viewFields.length === 0) {
+ return 0;
+ }
+
+ return viewFields.reduce((min, field) => Math.min(min, field.position), 0);
+ }
+
private async createView(
objectMetadata: ObjectMetadataEntity,
): Promise {
@@ -56,7 +111,7 @@ export class ObjectMetadataRelatedRecordsService {
fieldMetadataId: field.id,
position: index,
isVisible: true,
- size: 180,
+ size: DEFAULT_VIEW_FIELD_SIZE,
viewId: viewId,
workspaceId: objectMetadata.workspaceId,
}));
diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view.ts
index a852d9c63a..e1882954fb 100644
--- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view.ts
@@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
+import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
import {
BASE_OBJECT_STANDARD_FIELD_IDS,
COMPANY_STANDARD_FIELD_IDS,
@@ -37,7 +38,7 @@ export const companiesAllView = (
)?.id ?? '',
position: 0,
isVisible: true,
- size: 180,
+ size: DEFAULT_VIEW_FIELD_SIZE,
},
{
fieldMetadataId:
diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE.ts
new file mode 100644
index 0000000000..6be2a05d01
--- /dev/null
+++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE.ts
@@ -0,0 +1 @@
+export const DEFAULT_VIEW_FIELD_SIZE = 180;
diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/custom-all.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/custom-all.view.ts
index 8d7e98022a..ae8a315d15 100644
--- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/custom-all.view.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/custom-all.view.ts
@@ -1,6 +1,7 @@
import { msg } from '@lingui/core/macro';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
+import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/constants/DEFAULT_VIEW_FIELD_SIZE';
export const customAllView = (
objectMetadataItem: ObjectMetadataEntity,
@@ -39,13 +40,13 @@ export const customAllView = (
?.id ?? '',
position: 0,
isVisible: true,
- size: 180,
+ size: DEFAULT_VIEW_FIELD_SIZE,
},
...otherFields.map((field, index) => ({
fieldMetadataId: field.id,
position: index + 1,
isVisible: true,
- size: 180,
+ size: DEFAULT_VIEW_FIELD_SIZE,
})),
],
};