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
@@ -8,7 +8,6 @@ export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Postcard App',
description: 'Send postcards easily with Twenty',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
@@ -4,6 +4,5 @@ export default defineApplication({
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
description: 'An app with all entities at root level',
icon: 'IconFolder',
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
});
@@ -5,7 +5,6 @@ export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Rich App',
description: 'A simple rich app',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
@@ -580,6 +580,7 @@ type Application {
id: UUID!
name: String!
description: String
logo: String
version: String
universalIdentifier: String!
packageJsonChecksum: String
@@ -2202,7 +2203,6 @@ type MarketplaceApp {
id: String!
name: String!
description: String!
icon: String!
author: String!
category: String!
logo: String
@@ -414,6 +414,7 @@ export interface Application {
id: Scalars['UUID']
name: Scalars['String']
description?: Scalars['String']
logo?: Scalars['String']
version?: Scalars['String']
universalIdentifier: Scalars['String']
packageJsonChecksum?: Scalars['String']
@@ -1940,7 +1941,6 @@ export interface MarketplaceApp {
id: Scalars['String']
name: Scalars['String']
description: Scalars['String']
icon: Scalars['String']
author: Scalars['String']
category: Scalars['String']
logo?: Scalars['String']
@@ -3314,6 +3314,7 @@ export interface ApplicationGenqlSelection{
id?: boolean | number
name?: boolean | number
description?: boolean | number
logo?: boolean | number
version?: boolean | number
universalIdentifier?: boolean | number
packageJsonChecksum?: boolean | number
@@ -4923,7 +4924,6 @@ export interface MarketplaceAppGenqlSelection{
id?: boolean | number
name?: boolean | number
description?: boolean | number
icon?: boolean | number
author?: boolean | number
category?: boolean | number
logo?: boolean | number
@@ -1257,6 +1257,9 @@ export default {
"description": [
1
],
"logo": [
1
],
"version": [
1
],
@@ -4415,9 +4418,6 @@ export default {
"description": [
1
],
"icon": [
1
],
"author": [
1
],
@@ -77,7 +77,6 @@ export default defineApplication({
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
displayName: 'My Twenty App',
description: 'My first Twenty app',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
File diff suppressed because one or more lines are too long
@@ -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 {
@@ -5,11 +5,13 @@ import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledNameTableCell } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { AppChip } from '@/applications/components/AppChip';
export type SettingsToolTableRowProps = {
leftIcon: ReactNode;
name: string;
appLabel: string;
applicationId: string;
action?: ReactNode;
link?: string;
};
@@ -25,7 +27,7 @@ const StyledIconContainer = styled.div`
export const SettingsToolTableRow = ({
leftIcon,
name,
appLabel,
applicationId,
action,
link,
}: SettingsToolTableRowProps) => {
@@ -43,9 +45,9 @@ export const SettingsToolTableRow = ({
<StyledIconContainer>{leftIcon}</StyledIconContainer>
<OverflowingTextWithTooltip text={name} />
</TableCell>
<TableCell minWidth="0" overflow="hidden">
<OverflowingTextWithTooltip text={appLabel} />
</TableCell>
<StyledNameTableCell minWidth="0" overflow="hidden">
<AppChip applicationId={applicationId} />
</StyledNameTableCell>
<TableCell
align="right"
padding={`0 ${themeCssVariables.spacing[2]} 0 0`}
@@ -36,6 +36,7 @@ import {
SettingsToolTableRow,
TOOL_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
} from './SettingsToolTableRow';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
type ToolItem = {
identifier: string;
@@ -53,6 +54,7 @@ const FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE = gql`
id
name
universalIdentifier
logo
}
}
`;
@@ -90,6 +92,7 @@ export const SettingsToolsTable = () => {
id: string;
name: string;
universalIdentifier: string;
logo?: string | null;
}>;
}>(FIND_MANY_APPLICATIONS_FOR_TOOL_TABLE);
const { data: marketplaceAppsData } = useQuery<{
@@ -119,6 +122,20 @@ export const SettingsToolsTable = () => {
toolIdentifier: item.identifier,
});
const getToolApplicationId = (item: ToolItem) => {
if (isDefined(item.applicationId)) {
return item.applicationId;
}
return (
currentWorkspace?.installedApplications?.find(
(app) =>
app.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
)?.id ?? ''
);
};
const allTools: ToolItem[] = useMemo(
() => [
...logicFunctions
@@ -264,10 +281,6 @@ export const SettingsToolsTable = () => {
)
: undefined;
const appLabel = isCustom(item)
? (application?.name ?? t`Custom`)
: t`Standard`;
return (
<SettingsToolTableRow
key={item.identifier}
@@ -281,7 +294,7 @@ export const SettingsToolsTable = () => {
/>
}
name={item.name}
appLabel={appLabel}
applicationId={getToolApplicationId(item)}
action={
<IconChevronRight
size={theme.icon.size.md}
@@ -89,7 +89,10 @@ export const SettingsApplicationDetails = () => {
app?.displayName ?? application?.name ?? t`Application details`;
const description = app?.description ?? resolvedDescription;
const logoUrl =
app?.logoUrl ?? application?.applicationRegistration?.logoUrl ?? undefined;
app?.logoUrl ??
application?.logo ??
application?.applicationRegistration?.logoUrl ??
undefined;
const getScreenshots = () => {
if (app?.screenshots?.length) return app.screenshots;
@@ -283,6 +286,12 @@ export const SettingsApplicationDetails = () => {
applicationId={application.id}
installedApplication={application}
manifestContent={manifest}
applicationInfo={{
id: application.id,
name: displayName,
logo: application.logo,
universalIdentifier: application.universalIdentifier,
}}
/>
);
case 'permissions':
@@ -317,10 +326,7 @@ export const SettingsApplicationDetails = () => {
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={logoUrl}
applicationId={application?.id}
applicationName={application?.name}
universalIdentifier={application?.universalIdentifier}
/>
}
links={[
@@ -230,6 +230,11 @@ export const SettingsAvailableApplicationDetails = () => {
<SettingsApplicationDetailContentTab
applicationId={detail.universalIdentifier}
manifestContent={manifest}
applicationInfo={{
name: displayName,
logo: app?.logoUrl,
universalIdentifier: detail.universalIdentifier,
}}
/>
);
case 'permissions':
@@ -267,10 +272,7 @@ export const SettingsAvailableApplicationDetails = () => {
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={app?.logoUrl}
applicationId={application?.id}
applicationName={application?.name}
universalIdentifier={detail.universalIdentifier}
isUnlisted={isUnlisted}
/>
}
@@ -279,9 +281,7 @@ export const SettingsAvailableApplicationDetails = () => {
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
behaveAsLinks={false}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

@@ -13,23 +13,32 @@ import {
useIcons,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { AppChip } from '@/applications/components/AppChip';
export type ApplicationContentRow = {
key: string;
name: string;
applicationId?: string;
icon?: string;
secondary?: string;
link?: string;
};
const GRID_TEMPLATE_COLUMNS = '1fr auto 32px';
const GRID_TEMPLATE_COLUMNS = '200px 1fr 160px 32px';
export const SettingsApplicationContentSubtable = ({
title,
rows,
applicationId,
fallbackApplicationData,
}: {
title: string;
rows: ApplicationContentRow[];
applicationId?: string;
fallbackApplicationData?: {
logo?: string | null;
name?: string | null;
};
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
@@ -55,6 +64,12 @@ export const SettingsApplicationContentSubtable = ({
)}
<OverflowingTextWithTooltip text={row.name} />
</StyledNameTableCell>
<StyledNameTableCell minWidth="0" overflow="hidden" align={'right'}>
<AppChip
applicationId={row.applicationId ?? applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</StyledNameTableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.secondary}
@@ -0,0 +1,117 @@
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SETTINGS_OBJECT_TABLE_COLUMN_WIDTH } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTableRow';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/types/applicationDataTableRow';
const MAIN_ROW_GRID_COLUMNS = `180px 1fr ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`;
const StyledEmptyHeaderContainer = styled.div`
> div {
min-width: 0;
}
`;
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationDataTable = ({
objectRows,
fieldGroupRows,
}: {
objectRows: ApplicationDataTableRow[];
fieldGroupRows: ApplicationDataTableRow[];
}) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredObjectRows = useMemo(() => {
const normalizedSearch = normalizeSearchText(searchTerm);
if (normalizedSearch === '') {
return objectRows;
}
return objectRows.filter((row) =>
normalizeSearchText(row.labelPlural).includes(normalizedSearch),
);
}, [objectRows, searchTerm]);
const filteredFieldGroupRows = useMemo(() => {
const normalizedSearch = normalizeSearchText(searchTerm);
if (normalizedSearch === '') {
return fieldGroupRows;
}
return fieldGroupRows.filter((row) =>
normalizeSearchText(row.labelPlural).includes(normalizedSearch),
);
}, [fieldGroupRows, searchTerm]);
const shouldDisplayObjects = filteredObjectRows.length > 0;
const shouldDisplayFields = filteredFieldGroupRows.length > 0;
const hasSearchTerm = searchTerm.trim().length > 0;
const hasNoResults =
hasSearchTerm && !shouldDisplayObjects && !shouldDisplayFields;
if (objectRows.length === 0 && fieldGroupRows.length === 0) {
return null;
}
return (
<Section>
<H2Title
title={t`Data`}
description={t`Objects and fields managed by this app`}
/>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an object...`}
value={searchTerm}
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
{hasNoResults ? (
<SettingsEmptyPlaceholder>{t`No object found`}</SettingsEmptyPlaceholder>
) : (
<Table>
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`App`}</TableHeader>
<TableHeader align="right">{t`Fields`}</TableHeader>
<StyledEmptyHeaderContainer>
<TableHeader />
</StyledEmptyHeaderContainer>
</TableRow>
{shouldDisplayObjects && (
<TableSection title={t`Objects`}>
{filteredObjectRows.map((row) => (
<SettingsApplicationDataTableRow key={row.key} row={row} />
))}
</TableSection>
)}
{shouldDisplayFields && (
<TableSection title={t`Fields`}>
{filteredFieldGroupRows.map((row) => (
<SettingsApplicationDataTableRow key={row.key} row={row} />
))}
</TableSection>
)}
</Table>
)}
</Section>
);
};
@@ -0,0 +1,73 @@
import {
SETTINGS_OBJECT_TABLE_COLUMN_WIDTH,
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronRight, useIcons } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/types/applicationDataTableRow';
import { AppChip } from '@/applications/components/AppChip';
const MAIN_ROW_GRID_COLUMNS = `180px 1fr ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`;
const StyledNameContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledNameLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsApplicationDataTableRow = ({
row,
}: {
row: ApplicationDataTableRow;
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const Icon = getIcon(row.icon);
return (
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS} to={row.link}>
<StyledNameTableCell minWidth="0" overflow="hidden">
{isDefined(Icon) && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
<StyledNameContainer>
<StyledNameLabel title={row.labelPlural}>
{row.labelPlural}
</StyledNameLabel>
</StyledNameContainer>
</StyledNameTableCell>
<StyledNameTableCell minWidth="0" overflow="hidden">
<AppChip
applicationId={row.application?.id}
fallbackApplicationData={row.application}
/>
</StyledNameTableCell>
<TableCell align="right">{row.fieldsCount}</TableCell>
<StyledActionTableCell>
{row.link && (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
)}
</StyledActionTableCell>
</TableRow>
);
};
@@ -1,10 +1,10 @@
import { useApplicationAvatarColors } from '@/applications/hooks/useApplicationAvatarColors';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Avatar, IconEyeOff } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { useApplicationChipData } from '@/applications/hooks/useApplicationChipData';
type SettingsApplicationDetailTitleProps = {
displayName: string;
@@ -72,19 +72,15 @@ const StyledUnlistedBanner = styled.div`
export const SettingsApplicationDetailTitle = ({
displayName,
description,
logoUrl,
applicationId,
applicationName,
universalIdentifier,
isUnlisted = false,
}: SettingsApplicationDetailTitleProps) => {
const colors = useApplicationAvatarColors({
id: applicationId,
name: applicationName,
universalIdentifier,
});
const descriptionSummary = getApplicationDescriptionSummary(description);
const { applicationChipData } = useApplicationChipData({
applicationId,
});
return (
<StyledTitleContainer>
{isUnlisted && (
@@ -99,12 +95,12 @@ export const SettingsApplicationDetailTitle = ({
<Avatar
type="app"
size="lg"
avatarUrl={logoUrl}
placeholder={displayName}
placeholderColorSeed={universalIdentifier ?? displayName}
color={colors?.color}
backgroundColor={colors?.backgroundColor}
borderColor={colors?.borderColor}
avatarUrl={applicationChipData.logo}
placeholder={applicationChipData.name}
placeholderColorSeed={applicationChipData.seed}
color={applicationChipData.colors?.color}
backgroundColor={applicationChipData.colors?.backgroundColor}
borderColor={applicationChipData.colors?.borderColor}
/>
<StyledAppName>{displayName}</StyledAppName>
</StyledHeaderTop>
@@ -9,18 +9,13 @@ import { Tag } from 'twenty-ui/components';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { StyledNameTableCell } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
export type SettingsApplicationTableRowProps = {
action: ReactNode;
application: {
id?: string | null;
name?: string | null;
universalIdentifier?: string | null;
application: ApplicationDisplayData & {
description?: string | null;
logoUrl?: string | null;
applicationRegistration?: {
logoUrl?: string | null;
} | null;
};
hasUpdate?: boolean;
link?: string;
@@ -45,14 +40,9 @@ export const SettingsApplicationTableRow = ({
key={application.id}
to={link}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
>
<StyledNameTableCell minWidth="0" overflow="hidden">
<ApplicationDisplay application={application} />
</TableCell>
</StyledNameTableCell>
<TableCell gap={themeCssVariables.spacing[2]} minWidth="0">
<OverflowingTextWithTooltip text={descriptionSummary} />
{hasUpdate === true && (
@@ -10,6 +10,7 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
import { type Application } from '~/generated-metadata/graphql';
import {
type ApplicationContentRow,
@@ -19,7 +20,7 @@ import { normalizeSearchText } from '~/utils/normalizeSearchText';
type InstalledApplicationForContentTab = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
'objects' | 'frontComponents'
> & {
objects: { id: string }[];
frontComponents?: {
@@ -33,6 +34,7 @@ type SettingsApplicationDetailContentTabProps = {
applicationId: string;
installedApplication?: InstalledApplicationForContentTab;
manifestContent?: Manifest;
applicationInfo: ApplicationDisplayData;
};
const filterRows = (rows: ApplicationContentRow[], normalizedSearch: string) =>
@@ -49,6 +51,7 @@ export const SettingsApplicationDetailContentTab = ({
applicationId,
installedApplication,
manifestContent,
applicationInfo,
}: SettingsApplicationDetailContentTabProps) => {
const { t } = useLingui();
@@ -70,6 +73,11 @@ export const SettingsApplicationDetailContentTab = ({
manifestContent,
});
const fallbackApplicationData = {
logo: applicationInfo?.logo,
name: applicationInfo?.name,
};
const lifecycleOptions = {
postInstallUniversalIdentifier:
manifestContent?.application?.postInstallLogicFunction
@@ -167,10 +175,14 @@ export const SettingsApplicationDetailContentTab = ({
<SettingsApplicationContentSubtable
title={t`Objects`}
rows={filtered.objects}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Fields added to other objects`}
rows={filtered.fields}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</Table>
</Section>
@@ -186,18 +198,26 @@ export const SettingsApplicationDetailContentTab = ({
<SettingsApplicationContentSubtable
title={t`Page layouts`}
rows={filtered.pageLayouts}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Views`}
rows={filtered.views}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Navigation menu items`}
rows={filtered.navigation}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Front components`}
rows={filtered.frontComponents}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</Table>
</Section>
@@ -213,18 +233,26 @@ export const SettingsApplicationDetailContentTab = ({
<SettingsApplicationContentSubtable
title={t`Logic functions`}
rows={filtered.logicFunctions}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Agents`}
rows={filtered.agents}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Skills`}
rows={filtered.skills}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
<SettingsApplicationContentSubtable
title={t`Roles`}
rows={filtered.roles}
applicationId={applicationId}
fallbackApplicationData={fallbackApplicationData}
/>
</Table>
</Section>
@@ -1,4 +1,3 @@
import { useApplicationAvatarColors } from '@/applications/hooks/useApplicationAvatarColors';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import {
@@ -16,24 +15,21 @@ import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext, useMemo, useState } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsPath } from 'twenty-shared/types';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
Avatar,
CommandBlock,
H2Title,
IconArrowUpRight,
IconChevronRight,
IconCopy,
IconArrowUpRight,
OverflowingTextWithTooltip,
InlineBanner,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
type ApplicationRegistrationFragmentFragment,
FeatureFlagKey,
FindManyApplicationRegistrationsDocument,
} from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
@@ -43,6 +39,8 @@ import {
SettingsApplicationTableRow,
} from '~/pages/settings/applications/components/SettingsApplicationTableRow';
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
const StyledButtonContainer = styled.div`
display: flex;
@@ -61,31 +59,6 @@ const StyledTableRowsContainer = styled.div`
const NPM_PACKAGES_GRID_COLUMNS = '200px 1fr 36px';
type MarketplaceAppAvatarProps = {
application: { id: string; name: string; logo?: string | null };
};
const MarketplaceAppAvatar = ({ application }: MarketplaceAppAvatarProps) => {
const colors = useApplicationAvatarColors({
id: application.id,
name: application.name,
universalIdentifier: application.id,
});
return (
<Avatar
avatarUrl={application.logo || null}
placeholder={application.name}
placeholderColorSeed={application.id ?? application.name}
size="md"
type="app"
color={colors?.color}
backgroundColor={colors?.backgroundColor}
borderColor={colors?.borderColor}
/>
);
};
export const SettingsApplicationsDeveloperTab = () => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
@@ -268,8 +241,7 @@ export const SettingsApplicationsDeveloperTab = () => {
)}
>
<StyledNameTableCell>
<MarketplaceAppAvatar application={application} />
<OverflowingTextWithTooltip text={application.name} />
<ApplicationDisplay application={application} />
</StyledNameTableCell>
<TableCell
overflow="hidden"
@@ -0,0 +1,10 @@
import { type ApplicationDisplayData } from '@/applications/types/applicationDisplayData.type';
export type ApplicationDataTableRow = {
key: string;
labelPlural: string;
icon?: string;
fieldsCount: number;
link?: string;
application: ApplicationDisplayData;
};
@@ -0,0 +1,15 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
export const findObjectNameByUniversalIdentifier = (
universalIdentifier: string,
): string | undefined => {
for (const [objectName, objectDefinition] of Object.entries(
STANDARD_OBJECTS,
)) {
if (objectDefinition.universalIdentifier === universalIdentifier) {
return objectName;
}
}
return undefined;
};
@@ -11,7 +11,6 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
description: 'An app with all entities at root level',
icon: 'IconFolder',
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '[checksum]',
yarnLockChecksum: '[checksum]',
@@ -69,7 +69,6 @@ export const EXPECTED_MANIFEST: Manifest = {
description: 'A simple rich app',
displayName: 'Rich App',
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
icon: 'IconWorld',
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
@@ -6,7 +6,6 @@ describe('defineApplication', () => {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
@@ -22,7 +21,6 @@ describe('defineApplication', () => {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
applicationVariables: {
API_KEY: {
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
@@ -0,0 +1,19 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.2.0', 1777539664664)
export class AddLogoToApplicationFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."application" ADD "logo" text');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."application" DROP COLUMN "logo"',
);
}
}
@@ -19,6 +19,7 @@ import { AddIsPreInstalledToApplicationRegistrationFastInstanceCommand } from 's
import { AddProviderExecutedToAgentMessagePartFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-fast-1777012800000-add-provider-executed-to-agent-message-part';
import { BackfillPageLayoutWidgetPositionSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-instance-command-slow-1795000002000-backfill-page-layout-widget-position';
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -40,4 +41,5 @@ export const INSTANCE_COMMANDS = [
AddProviderExecutedToAgentMessagePartFastInstanceCommand,
BackfillPageLayoutWidgetPositionSlowInstanceCommand,
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
];
@@ -5,6 +5,8 @@ import { promises as fs } from 'fs';
import { resolve } from 'path';
import semver from 'semver';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -141,6 +143,7 @@ export class ApplicationInstallService {
existingApplication,
universalIdentifier,
name: resolvedPackage.manifest.application.displayName,
logo: resolvedPackage.manifest.application.logoUrl ?? null,
workspaceId: params.workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
@@ -454,11 +457,15 @@ export class ApplicationInstallService {
);
}
// TODO: mimeType should be defined, default to application/octet-stream, which won't be displayed
// inline by the browser (forced download) due to Content-Disposition security headers.
const { mimeType, ext } = await extractFileInfo({
file: content,
filename: relativePath,
});
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
await this.fileStorageService.writeFile({
sourceFile: content,
mimeType: undefined,
sourceFile: sanitizedContent,
mimeType,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
@@ -506,6 +513,7 @@ export class ApplicationInstallService {
existingApplication: ApplicationEntity | null;
universalIdentifier: string;
name: string;
logo: string | null;
workspaceId: string;
applicationRegistrationId: string;
sourceType: ApplicationRegistrationSourceType;
@@ -517,6 +525,7 @@ export class ApplicationInstallService {
return await this.applicationService.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
logo: params.logo,
sourcePath: params.universalIdentifier,
sourceType: params.sourceType,
applicationRegistrationId: params.applicationRegistrationId,
@@ -159,6 +159,7 @@ export class ApplicationSyncService {
return await this.applicationService.update(application.id, {
name,
description: manifest.application.description,
logo: manifest.application.logoUrl ?? null,
version: packageJson.version,
packageJsonChecksum: manifest.application.packageJsonChecksum,
yarnLockChecksum: manifest.application.yarnLockChecksum,
@@ -25,10 +25,6 @@ export class MarketplaceAppDTO {
@MaxLength(160)
description: string;
@IsString()
@Field()
icon: string;
@IsString()
@Field()
author: string;
@@ -87,7 +87,6 @@ export class MarketplaceQueryService {
id: registration.universalIdentifier,
name: app?.displayName ?? registration.name,
description: app?.description ?? '',
icon: app?.icon ?? 'IconApps',
author: `${app?.author ?? 'Unknown'}`,
category: app?.category ?? '',
logo: app?.logoUrl ?? undefined,
@@ -278,6 +278,14 @@ export class ApplicationRegistrationService {
params.universalIdentifier,
);
const curatedIdentifiers = new Set(
MARKETPLACE_CURATED_APPLICATIONS.map(
(entry) => entry.universalIdentifier,
),
);
const isFeatured = curatedIdentifiers.has(params.universalIdentifier);
if (isDefined(existing)) {
await this.applicationRegistrationRepository.save({
...existing,
@@ -286,16 +294,9 @@ export class ApplicationRegistrationService {
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
manifest: params.manifest,
isFeatured,
});
} else {
const curatedIdentifiers = new Set(
MARKETPLACE_CURATED_APPLICATIONS.map(
(entry) => entry.universalIdentifier,
),
);
const isFeatured = curatedIdentifiers.has(params.universalIdentifier);
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
@@ -50,6 +50,9 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@Column({ nullable: true, type: 'text' })
description: string | null;
@Column({ nullable: true, type: 'text' })
logo: string | null;
// TODO should not be nullable
@Column({ nullable: true, type: 'text' })
version: string | null;
@@ -34,6 +34,11 @@ export class ApplicationDTO {
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
logo?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
@@ -5,6 +5,7 @@ export const fromFlatApplicationToApplicationDto = ({
canBeUninstalled,
description,
id,
logo,
name,
packageJsonChecksum,
packageJsonFileId,
@@ -19,6 +20,7 @@ export const fromFlatApplicationToApplicationDto = ({
canBeUninstalled,
description: description ?? undefined,
id,
logo: logo ?? undefined,
name,
objects: [],
packageJsonChecksum: packageJsonChecksum ?? undefined,
@@ -25,6 +25,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
universalIdentifier: '20202020-81ee-42da-a281-668632f32fe7',
name: 'Workspace Custom Application',
description: null,
logo: null,
workspaceId: 'workspace-id',
version: null,
sourceType: ApplicationRegistrationSourceType.LOCAL,
@@ -20,6 +20,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
universalIdentifier: '20202020-81ee-42da-a281-668632f32fe7',
name: 'Workspace Custom Application',
description: null,
logo: null,
version: null,
workspaceId: 'workspace-id',
sourceType: ApplicationRegistrationSourceType.LOCAL,
@@ -10,6 +10,7 @@ export const APPLICATION_GQL_FIELDS = `
id
name
description
logo
version
universalIdentifier
canBeUninstalled
@@ -13,7 +13,6 @@ const MARKETPLACE_QUERY = `
description
author
sourcePackage
icon
category
logo
isFeatured
@@ -135,7 +134,6 @@ describe('Marketplace Catalog Sync (integration)', () => {
sourcePackage: '@twentyhq/app-data-enrichment',
manifest: {
application: {
icon: 'IconSparkles',
category: 'Data',
},
},
@@ -175,7 +173,6 @@ describe('Marketplace Catalog Sync (integration)', () => {
expect(curatedApp).toBeDefined();
expect(curatedApp.name).toBe('Data Enrichment');
expect(curatedApp.category).toBe('Data');
expect(curatedApp.icon).toBe('IconSparkles');
});
});
@@ -14,7 +14,6 @@ export const buildBaseManifest = ({
defaultRoleUniversalIdentifier: roleId,
displayName: 'Test Application',
description: 'Test application',
icon: 'IconTestPipe',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
@@ -8,7 +8,6 @@ export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
displayName: string;
description: string;
icon?: string;
applicationVariables?: ApplicationVariables;
serverVariables?: ServerVariables;
author?: string;
@@ -150,13 +150,15 @@ export const Avatar = ({
const showBackgroundColor = showPlaceholder;
const showBorderColor = showPlaceholder;
return (
<StyledAvatar
size={size}
backgroundColor={
Icon ? 'inherit' : showBackgroundColor ? fixedBackgroundColor : 'none'
}
borderColor={fixedBorderColor}
borderColor={showBorderColor ? fixedBorderColor : undefined}
color={fixedColor}
clickable={!isUndefined(onClick)}
rounded={type === 'rounded'}