Fix app design 6 (#19827)

Unify application display page and isntalled page

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
martmull
2026-04-20 09:29:25 +02:00
committed by GitHub
parent 42f57db005
commit 5dd7eba911
30 changed files with 2154 additions and 1602 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "1.23.0-canary.1",
"version": "1.23.0-canary.2",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -149,6 +149,25 @@ describe('copyBaseApplicationProject', () => {
);
});
it('should create an empty public directory in the scaffolded project', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const publicDirectoryPath = join(testAppDirectory, 'public');
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
expect(publicDirectoryStats.isDirectory()).toBe(true);
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
expect(publicDirectoryContents).toHaveLength(0);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -23,6 +23,8 @@ export const copyBaseApplicationProject = async ({
await renameDotfiles({ appDirectory });
await addEmptyPublicDirectory({ appDirectory });
await generateUniversalIdentifiers({
appDisplayName,
appDescription,
@@ -49,6 +51,14 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
}
};
const addEmptyPublicDirectory = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.ensureDir(join(appDirectory, 'public'));
};
const generateUniversalIdentifiers = async ({
appDisplayName,
appDescription,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "1.23.0-canary.1",
"version": "1.23.0-canary.2",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -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!]!
@@ -2032,33 +2060,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!
@@ -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[]
@@ -1738,36 +1769,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']
@@ -3101,6 +3102,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
@@ -3287,6 +3321,7 @@ export interface ApplicationGenqlSelection{
settingsCustomTabFrontComponentId?: boolean | number
defaultLogicFunctionRole?: RoleGenqlSelection
agents?: AgentGenqlSelection
frontComponents?: FrontComponentGenqlSelection
logicFunctions?: LogicFunctionGenqlSelection
objects?: ObjectGenqlSelection
applicationVariables?: ApplicationVariableGenqlSelection
@@ -4685,39 +4720,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
@@ -6377,6 +6379,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"')
@@ -7473,30 +7499,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"')
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -34,6 +34,12 @@ export const APPLICATION_FRAGMENT = gql`
agents {
...AgentFields
}
frontComponents {
id
name
description
applicationId
}
objects {
...ObjectMetadataFields
}
@@ -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',
@@ -112,7 +112,12 @@ export const SettingsAdminApps = () => {
mobileGridAutoColumns={TABLE_GRID_MOBILE}
isClickable
>
<TableCell color={themeCssVariables.font.color.primary}>
<TableCell
color={themeCssVariables.font.color.primary}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<OverflowingTextWithTooltip text={registration.name} />
</TableCell>
<TableCell overflow="hidden" align="right">
@@ -0,0 +1,232 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ComponentType, type ReactNode } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
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 isSafeUrl = (url: string | undefined): url is string => {
if (!isNonEmptyString(url)) return false;
try {
const parsed = new URL(url);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
};
const filteredContentEntries = (contentEntries ?? []).filter(
(entry) => entry.count > 0,
);
const hasDeveloperLinks =
isDefined(developerLinks) &&
(isNonEmptyString(developerLinks.websiteUrl) ||
isNonEmptyString(developerLinks.termsUrl) ||
isNonEmptyString(developerLinks.emailSupport) ||
isNonEmptyString(developerLinks.issueReportUrl) ||
isNonEmptyString(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>
{isSafeUrl(developerLinks.websiteUrl) && (
<StyledLink
href={developerLinks.websiteUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconWorld size={16} />
{t`Website`}
</StyledLink>
)}
{isSafeUrl(developerLinks.termsUrl) && (
<StyledLink
href={developerLinks.termsUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconLink size={16} />
{t`Terms / Privacy`}
</StyledLink>
)}
{isNonEmptyString(developerLinks.emailSupport) && (
<StyledLink
href={`mailto:${developerLinks.emailSupport}`}
target="_blank"
rel="noopener noreferrer"
>
<IconMail size={16} />
{t`Email support`}
</StyledLink>
)}
{isSafeUrl(developerLinks.issueReportUrl) && (
<StyledLink
href={developerLinks.issueReportUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconAlertTriangle size={16} />
{t`Report an issue`}
</StyledLink>
)}
{isSafeUrl(developerLinks.sourcePackageUrl) && (
<StyledLink
href={developerLinks.sourcePackageUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconBrandNpm size={16} />
{t`Npm package`}
</StyledLink>
)}
</StyledSidebarSection>
)}
</StyledSidebar>
);
};
@@ -0,0 +1,97 @@
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;
}
const safeIndex = Math.min(selectedScreenshotIndex, screenshots.length - 1);
return (
<>
<StyledScreenshotsContainer>
<StyledScreenshotImage
src={screenshots[safeIndex]}
alt={`${displayName} screenshot ${safeIndex + 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>
</>
);
};
@@ -0,0 +1,297 @@
import { renderHook } from '@testing-library/react';
import { useObjectAndFieldRows } from '@/settings/applications/hooks/useObjectAndFieldRows';
import { type Manifest } from 'twenty-shared/application';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
const mockObjectMetadataItems = getTestEnrichedObjectMetadataItemsMock();
const personObject = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const companyObject = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const APP_ID = 'test-app-id';
const wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useObjectAndFieldRows', () => {
describe('with installed application', () => {
it('should return object rows for installed application objects', () => {
const installedApplication = {
id: APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
agents: [],
logicFunctions: [],
frontComponents: [],
};
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
);
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
expect(result.current.objectRows[0].labelPlural).toBe(
personObject.labelPlural,
);
expect(result.current.objectRows[0].fieldsCount).toBeGreaterThan(0);
expect(result.current.objectRows[0].link).toBeDefined();
});
it('should return empty object rows when application has no objects', () => {
const installedApplication = {
id: APP_ID,
objects: [],
name: 'Test App',
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
agents: [],
logicFunctions: [],
frontComponents: [],
};
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
);
expect(result.current.objectRows).toHaveLength(0);
});
it('should return field group rows for fields added to other objects', () => {
const fieldBelongingToApp = companyObject.fields[0];
const installedApplication = {
id: fieldBelongingToApp.applicationId ?? APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
agents: [],
logicFunctions: [],
frontComponents: [],
};
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: installedApplication.id,
installedApplication,
}),
{ wrapper },
);
// Field group rows should not include the app's own objects
const hasOwnObject = result.current.fieldGroupRows.some(
(row) => row.key === personObject.nameSingular,
);
expect(hasOwnObject).toBe(false);
});
it('should exclude deny-listed objects from field group rows', () => {
const installedApplication = {
id: APP_ID,
objects: [],
name: 'Test App',
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
agents: [],
logicFunctions: [],
frontComponents: [],
};
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
);
const hasDeniedObject = result.current.fieldGroupRows.some(
(row) => row.key === 'timelineActivity' || row.key === 'favorite',
);
expect(hasDeniedObject).toBe(false);
});
});
describe('with manifest content', () => {
it('should return object rows from manifest objects', () => {
const manifestContent = {
objects: [
{
universalIdentifier: 'uid-1',
nameSingular: 'customObject',
namePlural: 'customObjects',
labelSingular: 'Custom Object',
labelPlural: 'Custom Objects',
icon: 'IconBox',
fields: [{ name: 'field1' }, { name: 'field2' }],
},
],
fields: [],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
);
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe('customObject');
expect(result.current.objectRows[0].labelPlural).toBe('Custom Objects');
expect(result.current.objectRows[0].fieldsCount).toBe(2);
expect(result.current.objectRows[0].tagItem.applicationId).toBe(
'app-uid',
);
});
it('should return field group rows grouped by object from manifest fields', () => {
const manifestContent = {
objects: [
{
universalIdentifier: 'custom-obj-uid',
nameSingular: 'customObj',
namePlural: 'customObjs',
labelSingular: 'Custom',
labelPlural: 'Customs',
icon: 'IconBox',
fields: [],
},
],
fields: [
{
objectUniversalIdentifier: 'custom-obj-uid',
name: 'field1',
},
{
objectUniversalIdentifier: 'custom-obj-uid',
name: 'field2',
},
{
objectUniversalIdentifier: 'custom-obj-uid',
name: 'field3',
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldGroupRows).toHaveLength(1);
expect(result.current.fieldGroupRows[0].key).toBe('customObj');
expect(result.current.fieldGroupRows[0].fieldsCount).toBe(3);
});
it('should return empty field group rows when manifest has no fields', () => {
const manifestContent = {
objects: [],
fields: [],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldGroupRows).toHaveLength(0);
});
it('should return empty rows when no data is provided', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
}),
{ wrapper },
);
expect(result.current.objectRows).toHaveLength(0);
expect(result.current.fieldGroupRows).toHaveLength(0);
});
});
describe('data source priority', () => {
it('should use installed application data when both sources are provided', () => {
const installedApplication = {
id: APP_ID,
objects: [{ id: personObject.id }],
name: 'Test App',
canBeUninstalled: true,
availablePackages: {},
applicationVariables: [],
agents: [],
logicFunctions: [],
frontComponents: [],
};
const manifestContent = {
objects: [
{
universalIdentifier: 'uid-1',
nameSingular: 'manifestObj',
namePlural: 'manifestObjs',
labelSingular: 'Manifest',
labelPlural: 'Manifests',
icon: 'IconBox',
fields: [],
},
],
fields: [],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
manifestContent,
}),
{ wrapper },
);
// Should use installed data, not manifest
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
expect(
result.current.objectRows.some((r) => r.key === 'manifestObj'),
).toBe(false);
});
});
});
@@ -0,0 +1,179 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
type InstalledApplicationForObjectRows = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
};
export const useObjectAndFieldRows = ({
applicationId,
installedApplication,
manifestContent,
}: {
applicationId: string;
installedApplication?: InstalledApplicationForObjectRows;
manifestContent?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedObjectIds = useMemo(
() => installedApplication?.objects.map((object) => object.id) ?? [],
[installedApplication?.objects],
);
const objectRows = useMemo((): ApplicationDataTableRow[] => {
if (isDefined(installedApplication)) {
if (installedApplication.objects.length === 0) {
return [];
}
return objectMetadataItems
.filter((item) => installedObjectIds.includes(item.id))
.map((item) => ({
key: item.nameSingular,
labelPlural: item.labelPlural,
icon: item.icon ?? undefined,
fieldsCount: item.fields.filter((f) => !isHiddenSystemField(f))
.length,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: item.namePlural,
}),
tagItem: {
isCustom: item.isCustom,
isRemote: item.isRemote,
applicationId: item.applicationId,
},
}));
}
return (manifestContent?.objects ?? []).map((appObject) => ({
key: appObject.nameSingular,
labelPlural: appObject.labelPlural,
icon: appObject.icon ?? undefined,
fieldsCount: appObject.fields.length,
tagItem: { applicationId },
}));
}, [
installedApplication,
manifestContent?.objects,
objectMetadataItems,
installedObjectIds,
applicationId,
]);
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (isDefined(installedApplication)) {
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
return objectMetadataItems
.filter((item) => {
if (installedObjectIds.includes(item.id)) return false;
if (FIELD_GROUP_DENY_LIST.includes(item.nameSingular)) return false;
return item.fields.some(
(field) => field.applicationId === installedApplication.id,
);
})
.map((item) => ({
key: item.nameSingular,
labelPlural: item.labelPlural,
icon: item.icon ?? undefined,
fieldsCount: item.fields.filter(
(field) => field.applicationId === installedApplication.id,
).length,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: item.namePlural,
}),
tagItem: {
isCustom: item.isCustom,
isRemote: item.isRemote,
applicationId: item.applicationId,
},
}));
}
const manifestFields = manifestContent?.fields ?? [];
const manifestObjects = manifestContent?.objects ?? [];
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,
);
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,
tagItem: {},
};
})
.filter(isDefined);
}, [
installedApplication,
manifestContent?.fields,
manifestContent?.objects,
objectMetadataItems,
installedObjectIds,
applicationId,
]);
return { objectRows, fieldGroupRows };
};
@@ -1,13 +1,15 @@
import { styled } from '@linaria/react';
import { type LogicFunction } from '~/generated-metadata/graphql';
import {
type LogicFunctionTableRow,
StyledTableRow,
} from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import {
IconChevronRight,
IconCode,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { StyledTableRow } from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconContainer = styled.span`
@@ -21,43 +23,13 @@ const StyledIconChevronRightContainer = styled(StyledIconContainer)`
export const SettingsLogicFunctionsFieldItemTableRow = ({
logicFunction,
to,
}: {
logicFunction: LogicFunction;
to: string;
logicFunction: LogicFunctionTableRow;
}) => {
const { theme } = useContext(ThemeContext);
const computeTrigger = () => {
const cronTrigger = logicFunction.cronTriggerSettings;
const routeTrigger = logicFunction.httpRouteTriggerSettings;
const databaseEventTriggerSettings =
logicFunction.databaseEventTriggerSettings;
const isTool = logicFunction.isTool;
if (isTool) {
return 'Tool';
}
if (cronTrigger) {
return 'Cron';
}
if (routeTrigger) {
return 'Route';
}
if (databaseEventTriggerSettings) {
return databaseEventTriggerSettings.eventName;
}
return '';
};
return (
<StyledTableRow to={to}>
<StyledTableRow to={logicFunction.link}>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
@@ -74,18 +46,20 @@ export const SettingsLogicFunctionsFieldItemTableRow = ({
whiteSpace="nowrap"
overflow="hidden"
>
<OverflowingTextWithTooltip text={computeTrigger()} />
<OverflowingTextWithTooltip text={logicFunction.trigger} />
</TableCell>
<TableCell
align="center"
padding={`0 ${themeCssVariables.spacing[1]} 0 ${themeCssVariables.spacing[2]}`}
>
<StyledIconChevronRightContainer>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconChevronRightContainer>
{logicFunction.link && (
<StyledIconChevronRightContainer>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconChevronRightContainer>
)}
</TableCell>
</StyledTableRow>
);
@@ -4,14 +4,17 @@ import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { type LogicFunction } from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
import React from 'react';
import { useParams } from 'react-router-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type LogicFunctionTableRow = {
key: string;
name: string;
trigger: string;
link?: string;
};
export const StyledTableRow = (
props: React.ComponentProps<typeof TableRow>,
) => (
@@ -29,10 +32,8 @@ const StyledTableBodyContainer = styled.div`
export const SettingsLogicFunctionsTable = ({
logicFunctions,
}: {
logicFunctions: LogicFunction[];
logicFunctions: LogicFunctionTableRow[];
}) => {
const { applicationId = '' } = useParams();
const { t } = useLingui();
if (logicFunctions.length === 0) {
@@ -48,14 +49,10 @@ export const SettingsLogicFunctionsTable = ({
</StyledTableRow>
<StyledTableBodyContainer>
<TableBody>
{logicFunctions.map((logicFunction: LogicFunction) => (
{logicFunctions.map((logicFunction) => (
<SettingsLogicFunctionsFieldItemTableRow
key={logicFunction.id}
key={logicFunction.key}
logicFunction={logicFunction}
to={getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: logicFunction.id,
})}
/>
))}
</TableBody>
@@ -1,29 +1,48 @@
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 { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useMemo, useState } 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,
IconGraph,
IconInfoCircle,
IconLego,
IconListDetails,
IconLock,
IconSettings,
} from 'twenty-ui/display';
import {
ApplicationRegistrationSourceType,
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
PermissionFlagType,
UninstallApplicationDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
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';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
const APPLICATION_DETAIL_ID = 'application-detail-id';
@@ -42,14 +61,133 @@ 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 { upgrade, isUpgrading } = useUpgradeApplication();
const canInstallMarketplaceApps = useHasPermissionFlag(
PermissionFlagType.MARKETPLACE_APPS,
);
const sourceType = application?.applicationRegistration?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id ?? application?.applicationRegistration?.id;
const currentVersion = application?.version;
const latestAvailableVersion =
detail?.latestAvailableVersion ??
application?.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isNpmApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
const handleUpgrade = async () => {
if (!isDefined(registrationId) || !isDefined(latestAvailableVersion)) {
return;
}
await upgrade({
appRegistrationId: registrationId,
targetVersion: latestAvailableVersion,
});
};
const [uninstallApplication] = useMutation(UninstallApplicationDocument);
const [isUninstalling, setIsUninstalling] = useState(false);
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const navigate = useNavigateSettings();
const handleUninstall = async () => {
if (!isDefined(application)) return;
setIsUninstalling(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 {
setIsUninstalling(false);
}
};
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const applicationObjectIds = useMemo(
() => new Set((application?.objects ?? []).map((obj) => obj.id)),
[application?.objects],
);
const appFieldExtensionsCount = useMemo(() => {
if (!isDefined(application)) return 0;
return objectMetadataItems
.filter((item) => !applicationObjectIds.has(item.id))
.reduce(
(total, item) =>
total +
item.fields.filter((field) => field.applicationId === application.id)
.length,
0,
);
}, [objectMetadataItems, applicationObjectIds, application]);
const contentEntries = [
{
icon: IconBox,
count: (application?.objects ?? []).length,
one: t`object`,
many: t`objects`,
},
{
icon: IconListDetails,
count: appFieldExtensionsCount,
one: t`field`,
many: t`fields`,
},
{
icon: IconCommand,
count: (application?.logicFunctions ?? []).length,
one: t`logic function`,
many: t`logic functions`,
},
{
icon: IconGraph,
count: (application?.frontComponents ?? []).length,
one: t`front component`,
many: t`front components`,
},
{
icon: IconLego,
count: (application?.agents ?? []).length,
one: t`agent`,
many: t`agents`,
},
];
const tabs: SingleTabProps[] = [
{ id: 'about', title: t`About`, Icon: IconInfoCircle },
{ id: 'content', title: t`Content`, Icon: IconBox },
@@ -78,17 +216,55 @@ 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={contentEntries}
currentVersion={currentVersion ?? undefined}
latestAvailableVersion={latestAvailableVersion ?? undefined}
developerLinks={
isDefined(app)
? {
websiteUrl: app.websiteUrl,
termsUrl: app.termsUrl,
emailSupport: app.emailSupport,
issueReportUrl: app.issueReportUrl,
}
: undefined
}
isInstalled={true}
canInstallMarketplaceApps={canInstallMarketplaceApps}
hasUpdate={hasUpdate}
onUpgrade={handleUpgrade}
isUpgrading={isUpgrading}
canBeUninstalled={application.canBeUninstalled}
onUninstall={handleUninstall}
isUninstalling={isUninstalling}
/>
);
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 +290,9 @@ export const SettingsApplicationDetails = () => {
<SubMenuTopBarContainer
title={
<SettingsApplicationDetailTitle
displayName={applicationName}
description={applicationDescription}
logoUrl={applicationLogoUrl}
displayName={displayName}
description={description}
logoUrl={logoUrl}
/>
}
links={[
@@ -128,16 +304,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>
);
@@ -1,192 +1,49 @@
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 { 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 { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { useUpgradeApplication } from '@/marketplace/hooks/useUpgradeApplication';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
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 +70,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 +83,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 +103,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;
@@ -263,55 +114,6 @@ export const SettingsAvailableApplicationDetails = () => {
});
};
const getActionButton = () => {
if (!canInstallMarketplaceApps) {
return null;
}
if (!isAlreadyInstalled) {
return (
<StyledSidebarSection>
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'primary'}
accent={'blue'}
onClick={handleInstall}
disabled={isInstalling}
/>
</StyledSidebarSection>
);
}
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>
);
}
return (
<StyledSidebarSection>
<Button
Icon={IconCheck}
title={t`Installed`}
variant={'secondary'}
accent={'default'}
disabled={isAlreadyInstalled}
/>
</StyledSidebarSection>
);
};
const contentEntries = [
{
icon: IconBox,
@@ -373,7 +175,7 @@ export const SettingsAvailableApplicationDetails = () => {
one: t`agent`,
many: t`agents`,
},
].filter((entry) => entry.count > 0);
];
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
@@ -384,7 +186,6 @@ export const SettingsAvailableApplicationDetails = () => {
{ id: 'about', title: t`About`, Icon: IconInfoCircle },
{ id: 'content', title: t`Content`, Icon: IconBox },
{ id: 'permissions', title: t`Permissions`, Icon: IconLock },
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
];
const renderActiveTabContent = () => {
@@ -393,158 +194,41 @@ 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,
}}
isInstalled={isAlreadyInstalled}
canInstallMarketplaceApps={canInstallMarketplaceApps}
onInstall={handleInstall}
isInstalling={isInstalling}
hasUpdate={hasUpdate}
onUpgrade={handleUpgrade}
isUpgrading={isUpgrading}
/>
);
case 'content':
return (
<SettingsAvailableApplicationDetailContentTab
<SettingsApplicationDetailContentTab
applicationId={detail.universalIdentifier}
content={manifest}
manifestContent={manifest}
/>
);
case 'permissions':
@@ -554,10 +238,7 @@ export const SettingsAvailableApplicationDetails = () => {
marketplaceAppObjects={manifest?.objects}
/>
);
case 'settings':
return (
<SettingsApplicationDetailSettingsTab application={application} />
);
default:
return null;
}
@@ -37,11 +37,13 @@ export const SettingsApplicationDataTableRow = ({
</TableCell>
<TableCell align="right">{row.fieldsCount}</TableCell>
<StyledActionTableCell>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
{row.link && (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.tertiary}
/>
)}
</StyledActionTableCell>
</TableRow>
);
@@ -8,7 +8,8 @@ import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationNameDescriptionTableItem = {
export type ApplicationNameDescriptionTableRow = {
key: string;
name: string;
description?: string | null;
};
@@ -22,7 +23,7 @@ export const SettingsApplicationNameDescriptionTable = ({
title: string;
description: string;
sectionTitle: string;
items: SettingsApplicationNameDescriptionTableItem[];
items: ApplicationNameDescriptionTableRow[];
}) => {
if (items.length === 0) {
return null;
@@ -38,7 +39,7 @@ export const SettingsApplicationNameDescriptionTable = ({
</TableRow>
<TableSection title={sectionTitle}>
{items.map((item) => (
<TableRow key={item.name} gridAutoColumns="180px 1fr">
<TableRow key={item.key} gridAutoColumns="180px 1fr">
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
@@ -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;
@@ -1,106 +1,182 @@
import { t } from '@lingui/core/macro';
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 { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
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 { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
type Application,
UninstallApplicationDocument,
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
IconCheck,
IconDownload,
IconTrash,
IconUpload,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
type ContentEntry,
type DeveloperLinks,
SettingsApplicationAboutSidebar,
} from '@/settings/applications/components/SettingsApplicationAboutSidebar';
import { SettingsApplicationScreenshotGallery } from '@/settings/applications/components/SettingsApplicationScreenshotGallery';
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
type SettingsApplicationDetailAboutTabProps = {
displayName: string;
description?: string;
aboutDescription?: string;
screenshots?: string[];
author?: string;
category?: string;
contentEntries?: ContentEntry[];
currentVersion?: string;
latestAvailableVersion?: string;
developerLinks?: DeveloperLinks;
isInstalled: boolean;
canInstallMarketplaceApps?: boolean;
onInstall?: () => void;
isInstalling?: boolean;
hasUpdate?: boolean;
onUpgrade?: () => void;
isUpgrading?: boolean;
canBeUninstalled?: boolean;
onUninstall?: () => void;
isUninstalling?: boolean;
};
const StyledContentContainer = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledMainContent = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
`;
export const SettingsApplicationDetailAboutTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & {
objects: { id: string }[];
};
}) => {
displayName,
description,
aboutDescription,
screenshots,
author,
category,
contentEntries,
currentVersion,
latestAvailableVersion,
developerLinks,
isInstalled,
canInstallMarketplaceApps,
onInstall,
isInstalling,
hasUpdate,
onUpgrade,
isUpgrading,
canBeUninstalled,
onUninstall,
isUninstalling,
}: SettingsApplicationDetailAboutTabProps) => {
const { openModal } = useModal();
const [isLoading, setIsLoading] = useState(false);
const hasScreenshots = isDefined(screenshots) && screenshots.length > 0;
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const markdownText =
aboutDescription ??
description ??
t`No description available for this application`;
const [uninstallApplication] = useMutation(UninstallApplicationDocument);
const navigate = useNavigateSettings();
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 getActionButton = () => {
if (!canInstallMarketplaceApps) {
return null;
}
if (!isInstalled) {
return (
<Button
Icon={IconDownload}
title={isInstalling ? t`Installing...` : t`Install`}
variant={'primary'}
accent={'blue'}
onClick={onInstall}
disabled={isInstalling}
/>
);
}
if (hasUpdate) {
return (
<Button
Icon={IconUpload}
title={
isUpgrading
? t`Upgrading...`
: t`Upgrade to ${latestAvailableVersion}`
}
variant={'secondary'}
accent={'blue'}
onClick={onUpgrade}
disabled={isUpgrading}
/>
);
}
if (canBeUninstalled) {
return (
<Button
Icon={IconTrash}
title={isUninstalling ? t`Uninstalling...` : t`Uninstall`}
variant={'secondary'}
accent={'danger'}
onClick={() => openModal(UNINSTALL_APPLICATION_MODAL_ID)}
disabled={isUninstalling}
/>
);
}
return (
<Button
Icon={IconCheck}
title={t`Installed`}
variant={'secondary'}
accent={'default'}
disabled={true}
/>
);
};
const confirmationValue = t`yes`;
return (
<>
<Section>
<SettingsApplicationVersionContainer
application={application}
latestAvailableVersion={latestAvailableVersion}
appRegistrationId={registrationId}
{hasScreenshots && (
<SettingsApplicationScreenshotGallery
screenshots={screenshots}
displayName={displayName}
/>
</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"
/>
)}
</Section>
)}
<StyledContentContainer>
<StyledMainContent>
<Section>
<LazyMarkdownRenderer text={markdownText} />
</Section>
</StyledMainContent>
<SettingsApplicationAboutSidebar
actionButton={getActionButton()}
author={author}
category={category}
contentEntries={contentEntries}
currentVersion={currentVersion}
latestAvailableVersion={latestAvailableVersion}
developerLinks={developerLinks}
/>
</StyledContentContainer>
{canBeUninstalled && isDefined(onUninstall) && (
<ConfirmationModal
confirmationPlaceholder={confirmationValue}
confirmationValue={confirmationValue}
@@ -112,11 +188,11 @@ export const SettingsApplicationDetailAboutTab = ({
uninstall this application.
</Trans>
}
onConfirmClick={handleUninstallApplication}
onConfirmClick={onUninstall}
confirmButtonText={t`Uninstall`}
loading={isLoading}
loading={isUninstalling}
/>
</>
)}
</>
);
};
@@ -1,122 +1,97 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { SettingsLogicFunctionsTable } from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
import {
type LogicFunctionTableRow,
SettingsLogicFunctionsTable,
} from '@/settings/logic-functions/components/SettingsLogicFunctionsTable';
import { useObjectAndFieldRows } from '@/settings/applications/hooks/useObjectAndFieldRows';
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 } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import {
SettingsApplicationDataTable,
type ApplicationDataTableRow,
} from '~/pages/settings/applications/components/SettingsApplicationDataTable';
type ApplicationNameDescriptionTableRow,
SettingsApplicationNameDescriptionTable,
} from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable';
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 }[];
};
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
applicationId,
installedApplication,
manifestContent,
}: SettingsApplicationDetailContentTabProps) => {
const { objectRows, fieldGroupRows } = useObjectAndFieldRows({
applicationId,
installedApplication,
manifestContent,
});
const applicationObjectIds = useMemo(
() => application?.objects.map((object) => object.id) ?? [],
[application?.objects],
);
const logicFunctionRows = useMemo((): LogicFunctionTableRow[] => {
const computeTrigger = (lf: {
isTool?: boolean;
cronTriggerSettings?: unknown;
httpRouteTriggerSettings?: unknown;
databaseEventTriggerSettings?: { eventName?: string } | null;
}): string => {
if (lf.isTool) return 'Tool';
if (lf.cronTriggerSettings) return 'Cron';
if (lf.httpRouteTriggerSettings) return 'Route';
if (lf.databaseEventTriggerSettings)
return lf.databaseEventTriggerSettings.eventName ?? '';
return '';
};
const objectRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(application) || application.objects.length === 0) {
return [];
if (isDefined(installedApplication)) {
return (installedApplication.logicFunctions ?? []).map((lf) => ({
key: lf.id,
name: lf.name,
trigger: computeTrigger(lf),
link: getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: lf.id,
}),
}));
}
return objectMetadataItems
.filter((objectMetadataItem) =>
applicationObjectIds.includes(objectMetadataItem.id),
)
.map((objectMetadataItem) => {
const nonSystemFields = objectMetadataItem.fields.filter(
(field) => !isHiddenSystemField(field),
);
return (manifestContent?.logicFunctions ?? []).map((lf) => ({
key: lf.universalIdentifier,
name: lf.name ?? lf.universalIdentifier,
trigger: computeTrigger(lf),
}));
}, [installedApplication, manifestContent?.logicFunctions, applicationId]);
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 frontComponentRows =
useMemo((): ApplicationNameDescriptionTableRow[] => {
if (isDefined(installedApplication)) {
return (installedApplication.frontComponents ?? []).map((fc) => ({
key: fc.name,
name: fc.name,
description: fc.description,
}));
}
const fieldGroupRows = useMemo((): ApplicationDataTableRow[] => {
if (!isDefined(application)) {
return [];
}
const FIELD_GROUP_DENY_LIST = ['timelineActivity', 'favorite'];
return objectMetadataItems
.filter((objectMetadataItem) => {
if (applicationObjectIds.includes(objectMetadataItem.id)) {
return false;
}
if (FIELD_GROUP_DENY_LIST.includes(objectMetadataItem.nameSingular)) {
return false;
}
const appFields = objectMetadataItem.fields.filter(
(field) => field.applicationId === application.id,
);
return appFields.length > 0;
})
.map((objectMetadataItem) => {
const appFieldsCount = objectMetadataItem.fields.filter(
(field) => field.applicationId === application.id,
).length;
return {
key: objectMetadataItem.nameSingular,
labelPlural: objectMetadataItem.labelPlural,
icon: objectMetadataItem.icon ?? undefined,
fieldsCount: appFieldsCount,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: objectMetadataItem.namePlural,
}),
tagItem: {
isCustom: objectMetadataItem.isCustom,
isRemote: objectMetadataItem.isRemote,
applicationId: objectMetadataItem.applicationId,
},
};
});
}, [objectMetadataItems, applicationObjectIds, application]);
if (!isDefined(application)) {
return null;
}
const { logicFunctions } = application;
const shouldDisplayLogicFunctions =
isDefined(logicFunctions) && logicFunctions?.length > 0;
// TODO: uncomment when adding back agents in application settings
// const shouldDisplayAgents = isDefined(agents) && agents.length > 0;
const shouldDisplayAgents = false;
return (manifestContent?.frontComponents ?? []).map((fc) => ({
key: fc.universalIdentifier,
name: fc.name ?? fc.universalIdentifier,
description: fc.description,
}));
}, [installedApplication, manifestContent?.frontComponents]);
return (
<>
@@ -124,24 +99,21 @@ export const SettingsApplicationDetailContentTab = ({
objectRows={objectRows}
fieldGroupRows={fieldGroupRows}
/>
{shouldDisplayLogicFunctions && (
{logicFunctionRows.length > 0 && (
<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`}
/>
<SettingsAiAgentsTable />
<SettingsLogicFunctionsTable logicFunctions={logicFunctionRows} />
</Section>
)}
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponentRows}
/>
</>
);
};
@@ -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,
}))}
/>
</>
);
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "1.23.0-canary.1",
"version": "1.23.0-canary.2",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
@@ -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,
@@ -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',
@@ -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',
@@ -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[];