f4ff234db8
## 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. -->
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util';
|
|
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
|
|
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
|
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
type FetchedField = {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
|
|
const getCompanyImageIdentifier = async (): Promise<string | null> => {
|
|
const { objects } = await findManyObjectMetadata({
|
|
expectToFail: false,
|
|
input: { filter: {}, paging: { first: 100 } },
|
|
gqlFields: `
|
|
id
|
|
nameSingular
|
|
imageIdentifierFieldMetadataId
|
|
`,
|
|
});
|
|
|
|
const company = objects.find((object) => object.nameSingular === 'company');
|
|
|
|
jestExpectToBeDefined(company);
|
|
|
|
return company.imageIdentifierFieldMetadataId ?? null;
|
|
};
|
|
|
|
describe('Standard object image identifier override should succeed', () => {
|
|
let companyObjectMetadataId: string;
|
|
let originalImageIdentifierFieldMetadataId: string | null;
|
|
let linkedinLinkFieldId: string;
|
|
|
|
beforeAll(async () => {
|
|
const { objects } = await findManyObjectMetadata({
|
|
expectToFail: false,
|
|
input: { filter: {}, paging: { first: 100 } },
|
|
gqlFields: `
|
|
id
|
|
nameSingular
|
|
imageIdentifierFieldMetadataId
|
|
`,
|
|
});
|
|
|
|
const company = objects.find((object) => object.nameSingular === 'company');
|
|
|
|
jestExpectToBeDefined(company);
|
|
companyObjectMetadataId = company.id;
|
|
originalImageIdentifierFieldMetadataId =
|
|
company.imageIdentifierFieldMetadataId ?? null;
|
|
|
|
const { fields } = await findManyFieldsMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
filter: { objectMetadataId: { eq: companyObjectMetadataId } },
|
|
paging: { first: 100 },
|
|
},
|
|
gqlFields: 'id name type',
|
|
});
|
|
|
|
const linkedinLinkField = fields
|
|
.map((edge: { node: FetchedField }) => edge.node)
|
|
.find((field: FetchedField) => field.name === 'linkedinLink');
|
|
|
|
jestExpectToBeDefined(linkedinLinkField);
|
|
linkedinLinkFieldId = linkedinLinkField.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: companyObjectMetadataId,
|
|
updatePayload: {
|
|
imageIdentifierFieldMetadataId:
|
|
originalImageIdentifierFieldMetadataId ?? undefined,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('stores a non-default image identifier in overrides and resolves it', async () => {
|
|
const {
|
|
data: { updateOneObject },
|
|
errors,
|
|
} = await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: companyObjectMetadataId,
|
|
updatePayload: {
|
|
imageIdentifierFieldMetadataId: linkedinLinkFieldId,
|
|
},
|
|
},
|
|
gqlFields: `
|
|
id
|
|
imageIdentifierFieldMetadataId
|
|
`,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(updateOneObject.imageIdentifierFieldMetadataId).toBe(
|
|
linkedinLinkFieldId,
|
|
);
|
|
});
|
|
|
|
it('persists the overridden image identifier across refetch', async () => {
|
|
const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
|
|
|
|
expect(imageIdentifierFieldMetadataId).toBe(linkedinLinkFieldId);
|
|
});
|
|
|
|
it('respects an explicit null override instead of falling back to the base value', async () => {
|
|
const {
|
|
data: { updateOneObject },
|
|
errors,
|
|
} = await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: companyObjectMetadataId,
|
|
updatePayload: {
|
|
imageIdentifierFieldMetadataId: null,
|
|
},
|
|
},
|
|
gqlFields: `
|
|
id
|
|
imageIdentifierFieldMetadataId
|
|
`,
|
|
});
|
|
|
|
expect(errors).toBeUndefined();
|
|
expect(updateOneObject.imageIdentifierFieldMetadataId).toBeNull();
|
|
|
|
const imageIdentifierFieldMetadataId = await getCompanyImageIdentifier();
|
|
|
|
expect(imageIdentifierFieldMetadataId).toBeNull();
|
|
expect(isDefined(imageIdentifierFieldMetadataId)).toBe(false);
|
|
});
|
|
});
|