From 128d2d394db62791fa25d638b478ebaa4780d54b Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 5 Jun 2026 10:06:30 +0200 Subject: [PATCH] feat: allow apps to add view fields to existing views (defineViewField) (#21160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Lets a Twenty application add **view fields (columns) to an existing view it does not own** — including standard views like the People index view — without redeclaring/owning that view. This mirrors the existing, working pattern by which an app adds a custom field to a standard object via `defineField` + `objectUniversalIdentifier`. The asymmetry being removed was purely in the manifest schema: `ViewFieldManifest` only existed *nested* inside `ViewManifest.fields[]`, so adding a view field forced declaring a `ViewManifest` — which the sync treats as a view the app creates and owns, and rejects when the UID is a standard view's. Validation, persistence, the FK aggregator machinery, and uninstall cleanup were already generic and cross-app-safe, so no engine changes were needed. ### Changes - **twenty-shared:** new top-level `StandaloneViewFieldManifest` (`ViewFieldManifest & { viewUniversalIdentifier }`), `Manifest.viewFields`, and a `SyncableEntity.ViewField` member. - **twenty-sdk:** `defineViewField` (validates `universalIdentifier` + `viewUniversalIdentifier` + `fieldMetadataUniversalIdentifier`), CLI manifest assembly of a top-level `viewFields` list, and `dev:add viewField` scaffolding. - **twenty-server:** one top-level loop over `manifest.viewFields` that reuses the existing `fromViewFieldManifestToUniversalFlatViewField` converter (already parameterized by `viewUniversalIdentifier`). No validator/persistence/aggregator changes. ### Notes for maintainers - Confirm the `Manifest.viewFields` optionality convention — implemented as a **required** array to mirror `fields`/`views`. - Two different apps adding a column for the same field to the same view conflicts on the existing unique `(fieldMetadataId, viewId)` partial index; the existing `flat-view-field-validator` duplicate check surfaces this as a structured validation error. - `dev:add viewField` scaffolding is included (was optional in the plan). ## Test Plan - [x] `twenty-shared` typecheck - [x] `twenty-sdk` 364 unit tests + `buildManifest` assembly test (rich-app fixture) + typecheck + prettier - [x] `twenty-server` typecheck + `lint:diff-with-main` - [x] **Server integration suite** `successful-manifest-update-view-field.integration-spec.ts` (4/4): - standalone view field attaches to the standard `allPeople` view without recreating it (sync succeeds, no `INVALID_VIEW_DATA`/`ENTITY_ALREADY_EXISTS`) - uninstall removes the contributed column while the standard view + its columns remain intact - duplicate `(view, field)` rejected with `METADATA_VALIDATION_FAILED` - unknown target view rejected - [x] Sibling `successful-manifest-update-field.integration-spec.ts` still green (no harness regression) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../post-cards.navigation-menu-item.ts | 2 +- .../viewFields/post-card-number-view-field.ts | 10 + .../app-dev/expected-manifest.ts | 1 + .../app-dev/expected-manifest.ts | 11 +- .../twenty-sdk/src/cli/commands/dev/add.ts | 9 + ...stub-twenty-sdk-define.plugin.spec.ts.snap | 1 + .../manifest-build-view-fields.spec.ts | 31 ++ .../__tests__/manifest-validate.spec.ts | 1 + .../build/manifest/manifest-build.ts | 18 ++ .../build/manifest/manifest-extract-config.ts | 3 + .../dev-mode-orchestrator-state.ts | 1 + .../cli/utilities/dev/ui/dev-ui-constants.ts | 1 + .../entity/entity-view-field-template.ts | 24 ++ .../define/common/types/define-entity.type.ts | 2 + packages/twenty-sdk/src/sdk/define/index.ts | 2 + .../__tests__/define-view-field.spec.ts | 56 ++++ .../define/view-fields/define-view-field.ts | 24 ++ .../application-manifest-migration.service.ts | 1 + ...-all-universal-flat-entity-maps.service.ts | 14 + .../resolve-manifest-asset-urls.util.spec.ts | 1 + ...n-cross-app-view-field.integration-spec.ts | 109 +++++++ ...fest-update-view-field.integration-spec.ts | 284 ++++++++++++++++++ .../utils/build-base-manifest.util.ts | 1 + .../enums/syncable-entities.enum.ts | 1 + .../twenty-shared/src/application/index.ts | 1 + .../src/application/manifestType.ts | 6 +- .../src/application/viewManifestType.ts | 6 +- 27 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-apps/fixtures/rich-app/src/viewFields/post-card-number-view-field.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-view-fields.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/entity-view-field-template.ts create mode 100644 packages/twenty-sdk/src/sdk/define/view-fields/__tests__/define-view-field.spec.ts create mode 100644 packages/twenty-sdk/src/sdk/define/view-fields/define-view-field.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-cross-app-view-field.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-view-field.integration-spec.ts diff --git a/packages/twenty-apps/fixtures/rich-app/src/navigation-menu-items/post-cards.navigation-menu-item.ts b/packages/twenty-apps/fixtures/rich-app/src/navigation-menu-items/post-cards.navigation-menu-item.ts index 4af77a61a1..c48050d39d 100644 --- a/packages/twenty-apps/fixtures/rich-app/src/navigation-menu-items/post-cards.navigation-menu-item.ts +++ b/packages/twenty-apps/fixtures/rich-app/src/navigation-menu-items/post-cards.navigation-menu-item.ts @@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types'; import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object'; export default defineNavigationMenuItem({ - universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d', + universalIdentifier: 'e8031eca-d6ea-4a4b-b828-38227dba896a', position: 0, type: NavigationMenuItemType.OBJECT, targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/fixtures/rich-app/src/viewFields/post-card-number-view-field.ts b/packages/twenty-apps/fixtures/rich-app/src/viewFields/post-card-number-view-field.ts new file mode 100644 index 0000000000..2d60b95cfc --- /dev/null +++ b/packages/twenty-apps/fixtures/rich-app/src/viewFields/post-card-number-view-field.ts @@ -0,0 +1,10 @@ +import { defineViewField } from 'twenty-sdk/define'; +import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view'; + +export default defineViewField({ + fieldMetadataUniversalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6', + position: 5, + universalIdentifier: 'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54', + viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID, + isVisible: true, +}); diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts index ea7c517d32..4e765c26ec 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts @@ -314,6 +314,7 @@ export const EXPECTED_MANIFEST: Manifest = { }, ], views: [], + viewFields: [], navigationMenuItems: [], pageLayouts: [], pageLayoutTabs: [], diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index bad203a5bf..5c55554f7a 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -1580,6 +1580,15 @@ export const EXPECTED_MANIFEST: Manifest = { universalIdentifier: 'b1a2b3c4-0005-4a7b-8c9d-0e1f2a3b4c5d', }, ], + viewFields: [ + { + fieldMetadataUniversalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6', + isVisible: true, + position: 5, + universalIdentifier: 'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54', + viewUniversalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d', + }, + ], navigationMenuItems: [ { type: NavigationMenuItemType.OBJECT, @@ -1590,7 +1599,7 @@ export const EXPECTED_MANIFEST: Manifest = { { type: NavigationMenuItemType.OBJECT, position: 0, - universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d', + universalIdentifier: 'e8031eca-d6ea-4a4b-b828-38227dba896a', targetObjectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', }, { diff --git a/packages/twenty-sdk/src/cli/commands/dev/add.ts b/packages/twenty-sdk/src/cli/commands/dev/add.ts index 0646bb7be0..2842477e71 100644 --- a/packages/twenty-sdk/src/cli/commands/dev/add.ts +++ b/packages/twenty-sdk/src/cli/commands/dev/add.ts @@ -24,6 +24,7 @@ import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template'; import { getConnectionProviderBaseFile } from '@/cli/utilities/entity/entity-connection-provider-template'; import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template'; import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template'; +import { getViewFieldBaseFile } from '@/cli/utilities/entity/entity-view-field-template'; import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils'; import { kebabCase } from '@/cli/utilities/string/kebab-case'; @@ -190,6 +191,14 @@ export class EntityAddCommand { return { name, file }; } + case SyncableEntity.ViewField: { + const name = await this.getEntityName(entity); + + const file = getViewFieldBaseFile({}); + + return { name, file }; + } + case SyncableEntity.NavigationMenuItem: { const name = await this.getEntityName(entity); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index d96182e611..e97f7f008e 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -82,6 +82,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "defineRole", "defineSkill", "defineView", + "defineViewField", ], } `; diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-view-fields.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-view-fields.spec.ts new file mode 100644 index 0000000000..7ca29ba00f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-view-fields.spec.ts @@ -0,0 +1,31 @@ +import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths'; +import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; + +const POST_CARD_NUMBER_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + 'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54'; +const ALL_POST_CARDS_VIEW_ID = 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d'; +const POST_CARD_NUMBER_FIELD_UNIVERSAL_IDENTIFIER = + '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6'; + +describe('buildManifest standalone view fields', () => { + it('collects top-level defineViewField exports into manifest.viewFields', async () => { + const { manifest, errors } = await buildManifest(RICH_APP_PATH); + + expect(errors).toEqual([]); + expect(manifest).not.toBeNull(); + + const viewField = manifest?.viewFields.find( + (entry) => + entry.universalIdentifier === + POST_CARD_NUMBER_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + ); + + expect(viewField).toBeDefined(); + expect(viewField?.viewUniversalIdentifier).toBe(ALL_POST_CARDS_VIEW_ID); + expect(viewField?.fieldMetadataUniversalIdentifier).toBe( + POST_CARD_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + ); + expect(viewField?.position).toBe(5); + expect(viewField?.isVisible).toBe(true); + }, 60000); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts index 695e3f6682..96817cd97d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts @@ -37,6 +37,7 @@ const validManifest: Manifest = { agents: [], publicAssets: [], views: [], + viewFields: [], navigationMenuItems: [], pageLayouts: [], pageLayoutTabs: [], diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index e52e971acb..b1a7418e58 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -46,6 +46,7 @@ import { type PreInstallLogicFunctionApplicationManifest, type RoleManifest, type SkillManifest, + type StandaloneViewFieldManifest, type ViewManifest, } from 'twenty-shared/application'; import { @@ -96,6 +97,7 @@ export const buildManifest = async ( const frontComponents: FrontComponentManifest[] = []; const publicAssets: AssetManifest[] = []; const views: ViewManifest[] = []; + const viewFields: StandaloneViewFieldManifest[] = []; const navigationMenuItems: NavigationMenuItemManifest[] = []; const pageLayouts: PageLayoutManifest[] = []; const pageLayoutTabs: PageLayoutTabManifest[] = []; @@ -118,6 +120,7 @@ export const buildManifest = async ( const frontComponentsFilePaths: string[] = []; const publicAssetsFilePaths: string[] = []; const viewsFilePaths: string[] = []; + const viewFieldsFilePaths: string[] = []; const navigationMenuItemsFilePaths: string[] = []; const pageLayoutsFilePaths: string[] = []; const pageLayoutTabsFilePaths: string[] = []; @@ -397,6 +400,19 @@ export const buildManifest = async ( viewsFilePaths.push(relativePath); break; } + case ManifestEntityKey.ViewFields: { + const extract = + await extractManifestFromFile({ + appPath, + filePath, + }); + + viewFields.push(extract.config); + errors.push(...extract.errors); + warnings.push(...(extract.warnings ?? [])); + viewFieldsFilePaths.push(relativePath); + break; + } case ManifestEntityKey.NavigationMenuItems: { const extract = await extractManifestFromFile({ @@ -575,6 +591,7 @@ export const buildManifest = async ( frontComponents: frontComponents.sort(byId), publicAssets: publicAssets.sort(byPath), views: views.sort(byId), + viewFields: viewFields.sort(byId), navigationMenuItems: navigationMenuItems.sort(byId), pageLayouts: pageLayouts.sort(byId), pageLayoutTabs: pageLayoutTabs.sort(byId), @@ -595,6 +612,7 @@ export const buildManifest = async ( frontComponents: frontComponentsFilePaths, publicAssets: publicAssetsFilePaths, views: viewsFilePaths, + viewFields: viewFieldsFilePaths, navigationMenuItems: navigationMenuItemsFilePaths, pageLayouts: pageLayoutsFilePaths, pageLayoutTabs: pageLayoutTabsFilePaths, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts index 70b8cdbb44..8594236767 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts @@ -16,6 +16,7 @@ export enum TargetFunction { DefineConnectionProvider = 'defineConnectionProvider', DefineFrontComponent = 'defineFrontComponent', DefineView = 'defineView', + DefineViewField = 'defineViewField', DefineNavigationMenuItem = 'defineNavigationMenuItem', DefinePageLayout = 'definePageLayout', DefinePageLayoutTab = 'definePageLayoutTab', @@ -36,6 +37,7 @@ export enum ManifestEntityKey { FrontComponents = 'frontComponents', PublicAssets = 'publicAssets', Views = 'views', + ViewFields = 'viewFields', NavigationMenuItems = 'navigationMenuItems', PageLayouts = 'pageLayouts', PageLayoutTabs = 'pageLayoutTabs', @@ -66,6 +68,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record< ManifestEntityKey.ConnectionProviders, [TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents, [TargetFunction.DefineView]: ManifestEntityKey.Views, + [TargetFunction.DefineViewField]: ManifestEntityKey.ViewFields, [TargetFunction.DefineNavigationMenuItem]: ManifestEntityKey.NavigationMenuItems, [TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 90df3db593..0fc27e0b70 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -74,6 +74,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record = { skills: SyncableEntity.Skill, connectionProviders: SyncableEntity.ConnectionProvider, views: SyncableEntity.View, + viewFields: SyncableEntity.ViewField, navigationMenuItems: SyncableEntity.NavigationMenuItem, pageLayouts: SyncableEntity.PageLayout, pageLayoutTabs: SyncableEntity.PageLayoutTab, diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index e0c3b04857..3560a7ecf6 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record = { [SyncableEntity.Role]: 'Roles', [SyncableEntity.Skill]: 'Skills', [SyncableEntity.View]: 'Views', + [SyncableEntity.ViewField]: 'View fields', [SyncableEntity.NavigationMenuItem]: 'Navigation menu items', [SyncableEntity.PageLayout]: 'Page layouts', [SyncableEntity.PageLayoutTab]: 'Page layout tabs', diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-field-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-field-template.ts new file mode 100644 index 0000000000..325b4ee1ae --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-field-template.ts @@ -0,0 +1,24 @@ +import { v4 } from 'uuid'; + +export const getViewFieldBaseFile = ({ + universalIdentifier = v4(), +}: { + universalIdentifier?: string; +}) => { + return `import { + defineViewField, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +export default defineViewField({ + universalIdentifier: '${universalIdentifier}', + // The universalIdentifier of the existing view to add this column to + viewUniversalIdentifier: 'STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS..views..universalIdentifier', + // The universalIdentifier of the field to display in that view + fieldMetadataUniversalIdentifier: '', + position: 0, + isVisible: true, + size: 150, +}); +`; +}; diff --git a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts index 1ba6fc6396..b63dee4373 100644 --- a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts +++ b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts @@ -17,6 +17,7 @@ import { type FieldManifest, type NavigationMenuItemManifest, type SkillManifest, + type StandaloneViewFieldManifest, } from 'twenty-shared/application'; export type ValidationResult = { @@ -40,6 +41,7 @@ export type DefinableEntity = | RoleConfig | SkillManifest | ViewConfig + | StandaloneViewFieldManifest | NavigationMenuItemManifest | PageLayoutConfig | PageLayoutTabConfig diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index 7cfbc85079..d09db7a5f3 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -143,9 +143,11 @@ export { SystemPermissionFlag } from 'twenty-shared/constants'; export { defineSkill } from '@/sdk/define/skills/define-skill'; export { defineView } from '@/sdk/define/views/define-view'; +export { defineViewField } from '@/sdk/define/view-fields/define-view-field'; export type { ViewConfig } from '@/sdk/define/views/view-config'; export { ViewKey } from '@/sdk/define/views/view-key'; export type { + StandaloneViewFieldManifest, ViewFieldGroupManifest, ViewFieldManifest, ViewFilterGroupManifest, diff --git a/packages/twenty-sdk/src/sdk/define/view-fields/__tests__/define-view-field.spec.ts b/packages/twenty-sdk/src/sdk/define/view-fields/__tests__/define-view-field.spec.ts new file mode 100644 index 0000000000..2c9ed516bc --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/view-fields/__tests__/define-view-field.spec.ts @@ -0,0 +1,56 @@ +import { defineViewField } from '@/sdk/define'; +import { type StandaloneViewFieldManifest } from 'twenty-shared/application'; + +const validConfig: StandaloneViewFieldManifest = { + universalIdentifier: '40b17076-ea50-4e42-968e-8989a95f2b5d', + viewUniversalIdentifier: '20202020-a002-4a02-8a02-ae0a1ea11a00', + fieldMetadataUniversalIdentifier: '4e0fd7ff-0bbc-47b2-baab-5fe2c0d12557', + position: 10, + isVisible: true, +}; + +describe('defineViewField', () => { + it('should return successful validation result for a complete config', () => { + const result = defineViewField(validConfig); + + expect(result.success).toBe(true); + expect(result.config).toEqual(validConfig); + expect(result.errors).toEqual([]); + }); + + it('should return error when universalIdentifier is missing', () => { + const { universalIdentifier: _, ...withoutUniversalIdentifier } = + validConfig; + + const result = defineViewField(withoutUniversalIdentifier as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'View field must have a universalIdentifier', + ); + }); + + it('should return error when viewUniversalIdentifier is missing', () => { + const { viewUniversalIdentifier: _, ...withoutViewUniversalIdentifier } = + validConfig; + + const result = defineViewField(withoutViewUniversalIdentifier as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'View field must have a viewUniversalIdentifier', + ); + }); + + it('should return error when fieldMetadataUniversalIdentifier is missing', () => { + const { fieldMetadataUniversalIdentifier: _, ...withoutField } = + validConfig; + + const result = defineViewField(withoutField as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'View field must have a fieldMetadataUniversalIdentifier', + ); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/view-fields/define-view-field.ts b/packages/twenty-sdk/src/sdk/define/view-fields/define-view-field.ts new file mode 100644 index 0000000000..42609374ae --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/view-fields/define-view-field.ts @@ -0,0 +1,24 @@ +import { type StandaloneViewFieldManifest } from 'twenty-shared/application'; + +import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type'; +import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result'; + +export const defineViewField: DefineEntity = ( + config, +) => { + const errors: string[] = []; + + if (!config.universalIdentifier) { + errors.push('View field must have a universalIdentifier'); + } + + if (!config.viewUniversalIdentifier) { + errors.push('View field must have a viewUniversalIdentifier'); + } + + if (!config.fieldMetadataUniversalIdentifier) { + errors.push('View field must have a fieldMetadataUniversalIdentifier'); + } + + return createValidationResult({ config, errors }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts index a37da1a386..4204dc4142 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts @@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService { agents: [], publicAssets: [], views: [], + viewFields: [], navigationMenuItems: [], pageLayouts: [], pageLayoutTabs: [], diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts index 29c6e9ec14..4be337448e 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts @@ -468,6 +468,20 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService { } } + for (const standaloneViewFieldManifest of manifest.viewFields ?? []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromViewFieldManifestToUniversalFlatViewField({ + viewFieldManifest: standaloneViewFieldManifest, + viewUniversalIdentifier: + standaloneViewFieldManifest.viewUniversalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatViewFieldMaps, + }); + } + for (const navigationMenuItemManifest of manifest.navigationMenuItems ?? []) { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts index cb876e9178..e7f09945fd 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts @@ -24,6 +24,7 @@ const buildMinimalManifest = ( agents: [], publicAssets: [], views: [], + viewFields: [], navigationMenuItems: [], pageLayouts: [], pageLayoutTabs: [], diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-cross-app-view-field.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-cross-app-view-field.integration-spec.ts new file mode 100644 index 0000000000..757fc86eaf --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-cross-app-view-field.integration-spec.ts @@ -0,0 +1,109 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import type { FieldManifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType, ViewType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const APP_A_ID = uuidv4(); +const APP_A_ROLE_ID = uuidv4(); +const APP_A_VIEW_ID = uuidv4(); + +const APP_B_ID = uuidv4(); +const APP_B_ROLE_ID = uuidv4(); +const APP_B_FIELD_ID = uuidv4(); +const APP_B_VIEW_FIELD_ID = uuidv4(); + +const PERSON_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.universalIdentifier; + +const appBPersonField: FieldManifest = { + universalIdentifier: APP_B_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'appBContributedColumn', + label: 'App B Contributed Column', + description: 'Custom field App B owns on the standard Person object', + icon: 'IconStar', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, +}; + +describe('Sync application should fail when creating a view field on a view owned by another app', () => { + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: APP_A_ID, + name: 'App A', + description: 'App owning the target view', + sourcePath: 'test-cross-app-view-field-app-a', + }); + + await setupApplicationForSync({ + applicationUniversalIdentifier: APP_B_ID, + name: 'App B', + description: 'App attempting to add a view field on App A view', + sourcePath: 'test-cross-app-view-field-app-b', + }); + + await syncApplication({ + manifest: buildBaseManifest({ + appId: APP_A_ID, + roleId: APP_A_ROLE_ID, + overrides: { + views: [ + { + universalIdentifier: APP_A_VIEW_ID, + name: 'App A View', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconList', + }, + ], + }, + }), + expectToFail: false, + }); + }, 60000); + + afterAll(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: APP_B_ID, + }); + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: APP_A_ID, + }); + }); + + it('rejects a standalone view field from App B targeting an App A view', async () => { + const { errors } = await syncApplication({ + manifest: buildBaseManifest({ + appId: APP_B_ID, + roleId: APP_B_ROLE_ID, + overrides: { + fields: [appBPersonField], + viewFields: [ + { + universalIdentifier: APP_B_VIEW_FIELD_ID, + viewUniversalIdentifier: APP_A_VIEW_ID, + fieldMetadataUniversalIdentifier: APP_B_FIELD_ID, + position: 0, + isVisible: true, + size: 150, + }, + ], + }, + }), + expectToFail: true, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + + const [error] = errors; + + expect(error.extensions.code).toBe('METADATA_VALIDATION_FAILED'); + expect(error.extensions.summary.totalErrors).toBe(1); + expect(error.extensions.summary.viewField).toBe(1); + expect(error.extensions.message).toMatch(/viewField/); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-view-field.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-view-field.integration-spec.ts new file mode 100644 index 0000000000..c141573610 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-view-field.integration-spec.ts @@ -0,0 +1,284 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util'; +import { findViewFields } from 'test/integration/metadata/suites/view-field/utils/find-view-fields.util'; +import { findViews } from 'test/integration/metadata/suites/view/utils/find-views.util'; +import { VIEW_FIELD_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; +import type { FieldManifest } from 'twenty-shared/application'; +import { type Manifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType, ViewKey } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_FIELD_ID = uuidv4(); +const TEST_VIEW_FIELD_ID = uuidv4(); +const TEST_SECOND_VIEW_FIELD_ID = uuidv4(); + +const PERSON_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.universalIdentifier; +const ALL_PEOPLE_VIEW_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.views.allPeople.universalIdentifier; + +const CUSTOM_FIELD_NAME = 'integrationContributedColumn'; + +const personFieldManifest: FieldManifest = { + universalIdentifier: TEST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: CUSTOM_FIELD_NAME, + label: 'Integration Contributed Column', + description: 'Custom field contributed to the standard Person object', + icon: 'IconStar', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, +}; + +const buildManifest = ( + overrides?: Partial>, +) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides, + }); + +const findPersonObject = async () => { + const objects = await findManyObjectMetadataWithIndexes({ + expectToFail: false, + }); + + const person = objects.find( + (object) => + object.universalIdentifier === PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + ); + + if (!person) { + throw new Error('Standard Person object not found in workspace'); + } + + return person; +}; + +const findAllPeopleViewId = async (personObjectId: string) => { + const { data } = await findViews({ + objectMetadataId: personObjectId, + gqlFields: 'id key name', + expectToFail: false, + }); + + const allPeopleView = data?.getViews.find( + (view) => view.key === ViewKey.INDEX, + ); + + if (!allPeopleView) { + throw new Error('Standard allPeople (INDEX) view not found for Person'); + } + + return allPeopleView.id; +}; + +const findAllPeopleViewFields = async (viewId: string) => { + const { data } = await findViewFields({ + viewId, + gqlFields: VIEW_FIELD_GQL_FIELDS, + expectToFail: false, + }); + + return data?.getViewFields ?? []; +}; + +describe('Manifest update - standalone view fields on existing views', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing standalone view field manifest updates', + sourcePath: 'test-manifest-update-view-field', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('attaches a standalone view field to the standard allPeople view without recreating the view', async () => { + const person = await findPersonObject(); + const allPeopleViewId = await findAllPeopleViewId(person.id); + const standardViewFields = await findAllPeopleViewFields(allPeopleViewId); + const standardViewFieldIds = standardViewFields.map( + (viewField) => viewField.id, + ); + + expect(standardViewFields.length).toBeGreaterThan(0); + + const { data, errors } = await syncApplication({ + manifest: buildManifest({ + fields: [personFieldManifest], + viewFields: [ + { + universalIdentifier: TEST_VIEW_FIELD_ID, + viewUniversalIdentifier: ALL_PEOPLE_VIEW_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: TEST_FIELD_ID, + position: 10, + isVisible: true, + size: 150, + }, + ], + }), + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(data?.syncApplication).toBeDefined(); + + const personAfterSync = await findPersonObject(); + const customField = personAfterSync.fieldsList.find( + (field) => field.universalIdentifier === TEST_FIELD_ID, + ); + + expect(customField).toBeDefined(); + + const viewFieldsAfterSync = await findAllPeopleViewFields(allPeopleViewId); + + const contributedViewField = viewFieldsAfterSync.find( + (viewField) => viewField.fieldMetadataId === customField?.id, + ); + + expect(contributedViewField).toBeDefined(); + expect(contributedViewField).toMatchObject({ + viewId: allPeopleViewId, + position: 10, + isVisible: true, + size: 150, + }); + + expect(viewFieldsAfterSync.length).toBe(standardViewFields.length + 1); + for (const standardViewFieldId of standardViewFieldIds) { + expect( + viewFieldsAfterSync.some( + (viewField) => viewField.id === standardViewFieldId, + ), + ).toBe(true); + } + }, 60000); + + it('removes the contributed view field on uninstall while keeping the standard view intact', async () => { + const person = await findPersonObject(); + const allPeopleViewId = await findAllPeopleViewId(person.id); + const standardViewFields = await findAllPeopleViewFields(allPeopleViewId); + const standardViewFieldIds = standardViewFields.map( + (viewField) => viewField.id, + ); + + await syncApplication({ + manifest: buildManifest({ + fields: [personFieldManifest], + viewFields: [ + { + universalIdentifier: TEST_VIEW_FIELD_ID, + viewUniversalIdentifier: ALL_PEOPLE_VIEW_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: TEST_FIELD_ID, + position: 10, + isVisible: true, + size: 150, + }, + ], + }), + expectToFail: false, + }); + + const personAfterSync = await findPersonObject(); + const customFieldId = personAfterSync.fieldsList.find( + (field) => field.universalIdentifier === TEST_FIELD_ID, + )?.id; + + expect(customFieldId).toBeDefined(); + expect( + (await findAllPeopleViewFields(allPeopleViewId)).some( + (viewField) => viewField.fieldMetadataId === customFieldId, + ), + ).toBe(true); + + await uninstallApplication({ + universalIdentifier: TEST_APP_ID, + expectToFail: false, + }); + + const allPeopleViewIdAfterUninstall = await findAllPeopleViewId(person.id); + + expect(allPeopleViewIdAfterUninstall).toBe(allPeopleViewId); + + const viewFieldsAfterUninstall = + await findAllPeopleViewFields(allPeopleViewId); + + expect( + viewFieldsAfterUninstall.some( + (viewField) => viewField.fieldMetadataId === customFieldId, + ), + ).toBe(false); + expect(viewFieldsAfterUninstall.length).toBe(standardViewFields.length); + for (const standardViewFieldId of standardViewFieldIds) { + expect( + viewFieldsAfterUninstall.some( + (viewField) => viewField.id === standardViewFieldId, + ), + ).toBe(true); + } + }, 60000); + + it('rejects two standalone view fields targeting the same field on the same view', async () => { + const { errors } = await syncApplication({ + manifest: buildManifest({ + fields: [personFieldManifest], + viewFields: [ + { + universalIdentifier: TEST_VIEW_FIELD_ID, + viewUniversalIdentifier: ALL_PEOPLE_VIEW_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: TEST_FIELD_ID, + position: 10, + isVisible: true, + }, + { + universalIdentifier: TEST_SECOND_VIEW_FIELD_ID, + viewUniversalIdentifier: ALL_PEOPLE_VIEW_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: TEST_FIELD_ID, + position: 11, + isVisible: true, + }, + ], + }), + expectToFail: true, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].extensions.code).toBe('METADATA_VALIDATION_FAILED'); + }, 60000); + + it('rejects a standalone view field whose target view does not exist', async () => { + const { errors } = await syncApplication({ + manifest: buildManifest({ + fields: [personFieldManifest], + viewFields: [ + { + universalIdentifier: TEST_VIEW_FIELD_ID, + viewUniversalIdentifier: uuidv4(), + fieldMetadataUniversalIdentifier: TEST_FIELD_ID, + position: 10, + isVisible: true, + }, + ], + }), + expectToFail: true, + }); + + expect(errors).toBeDefined(); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].extensions.code).toBe('METADATA_VALIDATION_FAILED'); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts index 1f6acc869f..fcf2283aa1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts @@ -34,6 +34,7 @@ export const buildBaseManifest = ({ frontComponents: [], publicAssets: [], views: [], + viewFields: [], navigationMenuItems: [], pageLayouts: [], pageLayoutTabs: [], diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts index 2c730dc906..3b7b859b53 100644 --- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -8,6 +8,7 @@ export enum SyncableEntity { Agent = 'agent', ConnectionProvider = 'connectionProvider', View = 'view', + ViewField = 'viewField', NavigationMenuItem = 'navigationMenuItem', PageLayout = 'pageLayout', PageLayoutTab = 'pageLayoutTab', diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 4feb6130e2..b58faed2f2 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -75,6 +75,7 @@ export type { ToolTriggerSettings } from './toolTriggerSettingsType'; export type { ViewManifestFilterValue, ViewFieldManifest, + StandaloneViewFieldManifest, ViewFilterManifest, ViewFilterGroupManifest, ViewGroupManifest, diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts index 8c7bea1cfd..2deb3ac24c 100644 --- a/packages/twenty-shared/src/application/manifestType.ts +++ b/packages/twenty-shared/src/application/manifestType.ts @@ -18,7 +18,10 @@ import { import { type PermissionFlagManifest } from './permissionFlagManifestType'; import { type RoleManifest } from './roleManifestType'; import { type SkillManifest } from './skillManifestType'; -import { type ViewManifest } from './viewManifestType'; +import { + type StandaloneViewFieldManifest, + type ViewManifest, +} from './viewManifestType'; export type Manifest = { application: ApplicationManifest; @@ -34,6 +37,7 @@ export type Manifest = { connectionProviders?: ConnectionProviderManifest[]; publicAssets: AssetManifest[]; views: ViewManifest[]; + viewFields: StandaloneViewFieldManifest[]; navigationMenuItems: NavigationMenuItemManifest[]; pageLayouts: PageLayoutManifest[]; pageLayoutTabs: PageLayoutTabManifest[]; diff --git a/packages/twenty-shared/src/application/viewManifestType.ts b/packages/twenty-shared/src/application/viewManifestType.ts index a728f89e13..103c07dab3 100644 --- a/packages/twenty-shared/src/application/viewManifestType.ts +++ b/packages/twenty-shared/src/application/viewManifestType.ts @@ -1,10 +1,10 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; import { - type ViewKey, type AggregateOperations, type ViewCalendarLayout, type ViewFilterGroupLogicalOperator, type ViewFilterOperand, + type ViewKey, type ViewOpenRecordIn, type ViewSortDirection, type ViewType, @@ -27,6 +27,10 @@ export type ViewFieldManifest = SyncableEntityOptions & { viewFieldGroupUniversalIdentifier?: string; }; +export type StandaloneViewFieldManifest = ViewFieldManifest & { + viewUniversalIdentifier: string; +}; + export type ViewFilterManifest = SyncableEntityOptions & { fieldMetadataUniversalIdentifier: string; operand: ViewFilterOperand;