feat: allow apps to add view fields to existing views (defineViewField) (#21160)
## 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)
This commit is contained in:
+1
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
+1
@@ -314,6 +314,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
},
|
||||
],
|
||||
views: [],
|
||||
viewFields: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
|
||||
+10
-1
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+1
@@ -82,6 +82,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
|
||||
"defineRole",
|
||||
"defineSkill",
|
||||
"defineView",
|
||||
"defineViewField",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
+31
@@ -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);
|
||||
});
|
||||
+1
@@ -37,6 +37,7 @@ const validManifest: Manifest = {
|
||||
agents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
viewFields: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
|
||||
@@ -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<StandaloneViewFieldManifest>({
|
||||
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<NavigationMenuItemManifest>({
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
|
||||
skills: SyncableEntity.Skill,
|
||||
connectionProviders: SyncableEntity.ConnectionProvider,
|
||||
views: SyncableEntity.View,
|
||||
viewFields: SyncableEntity.ViewField,
|
||||
navigationMenuItems: SyncableEntity.NavigationMenuItem,
|
||||
pageLayouts: SyncableEntity.PageLayout,
|
||||
pageLayoutTabs: SyncableEntity.PageLayoutTab,
|
||||
|
||||
@@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[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',
|
||||
|
||||
@@ -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.<fill-later>.views.<fill-later>.universalIdentifier',
|
||||
// The universalIdentifier of the field to display in that view
|
||||
fieldMetadataUniversalIdentifier: '<fill-later>',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type FieldManifest,
|
||||
type NavigationMenuItemManifest,
|
||||
type SkillManifest,
|
||||
type StandaloneViewFieldManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export type ValidationResult<T> = {
|
||||
@@ -40,6 +41,7 @@ export type DefinableEntity =
|
||||
| RoleConfig
|
||||
| SkillManifest
|
||||
| ViewConfig
|
||||
| StandaloneViewFieldManifest
|
||||
| NavigationMenuItemManifest
|
||||
| PageLayoutConfig
|
||||
| PageLayoutTabConfig
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<StandaloneViewFieldManifest> = (
|
||||
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 });
|
||||
};
|
||||
+1
@@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService {
|
||||
agents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
viewFields: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
|
||||
+14
@@ -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({
|
||||
|
||||
+1
@@ -24,6 +24,7 @@ const buildMinimalManifest = (
|
||||
agents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
viewFields: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
|
||||
+109
@@ -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);
|
||||
});
|
||||
+284
@@ -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<Pick<Manifest, 'fields' | 'viewFields'>>,
|
||||
) =>
|
||||
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);
|
||||
});
|
||||
+1
@@ -34,6 +34,7 @@ export const buildBaseManifest = ({
|
||||
frontComponents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
viewFields: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum SyncableEntity {
|
||||
Agent = 'agent',
|
||||
ConnectionProvider = 'connectionProvider',
|
||||
View = 'view',
|
||||
ViewField = 'viewField',
|
||||
NavigationMenuItem = 'navigationMenuItem',
|
||||
PageLayout = 'pageLayout',
|
||||
PageLayoutTab = 'pageLayoutTab',
|
||||
|
||||
@@ -75,6 +75,7 @@ export type { ToolTriggerSettings } from './toolTriggerSettingsType';
|
||||
export type {
|
||||
ViewManifestFilterValue,
|
||||
ViewFieldManifest,
|
||||
StandaloneViewFieldManifest,
|
||||
ViewFilterManifest,
|
||||
ViewFilterGroupManifest,
|
||||
ViewGroupManifest,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user