fix(server): preserve kanban/calendar fields in view manifest sync (#19946)

## Summary

The `fromViewManifestToUniversalFlatView` converter hardcoded five view
fields to `null` instead of reading them from the manifest:
- `mainGroupByFieldMetadataUniversalIdentifier`
- `kanbanAggregateOperation`
- `kanbanAggregateOperationFieldMetadataUniversalIdentifier`
- `calendarLayout`
- `calendarFieldMetadataUniversalIdentifier`

As a result **any** Kanban view in an app manifest is rejected by
`validateFlatViewCreation` with `"Kanban view must have a main group by
field"`, and any Calendar view would trip the `view.entity.ts` check
constraint requiring `calendarLayout` + `calendarFieldMetadataId` to be
non-null. Discovered while trying to install
[`twenty-crm-meeting-baas`](https://github.com/Meeting-BaaS/twenty-crm-meeting-baas)
which ships a Kanban view.

## Changes

- **Server converter**: read all five fields from the manifest (with `??
null` fallback).
- **`ViewManifest` type** (`twenty-shared`): add the five fields so SDK
users can set them type-safely.
- **Move `ViewCalendarLayout`** from `twenty-server` to `twenty-shared`
so the manifest type can reference it. Seven import sites updated; the
front-end imports via generated GraphQL types and is unaffected.
- **Unit tests**: extend
`from-view-manifest-to-universal-flat-view.util.spec.ts` with
preservation + null-default cases for both Kanban and Calendar (5 tests
total).
- **Regression coverage**: add a Kanban view
(`post-cards-by-status.view.ts`) to the `rich-app` fixture grouped by
the existing `status` SELECT field. The existing
`applications-install-delete-reinstall` e2e test now exercises the
Kanban path end-to-end — a future regression here would fail CI.

Note: `expected-manifest.ts` and the `views.length` assertion in
`manifest.tests.ts` were updated to reflect the new fixture view.

## Test plan

- [x] `nx test twenty-server --
from-view-manifest-to-universal-flat-view` → 5/5 pass
- [x] `nx typecheck twenty-shared` / `twenty-sdk` / `twenty-server` → no
new errors (one pre-existing unrelated error in
`admin-panel.module-factory.ts`)
- [x] `nx lint twenty-shared` / `twenty-sdk` → clean
- [x] Manual install of the Meeting BaaS app on a dev workspace succeeds
with the Kanban view after this fix
- [ ] CI: SDK e2e `applications-install-delete-reinstall` passes against
the new fixture view
- [ ] CI: integration test `calendar-field-deactivation-deletes-views`
still passes after the enum move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-22 16:31:00 +02:00
committed by GitHub
parent c2cf3eac50
commit b010599000
18 changed files with 276 additions and 20 deletions
@@ -13,6 +13,13 @@ export const POST_CARD_UNIVERSAL_IDENTIFIER =
export const CONTENT_FIELD_UNIVERSAL_IDENTIFIER =
'58a0a314-d7ea-4865-9850-7fb84e72f30b';
export const STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'87b675b8-dd8c-4448-b4ca-20e5a2234a1e';
export const DELIVERED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'e06abe72-5b44-4e7f-93be-afc185a3c433';
export default defineObject({
universalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
nameSingular: 'postCard',
@@ -47,7 +54,7 @@ export default defineObject({
name: 'recipientAddress',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
universalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
@@ -92,7 +99,7 @@ export default defineObject({
name: 'status',
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
universalIdentifier: DELIVERED_AT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
@@ -2,6 +2,7 @@ import { defineView } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
import { ViewType } from 'twenty-shared/types';
@@ -24,7 +25,7 @@ export default defineView({
},
{
universalIdentifier: 'bf1a2b3c-0002-4a7b-8c9d-0e1f2a3b4c5d',
fieldMetadataUniversalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
@@ -0,0 +1,32 @@
import { defineView } from 'twenty-sdk/define';
import { ViewCalendarLayout, ViewType } from 'twenty-shared/types';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
DELIVERED_AT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export const POST_CARDS_BY_DELIVERY_DATE_VIEW_ID =
'b1a2b3c4-0005-4a7b-8c9d-0e1f2a3b4c5d';
export default defineView({
universalIdentifier: POST_CARDS_BY_DELIVERY_DATE_VIEW_ID,
name: 'By Delivery Date',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: ViewType.CALENDAR,
icon: 'IconCalendarEvent',
position: 2,
calendarLayout: ViewCalendarLayout.MONTH,
calendarFieldMetadataUniversalIdentifier:
DELIVERED_AT_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'bf1a2b3c-0006-4a7b-8c9d-0e1f2a3b4c5d',
fieldMetadataUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
@@ -0,0 +1,63 @@
import { defineView } from 'twenty-sdk/define';
import { ViewType } from 'twenty-shared/types';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export const POST_CARDS_BY_STATUS_VIEW_ID =
'b1a2b3c4-0004-4a7b-8c9d-0e1f2a3b4c5d';
export default defineView({
universalIdentifier: POST_CARDS_BY_STATUS_VIEW_ID,
name: 'By Status',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
position: 1,
mainGroupByFieldMetadataUniversalIdentifier:
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'bf1a2b3c-0005-4a7b-8c9d-0e1f2a3b4c5d',
fieldMetadataUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
groups: [
{
universalIdentifier: 'bg1a2b3c-0001-4a7b-8c9d-0e1f2a3b4c5d',
fieldValue: 'DRAFT',
position: 0,
isVisible: true,
},
{
universalIdentifier: 'bg1a2b3c-0002-4a7b-8c9d-0e1f2a3b4c5d',
fieldValue: 'SENT',
position: 1,
isVisible: true,
},
{
universalIdentifier: 'bg1a2b3c-0003-4a7b-8c9d-0e1f2a3b4c5d',
fieldValue: 'DELIVERED',
position: 2,
isVisible: true,
},
{
universalIdentifier: 'bg1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
fieldValue: 'RETURNED',
position: 3,
isVisible: true,
},
{
universalIdentifier: 'bg1a2b3c-0005-4a7b-8c9d-0e1f2a3b4c5d',
fieldValue: 'LOST',
position: 4,
isVisible: true,
},
],
});
@@ -6,6 +6,7 @@ import {
NavigationMenuItemType,
RelationOnDeleteAction,
RelationType,
ViewCalendarLayout,
ViewType,
} from 'twenty-shared/types';
@@ -1473,6 +1474,79 @@ export const EXPECTED_MANIFEST: Manifest = {
type: ViewType.TABLE,
universalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
},
{
fields: [
{
fieldMetadataUniversalIdentifier:
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
isVisible: true,
position: 0,
size: 200,
universalIdentifier: 'bf1a2b3c-0005-4a7b-8c9d-0e1f2a3b4c5d',
},
],
groups: [
{
fieldValue: 'DRAFT',
isVisible: true,
position: 0,
universalIdentifier: 'bg1a2b3c-0001-4a7b-8c9d-0e1f2a3b4c5d',
},
{
fieldValue: 'SENT',
isVisible: true,
position: 1,
universalIdentifier: 'bg1a2b3c-0002-4a7b-8c9d-0e1f2a3b4c5d',
},
{
fieldValue: 'DELIVERED',
isVisible: true,
position: 2,
universalIdentifier: 'bg1a2b3c-0003-4a7b-8c9d-0e1f2a3b4c5d',
},
{
fieldValue: 'RETURNED',
isVisible: true,
position: 3,
universalIdentifier: 'bg1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
},
{
fieldValue: 'LOST',
isVisible: true,
position: 4,
universalIdentifier: 'bg1a2b3c-0005-4a7b-8c9d-0e1f2a3b4c5d',
},
],
icon: 'IconLayoutKanban',
mainGroupByFieldMetadataUniversalIdentifier:
'87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
name: 'By Status',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
position: 1,
type: ViewType.KANBAN,
universalIdentifier: 'b1a2b3c4-0004-4a7b-8c9d-0e1f2a3b4c5d',
},
{
calendarFieldMetadataUniversalIdentifier:
'e06abe72-5b44-4e7f-93be-afc185a3c433',
calendarLayout: ViewCalendarLayout.MONTH,
fields: [
{
fieldMetadataUniversalIdentifier:
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
isVisible: true,
position: 0,
size: 200,
universalIdentifier: 'bf1a2b3c-0006-4a7b-8c9d-0e1f2a3b4c5d',
},
],
icon: 'IconCalendarEvent',
name: 'By Delivery Date',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
position: 2,
type: ViewType.CALENDAR,
universalIdentifier: 'b1a2b3c4-0005-4a7b-8c9d-0e1f2a3b4c5d',
},
],
navigationMenuItems: [
{
@@ -24,7 +24,7 @@ export const defineManifestTests = (appPath: string): void => {
expect(manifest.frontComponents).toHaveLength(4);
expect(manifest.roles).toHaveLength(2);
expect(manifest.fields).toHaveLength(23);
expect(manifest.views).toHaveLength(3);
expect(manifest.views).toHaveLength(5);
expect(manifest.navigationMenuItems).toHaveLength(3);
expect(normalizeManifestForComparison(manifest)).toEqual(
@@ -1,4 +1,6 @@
import {
AggregateOperations,
ViewCalendarLayout,
ViewOpenRecordIn,
ViewType,
ViewVisibility,
@@ -65,4 +67,69 @@ describe('fromViewManifestToUniversalFlatView', () => {
expect(result.visibility).toBe(ViewVisibility.UNLISTED);
expect(result.openRecordIn).toBe(ViewOpenRecordIn.RECORD_PAGE);
});
it('should preserve kanban fields from the manifest', () => {
const result = fromViewManifestToUniversalFlatView({
viewManifest: {
universalIdentifier: 'view-uuid-3',
name: 'Kanban Board',
objectUniversalIdentifier: 'object-uuid-1',
type: ViewType.KANBAN,
mainGroupByFieldMetadataUniversalIdentifier: 'field-uuid-status',
kanbanAggregateOperation: AggregateOperations.COUNT,
kanbanAggregateOperationFieldMetadataUniversalIdentifier:
'field-uuid-amount',
},
applicationUniversalIdentifier,
now,
});
expect(result.mainGroupByFieldMetadataUniversalIdentifier).toBe(
'field-uuid-status',
);
expect(result.kanbanAggregateOperation).toBe(AggregateOperations.COUNT);
expect(
result.kanbanAggregateOperationFieldMetadataUniversalIdentifier,
).toBe('field-uuid-amount');
});
it('should default kanban and calendar fields to null when omitted', () => {
const result = fromViewManifestToUniversalFlatView({
viewManifest: {
universalIdentifier: 'view-uuid-4',
name: 'All Records',
objectUniversalIdentifier: 'object-uuid-1',
},
applicationUniversalIdentifier,
now,
});
expect(result.mainGroupByFieldMetadataUniversalIdentifier).toBeNull();
expect(result.kanbanAggregateOperation).toBeNull();
expect(
result.kanbanAggregateOperationFieldMetadataUniversalIdentifier,
).toBeNull();
expect(result.calendarLayout).toBeNull();
expect(result.calendarFieldMetadataUniversalIdentifier).toBeNull();
});
it('should preserve calendar fields from the manifest', () => {
const result = fromViewManifestToUniversalFlatView({
viewManifest: {
universalIdentifier: 'view-uuid-5',
name: 'Calendar View',
objectUniversalIdentifier: 'object-uuid-1',
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldMetadataUniversalIdentifier: 'field-uuid-date',
},
applicationUniversalIdentifier,
now,
});
expect(result.calendarLayout).toBe(ViewCalendarLayout.WEEK);
expect(result.calendarFieldMetadataUniversalIdentifier).toBe(
'field-uuid-date',
);
});
});
@@ -29,11 +29,15 @@ export const fromViewManifestToUniversalFlatView = ({
visibility: viewManifest.visibility ?? ViewVisibility.WORKSPACE,
openRecordIn: viewManifest.openRecordIn ?? ViewOpenRecordIn.SIDE_PANEL,
key: viewManifest.key ?? null,
kanbanAggregateOperation: null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier: null,
calendarLayout: null,
calendarFieldMetadataUniversalIdentifier: null,
mainGroupByFieldMetadataUniversalIdentifier: null,
kanbanAggregateOperation: viewManifest.kanbanAggregateOperation ?? null,
kanbanAggregateOperationFieldMetadataUniversalIdentifier:
viewManifest.kanbanAggregateOperationFieldMetadataUniversalIdentifier ??
null,
calendarLayout: viewManifest.calendarLayout ?? null,
calendarFieldMetadataUniversalIdentifier:
viewManifest.calendarFieldMetadataUniversalIdentifier ?? null,
mainGroupByFieldMetadataUniversalIdentifier:
viewManifest.mainGroupByFieldMetadataUniversalIdentifier ?? null,
shouldHideEmptyGroups: false,
anyFieldFilterValue: null,
createdByUserWorkspaceId: null,
@@ -11,6 +11,7 @@ import {
} from 'class-validator';
import {
AggregateOperations,
ViewCalendarLayout,
ViewOpenRecordIn,
ViewKey,
ViewType,
@@ -19,7 +20,6 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
@InputType()
export class CreateViewInput {
@@ -10,6 +10,7 @@ import {
} from 'class-validator';
import {
AggregateOperations,
ViewCalendarLayout,
ViewOpenRecordIn,
ViewType,
ViewVisibility,
@@ -17,7 +18,6 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
// TODO: this should be refactored like for view-field.input.ts
// This is a temporary fix as we were extending the CreateViewInput class which was adding default values for the non filled fields
@@ -3,6 +3,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
AggregateOperations,
ViewCalendarLayout,
ViewOpenRecordIn,
ViewType,
ViewVisibility,
@@ -16,7 +17,6 @@ import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-grou
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import { ViewSortDTO } from 'src/engine/metadata-modules/view-sort/dtos/view-sort.dto';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
registerEnumType(ViewOpenRecordIn, { name: 'ViewOpenRecordIn' });
registerEnumType(ViewType, { name: 'ViewType' });
@@ -14,9 +14,10 @@ import {
} from 'typeorm';
import {
AggregateOperations,
ViewCalendarLayout,
ViewKey,
ViewOpenRecordIn,
ViewType,
ViewKey,
ViewVisibility,
} from 'twenty-shared/types';
@@ -29,7 +30,6 @@ import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-g
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
// We could refactor this type to be dynamic to view type
@@ -3,12 +3,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
import {
FieldMetadataType,
OrderByDirection,
ViewCalendarLayout,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
@@ -4,6 +4,7 @@ import { type ToolSet } from 'ai';
import {
AggregateOperations,
FieldMetadataType,
ViewCalendarLayout,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
@@ -14,7 +15,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
@@ -7,11 +7,13 @@ import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { findOneView } from 'test/integration/metadata/suites/view/utils/find-one-view.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import {
FieldMetadataType,
ViewCalendarLayout,
ViewType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
const VIEW_WITH_CALENDAR_FIELDS = `
id
name
@@ -2,6 +2,7 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsT
import {
type ViewKey,
type AggregateOperations,
type ViewCalendarLayout,
type ViewFilterGroupLogicalOperator,
type ViewFilterOperand,
type ViewOpenRecordIn,
@@ -68,6 +69,11 @@ export type ViewManifest = SyncableEntityOptions & {
isCompact?: boolean;
visibility?: ViewVisibility;
openRecordIn?: ViewOpenRecordIn;
mainGroupByFieldMetadataUniversalIdentifier?: string;
kanbanAggregateOperation?: AggregateOperations;
kanbanAggregateOperationFieldMetadataUniversalIdentifier?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldMetadataUniversalIdentifier?: string;
fields?: ViewFieldManifest[];
filters?: ViewFilterManifest[];
filterGroups?: ViewFilterGroupManifest[];
@@ -279,6 +279,7 @@ export type {
export { StepLogicalOperator } from './StepFilters';
export { TwoFactorAuthenticationStrategy } from './TwoFactorAuthenticationStrategy';
export { IsValidGraphQLEnumName } from './validators/is-valid-graphql-enum-name.validator';
export { ViewCalendarLayout } from './ViewCalendarLayout';
export { ViewFilterGroupLogicalOperator } from './ViewFilterGroupLogicalOperator';
export { ViewFilterOperand } from './ViewFilterOperand';
export { ViewFilterOperandDeprecated } from './ViewFilterOperandDeprecated';