Factorize application pages

This commit is contained in:
martmull
2026-04-17 22:59:07 +02:00
parent 38a03abc06
commit b8dfce7300
19 changed files with 1765 additions and 1624 deletions

View File

@@ -296,6 +296,33 @@ type ApplicationVariable {
isSecret: Boolean!
}
type AuthToken {
token: String!
expiresAt: DateTime!
}
type ApplicationTokenPair {
applicationAccessToken: AuthToken!
applicationRefreshToken: AuthToken!
}
type FrontComponent {
id: UUID!
name: String!
description: String
sourceComponentPath: String!
builtComponentPath: String!
componentName: String!
builtComponentChecksum: String!
universalIdentifier: UUID
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
isHeadless: Boolean!
usesSdkClient: Boolean!
applicationTokenPair: ApplicationTokenPair
}
type LogicFunction {
id: UUID!
name: String!
@@ -565,6 +592,7 @@ type Application {
settingsCustomTabFrontComponentId: UUID
defaultLogicFunctionRole: Role
agents: [Agent!]!
frontComponents: [FrontComponent!]!
logicFunctions: [LogicFunction!]!
objects: [Object!]!
applicationVariables: [ApplicationVariable!]!
@@ -2359,33 +2387,6 @@ type UpsertRowLevelPermissionPredicatesResult {
predicateGroups: [RowLevelPermissionPredicateGroup!]!
}
type AuthToken {
token: String!
expiresAt: DateTime!
}
type ApplicationTokenPair {
applicationAccessToken: AuthToken!
applicationRefreshToken: AuthToken!
}
type FrontComponent {
id: UUID!
name: String!
description: String
sourceComponentPath: String!
builtComponentPath: String!
componentName: String!
builtComponentChecksum: String!
universalIdentifier: UUID
applicationId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
isHeadless: Boolean!
usesSdkClient: Boolean!
applicationTokenPair: ApplicationTokenPair
}
type LogicFunctionLogs {
"""Execution Logs"""
logs: String!

View File

@@ -247,6 +247,36 @@ export interface ApplicationVariable {
__typename: 'ApplicationVariable'
}
export interface AuthToken {
token: Scalars['String']
expiresAt: Scalars['DateTime']
__typename: 'AuthToken'
}
export interface ApplicationTokenPair {
applicationAccessToken: AuthToken
applicationRefreshToken: AuthToken
__typename: 'ApplicationTokenPair'
}
export interface FrontComponent {
id: Scalars['UUID']
name: Scalars['String']
description?: Scalars['String']
sourceComponentPath: Scalars['String']
builtComponentPath: Scalars['String']
componentName: Scalars['String']
builtComponentChecksum: Scalars['String']
universalIdentifier?: Scalars['UUID']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
isHeadless: Scalars['Boolean']
usesSdkClient: Scalars['Boolean']
applicationTokenPair?: ApplicationTokenPair
__typename: 'FrontComponent'
}
export interface LogicFunction {
id: Scalars['UUID']
name: Scalars['String']
@@ -396,6 +426,7 @@ export interface Application {
settingsCustomTabFrontComponentId?: Scalars['UUID']
defaultLogicFunctionRole?: Role
agents: Agent[]
frontComponents: FrontComponent[]
logicFunctions: LogicFunction[]
objects: Object[]
applicationVariables: ApplicationVariable[]
@@ -2040,36 +2071,6 @@ export interface UpsertRowLevelPermissionPredicatesResult {
__typename: 'UpsertRowLevelPermissionPredicatesResult'
}
export interface AuthToken {
token: Scalars['String']
expiresAt: Scalars['DateTime']
__typename: 'AuthToken'
}
export interface ApplicationTokenPair {
applicationAccessToken: AuthToken
applicationRefreshToken: AuthToken
__typename: 'ApplicationTokenPair'
}
export interface FrontComponent {
id: Scalars['UUID']
name: Scalars['String']
description?: Scalars['String']
sourceComponentPath: Scalars['String']
builtComponentPath: Scalars['String']
componentName: Scalars['String']
builtComponentChecksum: Scalars['String']
universalIdentifier?: Scalars['UUID']
applicationId: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
isHeadless: Scalars['Boolean']
usesSdkClient: Scalars['Boolean']
applicationTokenPair?: ApplicationTokenPair
__typename: 'FrontComponent'
}
export interface LogicFunctionLogs {
/** Execution Logs */
logs: Scalars['String']
@@ -3443,6 +3444,39 @@ export interface ApplicationVariableGenqlSelection{
__scalar?: boolean | number
}
export interface AuthTokenGenqlSelection{
token?: boolean | number
expiresAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationTokenPairGenqlSelection{
applicationAccessToken?: AuthTokenGenqlSelection
applicationRefreshToken?: AuthTokenGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FrontComponentGenqlSelection{
id?: boolean | number
name?: boolean | number
description?: boolean | number
sourceComponentPath?: boolean | number
builtComponentPath?: boolean | number
componentName?: boolean | number
builtComponentChecksum?: boolean | number
universalIdentifier?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
isHeadless?: boolean | number
usesSdkClient?: boolean | number
applicationTokenPair?: ApplicationTokenPairGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionGenqlSelection{
id?: boolean | number
name?: boolean | number
@@ -3629,6 +3663,7 @@ export interface ApplicationGenqlSelection{
settingsCustomTabFrontComponentId?: boolean | number
defaultLogicFunctionRole?: RoleGenqlSelection
agents?: AgentGenqlSelection
frontComponents?: FrontComponentGenqlSelection
logicFunctions?: LogicFunctionGenqlSelection
objects?: ObjectGenqlSelection
applicationVariables?: ApplicationVariableGenqlSelection
@@ -5341,39 +5376,6 @@ export interface UpsertRowLevelPermissionPredicatesResultGenqlSelection{
__scalar?: boolean | number
}
export interface AuthTokenGenqlSelection{
token?: boolean | number
expiresAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApplicationTokenPairGenqlSelection{
applicationAccessToken?: AuthTokenGenqlSelection
applicationRefreshToken?: AuthTokenGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FrontComponentGenqlSelection{
id?: boolean | number
name?: boolean | number
description?: boolean | number
sourceComponentPath?: boolean | number
builtComponentPath?: boolean | number
componentName?: boolean | number
builtComponentChecksum?: boolean | number
universalIdentifier?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
isHeadless?: boolean | number
usesSdkClient?: boolean | number
applicationTokenPair?: ApplicationTokenPairGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionLogsGenqlSelection{
/** Execution Logs */
logs?: boolean | number
@@ -7071,6 +7073,30 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AuthToken_possibleTypes: string[] = ['AuthToken']
export const isAuthToken = (obj?: { __typename?: any } | null): obj is AuthToken => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAuthToken"')
return AuthToken_possibleTypes.includes(obj.__typename)
}
const ApplicationTokenPair_possibleTypes: string[] = ['ApplicationTokenPair']
export const isApplicationTokenPair = (obj?: { __typename?: any } | null): obj is ApplicationTokenPair => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationTokenPair"')
return ApplicationTokenPair_possibleTypes.includes(obj.__typename)
}
const FrontComponent_possibleTypes: string[] = ['FrontComponent']
export const isFrontComponent = (obj?: { __typename?: any } | null): obj is FrontComponent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFrontComponent"')
return FrontComponent_possibleTypes.includes(obj.__typename)
}
const LogicFunction_possibleTypes: string[] = ['LogicFunction']
export const isLogicFunction = (obj?: { __typename?: any } | null): obj is LogicFunction => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunction"')
@@ -8423,30 +8449,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AuthToken_possibleTypes: string[] = ['AuthToken']
export const isAuthToken = (obj?: { __typename?: any } | null): obj is AuthToken => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAuthToken"')
return AuthToken_possibleTypes.includes(obj.__typename)
}
const ApplicationTokenPair_possibleTypes: string[] = ['ApplicationTokenPair']
export const isApplicationTokenPair = (obj?: { __typename?: any } | null): obj is ApplicationTokenPair => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationTokenPair"')
return ApplicationTokenPair_possibleTypes.includes(obj.__typename)
}
const FrontComponent_possibleTypes: string[] = ['FrontComponent']
export const isFrontComponent = (obj?: { __typename?: any } | null): obj is FrontComponent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFrontComponent"')
return FrontComponent_possibleTypes.includes(obj.__typename)
}
const LogicFunctionLogs_possibleTypes: string[] = ['LogicFunctionLogs']
export const isLogicFunctionLogs = (obj?: { __typename?: any } | null): obj is LogicFunctionLogs => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionLogs"')

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because one or more lines are too long

View File

@@ -34,6 +34,12 @@ export const APPLICATION_FRAGMENT = gql`
agents {
...AgentFields
}
frontComponents {
id
name
description
applicationId
}
objects {
...ObjectMetadataFields
}

View File

@@ -4,6 +4,7 @@ export const CUSTOM_WORKSPACE_APPLICATION_MOCK = {
id: 'dc75f982-35a2-4c1b-a63d-bd1131215377',
agents: [],
applicationVariables: [],
frontComponents: [],
availablePackages: {},
canBeUninstalled: false,
description: 'workpace custom application',

View File

@@ -1,29 +1,36 @@
import { SettingsPath } from 'twenty-shared/types';
import { t } from '@lingui/core/macro';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useParams } from 'react-router-dom';
import { useQuery } from '@apollo/client/react';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import type { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconApps,
IconBox,
IconCommand,
IconInfoCircle,
IconListDetails,
IconLock,
IconSettings,
} from 'twenty-ui/display';
import {
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationCustomTab } from '~/pages/settings/applications/tabs/SettingsApplicationCustomTab';
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { SettingsApplicationCustomTab } from '~/pages/settings/applications/tabs/SettingsApplicationCustomTab';
import type { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
const APPLICATION_DETAIL_ID = 'application-detail-id';
@@ -42,14 +49,54 @@ export const SettingsApplicationDetails = () => {
const application = data?.findOneApplication;
const applicationName = application?.name ?? t`Application details`;
const applicationDescription = application?.description ?? undefined;
const applicationLogoUrl =
application?.applicationRegistration?.logoUrl ?? undefined;
const { data: detailData } = useQuery(FindMarketplaceAppDetailDocument, {
variables: { universalIdentifier: application?.universalIdentifier ?? '' },
skip: !application?.universalIdentifier,
});
const detail = detailData?.findMarketplaceAppDetail;
const manifest = detail?.manifest as Manifest | undefined;
const app = manifest?.application;
const displayName =
app?.displayName ?? application?.name ?? t`Application details`;
const description = app?.description ?? application?.description ?? undefined;
const logoUrl =
app?.logoUrl ?? application?.applicationRegistration?.logoUrl ?? undefined;
const settingsCustomTabFrontComponentId =
application?.settingsCustomTabFrontComponentId;
const contentEntries = useMemo(
() => [
{
icon: IconBox,
count: (manifest?.objects ?? []).length,
one: t`object`,
many: t`objects`,
},
{
icon: IconListDetails,
count: (manifest?.fields ?? []).length,
one: t`field`,
many: t`fields`,
},
{
icon: IconCommand,
count: (manifest?.logicFunctions ?? []).length,
one: t`logic function`,
many: t`logic functions`,
},
{
icon: IconCommand,
count: (manifest?.frontComponents ?? []).length,
one: t`front component`,
many: t`front components`,
},
],
[manifest],
);
const tabs: SingleTabProps[] = [
{ id: 'about', title: t`About`, Icon: IconInfoCircle },
{ id: 'content', title: t`Content`, Icon: IconBox },
@@ -78,17 +125,53 @@ export const SettingsApplicationDetails = () => {
];
const renderActiveTabContent = () => {
if (!isDefined(application)) {
return <SettingsApplicationDetailSkeletonLoader />;
}
switch (activeTabId) {
case 'about':
return <SettingsApplicationDetailAboutTab application={application} />;
return (
<SettingsApplicationDetailAboutTab
displayName={displayName}
description={description}
aboutDescription={app?.aboutDescription}
screenshots={app?.screenshots}
author={app?.author}
category={app?.category}
contentEntries={isDefined(manifest) ? contentEntries : undefined}
currentVersion={application.version ?? undefined}
latestAvailableVersion={
detail?.latestAvailableVersion ??
application.applicationRegistration?.latestAvailableVersion ??
undefined
}
developerLinks={
isDefined(app)
? {
websiteUrl: app.websiteUrl,
termsUrl: app.termsUrl,
emailSupport: app.emailSupport,
issueReportUrl: app.issueReportUrl,
}
: undefined
}
isInstalled={true}
application={application}
/>
);
case 'content':
return (
<SettingsApplicationDetailContentTab application={application} />
<SettingsApplicationDetailContentTab
applicationId={application.id}
installedApplication={application}
manifestContent={manifest}
/>
);
case 'permissions':
return (
<SettingsApplicationPermissionsTab
defaultRoleId={application?.defaultRoleId}
defaultRoleId={application.defaultRoleId}
/>
);
case 'settings':
@@ -114,9 +197,9 @@ export const SettingsApplicationDetails = () => {
<SubMenuTopBarContainer
title={
<SettingsApplicationDetailTitle
displayName={applicationName}
description={applicationDescription}
logoUrl={applicationLogoUrl}
displayName={displayName}
description={description}
logoUrl={logoUrl}
/>
}
links={[
@@ -128,16 +211,12 @@ export const SettingsApplicationDetails = () => {
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: applicationName },
{ children: displayName },
]}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={APPLICATION_DETAIL_ID} />
{!isDefined(application) ? (
<SettingsApplicationDetailSkeletonLoader />
) : (
renderActiveTabContent()
)}
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@@ -1,192 +1,52 @@
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { useInstallMarketplaceApp } from '@/marketplace/hooks/useInstallMarketplaceApp';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { styled } from '@linaria/react';
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconAlertTriangle,
IconBook,
IconBox,
IconBrandNpm,
IconCheck,
IconCommand,
IconDownload,
IconGraph,
IconInfoCircle,
IconLego,
IconLink,
IconListDetails,
IconLock,
IconMail,
IconSettings,
IconShield,
IconUpload,
IconWorld,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useQuery } from '@apollo/client/react';
import {
PermissionFlagType,
FindOneApplicationByUniversalIdentifierDocument,
FindMarketplaceAppDetailDocument,
ApplicationRegistrationSourceType,
FindMarketplaceAppDetailDocument,
FindOneApplicationByUniversalIdentifierDocument,
PermissionFlagType,
} from '~/generated-metadata/graphql';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { SettingsAvailableApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsAvailableApplicationDetailContentTab';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail';
const StyledContentContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledMainContent = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
`;
const StyledSidebar = styled.div`
flex-shrink: 0;
width: 140px;
`;
const StyledSidebarSection = styled.div`
padding: ${themeCssVariables.spacing[3]} 0;
&:first-of-type {
padding-top: 0;
}
`;
const StyledSidebarLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledSidebarValue = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledContentItem = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledLink = styled.a`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledScreenshotsContainer = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 300px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledScreenshotImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledScreenshotThumbnails = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const StyledThumbnail = styled.div<{ isSelected?: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid
${({ isSelected }) =>
isSelected
? themeCssVariables.color.blue
: themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
flex: 1;
height: 60px;
justify-content: center;
overflow: hidden;
&:hover {
border-color: ${themeCssVariables.color.blue};
}
`;
const StyledThumbnailImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledSectionTitle = styled.h2`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0 0 ${themeCssVariables.spacing[3]} 0;
`;
const StyledAboutContainer = styled.div``;
export const SettingsAvailableApplicationDetails = () => {
const { availableApplicationId = '' } = useParams<{
availableApplicationId: string;
}>();
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
const { install, isInstalling } = useInstallMarketplaceApp();
const { upgrade, isUpgrading } = useUpgradeApplication();
const canInstallMarketplaceApps = useHasPermissionFlag(
PermissionFlagType.MARKETPLACE_APPS,
@@ -213,8 +73,6 @@ export const SettingsAvailableApplicationDetails = () => {
const displayName = app?.displayName ?? detail?.name ?? '';
const description = app?.description ?? '';
const screenshots = app?.screenshots ?? [];
const aboutDescription = app?.aboutDescription;
const currentVersion = application?.version;
const latestAvailableVersion = detail?.latestAvailableVersion;
@@ -228,14 +86,18 @@ export const SettingsAvailableApplicationDetails = () => {
: undefined;
const isUnlisted = isDefined(detail) && !detail.isListed;
const installedApp = applicationData?.findOneApplication;
const isAlreadyInstalled = isDefined(installedApp);
const hasScreenshots = screenshots.length > 0;
const isAlreadyInstalled = isDefined(application);
const defaultRole = manifest?.roles?.find(
(r) => r.universalIdentifier === app?.defaultRoleUniversalIdentifier,
);
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const handleInstall = async () => {
if (isDefined(detail)) {
await install({
@@ -244,14 +106,6 @@ export const SettingsAvailableApplicationDetails = () => {
}
};
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const { upgrade, isUpgrading } = useUpgradeApplication();
const handleUpgrade = async () => {
if (!isDefined(registrationId) || !isDefined(latestAvailableVersion)) {
return;
@@ -269,111 +123,72 @@ export const SettingsAvailableApplicationDetails = () => {
}
if (!isAlreadyInstalled) {
return (
<StyledSidebarSection>
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'primary'}
accent={'blue'}
onClick={handleInstall}
disabled={isInstalling}
/>
</StyledSidebarSection>
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'primary'}
accent={'blue'}
onClick={handleInstall}
disabled={isInstalling}
/>
);
}
if (hasUpdate && isDefined(registrationId)) {
return (
<StyledSidebarSection>
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant={'secondary'}
accent={'blue'}
onClick={handleUpgrade}
disabled={isUpgrading}
/>
</StyledSidebarSection>
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant={'secondary'}
accent={'blue'}
onClick={handleUpgrade}
disabled={isUpgrading}
/>
);
}
return (
<StyledSidebarSection>
<Button
Icon={IconCheck}
title={t`Installed`}
variant={'secondary'}
accent={'default'}
disabled={isAlreadyInstalled}
/>
</StyledSidebarSection>
<Button
Icon={IconCheck}
title={t`Installed`}
variant={'secondary'}
accent={'default'}
disabled={isAlreadyInstalled}
/>
);
};
const contentEntries = [
{
icon: IconBox,
count: (manifest?.objects ?? []).length,
one: t`object`,
many: t`objects`,
},
{
icon: IconListDetails,
count: (manifest?.fields ?? []).length,
one: t`field`,
many: t`fields`,
},
{
icon: IconCommand,
count: (manifest?.logicFunctions ?? []).length,
one: t`logic function`,
many: t`logic functions`,
},
{
icon: IconGraph,
count: (manifest?.frontComponents ?? []).filter(
(fc) =>
!isDefined(fc.command) &&
fc.universalIdentifier !==
manifest?.application
.settingsCustomTabFrontComponentUniversalIdentifier,
).length,
one: t`widget`,
many: t`widgets`,
},
{
icon: IconCommand,
count: (manifest?.frontComponents ?? []).filter(
(fc) => isDefined(fc.command) && !fc.isHeadless,
).length,
one: t`command`,
many: t`commands`,
},
{
icon: IconShield,
count: (manifest?.roles ?? []).filter(
(role) =>
role.universalIdentifier !==
manifest?.application.defaultRoleUniversalIdentifier,
).length,
one: t`role`,
many: t`roles`,
},
{
icon: IconBook,
count: (manifest?.skills ?? []).length,
one: t`skill`,
many: t`skills`,
},
{
icon: IconLego,
count: (manifest?.agents ?? []).length,
one: t`agent`,
many: t`agents`,
},
].filter((entry) => entry.count > 0);
const contentEntries = useMemo(
() => [
{
icon: IconBox,
count: (manifest?.objects ?? []).length,
one: t`object`,
many: t`objects`,
},
{
icon: IconListDetails,
count: (manifest?.fields ?? []).length,
one: t`field`,
many: t`fields`,
},
{
icon: IconCommand,
count: (manifest?.logicFunctions ?? []).length,
one: t`logic function`,
many: t`logic functions`,
},
{
icon: IconCommand,
count: (manifest?.frontComponents ?? []).length,
one: t`front component`,
many: t`front components`,
},
],
[manifest],
);
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
@@ -393,158 +208,36 @@ export const SettingsAvailableApplicationDetails = () => {
switch (activeTabId) {
case 'about':
return (
<>
{hasScreenshots && (
<StyledAboutContainer>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={screenshots[selectedScreenshotIndex]}
alt={`${displayName} screenshot ${selectedScreenshotIndex + 1}`}
/>
</StyledScreenshotsContainer>
<StyledScreenshotThumbnails>
{screenshots.slice(0, 6).map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${displayName} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
</StyledScreenshotThumbnails>
</StyledAboutContainer>
)}
<StyledContentContainer>
<StyledMainContent>
<Section>
<StyledSectionTitle>{t`About`}</StyledSectionTitle>
<LazyMarkdownRenderer
text={
aboutDescription
? aboutDescription
: t`No description available for this application`
}
/>
</Section>
</StyledMainContent>
<StyledSidebar>
{getActionButton()}
<StyledSidebarSection>
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
<StyledSidebarValue>
{app?.author ?? 'Unknown'}
</StyledSidebarValue>
</StyledSidebarSection>
{app?.category && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>{app.category}</StyledSidebarValue>
</StyledSidebarSection>
)}
{contentEntries.length > 0 && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Content`}</StyledSidebarLabel>
{contentEntries.map((entry) => (
<StyledContentItem key={entry.one}>
<entry.icon size={16} />
{entry.count}{' '}
{entry.count === 1 ? entry.one : entry.many}
</StyledContentItem>
))}
</StyledSidebarSection>
)}
{isAlreadyInstalled && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
<StyledSidebarValue>
{installedApp?.version ?? t`Unknown`}
</StyledSidebarValue>
</StyledSidebarSection>
)}
<StyledSidebarSection>
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
<StyledSidebarValue>
{detail.latestAvailableVersion ?? '0.0.0'}
</StyledSidebarValue>
</StyledSidebarSection>
{(app?.websiteUrl ||
app?.termsUrl ||
app?.emailSupport ||
app?.issueReportUrl) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
{app?.websiteUrl && (
<StyledLink
href={app.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
)}
{app?.termsUrl && (
<StyledLink
href={app.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconLink size={16} />
{t`Terms / Privacy`}
</StyledLink>
)}
{app?.emailSupport && (
<StyledLink
href={`mailto:${app.emailSupport}`}
target="_blank"
rel="noopener noreferrer"
>
<IconMail size={16} />
{t`Email support`}
</StyledLink>
)}
{app?.issueReportUrl && (
<StyledLink
href={app.issueReportUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconAlertTriangle size={16} />
{t`Report and issue`}
</StyledLink>
)}
{sourcePackageUrl && (
<StyledLink
href={sourcePackageUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconBrandNpm size={16} />
{t`Npm package`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>
</StyledContentContainer>
</>
<SettingsApplicationDetailAboutTab
displayName={displayName}
description={description}
aboutDescription={app?.aboutDescription}
screenshots={app?.screenshots}
author={app?.author ?? 'Unknown'}
category={app?.category}
contentEntries={contentEntries}
currentVersion={
isAlreadyInstalled
? (application.version ?? undefined)
: undefined
}
latestAvailableVersion={detail.latestAvailableVersion ?? '0.0.0'}
developerLinks={{
websiteUrl: app?.websiteUrl,
termsUrl: app?.termsUrl,
emailSupport: app?.emailSupport,
issueReportUrl: app?.issueReportUrl,
sourcePackageUrl,
}}
actionButton={getActionButton()}
isInstalled={false}
/>
);
case 'content':
return (
<SettingsAvailableApplicationDetailContentTab
<SettingsApplicationDetailContentTab
applicationId={detail.universalIdentifier}
content={manifest}
manifestContent={manifest}
/>
);
case 'permissions':

View File

@@ -0,0 +1,219 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ComponentType, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconAlertTriangle,
IconBrandNpm,
IconLink,
IconMail,
IconWorld,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type ContentEntry = {
icon: ComponentType<{ size?: number }>;
count: number;
one: string;
many: string;
};
export type DeveloperLinks = {
websiteUrl?: string;
termsUrl?: string;
emailSupport?: string;
issueReportUrl?: string;
sourcePackageUrl?: string;
};
type SettingsApplicationAboutSidebarProps = {
actionButton?: ReactNode;
author?: string;
category?: string;
contentEntries?: ContentEntry[];
currentVersion?: string;
latestAvailableVersion?: string;
developerLinks?: DeveloperLinks;
};
const StyledSidebar = styled.div`
flex-shrink: 0;
width: 140px;
`;
const StyledSidebarSection = styled.div`
padding: ${themeCssVariables.spacing[3]} 0;
&:first-of-type {
padding-top: 0;
}
`;
const StyledSidebarLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledSidebarValue = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledContentItem = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
&:last-of-type {
margin-bottom: 0;
}
`;
const StyledLink = styled.a`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
text-decoration: none;
&:hover {
text-decoration: underline;
}
&:last-of-type {
margin-bottom: 0;
}
`;
export const SettingsApplicationAboutSidebar = ({
actionButton,
author,
category,
contentEntries,
currentVersion,
latestAvailableVersion,
developerLinks,
}: SettingsApplicationAboutSidebarProps) => {
const filteredContentEntries = (contentEntries ?? []).filter(
(entry) => entry.count > 0,
);
const hasDeveloperLinks =
isDefined(developerLinks) &&
(isDefined(developerLinks.websiteUrl) ||
isDefined(developerLinks.termsUrl) ||
isDefined(developerLinks.emailSupport) ||
isDefined(developerLinks.issueReportUrl) ||
isDefined(developerLinks.sourcePackageUrl));
return (
<StyledSidebar>
{isDefined(actionButton) && (
<StyledSidebarSection>{actionButton}</StyledSidebarSection>
)}
{isDefined(author) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Created by`}</StyledSidebarLabel>
<StyledSidebarValue>{author}</StyledSidebarValue>
</StyledSidebarSection>
)}
{isDefined(category) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Category`}</StyledSidebarLabel>
<StyledSidebarValue>{category}</StyledSidebarValue>
</StyledSidebarSection>
)}
{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>
))}
</StyledSidebarSection>
)}
{isDefined(currentVersion) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Current`}</StyledSidebarLabel>
<StyledSidebarValue>{currentVersion}</StyledSidebarValue>
</StyledSidebarSection>
)}
{isDefined(latestAvailableVersion) && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Latest`}</StyledSidebarLabel>
<StyledSidebarValue>{latestAvailableVersion}</StyledSidebarValue>
</StyledSidebarSection>
)}
{hasDeveloperLinks && (
<StyledSidebarSection>
<StyledSidebarLabel>{t`Developers links`}</StyledSidebarLabel>
{developerLinks.websiteUrl && (
<StyledLink
href={developerLinks.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
)}
{developerLinks.termsUrl && (
<StyledLink
href={developerLinks.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconLink size={16} />
{t`Terms / Privacy`}
</StyledLink>
)}
{developerLinks.emailSupport && (
<StyledLink
href={`mailto:${developerLinks.emailSupport}`}
target="_blank"
rel="noopener noreferrer"
>
<IconMail size={16} />
{t`Email support`}
</StyledLink>
)}
{developerLinks.issueReportUrl && (
<StyledLink
href={developerLinks.issueReportUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconAlertTriangle size={16} />
{t`Report an issue`}
</StyledLink>
)}
{developerLinks.sourcePackageUrl && (
<StyledLink
href={developerLinks.sourcePackageUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconBrandNpm size={16} />
{t`Npm package`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>
);
};

View File

@@ -0,0 +1,95 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationScreenshotGalleryProps = {
screenshots: string[];
displayName: string;
};
const StyledScreenshotsContainer = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 300px;
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[2]};
overflow: hidden;
`;
const StyledScreenshotImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledScreenshotThumbnails = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[6]};
`;
const StyledThumbnail = styled.div<{ isSelected?: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: 1px solid
${({ isSelected }) =>
isSelected
? themeCssVariables.color.blue
: themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
flex: 1;
height: 60px;
justify-content: center;
overflow: hidden;
&:hover {
border-color: ${themeCssVariables.color.blue};
}
`;
const StyledThumbnailImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
export const SettingsApplicationScreenshotGallery = ({
screenshots,
displayName,
}: SettingsApplicationScreenshotGalleryProps) => {
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0);
if (screenshots.length === 0) {
return null;
}
return (
<>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={screenshots[selectedScreenshotIndex]}
alt={`${displayName} screenshot ${selectedScreenshotIndex + 1}`}
/>
</StyledScreenshotsContainer>
<StyledScreenshotThumbnails>
{screenshots.slice(0, 6).map((screenshot, index) => (
<StyledThumbnail
key={index}
isSelected={index === selectedScreenshotIndex}
onClick={() => setSelectedScreenshotIndex(index)}
>
<StyledThumbnailImage
src={screenshot}
alt={`${displayName} thumbnail ${index + 1}`}
/>
</StyledThumbnail>
))}
</StyledScreenshotThumbnails>
</>
);
};

View File

@@ -0,0 +1,93 @@
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useMutation } from '@apollo/client/react';
import { UninstallApplicationDocument } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { AppTooltip, H2Title, IconTrash } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
type SettingsApplicationUninstallSectionProps = {
universalIdentifier: string;
canBeUninstalled: boolean;
};
export const SettingsApplicationUninstallSection = ({
universalIdentifier,
canBeUninstalled,
}: SettingsApplicationUninstallSectionProps) => {
const { openModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [uninstallApplication] = useMutation(UninstallApplicationDocument);
const navigate = useNavigateSettings();
const handleUninstallApplication = async () => {
setIsLoading(true);
try {
await uninstallApplication({
variables: { universalIdentifier },
});
enqueueSuccessSnackBar({
message: t`Application successfully uninstalled.`,
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({ message: t`Error uninstalling application.` });
} finally {
setIsLoading(false);
}
};
const confirmationValue = t`yes`;
return (
<Section>
<H2Title
title={t`Manage your app`}
description={t`Uninstall this application`}
/>
<Button
accent="danger"
id={'uninstall-button-anchor'}
variant="secondary"
title={t`Uninstall`}
Icon={IconTrash}
disabled={!canBeUninstalled}
onClick={() =>
canBeUninstalled ? openModal(UNINSTALL_APPLICATION_MODAL_ID) : null
}
/>
{!canBeUninstalled && (
<AppTooltip
anchorSelect={`#uninstall-button-anchor`}
content={t`This application is required for your workspace to function properly and cannot be uninstalled.`}
place="bottom-start"
/>
)}
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to
uninstall this application.
</Trans>
}
onConfirmClick={handleUninstallApplication}
confirmButtonText={t`Uninstall`}
loading={isLoading}
/>
</Section>
);
};

View File

@@ -24,7 +24,10 @@ export const SettingsApplicationVersionContainer = ({
latestAvailableVersion,
appRegistrationId,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
application?: Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
};
latestAvailableVersion?: string | null;

View File

@@ -1,122 +1,124 @@
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H2Title, IconTrash, AppTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer';
import { Button } from 'twenty-ui/input';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { Trans } from '@lingui/react/macro';
import { SettingsPath } from 'twenty-shared/types';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useState } from 'react';
import { useMutation } from '@apollo/client/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type Application } from '~/generated-metadata/graphql';
import {
type Application,
UninstallApplicationDocument,
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
SettingsApplicationAboutSidebar,
type ContentEntry,
type DeveloperLinks,
} from '~/pages/settings/applications/components/SettingsApplicationAboutSidebar';
import { SettingsApplicationScreenshotGallery } from '~/pages/settings/applications/components/SettingsApplicationScreenshotGallery';
import { SettingsApplicationUninstallSection } from '~/pages/settings/applications/components/SettingsApplicationUninstallSection';
import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
export const SettingsApplicationDetailAboutTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & {
type SettingsApplicationDetailAboutTabProps = {
displayName: string;
description?: string;
aboutDescription?: string;
screenshots?: string[];
author?: string;
category?: string;
contentEntries?: ContentEntry[];
currentVersion?: string;
latestAvailableVersion?: string;
developerLinks?: DeveloperLinks;
actionButton?: ReactNode;
isInstalled: boolean;
application?: Omit<Application, 'objects' | 'frontComponents'> & {
objects: { id: string }[];
};
}) => {
const { openModal } = useModal();
};
const [isLoading, setIsLoading] = useState(false);
const StyledContentContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[4]};
`;
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const StyledMainContent = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
`;
const [uninstallApplication] = useMutation(UninstallApplicationDocument);
const StyledSectionTitle = styled.h2`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.xl};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0 0 ${themeCssVariables.spacing[3]} 0;
`;
const navigate = useNavigateSettings();
export const SettingsApplicationDetailAboutTab = ({
displayName,
description,
aboutDescription,
screenshots,
author,
category,
contentEntries,
currentVersion,
latestAvailableVersion,
developerLinks,
actionButton,
isInstalled,
application,
}: SettingsApplicationDetailAboutTabProps) => {
const hasScreenshots = isDefined(screenshots) && screenshots.length > 0;
const registrationId = application?.applicationRegistrationId;
const latestAvailableVersion =
application?.applicationRegistration?.latestAvailableVersion ?? null;
if (!isDefined(application)) {
return null;
}
const handleUninstallApplication = async () => {
setIsLoading(true);
try {
await uninstallApplication({
variables: { universalIdentifier: application.universalIdentifier },
});
enqueueSuccessSnackBar({
message: t`Application successfully uninstalled.`,
});
navigate(SettingsPath.Applications);
} catch {
enqueueErrorSnackBar({ message: t`Error uninstalling application.` });
} finally {
setIsLoading(false);
}
};
const confirmationValue = t`yes`;
const markdownText =
aboutDescription ??
description ??
t`No description available for this application`;
return (
<>
<Section>
<SettingsApplicationVersionContainer
application={application}
{hasScreenshots && (
<SettingsApplicationScreenshotGallery
screenshots={screenshots}
displayName={displayName}
/>
)}
<StyledContentContainer>
<StyledMainContent>
<Section>
<StyledSectionTitle>{t`About`}</StyledSectionTitle>
<LazyMarkdownRenderer text={markdownText} />
</Section>
</StyledMainContent>
<SettingsApplicationAboutSidebar
actionButton={actionButton}
author={author}
category={category}
contentEntries={contentEntries}
currentVersion={currentVersion}
latestAvailableVersion={latestAvailableVersion}
appRegistrationId={registrationId}
developerLinks={developerLinks}
/>
</Section>
<>
<Section>
<H2Title
title={t`Manage your app`}
description={t`Uninstall this application`}
/>
<Button
accent="danger"
id={'uninstall-button-anchor'}
variant="secondary"
title={t`Uninstall`}
Icon={IconTrash}
disabled={!application.canBeUninstalled}
onClick={() =>
application.canBeUninstalled
? openModal(UNINSTALL_APPLICATION_MODAL_ID)
: null
}
/>
{!application.canBeUninstalled && (
<AppTooltip
anchorSelect={`#uninstall-button-anchor`}
content={t`This application is required for your workspace to function properly and cannot be uninstalled.`}
place="bottom-start"
</StyledContentContainer>
{isInstalled && isDefined(application) && (
<>
<Section>
<SettingsApplicationVersionContainer
application={application}
latestAvailableVersion={
application.applicationRegistration?.latestAvailableVersion ??
null
}
appRegistrationId={application.applicationRegistrationId}
/>
)}
</Section>
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
modalInstanceId={UNINSTALL_APPLICATION_MODAL_ID}
title={t`Uninstall Application?`}
subtitle={
<Trans>
Please type {`"${confirmationValue}"`} to confirm you want to
uninstall this application.
</Trans>
}
onConfirmClick={handleUninstallApplication}
confirmButtonText={t`Uninstall`}
loading={isLoading}
/>
</>
</Section>
<SettingsApplicationUninstallSection
universalIdentifier={application.universalIdentifier}
canBeUninstalled={application.canBeUninstalled}
/>
</>
)}
</>
);
};

View File

@@ -4,119 +4,234 @@ import { SettingsLogicFunctionsTable } from '@/settings/logic-functions/componen
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type Application } from '~/generated-metadata/graphql';
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
import {
SettingsApplicationDataTable,
type ApplicationDataTableRow,
} from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import { SettingsApplicationNameDescriptionTable } from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable';
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
type InstalledApplicationForContentTab = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
frontComponents?: { name: string; description?: string | null }[];
};
type SettingsApplicationDetailContentTabProps = {
applicationId: string;
installedApplication?: InstalledApplicationForContentTab;
manifestContent?: Manifest;
};
export const SettingsApplicationDetailContentTab = ({
application,
}: {
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
objects: { id: string }[];
};
}) => {
applicationId,
installedApplication,
manifestContent,
}: SettingsApplicationDetailContentTabProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const applicationObjectIds = useMemo(
() => application?.objects.map((object) => object.id) ?? [],
[application?.objects],
// Installed app: object rows from workspace metadata
const installedObjectIds = useMemo(
() => installedApplication?.objects.map((object) => object.id) ?? [],
[installedApplication?.objects],
);
const objectRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(application) || application.objects.length === 0) {
const installedObjectRows = useMemo((): ApplicationDataTableRow[] => {
if (
!isDefined(installedApplication) ||
installedApplication.objects.length === 0
) {
return [];
}
return objectMetadataItems
.filter((objectMetadataItem) =>
applicationObjectIds.includes(objectMetadataItem.id),
)
.map((objectMetadataItem) => {
const nonSystemFields = objectMetadataItem.fields.filter(
(field) => !isHiddenSystemField(field),
);
.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,
}),
tagItem: {
isCustom: item.isCustom,
isRemote: item.isRemote,
applicationId: item.applicationId,
},
}));
}, [installedApplication, objectMetadataItems, installedObjectIds]);
return {
key: objectMetadataItem.nameSingular,
labelPlural: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
fieldsCount: nonSystemFields.length,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: objectMetadataItem.namePlural,
}),
tagItem: {
isCustom: objectMetadataItem.isCustom,
isRemote: objectMetadataItem.isRemote,
applicationId: objectMetadataItem.applicationId,
},
};
});
}, [application, objectMetadataItems, applicationObjectIds]);
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(application)) {
const installedFieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(installedApplication)) {
return [];
}
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
return objectMetadataItems
.filter((objectMetadataItem) => {
if (applicationObjectIds.includes(objectMetadataItem.id)) {
return false;
}
.filter((item) => {
if (installedObjectIds.includes(item.id)) return false;
if (FIELD_GROUP_DENY_LIST.includes(item.nameSingular)) return false;
if (FIELD_GROUP_DENY_LIST.includes(objectMetadataItem.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,
}),
tagItem: {
isCustom: item.isCustom,
isRemote: item.isRemote,
applicationId: item.applicationId,
},
}));
}, [objectMetadataItems, installedObjectIds, installedApplication]);
const appFields = objectMetadataItem.fields.filter(
(field) => field.applicationId === application.id,
// Manifest: object rows from manifest data
const manifestObjects = useMemo(
() => manifestContent?.objects ?? [],
[manifestContent?.objects],
);
const manifestFields = useMemo(
() => manifestContent?.fields ?? [],
[manifestContent?.fields],
);
const manifestObjectRows = useMemo(
(): ApplicationDataTableRow[] =>
manifestObjects.map((appObject) => ({
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: appObject.fields.length,
tagItem: { applicationId },
})),
[manifestObjects, applicationId],
);
const manifestFieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (manifestFields.length === 0) {
return [];
}
const groupMap = new Map<
string,
{ objectUniversalIdentifier: string; count: number }
>();
for (const field of manifestFields) {
const objectUid = field.objectUniversalIdentifier;
const existing = groupMap.get(objectUid);
if (isDefined(existing)) {
existing.count++;
} else {
groupMap.set(objectUid, {
objectUniversalIdentifier: objectUid,
count: 1,
});
}
}
return Array.from(groupMap.values())
.map((group) => {
const appObject = manifestObjects.find(
(obj) => obj.universalIdentifier === group.objectUniversalIdentifier,
);
return appFields.length > 0;
})
.map((objectMetadataItem) => {
const appFieldsCount = objectMetadataItem.fields.filter(
(field) => field.applicationId === application.id,
).length;
if (isDefined(appObject)) {
return {
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: group.count,
tagItem: { applicationId },
};
}
const standardObjectName = findObjectNameByUniversalIdentifier(
group.objectUniversalIdentifier,
);
const objectMetadataItem = isDefined(standardObjectName)
? objectMetadataItems.find(
(item) => item.nameSingular === standardObjectName,
)
: undefined;
if (!isDefined(objectMetadataItem)) {
return;
}
return {
key: objectMetadataItem.nameSingular,
labelPlural: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
fieldsCount: appFieldsCount,
fieldsCount: group.count,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: objectMetadataItem.namePlural,
}),
tagItem: {
isCustom: objectMetadataItem.isCustom,
isRemote: objectMetadataItem.isRemote,
applicationId: objectMetadataItem.applicationId,
},
tagItem: {},
};
});
}, [objectMetadataItems, applicationObjectIds, application]);
})
.filter(isDefined);
}, [manifestFields, objectMetadataItems, manifestObjects, applicationId]);
if (!isDefined(application)) {
return null;
}
// Choose data source: installed app data takes precedence
const objectRows = isDefined(installedApplication)
? installedObjectRows
: manifestObjectRows;
const fieldGroupRows = isDefined(installedApplication)
? installedFieldGroupRows
: manifestFieldGroupRows;
const { logicFunctions } = application;
// Front components
const frontComponentItems = useMemo(() => {
if (isDefined(installedApplication)) {
return (installedApplication.frontComponents ?? []).map((fc) => ({
name: fc.name,
description: fc.description,
}));
}
const shouldDisplayLogicFunctions =
isDefined(logicFunctions) && logicFunctions?.length > 0;
return (manifestContent?.frontComponents ?? []).map((fc) => ({
name: fc.name ?? fc.universalIdentifier,
description: fc.description,
}));
}, [installedApplication, manifestContent?.frontComponents]);
// TODO: uncomment when adding back agents in application settings
// const shouldDisplayAgents = isDefined(agents) && agents.length > 0;
const shouldDisplayAgents = false;
// Logic functions
const installedLogicFunctions = installedApplication?.logicFunctions;
const hasInstalledLogicFunctions =
isDefined(installedLogicFunctions) && installedLogicFunctions.length > 0;
const manifestLogicFunctionItems = useMemo(() => {
if (isDefined(installedApplication)) {
return [];
}
return (manifestContent?.logicFunctions ?? []).map((lf) => ({
name: lf.name ?? lf.universalIdentifier,
description: lf.description,
}));
}, [installedApplication, manifestContent?.logicFunctions]);
return (
<>
@@ -124,24 +239,31 @@ export const SettingsApplicationDetailContentTab = ({
objectRows={objectRows}
fieldGroupRows={fieldGroupRows}
/>
{shouldDisplayLogicFunctions && (
{hasInstalledLogicFunctions && (
<Section>
<H2Title
title={t`Logic`}
description={t`Logic functions powering this app`}
/>
<SettingsLogicFunctionsTable logicFunctions={logicFunctions} />
</Section>
)}
{shouldDisplayAgents && (
<Section>
<H2Title
title={t`Agents`}
description={t`Agents powering this app`}
<SettingsLogicFunctionsTable
logicFunctions={installedLogicFunctions}
/>
<SettingsAIAgentsTable />
</Section>
)}
{!isDefined(installedApplication) && (
<SettingsApplicationNameDescriptionTable
title={t`Logic functions`}
description={t`Logic functions provided by this app`}
sectionTitle={t`Logic functions`}
items={manifestLogicFunctionItems}
/>
)}
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponentItems}
/>
</>
);
};

View File

@@ -1,196 +0,0 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
type ApplicationDataTableRow,
SettingsApplicationDataTable,
} from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import { SettingsApplicationNameDescriptionTable } from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable';
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
export const SettingsAvailableApplicationDetailContentTab = ({
applicationId,
content,
}: {
applicationId: string;
content?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const objects = useMemo(() => content?.objects ?? [], [content?.objects]);
const fields = useMemo(() => content?.fields ?? [], [content?.fields]);
const logicFunctions = content?.logicFunctions ?? [];
const frontComponents = content?.frontComponents ?? [];
const objectRows = useMemo(
(): ApplicationDataTableRow[] =>
objects.map((appObject) => ({
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: appObject.fields.length,
tagItem: { applicationId },
})),
[objects, applicationId],
);
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (fields.length === 0) {
return [];
}
const groupMap = new Map<
string,
{
objectUniversalIdentifier: string;
count: number;
}
>();
for (const field of fields) {
const objectUid = field.objectUniversalIdentifier;
const existing = groupMap.get(objectUid);
if (isDefined(existing)) {
existing.count++;
} else {
groupMap.set(objectUid, {
objectUniversalIdentifier: objectUid,
count: 1,
});
}
}
return Array.from(groupMap.values())
.map((group) => {
const appObject = objects.find(
(obj) => obj.universalIdentifier === group.objectUniversalIdentifier,
);
if (isDefined(appObject)) {
return {
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: group.count,
tagItem: { applicationId },
};
}
const standardObjectName = findObjectNameByUniversalIdentifier(
group.objectUniversalIdentifier,
);
const objectMetadataItem = isDefined(standardObjectName)
? objectMetadataItems.find(
(item) => item.nameSingular === standardObjectName,
)
: undefined;
if (!isDefined(objectMetadataItem)) {
return;
}
return {
key: objectMetadataItem.nameSingular,
labelPlural: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
fieldsCount: group.count,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: objectMetadataItem.namePlural,
}),
tagItem: {},
};
})
.filter(isDefined);
}, [fields, objectMetadataItems, objects, applicationId]);
const roles = content?.roles ?? [];
const skills = content?.skills ?? [];
const agents = content?.agents ?? [];
const views = content?.views ?? [];
const navigationMenuItems = content?.navigationMenuItems ?? [];
const pageLayouts = content?.pageLayouts ?? [];
return (
<>
<SettingsApplicationDataTable
objectRows={objectRows}
fieldGroupRows={fieldGroupRows}
/>
<SettingsApplicationNameDescriptionTable
title={t`Logic functions`}
description={t`Logic functions provided by this app`}
sectionTitle={t`Logic functions`}
items={logicFunctions.map((lf) => ({
name: lf.name ?? lf.universalIdentifier,
description: lf.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponents.map((fc) => ({
name: fc.name ?? fc.universalIdentifier,
description: fc.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Roles`}
description={t`Roles defined by this app`}
sectionTitle={t`Roles`}
items={roles.map((role) => ({
name: role.label,
description: role.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Skills`}
description={t`Skills provided by this app`}
sectionTitle={t`Skills`}
items={skills.map((skill) => ({
name: skill.label ?? skill.name,
description: skill.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Agents`}
description={t`Agents provided by this app`}
sectionTitle={t`Agents`}
items={agents.map((agent) => ({
name: agent.label ?? agent.name,
description: agent.description,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Views`}
description={t`Views created by this app`}
sectionTitle={t`Views`}
items={views.map((view) => ({
name: view.name,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Navigation menu items`}
description={t`Navigation items added by this app`}
sectionTitle={t`Navigation items`}
items={navigationMenuItems.map((item) => ({
name: item.name ?? item.universalIdentifier,
}))}
/>
<SettingsApplicationNameDescriptionTable
title={t`Page layouts`}
description={t`Page layouts defined by this app`}
sectionTitle={t`Page layouts`}
items={pageLayouts.map((layout) => ({
name: layout.name,
}))}
/>
</>
);
};

View File

@@ -20,6 +20,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@@ -129,6 +130,15 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
})
objects: Relation<ObjectMetadataEntity[]>;
@OneToMany(
() => FrontComponentEntity,
(frontComponent) => frontComponent.application,
{
onDelete: 'CASCADE',
},
)
frontComponents: Relation<FrontComponentEntity[]>;
@OneToMany(
() => ApplicationVariableEntity,
(applicationVariable) => applicationVariable.application,

View File

@@ -122,6 +122,7 @@ export class ApplicationService {
relations: [
'logicFunctions',
'agents',
'frontComponents',
'objects',
'applicationVariables',
'packageJsonFile',
@@ -157,6 +158,7 @@ export class ApplicationService {
relations: [
'logicFunctions',
'agents',
'frontComponents',
'objects',
'applicationVariables',
'packageJsonFile',

View File

@@ -3,6 +3,7 @@ import { type ApplicationEntity } from 'src/engine/core-modules/application/appl
export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'workspace',
'agents',
'frontComponents',
'logicFunctions',
'objects',
'applicationVariables',

View File

@@ -13,6 +13,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-summary.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@@ -91,6 +92,9 @@ export class ApplicationDTO {
@Field(() => [AgentDTO])
agents?: AgentDTO[];
@Field(() => [FrontComponentDTO])
frontComponents?: FrontComponentDTO[];
@Field(() => [LogicFunctionDTO])
logicFunctions?: LogicFunctionDTO[];