Fix application icons (#20142)

fixes application chip (icon Name) in all setting tables

## After
<img width="1200" height="896" alt="image"
src="https://github.com/user-attachments/assets/bd377f47-1d52-4142-b904-f2ce90c1db78"
/>
<img width="1200" height="917" alt="image"
src="https://github.com/user-attachments/assets/f49cc742-f11e-47e3-86ed-34beffe493c7"
/>
<img width="1234" height="878" alt="image"
src="https://github.com/user-attachments/assets/2ab459de-5f9d-4d39-9490-eec4ed9ee432"
/>
<img width="1239" height="845" alt="image"
src="https://github.com/user-attachments/assets/3c1bf258-285a-47b9-a60d-05ba1564334d"
/>
<img width="1183" height="907" alt="image"
src="https://github.com/user-attachments/assets/715b2470-2d88-48e3-88ac-d3daf3451717"
/>
<img width="1300" height="912" alt="image"
src="https://github.com/user-attachments/assets/d7c829fa-bf1d-4f19-82de-a8bf29e22bfa"
/>
This commit is contained in:
martmull
2026-04-30 11:32:04 +02:00
committed by GitHub
parent 842e679cc6
commit bddd23fd9c
59 changed files with 772 additions and 205 deletions
@@ -1,10 +1,19 @@
import { useApplicationChipData } from '@/applications/hooks/useApplicationChipData';
import { styled } from '@linaria/react';
import { Avatar } from 'twenty-ui/display';
import {
Avatar,
type AvatarSize,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type AppChipProps = {
applicationId: string;
size?: AvatarSize;
applicationId?: string | null;
fallbackApplicationData?: {
logo?: string | null;
name?: string | null;
};
className?: string;
};
@@ -20,29 +29,30 @@ const StyledContainer = styled.div`
overflow: hidden;
`;
const StyledLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const AppChip = ({ applicationId, className }: AppChipProps) => {
const { applicationChipData } = useApplicationChipData({ applicationId });
export const AppChip = ({
applicationId,
size = 'sm',
fallbackApplicationData,
className,
}: AppChipProps) => {
const { applicationChipData } = useApplicationChipData({
applicationId,
fallbackApplicationData,
});
return (
<StyledContainer className={className}>
<Avatar
type="app"
size="sm"
size={size}
avatarUrl={applicationChipData.logo}
placeholder={applicationChipData.name}
placeholderColorSeed={applicationChipData.seed}
color={applicationChipData.colors?.color}
backgroundColor={applicationChipData.colors?.backgroundColor}
borderColor={applicationChipData.colors?.borderColor}
/>
<StyledLabel title={applicationChipData.name}>
{applicationChipData.name}
</StyledLabel>
<OverflowingTextWithTooltip text={applicationChipData.name} />
</StyledContainer>
);
};
@@ -1,41 +1,29 @@
import { useApplicationAvatarColors } from '@/applications/hooks/useApplicationAvatarColors';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
type ApplicationDisplayData = {
id?: string | null;
name?: string | null;
universalIdentifier?: string | null;
logoUrl?: string | null;
applicationRegistration?: {
logoUrl?: string | null;
} | null;
};
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { AppChip } from '@/applications/components/AppChip';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { styled } from '@linaria/react';
type ApplicationDisplayProps = {
application: ApplicationDisplayData;
application?: ApplicationDisplayData;
};
const StyledAppChip = styled(AppChip)`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
gap: ${themeCssVariables.spacing[2]};
`;
export const ApplicationDisplay = ({
application,
}: ApplicationDisplayProps) => {
const colors = useApplicationAvatarColors(application);
const name = application.name ?? '';
const logoUrl =
application.logoUrl ?? application.applicationRegistration?.logoUrl;
return (
<>
<Avatar
type="app"
size="md"
avatarUrl={logoUrl ?? undefined}
placeholder={name}
placeholderColorSeed={application.universalIdentifier ?? name}
color={colors?.color}
backgroundColor={colors?.backgroundColor}
borderColor={colors?.borderColor}
/>
<OverflowingTextWithTooltip text={name} />
</>
<StyledAppChip
size="md"
applicationId={application?.id}
fallbackApplicationData={{
logo: application?.logo,
name: application?.name,
}}
/>
);
};
@@ -11,6 +11,7 @@ export const APPLICATION_FRAGMENT = gql`
id
name
description
logo
version
universalIdentifier
applicationRegistrationId
@@ -6,6 +6,7 @@ export const FIND_MANY_APPLICATIONS = gql`
id
name
description
logo
version
universalIdentifier
applicationRegistrationId
@@ -1,24 +1,30 @@
import { CurrentApplicationContext } from '@/applications/contexts/CurrentApplicationContext';
import {
useApplicationAvatarColors,
type ApplicationAvatarColors,
useApplicationAvatarColors,
} from '@/applications/hooks/useApplicationAvatarColors';
import { isTwentyStandardApplication } from '@/applications/utils/isTwentyStandardApplication';
import { isWorkspaceCustomApplication } from '@/applications/utils/isWorkspaceCustomApplication';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { buildApplicationLogoUrl } from '@/applications/utils/buildApplicationLogoUrl';
import CustomLogo from '~/pages/settings/applications/assets/custom-illustrations/custom-logo.webp';
import StandardLogo from '~/pages/settings/applications/assets/standard-illustrations/standard-logo.webp';
type UseApplicationChipDataArgs = {
applicationId: string;
applicationId?: string | null;
fallbackApplicationData?: {
logo?: string | null;
name?: string | null;
};
};
type ApplicationChipData = {
name: string;
seed: string;
colors?: ApplicationAvatarColors;
logo?: string;
};
type UseApplicationChipDataReturnType = {
@@ -27,8 +33,8 @@ type UseApplicationChipDataReturnType = {
export const useApplicationChipData = ({
applicationId,
fallbackApplicationData,
}: UseApplicationChipDataArgs): UseApplicationChipDataReturnType => {
const currentApplicationId = useContext(CurrentApplicationContext);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const application = currentWorkspace?.installedApplications.find(
@@ -40,28 +46,39 @@ export const useApplicationChipData = ({
if (!isDefined(application)) {
return {
applicationChipData: {
name: '',
seed: applicationId,
name: fallbackApplicationData?.name ?? '',
logo: fallbackApplicationData?.logo ?? '',
seed: fallbackApplicationData?.name ?? '',
},
};
}
const isCurrent =
isDefined(currentApplicationId) && currentApplicationId === applicationId;
const isStandard = isTwentyStandardApplication(application);
const displayName = isCurrent
? t`This app`
: isTwentyStandardApplication(application)
? t`Standard`
: isWorkspaceCustomApplication(application, currentWorkspace)
? t`Custom`
: application.name;
const isCustom = isWorkspaceCustomApplication(application, currentWorkspace);
const displayName = isStandard
? t`Standard`
: isCustom
? t`Custom`
: application.name;
const logo = isStandard
? new URL(StandardLogo, window.location.href).toString()
: isCustom
? new URL(CustomLogo, window.location.href).toString()
: buildApplicationLogoUrl({
applicationId: application.id,
logo: application.logo,
workspaceId: currentWorkspace?.id,
});
return {
applicationChipData: {
name: displayName,
seed: application.universalIdentifier ?? application.name,
seed: application.name,
colors,
logo,
},
};
};
@@ -0,0 +1,6 @@
export type ApplicationDisplayData = {
id?: string | null;
name?: string | null;
universalIdentifier?: string | null;
logo?: string | null;
};
@@ -0,0 +1,26 @@
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { isDefined } from 'twenty-shared/utils';
export const buildApplicationLogoUrl = ({
workspaceId,
applicationId,
logo,
}: {
workspaceId?: string | null;
applicationId?: string | null;
logo?: string | null;
}): string | undefined => {
if (
!isDefined(logo) ||
logo.startsWith('http://') ||
logo.startsWith('https://')
) {
return logo ?? undefined;
}
if (!isDefined(workspaceId) || !isDefined(applicationId)) {
return undefined;
}
return `${REACT_APP_SERVER_BASE_URL}/public-assets/${workspaceId}/${applicationId}/${logo}`;
};
@@ -47,7 +47,7 @@ export type CurrentWorkspace = Pick<
workspaceCustomApplication: Pick<Application, 'id'> | null;
installedApplications: Pick<
Application,
'id' | 'name' | 'universalIdentifier'
'id' | 'name' | 'universalIdentifier' | 'logo'
>[];
};
@@ -5,7 +5,6 @@ export const MARKETPLACE_APP_FRAGMENT = gql`
id
name
description
icon
author
category
logo
@@ -1,4 +1,4 @@
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { AppChip } from '@/applications/components/AppChip';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -173,7 +173,13 @@ const SettingsAdminAppsTableRow = ({
minWidth="0"
overflow="hidden"
>
<ApplicationDisplay application={registration} />
<AppChip
size="md"
fallbackApplicationData={{
logo: registration.logoUrl,
name: registration.name,
}}
/>
</TableCell>
<TableCell overflow="hidden" align="right">
{getFormattedSource(registration)}
@@ -25,8 +25,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
it('should return object rows for installed application objects', () => {
const installedApplication = {
id: APP_ID,
universalIdentifier: APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
logo: null,
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
@@ -53,8 +55,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
it('should return empty object rows when application has no objects', () => {
const installedApplication = {
id: APP_ID,
universalIdentifier: APP_ID,
objects: [],
name: 'Test App',
logo: null,
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
@@ -79,8 +83,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const installedApplication = {
id: fieldBelongingToApp.applicationId ?? APP_ID,
universalIdentifier: fieldBelongingToApp.applicationId ?? APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
logo: null,
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
@@ -106,8 +112,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
it('should exclude deny-listed objects from field rows', () => {
const installedApplication = {
id: APP_ID,
universalIdentifier: APP_ID,
objects: [],
name: 'Test App',
logo: null,
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
@@ -261,8 +269,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
it('should use installed application data when both sources are provided', () => {
const installedApplication = {
id: APP_ID,
universalIdentifier: APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
logo: null,
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
@@ -67,6 +67,7 @@ export const useComputeObjectAndFieldsContentForApplication = ({
.map((field) => ({
key: `${item.id}-${field.id}`,
name: field.label,
applicationId: item?.applicationId ?? undefined,
icon: field.icon ?? undefined,
secondary: t`on ${item.labelSingular}`,
link: getSettingsPath(SettingsPath.ObjectFieldEdit, {
@@ -0,0 +1,54 @@
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { getInstalledApplicationObjectAndFieldRows } from '@/settings/applications/utils/getInstalledApplicationObjectAndFieldRows';
import { getManifestObjectAndFieldRows } from '@/settings/applications/utils/getManifestObjectAndFieldRows';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
type InstalledApplicationForObjectRows = Omit<
Application,
'objects' | 'frontComponents'
> & {
objects: { id: string }[];
};
export const useObjectAndFieldRows = ({
installedApplication,
manifestContent,
applicationInfo,
}: {
applicationId: string;
installedApplication?: InstalledApplicationForObjectRows;
manifestContent?: Manifest;
applicationInfo?: ApplicationDisplayData;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const installedApplications = currentWorkspace?.installedApplications;
return useMemo(() => {
if (isDefined(installedApplication)) {
return getInstalledApplicationObjectAndFieldRows({
installedApplication,
objectMetadataItems,
installedApplications,
});
}
return getManifestObjectAndFieldRows({
manifestContent,
objectMetadataItems,
applicationInfo,
});
}, [
installedApplication,
manifestContent,
objectMetadataItems,
installedApplications,
applicationInfo,
]);
};
@@ -0,0 +1,79 @@
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/types/applicationDataTableRow';
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
type InstalledApplication = Omit<Application, 'objects' | 'frontComponents'> & {
objects: { id: string }[];
};
export const getInstalledApplicationObjectAndFieldRows = ({
installedApplication,
objectMetadataItems,
installedApplications,
}: {
installedApplication: InstalledApplication;
objectMetadataItems: EnrichedObjectMetadataItem[];
installedApplications?: ApplicationDisplayData[];
}): {
objectRows: ApplicationDataTableRow[];
fieldGroupRows: ApplicationDataTableRow[];
} => {
const installedObjectIds = installedApplication.objects.map(
(object) => object.id,
);
const objectRows: ApplicationDataTableRow[] =
installedApplication.objects.length === 0
? []
: objectMetadataItems
.filter((item) => installedObjectIds.includes(item.id))
.map((item) => ({
key: item.nameSingular,
labelPlural: item.labelPlural,
icon: item.icon ?? undefined,
fieldsCount: item.fields.filter((f) => !isHiddenSystemField(f))
.length,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: item.namePlural,
}),
application: {
id: installedApplication.id,
logo: installedApplication.logo,
name: installedApplication.name,
universalIdentifier: installedApplication.universalIdentifier,
},
}))
.sort((a, b) => a.labelPlural.localeCompare(b.labelPlural));
const fieldGroupRows: ApplicationDataTableRow[] = objectMetadataItems
.filter((item) => {
if (installedObjectIds.includes(item.id)) return false;
if (FIELD_GROUP_DENY_LIST.includes(item.nameSingular)) return false;
return item.fields.some(
(field) => field.applicationId === installedApplication.id,
);
})
.map((item) => ({
key: item.nameSingular,
labelPlural: item.labelPlural,
icon: item.icon ?? undefined,
fieldsCount: item.fields.filter(
(field) => field.applicationId === installedApplication.id,
).length,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: item.namePlural,
}),
application:
installedApplications?.find((app) => app.id === item.applicationId) ??
{},
}));
return { objectRows, fieldGroupRows };
};
@@ -0,0 +1,102 @@
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/types/applicationDataTableRow';
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
export const getManifestObjectAndFieldRows = ({
manifestContent,
objectMetadataItems,
applicationInfo,
}: {
manifestContent?: Manifest;
objectMetadataItems: EnrichedObjectMetadataItem[];
applicationInfo?: ApplicationDisplayData;
}): {
objectRows: ApplicationDataTableRow[];
fieldGroupRows: ApplicationDataTableRow[];
} => {
const manifestObjects = manifestContent?.objects ?? [];
const manifestFields = manifestContent?.fields ?? [];
const objectRows: ApplicationDataTableRow[] = manifestObjects
.map((appObject) => ({
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: appObject.fields.filter((f) => !isHiddenSystemField(f))
.length,
application: applicationInfo ?? {},
}))
.sort((a, b) => a.labelPlural.localeCompare(b.labelPlural));
if (manifestFields.length === 0) {
return { objectRows, fieldGroupRows: [] };
}
const manifestObjectUids = new Set(
manifestObjects.map((obj) => obj.universalIdentifier),
);
const groupMap = new Map<
string,
{ objectUniversalIdentifier: string; count: number }
>();
for (const field of manifestFields) {
const objectUid = field.objectUniversalIdentifier;
if (manifestObjectUids.has(objectUid)) {
continue;
}
const existing = groupMap.get(objectUid);
if (isDefined(existing)) {
existing.count++;
} else {
groupMap.set(objectUid, {
objectUniversalIdentifier: objectUid,
count: 1,
});
}
}
const fieldGroupRows: ApplicationDataTableRow[] = Array.from(
groupMap.values(),
)
.map((group) => {
const standardObjectName = findObjectNameByUniversalIdentifier(
group.objectUniversalIdentifier,
);
const objectMetadataItem = isDefined(standardObjectName)
? objectMetadataItems.find(
(item) => item.nameSingular === standardObjectName,
)
: undefined;
if (!isDefined(objectMetadataItem)) {
return;
}
if (FIELD_GROUP_DENY_LIST.includes(objectMetadataItem.nameSingular)) {
return;
}
return {
key: objectMetadataItem.nameSingular,
labelPlural: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
fieldsCount: group.count,
application: applicationInfo ?? {},
};
})
.filter(isDefined);
return { objectRows, fieldGroupRows };
};
@@ -70,6 +70,7 @@ export const USER_QUERY_FRAGMENT = gql`
id
name
universalIdentifier
logo
}
isCustomDomainEnabled
workspaceUrls {