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:
+49
-24
@@ -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)();
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user