128d2d394d
## 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)
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
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);
|
|
});
|