fix: #19173 correct labels and icons for custom object default relations (#19224)

**### Problem**
When creating a custom Data Model object, the auto-generated Note and
Task relations had incorrect labels ("Note Targets", "Task Targets") and
a wrong hardcoded icon (IconBuildingSkyscraper).

Expected behavior is to use user-friendly labels ("Notes", "Tasks") and
proper icons, consistent with standard objects like Company and Person.

**Root causes:**

* `icon` in `createFieldInput` was hardcoded to
`'IconBuildingSkyscraper'`
* `label` was derived from `targetFlatObjectMetadata.labelPlural`, which
returns system labels (e.g., "Note Targets") instead of display labels

---

**Fix**

* Added `sourceFieldOverridesByRelationObjectNameSingular` map to define
correct labels and icons for all default relation types
* Ensures consistency with standard objects

Mappings:

* noteTarget: "Note Targets" → "Notes", IconBuildingSkyscraper →
IconNotes

* taskTarget: "Task Targets" → "Tasks", IconBuildingSkyscraper →
IconCheckbox

* attachment: "Attachments" → "Attachments", IconBuildingSkyscraper →
IconFileImport

* timelineActivity: "Timeline Activities" → "Timeline Activities",
IconBuildingSkyscraper → IconTimelineEvent

* favorite: "Favorites" → "Favorites", IconBuildingSkyscraper →
IconHeart

* Added type safety using:
`satisfies Record<(typeof
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS)[number], ...>`
  This ensures new default relations must be explicitly defined

* Renamed variable:
  `icon` → `targetFieldIcon`
  for better clarity (it is only used for the target field)

---

**Limitations**

* Applies only to newly created custom objects
* Existing objects will keep incorrect labels/icons
* Requires a separate data migration to fix existing data

---

**Testing**

1. Go to Settings → Data Model
2. Create a new custom object
3. Verify:

   * Labels show "Notes" and "Tasks" (not "Note Targets"/"Task Targets")
   * Icons match those used in standard objects (e.g., Company, Person)


---

## Update (reworked while merging main)

The original approach was reworked:

- The label/icon mapping now lives in a shared
`STANDARD_RELATION_FIELD_PROPERTIES_BY_RELATION_OBJECT` constant
(`msg`-based, so labels stay translatable), used as the single source of
truth. Dropped the unused `favorite` entry.
- Standard objects now reference that same constant explicitly at each
call site (uniformization) instead of duplicating the values. Objects
that intentionally differ keep their explicit overrides: note/task →
`Relations`, person/workspaceMember → `Events`, workflow attachments →
`IconFileUpload`.
- Fixed an unrelated typo found along the way: Company's
`timelineActivities` icon was `IconIconTimelineEvent`.
- For the history (supersedes the "Limitations" above): added a `2.9.0`
workspace upgrade command
`upgrade:2-9:fix-standard-relation-field-labels-icons` that re-syncs
**standard** objects' default relation labels/icons against the source
of truth. It deliberately leaves **custom** objects untouched — their
relation fields are user-editable and must not be overwritten by an
upgrade.

## Testing / Verification

Verified locally end-to-end:

**New custom objects**
- Created a custom object via the Data Model UI and via the metadata API
— its note/task/attachment/timeline relations now show `Notes` / `Tasks`
/ `Attachments` / `Timeline Activities` with the correct icons instead
of `Note Targets` + `IconBuildingSkyscraper`.

**Standard uniformization (value-preserving)**
- Re-seeded a workspace on this branch and inspected all 25
default-relation field definitions across the 10 standard objects: every
canonical value is unchanged, every intentional variant (Relations /
Events / IconFileUpload) is preserved, and the only diff vs `main` is
the Company `IconIconTimelineEvent` → `IconTimelineEvent` fix.

**Upgrade command (existing workspaces)**
- Simulated a real upgrade: seeded a workspace on `main` (Company icon
typo present), created a custom object via the metadata API (it came out
with the old buggy labels, as expected on `main`), then switched to this
branch and ran the command.
- Confirmed via both the metadata API and direct DB inspection:
Company's standard `timelineActivities` icon healed to
`IconTimelineEvent`, while the custom object's relations were left
untouched.
- Idempotent: re-running reports "already up to date".

**CI**: typecheck, lint, server unit tests, and all server
integration-test shards green.

---------

Co-authored-by: Manish Kumar <manishkumar@Mac.lan>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Manish Kumar
2026-06-15 00:36:58 +05:30
committed by GitHub
parent a3fe9efb69
commit 25b0e4d81c
15 changed files with 314 additions and 45 deletions
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { FixStandardRelationFieldLabelsIconsCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000040000-fix-standard-relation-field-labels-icons.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [FixStandardRelationFieldLabelsIconsCommand],
})
export class V2_14_UpgradeVersionCommandModule {}
@@ -0,0 +1,148 @@
import { Command } from 'nest-commander';
import {
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
STANDARD_OBJECTS,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
// The default relation fields every object gets point to one of the default
// relation objects (note/task/attachment/timeline). On standard objects their
// labels/icons are system-owned, so we re-sync any drift against the
// source-of-truth definition (e.g. the Company timelineActivities
// IconIconTimelineEvent typo). Custom objects are left untouched on purpose:
// their relation fields are user-editable.
const DEFAULT_RELATION_TARGET_UNIVERSAL_IDENTIFIERS = new Set<string>(
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map(
(objectName) => STANDARD_OBJECTS[objectName].universalIdentifier,
),
);
@RegisteredWorkspaceCommand('2.14.0', 1799000040000)
@Command({
name: 'upgrade:2-14:fix-standard-relation-field-labels-icons',
description:
"Re-sync standard objects' default relation field labels/icons (note/task/attachment/timeline) against the source of truth, healing drift such as the Company timelineActivities IconIconTimelineEvent typo.",
})
export class FixStandardRelationFieldLabelsIconsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatFieldMetadataMaps: existingFlatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const now = new Date().toISOString();
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now,
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const fieldsToUpdate = Object.values(
standardAllFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(standardField) =>
isDefined(
standardField.relationTargetObjectMetadataUniversalIdentifier,
) &&
DEFAULT_RELATION_TARGET_UNIVERSAL_IDENTIFIERS.has(
standardField.relationTargetObjectMetadataUniversalIdentifier,
),
)
.map((standardField) => {
const existingField =
existingFlatFieldMetadataMaps.byUniversalIdentifier[
standardField.universalIdentifier
];
if (
!isDefined(existingField) ||
(existingField.label === standardField.label &&
existingField.icon === standardField.icon)
) {
return undefined;
}
return {
...existingField,
label: standardField.label,
icon: standardField.icon,
updatedAt: now,
};
})
.filter(isDefined);
if (fieldsToUpdate.length === 0) {
this.logger.log(
`Standard relation field labels/icons already up to date for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Workspace ${workspaceId}: ${fieldsToUpdate.length} standard relation field(s) to heal`,
);
if (isDryRun) {
return;
}
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: fieldsToUpdate,
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (result.status === 'fail') {
throw new Error(
`Migration failed for workspace ${workspaceId} while healing standard relation field labels/icons`,
);
}
this.logger.log(
`Healed ${fieldsToUpdate.length} standard relation field(s) for workspace ${workspaceId}`,
);
}
}
@@ -14,6 +14,7 @@ import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-13/2-13-upgrade-version-command.module';
import { V2_14_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-14/2-14-upgrade-version-command.module';
@Module({
imports: [
@@ -31,6 +32,7 @@ import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_9_UpgradeVersionCommandModule,
V2_10_UpgradeVersionCommandModule,
V2_13_UpgradeVersionCommandModule,
V2_14_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}