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
@@ -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,
});
`;
};