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
@@ -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,
|
||||
});
|
||||
`;
|
||||
};
|
||||
Reference in New Issue
Block a user