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,9 +1,10 @@
|
||||
import { type MouseEvent } from 'react';
|
||||
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
|
||||
type MentionMenuListItemProps = {
|
||||
recordId: string;
|
||||
@@ -24,6 +25,12 @@ export const MentionMenuListItem = ({
|
||||
isSelected,
|
||||
onClick,
|
||||
}: MentionMenuListItemProps) => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === objectNameSingular,
|
||||
);
|
||||
|
||||
const handleClick = (event?: MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
@@ -42,7 +49,7 @@ export const MentionMenuListItem = ({
|
||||
placeholder={label}
|
||||
placeholderColorSeed={recordId}
|
||||
avatarUrl={getAbsoluteImageUrl(imageUrl)}
|
||||
type={getAvatarType(objectNameSingular) ?? 'rounded'}
|
||||
type={getAvatarType(objectMetadataItem)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export const recordIdentifierToObjectRecordIdentifier = ({
|
||||
recordIdentifier: RecordIdentifierDTO;
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
}): ObjectRecordIdentifier => {
|
||||
const avatarType = getAvatarType(objectMetadataItem.nameSingular);
|
||||
const avatarType = getAvatarType(objectMetadataItem);
|
||||
|
||||
const basePathToShowPage = getBasePathToShowPage({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
+1
-2
@@ -1,12 +1,11 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getLinkFaviconUrl, isDefined } from 'twenty-shared/utils';
|
||||
import { getIconTileColorShades } from 'twenty-ui/data-display';
|
||||
import { type IconComponent } from 'twenty-ui/icon';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
|
||||
import { getLinkFaviconUrl } from '@/navigation-menu-item/display/link/utils/getLinkFaviconUrl';
|
||||
|
||||
const failedFaviconUrls = new Set<string>();
|
||||
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
|
||||
|
||||
export const getLinkFaviconUrl = (
|
||||
link: string | null | undefined,
|
||||
): string | undefined => {
|
||||
const trimmed = (link ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized =
|
||||
trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
try {
|
||||
const hostname = new URL(normalized).hostname;
|
||||
return getLogoUrlFromDomainName(hostname);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -65,7 +65,7 @@ describe('useGetObjectRecordIdentifierByNameSingular', () => {
|
||||
rerender({
|
||||
record: {
|
||||
id: 'recordId',
|
||||
domainName: 'https://cool-company.com',
|
||||
domainName: { primaryLinkUrl: 'https://cool-company.com' },
|
||||
},
|
||||
objectNameSingular: 'company',
|
||||
});
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getImageIdentifierFieldValue } from '@/object-metadata/utils/getImageIdentifierFieldValue';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const buildFieldMetadataItem = (
|
||||
type: FieldMetadataType,
|
||||
name = 'imageField',
|
||||
): FieldMetadataItem => ({ name, type }) as FieldMetadataItem;
|
||||
|
||||
const buildRecord = (value: unknown, name = 'imageField'): ObjectRecord =>
|
||||
({ id: 'record-id', [name]: value }) as unknown as ObjectRecord;
|
||||
|
||||
describe('getImageIdentifierFieldValue', () => {
|
||||
it('returns null when the image identifier field metadata item is undefined', () => {
|
||||
expect(getImageIdentifierFieldValue(buildRecord('value'), undefined)).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the field value is not defined on the record', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord(undefined),
|
||||
buildFieldMetadataItem(FieldMetadataType.FILES),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
describe('FILES', () => {
|
||||
it('returns the url of the first file', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord([{ url: 'https://example.com/a.png' }]),
|
||||
buildFieldMetadataItem(FieldMetadataType.FILES),
|
||||
),
|
||||
).toBe('https://example.com/a.png');
|
||||
});
|
||||
|
||||
it('returns null for an empty files array', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord([]),
|
||||
buildFieldMetadataItem(FieldMetadataType.FILES),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LINKS', () => {
|
||||
it('returns the favicon url when requests to twenty-icons are allowed', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord({ primaryLinkUrl: 'twenty.com' }),
|
||||
buildFieldMetadataItem(FieldMetadataType.LINKS),
|
||||
true,
|
||||
),
|
||||
).toBe('https://twenty-icons.com/twenty.com');
|
||||
});
|
||||
|
||||
it('returns null when requests to twenty-icons are not allowed', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord({ primaryLinkUrl: 'twenty.com' }),
|
||||
buildFieldMetadataItem(FieldMetadataType.LINKS),
|
||||
false,
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null when the primary link url is not defined', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord({ primaryLinkUrl: null }),
|
||||
buildFieldMetadataItem(FieldMetadataType.LINKS),
|
||||
true,
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unsupported field types (image identifiers are constrained to FILES/LINKS)', () => {
|
||||
expect(
|
||||
getImageIdentifierFieldValue(
|
||||
buildRecord('https://example.com/avatar.png'),
|
||||
buildFieldMetadataItem(FieldMetadataType.TEXT),
|
||||
),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,39 @@
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
|
||||
import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getAvatarType = (objectNameSingular: string) => {
|
||||
if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) {
|
||||
export const getAvatarType = (
|
||||
objectMetadataItem?: Pick<
|
||||
EnrichedObjectMetadataItem,
|
||||
'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
|
||||
>,
|
||||
) => {
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return 'rounded';
|
||||
}
|
||||
|
||||
if (objectNameSingular === CoreObjectNameSingular.Company) {
|
||||
return 'squared';
|
||||
if (
|
||||
objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember
|
||||
) {
|
||||
return 'rounded';
|
||||
}
|
||||
|
||||
const imageIdentifierFieldMetadataItem =
|
||||
getImageIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
||||
if (isDefined(imageIdentifierFieldMetadataItem)) {
|
||||
switch (imageIdentifierFieldMetadataItem.type) {
|
||||
case FieldMetadataType.LINKS:
|
||||
return 'squared';
|
||||
case FieldMetadataType.FILES:
|
||||
return 'rounded';
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
objectNameSingular === CoreObjectNameSingular.Task ||
|
||||
objectNameSingular === CoreObjectNameSingular.Note
|
||||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Task ||
|
||||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Note
|
||||
) {
|
||||
return 'icon';
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type Company } from '@/companies/types/Company';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
|
||||
|
||||
export const getAvatarUrl = (
|
||||
@@ -16,22 +16,10 @@ export const getAvatarUrl = (
|
||||
return record.avatarUrl ?? undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
objectNameSingular === CoreObjectNameSingular.Company &&
|
||||
allowRequestsToTwentyIcons === true
|
||||
) {
|
||||
return getLogoUrlFromDomainName(
|
||||
getCompanyDomainName(record as Company) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (objectNameSingular === CoreObjectNameSingular.Person) {
|
||||
return record.avatarFile?.[0]?.url ?? '';
|
||||
}
|
||||
|
||||
const imageIdentifierFieldValue = getImageIdentifierFieldValue(
|
||||
record,
|
||||
imageIdentifierFieldMetadataItem,
|
||||
allowRequestsToTwentyIcons,
|
||||
);
|
||||
|
||||
if (isDefined(imageIdentifierFieldValue)) {
|
||||
|
||||
+6
-8
@@ -1,16 +1,14 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { isImageIdentifierField } from '@/object-metadata/utils/isImageIdentifierField';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
|
||||
export const getImageIdentifierFieldMetadataItem = (
|
||||
objectMetadataItem: Pick<
|
||||
EnrichedObjectMetadataItem,
|
||||
'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
|
||||
'fields' | 'imageIdentifierFieldMetadataId'
|
||||
>,
|
||||
): FieldMetadataItem | undefined =>
|
||||
objectMetadataItem.fields.find((fieldMetadataItem) =>
|
||||
isImageIdentifierField({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
}),
|
||||
objectMetadataItem.fields.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id ===
|
||||
objectMetadataItem.imageIdentifierFieldMetadataId,
|
||||
);
|
||||
|
||||
+37
-5
@@ -1,14 +1,46 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { getLinkFaviconUrl, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getImageIdentifierFieldValue = (
|
||||
record: ObjectRecord,
|
||||
imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
|
||||
) => {
|
||||
if (isDefined(imageIdentifierFieldMetadataItem?.name)) {
|
||||
return record[imageIdentifierFieldMetadataItem.name] as string;
|
||||
allowRequestsToTwentyIcons?: boolean,
|
||||
): string | null => {
|
||||
if (!isDefined(imageIdentifierFieldMetadataItem?.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const fieldValue = record[imageIdentifierFieldMetadataItem.name];
|
||||
|
||||
if (!isDefined(fieldValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (imageIdentifierFieldMetadataItem.type) {
|
||||
case FieldMetadataType.FILES: {
|
||||
const url = Array.isArray(fieldValue) ? fieldValue[0]?.url : undefined;
|
||||
|
||||
return isNonEmptyString(url) ? url : null;
|
||||
}
|
||||
case FieldMetadataType.LINKS: {
|
||||
if (allowRequestsToTwentyIcons !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryLinkUrl =
|
||||
typeof fieldValue === 'object' && 'primaryLinkUrl' in fieldValue
|
||||
? fieldValue.primaryLinkUrl
|
||||
: undefined;
|
||||
|
||||
return isNonEmptyString(primaryLinkUrl)
|
||||
? (getLinkFaviconUrl(primaryLinkUrl) ?? null)
|
||||
: null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
+4
-5
@@ -1,4 +1,5 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
|
||||
@@ -31,13 +32,11 @@ export const getObjectRecordIdentifier = ({
|
||||
labelIdentifierFieldMetadataItem,
|
||||
);
|
||||
|
||||
const imageIdentifierFieldMetadata = objectMetadataItem.fields.find(
|
||||
(field) => field.id === objectMetadataItem.imageIdentifierFieldMetadataId,
|
||||
);
|
||||
const imageIdentifierFieldMetadata =
|
||||
getImageIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
||||
const avatarType = getAvatarType(objectMetadataItem.nameSingular);
|
||||
const avatarType = getAvatarType(objectMetadataItem);
|
||||
|
||||
// TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database
|
||||
const avatarUrl = getAvatarUrl(
|
||||
objectMetadataItem.nameSingular,
|
||||
record,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
|
||||
export const isImageIdentifierField = ({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
}: {
|
||||
fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'name'>;
|
||||
objectMetadataItem: Pick<
|
||||
EnrichedObjectMetadataItem,
|
||||
'imageIdentifierFieldMetadataId' | 'nameSingular'
|
||||
>;
|
||||
}) => {
|
||||
if (
|
||||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company &&
|
||||
fieldMetadataItem.name === 'domainName'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) {
|
||||
return fieldMetadataItem.name === 'avatarFile';
|
||||
}
|
||||
|
||||
return (
|
||||
fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId
|
||||
);
|
||||
};
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
|
||||
{
|
||||
"accountOwner": {
|
||||
"avatarUrl": true,
|
||||
"id": true,
|
||||
"name": true,
|
||||
},
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ export const MultipleRecordPickerMenuItemContent = ({
|
||||
placeholderColorSeed={searchRecord.recordId}
|
||||
placeholder={displayText}
|
||||
size="md"
|
||||
type={getAvatarType(objectMetadataItem.nameSingular) ?? 'rounded'}
|
||||
type={getAvatarType(objectMetadataItem)}
|
||||
/>
|
||||
}
|
||||
text={displayText}
|
||||
|
||||
+19
-6
@@ -1,3 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
|
||||
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
|
||||
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
|
||||
@@ -7,13 +10,13 @@ import { type RecordPickerPickableMorphItem } from '@/object-record/record-picke
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
|
||||
type SingleRecordPickerMenuItemProps = {
|
||||
morphItem: RecordPickerPickableMorphItem;
|
||||
@@ -51,6 +54,18 @@ export const SingleRecordPickerMenuItem = ({
|
||||
recordPickerComponentInstanceId,
|
||||
);
|
||||
|
||||
const objectMetadataItem = useMemo(
|
||||
() =>
|
||||
singleRecordPickerSearchableObjectMetadataItems.find(
|
||||
(searchableObjectMetadataItem: EnrichedObjectMetadataItem) =>
|
||||
searchableObjectMetadataItem.id === morphItem.objectMetadataId,
|
||||
),
|
||||
[
|
||||
singleRecordPickerSearchableObjectMetadataItems,
|
||||
morphItem.objectMetadataId,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isDefined(searchRecordStore)) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,9 +93,7 @@ export const SingleRecordPickerMenuItem = ({
|
||||
placeholderColorSeed={morphItem.recordId}
|
||||
placeholder={searchRecordStore.label}
|
||||
size="md"
|
||||
type={
|
||||
getAvatarType(searchRecordStore.objectNameSingular) ?? 'rounded'
|
||||
}
|
||||
type={getAvatarType(objectMetadataItem)}
|
||||
/>
|
||||
}
|
||||
contextualText={
|
||||
|
||||
+6
-8
@@ -1,10 +1,9 @@
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
|
||||
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
|
||||
import { usePersonAvatarUpload } from '@/object-record/record-show/hooks/usePersonAvatarUpload';
|
||||
import { useRecordImageIdentifierUpload } from '@/object-record/record-show/hooks/useRecordImageIdentifierUpload';
|
||||
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
|
||||
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
@@ -49,7 +48,10 @@ export const SummaryCard = ({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const { onUploadPicture } = usePersonAvatarUpload(objectRecordId);
|
||||
const { onUploadPicture } = useRecordImageIdentifierUpload({
|
||||
objectNameSingular,
|
||||
recordId: objectRecordId,
|
||||
});
|
||||
|
||||
const isMobile = useIsMobile() || isInSidePanel;
|
||||
|
||||
@@ -115,11 +117,7 @@ export const SummaryCard = ({
|
||||
</FieldContext.Provider>
|
||||
}
|
||||
avatarType={recordIdentifier?.avatarType ?? 'rounded'}
|
||||
onUploadPicture={
|
||||
objectNameSingular === CoreObjectNameSingular.Person
|
||||
? onUploadPicture
|
||||
: undefined
|
||||
}
|
||||
onUploadPicture={onUploadPicture}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
UploadFilesFieldFileDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const usePersonAvatarUpload = (personRecordId: string) => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
|
||||
const { objectMetadataItem: personMetadata } = useObjectMetadataItem({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
});
|
||||
|
||||
const avatarFileFieldMetadataId = personMetadata.fields.find(
|
||||
(field) =>
|
||||
field.type === FieldMetadataType.FILES && field.name === 'avatarFile',
|
||||
)?.id;
|
||||
|
||||
const onUploadPicture = async (file: File) => {
|
||||
assertIsDefinedOrThrow(
|
||||
avatarFileFieldMetadataId,
|
||||
new Error(t`Avatar file field not found for person object`),
|
||||
);
|
||||
|
||||
const result = await uploadFilesFieldFile({
|
||||
variables: { file, fieldMetadataId: avatarFileFieldMetadataId },
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadFilesFieldFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Person,
|
||||
idToUpdate: personRecordId,
|
||||
updateOneRecordInput: {
|
||||
avatarFile: [
|
||||
{
|
||||
fileId: uploadedFile.id,
|
||||
label: file.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { onUploadPicture };
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
|
||||
import { useApolloClient, useMutation } from '@apollo/client/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
UploadFilesFieldFileDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseRecordImageIdentifierUploadParams = {
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
};
|
||||
|
||||
export const useRecordImageIdentifierUpload = ({
|
||||
objectNameSingular,
|
||||
recordId,
|
||||
}: UseRecordImageIdentifierUploadParams) => {
|
||||
const apolloClient = useApolloClient();
|
||||
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
|
||||
client: apolloClient,
|
||||
});
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
|
||||
|
||||
const imageIdentifierFieldMetadataItem =
|
||||
getImageIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
||||
const filesImageIdentifierFieldMetadataItem =
|
||||
imageIdentifierFieldMetadataItem?.type === FieldMetadataType.FILES
|
||||
? imageIdentifierFieldMetadataItem
|
||||
: undefined;
|
||||
|
||||
const isImageIdentifierFieldReadOnly = useIsRecordFieldReadOnly({
|
||||
recordId,
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
fieldMetadataId: filesImageIdentifierFieldMetadataItem?.id ?? '',
|
||||
});
|
||||
|
||||
const canUploadImageIdentifier =
|
||||
isDefined(filesImageIdentifierFieldMetadataItem) &&
|
||||
!isImageIdentifierFieldReadOnly;
|
||||
|
||||
const onUploadPicture = async (file: File) => {
|
||||
if (!isDefined(filesImageIdentifierFieldMetadataItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadFilesFieldFile({
|
||||
variables: {
|
||||
file,
|
||||
fieldMetadataId: filesImageIdentifierFieldMetadataItem.id,
|
||||
},
|
||||
});
|
||||
|
||||
const uploadedFile = result?.data?.uploadFilesFieldFile;
|
||||
|
||||
if (!isDefined(uploadedFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
objectNameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[filesImageIdentifierFieldMetadataItem.name]: [
|
||||
{
|
||||
fileId: uploadedFile.id,
|
||||
label: file.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onUploadPicture: canUploadImageIdentifier ? onUploadPicture : undefined,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
|
||||
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
|
||||
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
|
||||
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
|
||||
@@ -72,13 +73,9 @@ export const getRecordChipGenerators = (
|
||||
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
|
||||
|
||||
const imageIdentifierFieldMetadataToUse =
|
||||
objectMetadataItemToUse.fields.find(
|
||||
(field) =>
|
||||
field.id ===
|
||||
objectMetadataItemToUse.imageIdentifierFieldMetadataId,
|
||||
);
|
||||
getImageIdentifierFieldMetadataItem(objectMetadataItemToUse);
|
||||
|
||||
const avatarType = getAvatarType(objectNameSingularToFind);
|
||||
const avatarType = getAvatarType(objectMetadataItemToUse);
|
||||
|
||||
return [
|
||||
fieldMetadataItem.name,
|
||||
|
||||
+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