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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user