Fix Apps UI: replace 'Managed' label with actual app name and unify app icons (#19897)

## Summary

- The Data Model table was labeling core Twenty objects (e.g. Person,
Company) as **Managed** even though they are part of the standard
application. This PR teaches the frontend to resolve an `applicationId`
back to its real application name (`Standard`, `Custom`, or any
installed app), and removes the misleading **Managed** label entirely.
- Introduces a single, consistent way to render an "app badge" across
the settings UI:
- new `Avatar` variant `type="app"` (rounded 4px corners + 1px
deterministic border derived from `placeholderColorSeed`)
- new `AppChip` component (icon + name) backed by a new
`useApplicationChipData` hook
- new `useApplicationsByIdMap` hook + `CurrentApplicationContext` so the
chip can render **This app** when shown inside the matching app's detail
page
- Reuses these primitives on:
- the application detail page header (`SettingsApplicationDetailTitle`)
  - the Installed / My apps tables (`SettingsApplicationTableRow`)
  - the NPM packages list (`SettingsApplicationsDeveloperTab`)
- Backend: exposes a minimal `installedApplications { id name
universalIdentifier }` field on `Workspace` (resolved from the workspace
cache, soft-deleted entries filtered out) so the frontend can resolve
`applicationId` -> name without N+1 fetches.
- Cleanup: deletes `getItemTagInfo` and inlines its tiny
responsibilities into the components that need them, matching the
`RecordChip` pattern.
This commit is contained in:
Charles Bochet 2026-04-21 00:44:14 +02:00 committed by GitHub
parent 9a963ddeca
commit 96fc98e710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 564 additions and 290 deletions

View file

@ -830,6 +830,7 @@ type Workspace {
workspaceCustomApplication: Application
featureFlags: [FeatureFlag!]
billingSubscriptions: [BillingSubscription!]!
installedApplications: [Application!]!
currentBillingSubscription: BillingSubscription
billingEntitlements: [BillingEntitlement!]!
hasValidEnterpriseKey: Boolean!

View file

@ -619,6 +619,7 @@ export interface Workspace {
workspaceCustomApplication?: Application
featureFlags?: FeatureFlag[]
billingSubscriptions: BillingSubscription[]
installedApplications: Application[]
currentBillingSubscription?: BillingSubscription
billingEntitlements: BillingEntitlement[]
hasValidEnterpriseKey: Scalars['Boolean']
@ -3504,6 +3505,7 @@ export interface WorkspaceGenqlSelection{
workspaceCustomApplication?: ApplicationGenqlSelection
featureFlags?: FeatureFlagGenqlSelection
billingSubscriptions?: BillingSubscriptionGenqlSelection
installedApplications?: ApplicationGenqlSelection
currentBillingSubscription?: BillingSubscriptionGenqlSelection
billingEntitlements?: BillingEntitlementGenqlSelection
hasValidEnterpriseKey?: boolean | number

View file

@ -1765,6 +1765,9 @@ export default {
"billingSubscriptions": [
138
],
"installedApplications": [
52
],
"currentBillingSubscription": [
138
],

File diff suppressed because one or more lines are too long

View file

@ -87,6 +87,7 @@ const mockWorkspace = {
useRecommendedModels: true,
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
installedApplications: [],
};
const createMockOptions = (): Options => ({

View file

@ -0,0 +1,64 @@
import { useApplicationChipData } from '@/applications/hooks/useApplicationChipData';
import { Avatar } from 'twenty-ui/display';
import {
Chip,
ChipAccent,
type ChipSize,
ChipVariant,
LinkChip,
} from 'twenty-ui/components';
type AppChipProps = {
applicationId: string;
variant?: ChipVariant;
size?: ChipSize;
to?: string;
className?: string;
};
export const AppChip = ({
applicationId,
variant,
size,
to,
className,
}: AppChipProps) => {
const { applicationChipData } = useApplicationChipData({ applicationId });
const leftComponent = (
<Avatar
type="app"
size="md"
placeholder={applicationChipData.name}
placeholderColorSeed={applicationChipData.seed}
color={applicationChipData.colors?.color}
backgroundColor={applicationChipData.colors?.backgroundColor}
borderColor={applicationChipData.colors?.borderColor}
/>
);
if (to) {
return (
<LinkChip
className={className}
label={applicationChipData.name}
leftComponent={leftComponent}
size={size}
variant={variant ?? ChipVariant.Highlighted}
accent={ChipAccent.TextPrimary}
to={to}
/>
);
}
return (
<Chip
className={className}
label={applicationChipData.name}
leftComponent={leftComponent}
size={size}
variant={variant ?? ChipVariant.Transparent}
accent={ChipAccent.TextPrimary}
/>
);
};

View file

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const CurrentApplicationContext = createContext<string | null>(null);

View file

@ -0,0 +1,59 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import {
TWENTY_STANDARD_APPLICATION_NAME,
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
export type ApplicationAvatarColors = {
color: string;
backgroundColor: string;
borderColor: string;
};
type UseApplicationAvatarColorsArgs = {
id?: string | null;
name?: string | null;
universalIdentifier?: string | null;
};
export const useApplicationAvatarColors = (
application: UseApplicationAvatarColorsArgs | null | undefined,
): ApplicationAvatarColors | undefined => {
const { theme } = useContext(ThemeContext);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
if (!isDefined(application) || !isDefined(application.id)) {
return undefined;
}
const isStandard =
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER ||
application.name === TWENTY_STANDARD_APPLICATION_NAME;
const isCustom =
isDefined(currentWorkspace?.workspaceCustomApplication?.id) &&
currentWorkspace.workspaceCustomApplication.id === application.id;
if (isStandard) {
return {
backgroundColor: theme.color.blue5,
borderColor: theme.color.blue6,
color: theme.color.blue12,
};
}
if (isCustom) {
return {
backgroundColor: theme.color.orange5,
borderColor: theme.color.orange6,
color: theme.color.orange12,
};
}
return undefined;
};

View file

@ -0,0 +1,57 @@
import { CurrentApplicationContext } from '@/applications/contexts/CurrentApplicationContext';
import {
useApplicationAvatarColors,
type ApplicationAvatarColors,
} from '@/applications/hooks/useApplicationAvatarColors';
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';
type UseApplicationChipDataArgs = {
applicationId: string;
};
type ApplicationChipData = {
name: string;
seed: string;
colors?: ApplicationAvatarColors;
};
type UseApplicationChipDataReturnType = {
applicationChipData: ApplicationChipData;
};
export const useApplicationChipData = ({
applicationId,
}: UseApplicationChipDataArgs): UseApplicationChipDataReturnType => {
const currentApplicationId = useContext(CurrentApplicationContext);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const application = currentWorkspace?.installedApplications.find(
(installedApplication) => installedApplication.id === applicationId,
);
const colors = useApplicationAvatarColors(application);
if (!isDefined(application)) {
return {
applicationChipData: {
name: '',
seed: applicationId,
},
};
}
const isCurrent =
isDefined(currentApplicationId) && currentApplicationId === applicationId;
return {
applicationChipData: {
name: isCurrent ? t`This app` : application.name,
seed: application.universalIdentifier ?? application.name,
colors,
},
};
};

View file

@ -45,6 +45,10 @@ export type CurrentWorkspace = Pick<
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
workspaceCustomApplication: Pick<Application, 'id'> | null;
installedApplications: Pick<
Application,
'id' | 'name' | 'universalIdentifier'
>[];
};
export const currentWorkspaceState = createAtomState<CurrentWorkspace | null>({

View file

@ -19,6 +19,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
isLabelSyncedWithName
applicationId
fieldsList {
id
universalIdentifier
@ -207,6 +208,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
isLabelSyncedWithName
applicationId
}
}
`;
@ -230,6 +232,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
isLabelSyncedWithName
applicationId
}
}
`;

View file

@ -19,6 +19,7 @@ export const query = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
isLabelSyncedWithName
applicationId
}
}
`;
@ -41,4 +42,5 @@ export const responseData = {
updatedAt: '',
labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1',
imageIdentifierFieldMetadataId: '',
applicationId: null,
};

View file

@ -29,6 +29,7 @@ describe('useColumnDefinitionsFromObjectMetadata', () => {
workspaceCustomApplication: {
id: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
},
installedApplications: [],
id: '1',
featureFlags: [],
allowImpersonation: false,

View file

@ -1,11 +1,13 @@
import { isNonEmptyString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ComponentType, type ReactNode } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
IconAlertTriangle,
IconBrandNpm,
type IconComponent,
IconLink,
IconMail,
IconWorld,
@ -13,7 +15,7 @@ import {
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type ContentEntry = {
icon: ComponentType<{ size?: number }>;
icon: IconComponent;
count: number;
one: string;
many: string;
@ -62,17 +64,11 @@ const StyledSidebarValue = styled.div`
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledContentItem = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
const StyledContentList = styled.div`
align-items: flex-start;
display: flex;
font-size: ${themeCssVariables.font.size.sm};
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledLink = styled.a`
@ -149,12 +145,18 @@ export const SettingsApplicationAboutSidebar = ({
{filteredContentEntries.length > 0 && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
{filteredContentEntries.map((entry) => (
<StyledContentItem key={entry.one}>
<entry.icon size={16} />
{entry.count} {entry.count === 1 ? entry.one : entry.many}
</StyledContentItem>
))}
<StyledContentList>
{filteredContentEntries.map((entry) => (
<Tag
key={entry.one}
color="gray"
Icon={entry.icon}
text={`${entry.count} ${
entry.count === 1 ? entry.one : entry.many
}`}
/>
))}
</StyledContentList>
</StyledSidebarSection>
)}

View file

@ -1,10 +1,7 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { styled } from '@linaria/react';
import { AppChip } from '@/applications/components/AppChip';
import { Avatar } from 'twenty-ui/display';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { Chip, ChipAccent, ChipVariant } from 'twenty-ui/components';
import { isDefined } from 'twenty-shared/utils';
type SettingsItemTypeTagProps = {
item: {
@ -15,38 +12,30 @@ type SettingsItemTypeTagProps = {
className?: string;
};
const StyledContainer = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[1]};
`;
export const SettingsItemTypeTag = ({
className,
item: { isCustom, isRemote, applicationId },
item: { isRemote, applicationId },
}: SettingsItemTypeTagProps) => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const itemTagInfo = getItemTagInfo({
item: { isCustom, isRemote, applicationId },
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
});
const { theme } = useContext(ThemeContext);
return (
<StyledContainer className={className}>
<Avatar
placeholder={itemTagInfo.labelText}
placeholderColorSeed={itemTagInfo.labelText}
type="squared"
size="xs"
color={theme.tag.text[itemTagInfo.labelColor]}
backgroundColor={theme.tag.background[itemTagInfo.labelColor]}
if (isDefined(applicationId)) {
return <AppChip applicationId={applicationId} className={className} />;
} else if (isRemote === true) {
return (
<Chip
className={className}
label="Remote"
variant={ChipVariant.Transparent}
accent={ChipAccent.TextPrimary}
leftComponent={
<Avatar
type="app"
size="md"
placeholder="Remote"
placeholderColorSeed="Remote"
/>
}
/>
{itemTagInfo.labelText}
</StyledContainer>
);
);
} else {
return null;
}
};

View file

@ -1,56 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
export type ItemTagInfo =
| StandardItemTagInfo
| CustomItemTagInfo
| RemoteItemTagInfo
| ManagedItemTagInfo;
type StandardItemTagInfo = {
labelText: 'Standard';
labelColor: 'blue';
};
type CustomItemTagInfo = {
labelText: 'Custom';
labelColor: 'orange';
};
type RemoteItemTagInfo = {
labelText: 'Remote';
labelColor: 'green';
};
type ManagedItemTagInfo = {
labelText: 'Managed';
labelColor: 'sky';
};
export const getItemTagInfo = ({
item: { isCustom, isRemote, applicationId },
workspaceCustomApplicationId,
}: {
item: {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
};
workspaceCustomApplicationId?: string;
}): ItemTagInfo => {
if (
isDefined(applicationId) &&
applicationId !== workspaceCustomApplicationId
) {
return { labelText: 'Managed', labelColor: 'sky' };
}
if (isCustom!!) {
return { labelText: 'Custom', labelColor: 'orange' };
}
if (isRemote!!) {
return { labelText: 'Remote', labelColor: 'green' };
}
return { labelText: 'Standard', labelColor: 'blue' };
};

View file

@ -17,11 +17,13 @@ const StyledHeaderTitle = styled.div`
type SettingsLogicFunctionLabelContainerProps = {
value: string;
onChange: (value: string) => void;
readonly?: boolean;
};
export const SettingsLogicFunctionLabelContainer = ({
value,
onChange,
readonly = false,
}: SettingsLogicFunctionLabelContainerProps) => {
return (
<StyledHeaderTitle>
@ -31,6 +33,7 @@ export const SettingsLogicFunctionLabelContainer = ({
value={value}
onChange={onChange}
placeholder={t`Function name`}
disabled={readonly}
/>
</StyledHeaderTitle>
);

View file

@ -18,7 +18,7 @@ const StyledIconContainer = styled.span`
`;
const StyledIconChevronRightContainer = styled(StyledIconContainer)`
color: ${themeCssVariables.font.color.tertiary};
color: ${themeCssVariables.font.color.light};
`;
export const SettingsLogicFunctionsFieldItemTableRow = ({

View file

@ -5,17 +5,20 @@ import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFu
export const SettingsLogicFunctionSettingsTab = ({
formValues,
onChange,
readonly = false,
}: {
formValues: LogicFunctionFormValues;
onChange: <TKey extends keyof LogicFunctionFormValues>(
key: TKey,
) => (value: LogicFunctionFormValues[TKey]) => void;
readonly?: boolean;
}) => {
return (
<>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly={readonly}
/>
<SettingsLogicFunctionTabEnvironmentVariablesSection />
</>

View file

@ -57,11 +57,13 @@ export const TableSection = ({
<IconChevronUp
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
) : (
<IconChevronDown
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
)}
</StyledSectionHeader>

View file

@ -66,6 +66,11 @@ export const USER_QUERY_FRAGMENT = gql`
workspaceCustomApplication {
id
}
installedApplications {
id
name
universalIdentifier
}
isCustomDomainEnabled
workspaceUrls {
...WorkspaceUrlsFragment

View file

@ -1,3 +1,4 @@
import { CurrentApplicationContext } from '@/applications/contexts/CurrentApplicationContext';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
@ -287,30 +288,35 @@ export const SettingsApplicationDetails = () => {
};
return (
<SubMenuTopBarContainer
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={logoUrl}
/>
}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: displayName },
]}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={APPLICATION_DETAIL_ID} />
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
<CurrentApplicationContext.Provider value={application?.id ?? null}>
<SubMenuTopBarContainer
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={logoUrl}
applicationId={application?.id}
applicationName={application?.name}
universalIdentifier={application?.universalIdentifier}
/>
}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: displayName },
]}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={APPLICATION_DETAIL_ID} />
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</CurrentApplicationContext.Provider>
);
};

View file

@ -1,3 +1,4 @@
import { CurrentApplicationContext } from '@/applications/contexts/CurrentApplicationContext';
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
@ -249,36 +250,41 @@ export const SettingsAvailableApplicationDetails = () => {
}
return (
<SubMenuTopBarContainer
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: displayName },
]}
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={app?.logoUrl}
isUnlisted={isUnlisted}
/>
}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
behaveAsLinks={false}
/>
<CurrentApplicationContext.Provider value={application?.id ?? null}>
<SubMenuTopBarContainer
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: displayName },
]}
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
logoUrl={app?.logoUrl}
applicationId={application?.id}
applicationName={application?.name}
universalIdentifier={detail.universalIdentifier}
isUnlisted={isUnlisted}
/>
}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
behaveAsLinks={false}
/>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
</CurrentApplicationContext.Provider>
);
};

View file

@ -5,15 +5,30 @@ import {
} 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 } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
const MAIN_ROW_GRID_COLUMNS = '180px 1fr 98.7px 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,
}: {
@ -26,13 +41,17 @@ export const SettingsApplicationDataTableRow = ({
return (
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS} to={row.link}>
<StyledNameTableCell>
<StyledNameTableCell minWidth="0" overflow="hidden">
{isDefined(Icon) && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
{row.labelPlural}
<StyledNameContainer>
<StyledNameLabel title={row.labelPlural}>
{row.labelPlural}
</StyledNameLabel>
</StyledNameContainer>
</StyledNameTableCell>
<TableCell>
<TableCell minWidth="0" overflow="hidden">
<SettingsItemTypeTag item={row.tagItem} />
</TableCell>
<TableCell align="right">{row.fieldsCount}</TableCell>
@ -41,7 +60,7 @@ export const SettingsApplicationDataTableRow = ({
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
color={theme.font.color.light}
/>
)}
</StyledActionTableCell>

View file

@ -1,13 +1,17 @@
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 { IconEyeOff } from 'twenty-ui/display';
import { Avatar, IconEyeOff } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
type SettingsApplicationDetailTitleProps = {
displayName: string;
description?: string;
logoUrl?: string;
applicationId?: string;
applicationName?: string;
universalIdentifier?: string;
isUnlisted?: boolean;
};
@ -36,37 +40,6 @@ const StyledHeaderTop = styled.div`
gap: ${themeCssVariables.spacing[2]};
`;
const StyledLogo = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.tertiary};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-shrink: 0;
height: 24px;
justify-content: center;
overflow: hidden;
width: 24px;
`;
const StyledLogoImage = styled.img`
height: 32px;
object-fit: contain;
width: 32px;
`;
const StyledLogoPlaceholder = styled.div`
align-items: center;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.xs};
color: ${themeCssVariables.font.color.inverted};
display: flex;
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.medium};
height: 32px;
justify-content: center;
width: 32px;
`;
const StyledAppName = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
@ -99,8 +72,17 @@ export const SettingsApplicationDetailTitle = ({
displayName,
description,
logoUrl,
applicationId,
applicationName,
universalIdentifier,
isUnlisted = false,
}: SettingsApplicationDetailTitleProps) => {
const colors = useApplicationAvatarColors({
id: applicationId,
name: applicationName,
universalIdentifier,
});
return (
<StyledTitleContainer>
{isUnlisted && (
@ -112,15 +94,16 @@ export const SettingsApplicationDetailTitle = ({
<StyledHeader>
<StyledHeaderLeft>
<StyledHeaderTop>
<StyledLogo>
{logoUrl ? (
<StyledLogoImage src={logoUrl} alt={displayName} />
) : (
<StyledLogoPlaceholder>
{displayName.charAt(0).toUpperCase()}
</StyledLogoPlaceholder>
)}
</StyledLogo>
<Avatar
type="app"
size="lg"
avatarUrl={logoUrl}
placeholder={displayName}
placeholderColorSeed={universalIdentifier ?? displayName}
color={colors?.color}
backgroundColor={colors?.backgroundColor}
borderColor={colors?.borderColor}
/>
<StyledAppName>{displayName}</StyledAppName>
</StyledHeaderTop>
{description && (

View file

@ -1,10 +1,10 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
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 { Table } from '@/ui/layout/table/components/Table';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@ -43,10 +43,14 @@ export const SettingsApplicationNameDescriptionTable = ({
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
>
{item.name}
<OverflowingTextWithTooltip text={item.name} />
</TableCell>
<TableCell minWidth="0" overflow="hidden">
<OverflowingTextWithTooltip text={item.description ?? ''} />
</TableCell>
<TableCell>{item.description ?? ''}</TableCell>
</TableRow>
))}
</TableSection>

View file

@ -1,10 +1,11 @@
import { type ReactNode } from 'react';
import { t } from '@lingui/core/macro';
import { useApplicationAvatarColors } from '@/applications/hooks/useApplicationAvatarColors';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { t } from '@lingui/core/macro';
import { Tag } from 'twenty-ui/components';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
@ -24,6 +25,8 @@ export const SettingsApplicationTableRow = ({
hasUpdate,
link,
}: SettingsApplicationTableRowProps) => {
const colors = useApplicationAvatarColors(application);
return (
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
@ -36,6 +39,17 @@ export const SettingsApplicationTableRow = ({
minWidth="0"
overflow="hidden"
>
<Avatar
type="app"
size="md"
placeholder={application.name}
placeholderColorSeed={
application.universalIdentifier ?? application.name
}
color={colors?.color}
backgroundColor={colors?.backgroundColor}
borderColor={colors?.borderColor}
/>
<OverflowingTextWithTooltip text={application.name} />
</TableCell>
<TableCell gap={themeCssVariables.spacing[2]} minWidth="0">

View file

@ -92,6 +92,7 @@ export const SettingsApplicationsTable = ({
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
}
link={getSettingsPath(SettingsPath.ApplicationDetail, {

View file

@ -1,3 +1,4 @@
import { useApplicationAvatarColors } from '@/applications/hooks/useApplicationAvatarColors';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import {
@ -59,6 +60,31 @@ 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);
@ -183,6 +209,7 @@ export const SettingsApplicationsDeveloperTab = () => {
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
}
link={getRegistrationLink(registration)}
@ -240,13 +267,7 @@ export const SettingsApplicationsDeveloperTab = () => {
)}
>
<StyledNameTableCell>
<Avatar
avatarUrl={application.logo || null}
placeholder={application.name}
placeholderColorSeed={application.name}
size="md"
type="squared"
/>
<MarketplaceAppAvatar application={application} />
<OverflowingTextWithTooltip text={application.name} />
</StyledNameTableCell>
<TableCell
@ -262,7 +283,7 @@ export const SettingsApplicationsDeveloperTab = () => {
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
color={theme.font.color.light}
/>
</StyledActionTableCell>
</TableRow>

View file

@ -6,6 +6,7 @@ export type ApplicationWithoutRelation = Pick<
| 'name'
| 'description'
| 'version'
| 'universalIdentifier'
| 'applicationRegistrationId'
| 'applicationRegistration'
>;

View file

@ -13,7 +13,6 @@ import {
StyledStickyFirstCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { SettingsObjectInactiveMenuDropDown } from '@/settings/data-model/objects/components/SettingsObjectInactiveMenuDropDown';
import { getItemTagInfo } from '@/settings/data-model/utils/getItemTagInfo';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -86,6 +85,7 @@ export const SettingsObjectTable = ({
useCombinedGetTotalCount();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const installedApplications = currentWorkspace?.installedApplications;
const allObjectSettingsArray = useMemo(
() =>
@ -94,11 +94,11 @@ export const SettingsObjectTable = ({
({
objectMetadataItem,
labelPlural: objectMetadataItem.labelPlural,
objectTypeLabel: getItemTagInfo({
item: objectMetadataItem,
workspaceCustomApplicationId:
currentWorkspace?.workspaceCustomApplication?.id,
}).labelText,
objectTypeLabel:
installedApplications?.find(
(application) =>
application.id === objectMetadataItem.applicationId,
)?.name ?? (objectMetadataItem.isRemote ? 'Remote' : ''),
fieldsCount: objectMetadataItem.fields.filter(
(field) => !isHiddenSystemField(field),
).length,
@ -111,7 +111,7 @@ export const SettingsObjectTable = ({
[
objectMetadataItems,
totalCountByObjectMetadataItemNamePlural,
currentWorkspace,
installedApplications,
],
);

View file

@ -29,7 +29,7 @@ import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogic
const LOGIC_FUNCTION_DETAIL_ID = 'logic-function-detail';
export const SettingsLogicFunctionDetail = () => {
const { logicFunctionId = '', applicationId = '' } = useParams();
const { logicFunctionId = '', applicationId } = useParams();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@ -37,8 +37,8 @@ export const SettingsLogicFunctionDetail = () => {
const { data, loading: applicationLoading } = useQuery(
FindOneApplicationDocument,
{
variables: { id: applicationId },
skip: !applicationId,
variables: { id: applicationId ?? '' },
skip: !isDefined(applicationId),
},
);
@ -47,7 +47,8 @@ export const SettingsLogicFunctionDetail = () => {
const workspaceCustomApplicationId =
currentWorkspace?.workspaceCustomApplication?.id;
const isManaged = applicationId !== workspaceCustomApplicationId;
const isReadonly =
isDefined(applicationId) && applicationId !== workspaceCustomApplicationId;
const instanceId = `${LOGIC_FUNCTION_DETAIL_ID}-${logicFunctionId}`;
@ -74,8 +75,8 @@ export const SettingsLogicFunctionDetail = () => {
id: 'editor',
title: t`Editor`,
Icon: IconCode,
disabled: isManaged,
hide: isManaged,
disabled: isReadonly,
hide: isReadonly,
},
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
{ id: 'test', title: t`Test`, Icon: IconPlayerPlay },
@ -87,41 +88,40 @@ export const SettingsLogicFunctionDetail = () => {
const isSettingsTab = activeTabId === 'settings';
const isTestTab = activeTabId === 'test';
const breadcrumbLinks =
isDefined(applicationId) && applicationId !== ''
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{
children: `${applicationName}`,
href: getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'content',
),
},
{ children: `${logicFunction?.name}` },
]
: [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{ children: `${logicFunction?.name}` },
];
const breadcrumbLinks = isDefined(applicationId)
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{
children: `${applicationName}`,
href: getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'content',
),
},
{ children: `${logicFunction?.name}` },
]
: [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{ children: `${logicFunction?.name}` },
];
const files = [
{
@ -139,6 +139,7 @@ export const SettingsLogicFunctionDetail = () => {
<SettingsLogicFunctionLabelContainer
value={formValues.name}
onChange={onChange('name')}
readonly={isReadonly}
/>
}
links={breadcrumbLinks}
@ -160,6 +161,7 @@ export const SettingsLogicFunctionDetail = () => {
<SettingsLogicFunctionSettingsTab
formValues={formValues}
onChange={onChange}
readonly={isReadonly}
/>
)}
{isTestTab && (

View file

@ -61,6 +61,7 @@ const PRO_METERED_MONTHLY_PRICE = PRO_METERED_PRODUCT?.prices?.find(
export const mockCurrentWorkspace = {
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
installedApplications: [CUSTOM_WORKSPACE_APPLICATION_MOCK],
subdomain: 'acme.twenty.com',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6w',
displayName: 'Twenty',

View file

@ -13,6 +13,7 @@ import {
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/application-package/utils/get-default-application-package-fields.util';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@ -138,6 +139,20 @@ export class ApplicationService {
});
}
async findManyInstalledFlatApplications(
workspaceId: string,
): Promise<FlatApplication[]> {
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatApplicationMaps',
]);
return Object.values(flatApplicationMaps.byId).filter(
(flatApplication): flatApplication is FlatApplication =>
isDefined(flatApplication) && !isDefined(flatApplication.deletedAt),
);
}
async findOneApplication({
id,
universalIdentifier,

View file

@ -258,6 +258,18 @@ export class WorkspaceResolver {
}
}
@ResolveField(() => [ApplicationDTO])
async installedApplications(
@Parent() workspace: WorkspaceEntity,
): Promise<ApplicationDTO[]> {
const flatApplications =
await this.applicationService.findManyInstalledFlatApplications(
workspace.id,
);
return flatApplications.map(fromFlatApplicationToApplicationDto);
}
@ResolveField(() => BillingSubscriptionEntity, { nullable: true })
async currentBillingSubscription(
@Parent() workspace: WorkspaceEntity,

View file

@ -1,9 +1,14 @@
import {
TWENTY_STANDARD_APPLICATION_NAME,
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
} from 'twenty-shared/application';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
export const TWENTY_STANDARD_APPLICATION = {
universalIdentifier: '20202020-64aa-4b6f-b003-9c74b97cee20',
name: 'Standard',
universalIdentifier: TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
name: TWENTY_STANDARD_APPLICATION_NAME,
description:
'Twenty is an open-source CRM that allows you to manage your sales and customer relationships',
version: '1.0.1',

View file

@ -0,0 +1 @@
export const TWENTY_STANDARD_APPLICATION_NAME = 'Standard';

View file

@ -0,0 +1,2 @@
export const TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER =
'20202020-64aa-4b6f-b003-9c74b97cee20';

View file

@ -18,6 +18,8 @@ export { DEFAULT_APP_ACCESS_TOKEN_NAME } from './constants/DefaultAppAccessToken
export { GENERATED_DIR } from './constants/GeneratedDirectory';
export { NODE_ESM_CJS_BANNER } from './constants/NodeEsmCjsBanner';
export { OUTPUT_DIR } from './constants/OutputDirectory';
export { TWENTY_STANDARD_APPLICATION_NAME } from './constants/TwentyStandardApplicationName';
export { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from './constants/TwentyStandardApplicationUniversalIdentifier';
export { SyncableEntity } from './enums/syncable-entities.enum';
export type {
RegularFieldManifest,

View file

@ -20,6 +20,7 @@ const StyledAvatar = styled.div<{
clickable?: boolean;
color: string;
backgroundColor: string;
borderColor?: string;
backgroundTransparentLight: string;
type?: Nullable<AvatarType>;
}>`
@ -29,8 +30,13 @@ const StyledAvatar = styled.div<{
user-select: none;
border-radius: ${({ rounded, type }) => {
return rounded ? '50%' : type === 'icon' ? '4px' : '2px';
if (rounded) return '50%';
if (type === 'icon') return '4px';
return '2px';
}};
border: ${({ type, borderColor }) =>
type === 'app' && borderColor ? `1px solid ${borderColor}` : 'none'};
box-sizing: border-box;
display: flex;
font-size: ${({ size }) => AVATAR_PROPERTIES_BY_SIZE[size].fontSize};
height: ${({ size }) => AVATAR_PROPERTIES_BY_SIZE[size].width};
@ -69,6 +75,7 @@ export type AvatarProps = {
type?: Nullable<AvatarType>;
color?: string;
backgroundColor?: string;
borderColor?: string;
onClick?: () => void;
};
@ -83,6 +90,7 @@ export const Avatar = ({
type = 'squared',
color,
backgroundColor,
borderColor,
}: AvatarProps) => {
const { theme } = useContext(ThemeContext);
@ -124,10 +132,22 @@ export const Avatar = ({
: (backgroundColor ??
stringToThemeColorP3String({
string: placeholderColorSeed ?? '',
variant: 4,
variant: type === 'app' ? 5 : 4,
theme,
}));
const fixedBorderColor =
type === 'app'
? (borderColor ??
(isPlaceholderFirstCharEmpty
? undefined
: stringToThemeColorP3String({
string: placeholderColorSeed ?? '',
variant: 6,
theme,
})))
: undefined;
const showBackgroundColor = showPlaceholder;
return (
@ -136,6 +156,7 @@ export const Avatar = ({
backgroundColor={
Icon ? 'inherit' : showBackgroundColor ? fixedBackgroundColor : 'none'
}
borderColor={fixedBorderColor}
color={fixedColor}
clickable={!isUndefined(onClick)}
rounded={type === 'rounded'}

View file

@ -39,3 +39,12 @@ export const NoAvatarPictureSquared: Story = {
...Squared.args,
},
};
export const App: Story = {
args: {
type: 'app',
avatarUrl: '',
placeholder: 'Acme',
placeholderColorSeed: 'acme-app',
},
};

View file

@ -1 +1 @@
export type AvatarType = 'squared' | 'rounded' | 'icon';
export type AvatarType = 'squared' | 'rounded' | 'icon' | 'app';