Feat: group records by many to one relation (#22123)

## Group records by relation (Kanban + Table)

Adds grouping by `MANY_TO_ONE` relation fields on both board and table
views, reusing the existing `ViewGroup` storage (`fieldValue = related
record id`).

- **New group** record picker to create relation-backed groups (board
column + table row)
- Relation-aware group headers (name/avatar), filtering, and drag-drop
(writes the FK join column) — all using one canonical `${name}Id` column
- Sort menu hides alphabetical options when grouping by a relation (no
comparable title)
- A group whose backing record no longer exists renders a "Deleted" chip
instead of a blank header
- **Backend:** allow `MANY_TO_ONE` relations as the Kanban
`mainGroupByField` in the flat-view validator


https://github.com/user-attachments/assets/267077a6-2667-4506-b178-eee420a16f20



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22123?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-06-25 17:25:50 +05:30
committed by GitHub
parent 108cc5b6a5
commit db5338cf32
44 changed files with 981 additions and 238 deletions
@@ -1,11 +1,13 @@
import { msg, t } from '@lingui/core/macro';
import { type ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
import { isMorphOrRelationUniversalFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
@@ -14,6 +16,28 @@ import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-man
export class FlatViewValidatorService {
constructor() {}
private isAllowedKanbanMainGroupByField({
mainGroupByFieldMetadata,
}: {
mainGroupByFieldMetadata: UniversalFlatFieldMetadata;
}): boolean {
if (mainGroupByFieldMetadata.type === FieldMetadataType.SELECT) {
return true;
}
if (
!isMorphOrRelationUniversalFlatFieldMetadata(mainGroupByFieldMetadata)
) {
return false;
}
return (
mainGroupByFieldMetadata.type === FieldMetadataType.RELATION &&
mainGroupByFieldMetadata.universalSettings?.relationType ===
RelationType.MANY_TO_ONE
);
}
public validateFlatViewUpdate({
universalIdentifier,
flatEntityUpdate,
@@ -104,11 +128,15 @@ export class FlatViewValidatorService {
message: t`Kanban main group by field metadata not found`,
userFriendlyMessage: msg`Kanban main group by field metadata not found`,
});
} else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) {
} else if (
!this.isAllowedKanbanMainGroupByField({
mainGroupByFieldMetadata,
})
) {
validationResult.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Kanban main group by field must be a SELECT field`,
userFriendlyMessage: msg`Kanban main group by field must be a select field`,
message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`,
userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`,
});
}
}
@@ -133,11 +161,15 @@ export class FlatViewValidatorService {
message: t`Kanban main group by field metadata not found`,
userFriendlyMessage: msg`Kanban main group by field metadata not found`,
});
} else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) {
} else if (
!this.isAllowedKanbanMainGroupByField({
mainGroupByFieldMetadata,
})
) {
validationResult.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Kanban main group by field must be a SELECT field`,
userFriendlyMessage: msg`Kanban main group by field must be a select field`,
message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`,
userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`,
});
}
}
@@ -300,11 +332,15 @@ export class FlatViewValidatorService {
message: t`Kanban main group by field metadata not found`,
userFriendlyMessage: msg`Kanban main group by field metadata not found`,
});
} else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) {
} else if (
!this.isAllowedKanbanMainGroupByField({
mainGroupByFieldMetadata,
})
) {
validationResult.errors.push({
code: ViewExceptionCode.INVALID_VIEW_DATA,
message: t`Kanban main group by field must be a SELECT field`,
userFriendlyMessage: msg`Kanban main group by field must be a select field`,
message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`,
userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`,
});
}
}