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:
@@ -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