feat: configure standard views and migrate attachment seeds to FILES field (#17958)
## Summary - Add default visible view fields for `timelineActivity`, `attachment`, `noteTarget`, `taskTarget`, and `workspaceMember` objects so they display useful columns out of the box - Standardize morph relation field labels to "Target" with `IconArrowUpRight` for consistency across all pivot/junction tables - Mark deprecated fields (`fullPath`, `fileCategory`, `linkedRecordCachedName`, `linkedRecordId`, `linkedObjectMetadataId`) as `isSystem` to hide them from the UI column picker - Fix morph field deduplication logic (`pickMorphGroupSurvivor`) to prefer active, non-system fields over auto-generated system fields from custom objects - Migrate attachment seeds from legacy `fullPath`/`fileCategory` to the new `FILES` field type, creating proper `FileEntity` records in `core.file` via `fileStorageService.writeFile()` - Restore `customDomain` in the user query fragment <img width="825" height="754" alt="Screenshot 2026-02-15 at 15 44 27" src="https://github.com/user-attachments/assets/9596a3dd-8d3a-43c0-925a-0adef9ee68a8" /> <img width="736" height="731" alt="Screenshot 2026-02-15 at 15 44 13" src="https://github.com/user-attachments/assets/cd1a66c5-731d-43e6-bbc3-703cbeda1652" /> <img width="722" height="757" alt="Screenshot 2026-02-15 at 15 44 03" src="https://github.com/user-attachments/assets/b5210546-6a40-4940-8e4f-874818a614fb" /> <img width="907" height="757" alt="Screenshot 2026-02-15 at 15 43 52" src="https://github.com/user-attachments/assets/ead5b9a8-1989-4d68-9640-583da6233711" /> <img width="1002" height="731" alt="Screenshot 2026-02-15 at 15 43 38" src="https://github.com/user-attachments/assets/38accb8c-f5d5-4bfc-b245-06389849810b" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches migration/upgrade commands that write to core metadata tables and adjust field/view definitions, plus changes dev seeding to create `core.file` records; mistakes could affect UI visibility or seed integrity across workspaces. > > **Overview** > Adds a new `upgrade:1-18:backfill-standard-views-and-field-metadata` command that, per workspace, marks specific fields as `isSystem`, normalizes morph-relation field `label`/`icon` to `Target`/`IconArrowUpRight`, and backfills missing standard `view`/`viewField` rows for `attachment`, `noteTarget`, `taskTarget`, `timelineActivity`, and `workspaceMember`, followed by cache invalidation + metadata version bump. > > Refactors morph-relation deduplication to pick a single survivor per `morphId` using a new `pickMorphGroupSurvivor` rule (prefer active + non-system, then smallest id), with new unit tests. > > Updates standard metadata generators and snapshots to reflect the new system flags and default view fields, and rewrites attachment dev seeding to populate the new `file` (FILES field) JSON and create corresponding `core.file` entries via `FileStorageService.writeFile` with workspace-scoped file IDs. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b1939bbf6f8cce294f9b4cdec06b19778daa205e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { filterMorphRelationDuplicateFields } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
const makeMorphField = (
|
||||
overrides: Partial<FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>> & {
|
||||
id: string;
|
||||
morphId: string;
|
||||
},
|
||||
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> =>
|
||||
({
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
|
||||
|
||||
const makeTextField = (id: string): FlatFieldMetadata<FieldMetadataType.TEXT> =>
|
||||
({
|
||||
id,
|
||||
type: FieldMetadataType.TEXT,
|
||||
}) as FlatFieldMetadata<FieldMetadataType.TEXT>;
|
||||
|
||||
describe('filterMorphRelationDuplicateFields', () => {
|
||||
it('should return all fields when there are no morph fields', () => {
|
||||
const fields = [makeTextField('t1'), makeTextField('t2')];
|
||||
|
||||
expect(filterMorphRelationDuplicateFields(fields)).toEqual(fields);
|
||||
});
|
||||
|
||||
it('should return all fields when morph fields have distinct morphIds', () => {
|
||||
const morph1 = makeMorphField({ id: 'a', morphId: 'morph-1' });
|
||||
const morph2 = makeMorphField({ id: 'b', morphId: 'morph-2' });
|
||||
const text = makeTextField('t1');
|
||||
|
||||
const result = filterMorphRelationDuplicateFields([morph1, text, morph2]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toContain(text);
|
||||
expect(result).toContain(morph1);
|
||||
expect(result).toContain(morph2);
|
||||
});
|
||||
|
||||
it('should deduplicate morph fields sharing the same morphId', () => {
|
||||
const standard = makeMorphField({
|
||||
id: 'b',
|
||||
morphId: 'morph-1',
|
||||
isSystem: false,
|
||||
});
|
||||
const system = makeMorphField({
|
||||
id: 'a',
|
||||
morphId: 'morph-1',
|
||||
isSystem: true,
|
||||
});
|
||||
const text = makeTextField('t1');
|
||||
|
||||
const result = filterMorphRelationDuplicateFields([system, text, standard]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(text);
|
||||
expect(result).toContain(standard);
|
||||
expect(result).not.toContain(system);
|
||||
});
|
||||
|
||||
it('should handle multiple morph groups independently', () => {
|
||||
const group1Best = makeMorphField({
|
||||
id: 'a',
|
||||
morphId: 'morph-1',
|
||||
isSystem: false,
|
||||
});
|
||||
const group1Dup = makeMorphField({
|
||||
id: 'b',
|
||||
morphId: 'morph-1',
|
||||
isSystem: true,
|
||||
});
|
||||
const group2Best = makeMorphField({
|
||||
id: 'c',
|
||||
morphId: 'morph-2',
|
||||
isSystem: false,
|
||||
});
|
||||
const group2Dup = makeMorphField({
|
||||
id: 'd',
|
||||
morphId: 'morph-2',
|
||||
isSystem: true,
|
||||
});
|
||||
|
||||
const result = filterMorphRelationDuplicateFields([
|
||||
group1Dup,
|
||||
group2Dup,
|
||||
group1Best,
|
||||
group2Best,
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(group1Best);
|
||||
expect(result).toContain(group2Best);
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(filterMorphRelationDuplicateFields([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
|
||||
|
||||
const makeMorphField = (
|
||||
overrides: Partial<FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>> & {
|
||||
id: string;
|
||||
},
|
||||
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> =>
|
||||
({
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
morphId: 'morph-1',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
|
||||
|
||||
describe('pickMorphGroupSurvivor', () => {
|
||||
it('should return the only field when group has one element', () => {
|
||||
const field = makeMorphField({ id: 'a' });
|
||||
|
||||
expect(pickMorphGroupSurvivor([field])).toBe(field);
|
||||
});
|
||||
|
||||
it('should prefer active non-system over active system', () => {
|
||||
const standard = makeMorphField({
|
||||
id: 'b',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const system = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([system, standard])).toBe(standard);
|
||||
});
|
||||
|
||||
it('should prefer active over inactive', () => {
|
||||
const active = makeMorphField({
|
||||
id: 'b',
|
||||
isActive: true,
|
||||
isSystem: true,
|
||||
});
|
||||
const inactive = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([inactive, active])).toBe(active);
|
||||
});
|
||||
|
||||
it('should break ties by smallest id', () => {
|
||||
const fieldA = makeMorphField({
|
||||
id: 'aaa',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const fieldB = makeMorphField({
|
||||
id: 'bbb',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([fieldB, fieldA])).toBe(fieldA);
|
||||
});
|
||||
|
||||
it('should prefer active+non-system (score 3) over inactive+non-system (score 1)', () => {
|
||||
const best = makeMorphField({
|
||||
id: 'z',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
});
|
||||
const worse = makeMorphField({
|
||||
id: 'a',
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
expect(pickMorphGroupSurvivor([worse, best])).toBe(best);
|
||||
});
|
||||
});
|
||||
+30
-40
@@ -2,52 +2,42 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
|
||||
|
||||
export const filterMorphRelationDuplicateFields = (
|
||||
flatFieldMetadatas: FlatFieldMetadata[],
|
||||
): FlatFieldMetadata[] => {
|
||||
const initialAccumulator: {
|
||||
morphFlatFieldMetadatas: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[];
|
||||
otherFlatFieldMetadatas: FlatFieldMetadata[];
|
||||
} = {
|
||||
morphFlatFieldMetadatas: [],
|
||||
otherFlatFieldMetadatas: [],
|
||||
};
|
||||
const { morphFlatFieldMetadatas, otherFlatFieldMetadatas } =
|
||||
flatFieldMetadatas.reduce((acc, flatFieldMetadata) => {
|
||||
if (
|
||||
isFlatFieldMetadataOfType(
|
||||
flatFieldMetadata,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
...acc,
|
||||
morphFlatFieldMetadatas: [
|
||||
...acc.morphFlatFieldMetadatas,
|
||||
flatFieldMetadata,
|
||||
],
|
||||
};
|
||||
}
|
||||
const otherFlatFieldMetadatas: FlatFieldMetadata[] = [];
|
||||
const morphGroupsByMorphId = new Map<
|
||||
string,
|
||||
FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[]
|
||||
>();
|
||||
|
||||
return {
|
||||
...acc,
|
||||
otherFlatFieldMetadatas: [
|
||||
...acc.otherFlatFieldMetadatas,
|
||||
flatFieldMetadata,
|
||||
],
|
||||
};
|
||||
}, initialAccumulator);
|
||||
for (const flatFieldMetadata of flatFieldMetadatas) {
|
||||
if (
|
||||
isFlatFieldMetadataOfType(
|
||||
flatFieldMetadata,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
)
|
||||
) {
|
||||
const existing =
|
||||
morphGroupsByMorphId.get(flatFieldMetadata.morphId) ?? [];
|
||||
|
||||
const filteredMorphFlatFieldMetadatas = morphFlatFieldMetadatas.filter(
|
||||
(currentField) =>
|
||||
!morphFlatFieldMetadatas.some(
|
||||
(otherField) =>
|
||||
currentField.id !== otherField.id &&
|
||||
otherField.morphId === currentField.morphId &&
|
||||
otherField.id < currentField.id,
|
||||
),
|
||||
);
|
||||
morphGroupsByMorphId.set(flatFieldMetadata.morphId, [
|
||||
...existing,
|
||||
flatFieldMetadata,
|
||||
]);
|
||||
} else {
|
||||
otherFlatFieldMetadatas.push(flatFieldMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMorphFlatFieldMetadatas: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[] =
|
||||
[];
|
||||
|
||||
for (const group of morphGroupsByMorphId.values()) {
|
||||
filteredMorphFlatFieldMetadatas.push(pickMorphGroupSurvivor(group));
|
||||
}
|
||||
|
||||
return [...otherFlatFieldMetadatas, ...filteredMorphFlatFieldMetadatas];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
// Prefers active non-system fields (standard targets) over system ones
|
||||
// (auto-created for custom objects). Smallest id breaks ties.
|
||||
const scoreMorphField = (
|
||||
field: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>,
|
||||
): number => (field.isActive ? 2 : 0) + (field.isSystem ? 0 : 1);
|
||||
|
||||
export const pickMorphGroupSurvivor = (
|
||||
group: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>[],
|
||||
): FlatFieldMetadata<FieldMetadataType.MORPH_RELATION> => {
|
||||
return group.reduce((best, current) => {
|
||||
const diff = scoreMorphField(current) - scoreMorphField(best);
|
||||
|
||||
return diff > 0 || (diff === 0 && current.id < best.id) ? current : best;
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user