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:
Abdul Rahman
2026-07-15 19:15:47 +05:30
committed by GitHub
parent e31e6c7794
commit f4ff234db8
58 changed files with 1986 additions and 430 deletions
@@ -2,6 +2,7 @@ import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { useGetIsMetadataItemCustom } from '@/object-metadata/hooks/useGetIsMetadataItemCustom';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
@@ -10,10 +11,10 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
import { zodResolver } from '@hookform/resolvers/zod';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import {
isImageIdentifierFieldMetadataType,
isLabelIdentifierFieldMetadataTypes,
isSearchableFieldType,
} from 'twenty-shared/utils';
@@ -67,9 +68,18 @@ export const SettingsDataModelObjectIdentifiersForm = ({
const handleSave = async (
formValues: SettingsDataModelObjectIdentifiersFormValues,
) => {
const {
labelIdentifierFieldMetadataId: _labelIdentifierFieldMetadataId,
...payloadWithoutLabelIdentifier
} = formValues;
const updatePayload = isCustomObject
? formValues
: payloadWithoutLabelIdentifier;
const result = await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: formValues,
updatePayload,
});
if (result.status === 'successful') {
@@ -78,23 +88,25 @@ export const SettingsDataModelObjectIdentifiersForm = ({
};
const { getIcon } = useIcons();
const labelIdentifierFieldOptions = useMemo(
() =>
getActiveFieldMetadataItems(objectMetadataItem)
.filter(
({ id, type }) =>
(isLabelIdentifierFieldMetadataTypes(type) &&
isSearchableFieldType(type)) ||
objectMetadataItem.labelIdentifierFieldMetadataId === id,
)
.map<SelectOption<string | null>>((fieldMetadataItem) => ({
Icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
value: fieldMetadataItem.id,
})),
[getIcon, objectMetadataItem],
);
const imageIdentifierFieldOptions: SelectOption<string | null>[] = [];
const mapFieldToSelectOption = (
fieldMetadataItem: FieldMetadataItem,
): SelectOption<string | null> => ({
Icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
value: fieldMetadataItem.id,
});
const labelIdentifierFieldOptions = getActiveFieldMetadataItems(
objectMetadataItem,
)
.filter(
({ id, type }) =>
(isLabelIdentifierFieldMetadataTypes(type) &&
isSearchableFieldType(type)) ||
objectMetadataItem.labelIdentifierFieldMetadataId === id,
)
.map(mapFieldToSelectOption);
const emptyOption: SelectOption<string | null> = {
Icon: IconCircleOff,
@@ -102,6 +114,17 @@ export const SettingsDataModelObjectIdentifiersForm = ({
value: null,
};
const imageIdentifierFieldOptions = [
emptyOption,
...getActiveFieldMetadataItems(objectMetadataItem)
.filter(
({ id, type }) =>
isImageIdentifierFieldMetadataType(type) ||
objectMetadataItem.imageIdentifierFieldMetadataId === id,
)
.map(mapFieldToSelectOption),
];
const navigate = useNavigate();
return (
@@ -112,14 +135,16 @@ export const SettingsDataModelObjectIdentifiersForm = ({
fieldName: LABEL_IDENTIFIER_FIELD_METADATA_ID,
options: labelIdentifierFieldOptions,
defaultValue: objectMetadataItem.labelIdentifierFieldMetadataId,
disabled: !isCustomObject || readonly,
},
{
label: t`Record image`,
fieldName: IMAGE_IDENTIFIER_FIELD_METADATA_ID,
options: imageIdentifierFieldOptions,
defaultValue: null,
defaultValue: objectMetadataItem.imageIdentifierFieldMetadataId,
disabled: readonly,
},
].map(({ fieldName, label, options, defaultValue }) => (
].map(({ fieldName, label, options, defaultValue, disabled }) => (
<Controller
key={fieldName}
name={fieldName}
@@ -134,7 +159,7 @@ export const SettingsDataModelObjectIdentifiersForm = ({
options={options}
value={value}
withSearchInput={label === t`Record label`}
disabled={!isCustomObject || readonly}
disabled={disabled}
callToActionButton={
label === t`Record label`
? {
@@ -146,8 +171,8 @@ export const SettingsDataModelObjectIdentifiersForm = ({
}
: undefined
}
onChange={(value) => {
onChange(value);
onChange={(newValue) => {
onChange(newValue);
formConfig.handleSubmit(handleSave)();
}}
/>