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:
martmull
2026-06-05 10:06:30 +02:00
committed by GitHub
parent ff9b5a5cad
commit 128d2d394d
27 changed files with 617 additions and 4 deletions
@@ -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,
});
@@ -314,6 +314,7 @@ export const EXPECTED_MANIFEST: Manifest = {
},
],
views: [],
viewFields: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
@@ -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);
@@ -82,6 +82,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"defineRole",
"defineSkill",
"defineView",
"defineViewField",
],
}
`;
@@ -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);
});
@@ -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 });
};
@@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService {
agents: [],
publicAssets: [],
views: [],
viewFields: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
@@ -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({
@@ -24,6 +24,7 @@ const buildMinimalManifest = (
agents: [],
publicAssets: [],
views: [],
viewFields: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
@@ -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);
});
@@ -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);
});
@@ -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;