feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary Today the avatar/icon shown for a record is hardcoded per object — Company pulls a favicon from its domain link, Person uses `avatarUrl`, etc. This PR replaces that hardcoding with a generic, data-driven abstraction based on a configurable **image identifier field** on each object's metadata (mirroring the existing **label identifier** concept). An object's image identifier can point to: - a **`FILES`** field → the uploaded image is used directly (rounded avatar), or - a **`LINKS`** field → a favicon is derived from the primary URL via the Twenty icons service (squared avatar), gated by `ALLOW_REQUESTS_TO_TWENTY_ICONS`. This lets any object type (Opportunity, a custom "Listing", etc.) define its own avatar/icon without code changes, and makes the field configurable/overridable for standard objects. ## ❓ Open question: also allow `TEXT` → direct image URL? Right now the image identifier is restricted to `FILES` (uploaded file) and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image URL** (e.g. an imported/synced photo URL stored in a text field). There's precedent for it — Person's avatar was originally a `TEXT` `avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a `TEXT` field has no favicon-vs-image ambiguity, and selecting it as the image identifier is itself the declaration of intent). It's a small, clean extension: - add `TEXT` to the allowed image-identifier types, - add an explicit `TEXT → raw URL` case - `getAvatarType`: `TEXT → rounded`. Caveats: it relies on admin assertion that the text values are image URLs (no data-level guarantee), and external image URLs load third-party content in the browser (IP-leak/hotlinking, same as favicons — a proxy/cache would be the more robust long-term answer). ### ✅ Resolution Decision: **we will not support `TEXT` as an image identifier.** Image identifiers stay restricted to `FILES` and `LINKS`, and any other type fails closed (returns no avatar) on both the frontend and backend. Instead, the legacy items that still rely on a `TEXT` avatar — Person's deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember remains an exception (its `avatarUrl` still resolves through the existing CorePicture path), and legacy Person `avatarUrl` values that haven't been migrated will show initials placeholders. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
@@ -33,6 +33,7 @@ exports[`registry-derived override property maps derives the overridable propert
|
||||
"icon",
|
||||
"labelPlural",
|
||||
"labelSingular",
|
||||
"imageIdentifierFieldMetadataId",
|
||||
],
|
||||
"objectPermission": [],
|
||||
"pageLayout": [],
|
||||
|
||||
+1
@@ -279,6 +279,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toStringify: false,
|
||||
// @ts-expect-error remove once https://github.com/twentyhq/core-team-issues/issues/2172 has been resolved
|
||||
universalProperty: 'imageIdentifierFieldMetadataUniversalIdentifier',
|
||||
isOverridable: true,
|
||||
},
|
||||
targetTableName: {
|
||||
toCompare: false,
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
|
||||
'namePlural',
|
||||
'nameSingular',
|
||||
'labelIdentifierFieldMetadataId',
|
||||
'imageIdentifierFieldMetadataId',
|
||||
],
|
||||
standard: [
|
||||
'color',
|
||||
@@ -22,6 +23,7 @@ export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = {
|
||||
'isSearchable',
|
||||
'labelPlural',
|
||||
'labelSingular',
|
||||
'imageIdentifierFieldMetadataId',
|
||||
],
|
||||
} as const satisfies Record<
|
||||
'standard' | 'custom',
|
||||
|
||||
+60
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isDefined,
|
||||
isImageIdentifierFieldMetadataType,
|
||||
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
@@ -63,6 +64,52 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
|
||||
);
|
||||
}
|
||||
|
||||
const requestedImageIdentifierFieldMetadataId =
|
||||
rawUpdateObjectInput.update.imageIdentifierFieldMetadataId;
|
||||
|
||||
if (isDefined(requestedImageIdentifierFieldMetadataId)) {
|
||||
const imageIdentifierFlatFieldMetadata =
|
||||
findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: requestedImageIdentifierFieldMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(imageIdentifierFlatFieldMetadata)) {
|
||||
throw new ObjectMetadataException(
|
||||
'Field declared as image identifier not found',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
imageIdentifierFlatFieldMetadata.objectMetadataId !==
|
||||
existingFlatObjectMetadata.id
|
||||
) {
|
||||
throw new ObjectMetadataException(
|
||||
'Field declared as image identifier does not belong to this object',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isImageIdentifierFieldMetadataType(
|
||||
imageIdentifierFlatFieldMetadata.type,
|
||||
)
|
||||
) {
|
||||
throw new ObjectMetadataException(
|
||||
'Field cannot be used as image identifier due to its type: should be of type Files or Links',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageIdentifierFlatFieldMetadata.isActive) {
|
||||
throw new ObjectMetadataException(
|
||||
'Field cannot be used as image identifier because it is deactivated',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isStandardObject = belongsToTwentyStandardApp(
|
||||
existingFlatObjectMetadata,
|
||||
);
|
||||
@@ -97,6 +144,19 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities =
|
||||
flatFieldMetadata?.universalIdentifier;
|
||||
}
|
||||
|
||||
if ('imageIdentifierFieldMetadataId' in updatedEditableObjectProperties) {
|
||||
const { imageIdentifierFieldMetadataId } =
|
||||
updatedEditableObjectProperties;
|
||||
|
||||
toFlatObjectMetadata.imageIdentifierFieldMetadataUniversalIdentifier =
|
||||
isDefined(imageIdentifierFieldMetadataId)
|
||||
? findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: imageIdentifierFieldMetadataId,
|
||||
}).universalIdentifier
|
||||
: null;
|
||||
}
|
||||
|
||||
const {
|
||||
flatIndexMetadatasToUpdate,
|
||||
flatViewFieldsToCreate,
|
||||
|
||||
+5
@@ -6,6 +6,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
|
||||
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
|
||||
@@ -22,6 +23,7 @@ export class NavigationMenuItemRecordIdentifierService {
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async resolveRecordIdentifier({
|
||||
@@ -125,6 +127,9 @@ export class NavigationMenuItemRecordIdentifierService {
|
||||
record,
|
||||
flatObjectMetadata: objectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
allowRequestsToTwentyIcons: this.twentyConfigService.get(
|
||||
'ALLOW_REQUESTS_TO_TWENTY_ICONS',
|
||||
),
|
||||
signUrl: (fileId: string, fileFolder: FileFolder) =>
|
||||
this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
|
||||
+12
-10
@@ -7,12 +7,10 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
|
||||
|
||||
const ID_FIELD = 'id' as const;
|
||||
|
||||
const COMPANY_AVATAR_COLUMN = 'domainNamePrimaryLinkUrl' as const;
|
||||
const PERSON_AVATAR_COLUMN = 'avatarFile' as const;
|
||||
|
||||
export const getMinimalSelectForRecordIdentifier = ({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
@@ -47,15 +45,19 @@ export const getMinimalSelectForRecordIdentifier = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (flatObjectMetadata.nameSingular === 'company') {
|
||||
selectColumns.push(COMPANY_AVATAR_COLUMN);
|
||||
//TODO: Temporary solution before imageIdentifier refactor
|
||||
} else if (flatObjectMetadata.nameSingular === 'person') {
|
||||
selectColumns.push(PERSON_AVATAR_COLUMN);
|
||||
} else if (isDefined(flatObjectMetadata.imageIdentifierFieldMetadataId)) {
|
||||
if (flatObjectMetadata.nameSingular === 'workspaceMember') {
|
||||
selectColumns.push('avatarUrl');
|
||||
|
||||
return selectColumns;
|
||||
}
|
||||
|
||||
const imageIdentifierFieldMetadataId =
|
||||
getEffectiveImageIdentifierFieldMetadataId(flatObjectMetadata);
|
||||
|
||||
if (isDefined(imageIdentifierFieldMetadataId)) {
|
||||
const imageField = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
flatEntityId: flatObjectMetadata.imageIdentifierFieldMetadataId,
|
||||
flatEntityId: imageIdentifierFieldMetadataId,
|
||||
});
|
||||
|
||||
if (isDefined(imageField)) {
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export class UpdateObjectPayload {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
imageIdentifierFieldMetadataId?: string;
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
+11
-3
@@ -9,8 +9,8 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
@@ -28,12 +28,13 @@ import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadat
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { ObjectRecordCountDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-record-count.dto';
|
||||
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { getEffectiveImageIdentifierFieldMetadataId } from 'src/engine/metadata-modules/object-metadata/utils/get-effective-image-identifier-field-metadata-id.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { ObjectRecordCountService } from 'src/engine/metadata-modules/object-metadata/object-record-count.service';
|
||||
import { SearchFieldMetadataDTO } from 'src/engine/metadata-modules/search-field-metadata/dtos/search-field-metadata.dto';
|
||||
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
|
||||
import { resolveEffectiveEntityProperty } from 'src/engine/metadata-modules/utils/resolve-effective-entity-property.util';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { SearchFieldMetadataDTO } from 'src/engine/metadata-modules/search-field-metadata/dtos/search-field-metadata.dto';
|
||||
import { resolveEffectiveEntityProperty } from 'src/engine/metadata-modules/utils/resolve-effective-entity-property.util';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@MetadataResolver(() => ObjectMetadataDTO)
|
||||
@@ -177,6 +178,13 @@ export class ObjectMetadataResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => UUIDScalarType, { nullable: true })
|
||||
imageIdentifierFieldMetadataId(
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
): string | null {
|
||||
return getEffectiveImageIdentifierFieldMetadataId(objectMetadata);
|
||||
}
|
||||
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
|
||||
@Mutation(() => ObjectMetadataDTO)
|
||||
async createOneObject(
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export type ObjectMetadataOverrides = {
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
translations?: Partial<
|
||||
Record<
|
||||
keyof typeof APP_LOCALES,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectMetadataOverrides } from 'src/engine/metadata-modules/object-metadata/types/object-metadata-overrides.type';
|
||||
|
||||
type ImageIdentifierResolvableObjectMetadata = {
|
||||
overrides?: ObjectMetadataOverrides | null;
|
||||
imageIdentifierFieldMetadataId?: string | null;
|
||||
};
|
||||
|
||||
export const getEffectiveImageIdentifierFieldMetadataId = (
|
||||
objectMetadata: ImageIdentifierResolvableObjectMetadata,
|
||||
): string | null => {
|
||||
const { overrides } = objectMetadata;
|
||||
|
||||
if (isDefined(overrides) && 'imageIdentifierFieldMetadataId' in overrides) {
|
||||
return overrides.imageIdentifierFieldMetadataId ?? null;
|
||||
}
|
||||
|
||||
return objectMetadata.imageIdentifierFieldMetadataId ?? null;
|
||||
};
|
||||
Reference in New Issue
Block a user