Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 78c4f9c73c fix: add defensive check for package.json in copyDependenciesInMemory
https://sonarly.com/issue/32080?type=bug

Logic function execution fails with "File not found" error when attempting to build Lambda dependency layers because the code unconditionally downloads package.json from S3 without checking if it exists first. Upgraded workspaces lack this file entirely, causing FileStorageException.

Fix: The fix implements a defensive check for the `package.json` file in `copyDependenciesInMemory()` method, preventing FileStorageException when the file is missing from S3.

**Changes made:**

1. Added import for `SEED_DEPENDENCIES_DIRNAME` constant which points to the seed-dependencies directory containing default dependency files
2. Added `path` to the path module import (previously only importing `dirname` and `join`)
3. Modified `copyDependenciesInMemory()` to check if both `package.json` and `yarn.lock` exist in parallel using `Promise.all()`
4. Added conditional logic for `package.json`: if it exists in S3, download it; otherwise, copy the seed package.json from the local seed-dependencies directory
5. Kept existing conditional logic for `yarn.lock` unchanged

**Why this fixes the issue:**

Workspaces created before the application-system feature, or upgraded from earlier versions, do not have dependency files in S3. When logic functions are executed, the system attempts to retrieve these files. Previously, the code unconditionally tried to download `package.json` without checking if it existed first, causing FileStorageException(FILE_NOT_FOUND) which prevented the entire logic function build process.

By checking file existence first and falling back to the seed package.json (which contains all necessary production dependencies), logic functions can now execute successfully on any workspace, regardless of when it was created or whether it was upgraded.

This follows the exact same defensive pattern already implemented for `yarn.lock` handling and matches the reference fix that was previously developed (commit a730cf3f7d).
2026-04-28 12:50:14 +00:00
486 changed files with 25684 additions and 30127 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 47.3,
lines: 45.9,
statements: 47.9,
lines: 46,
functions: 39.5,
},
},
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -180,28 +180,6 @@ const SettingsApplicationDetails = lazy(() =>
),
);
const SettingsApplicationFrontComponentDetail = lazy(() =>
import(
'~/pages/settings/applications/SettingsApplicationFrontComponentDetail'
).then((module) => ({
default: module.SettingsApplicationFrontComponentDetail,
})),
);
const SettingsLayoutViewDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
default: module.SettingsLayoutViewDetail,
})),
);
const SettingsLayoutPageLayoutDetail = lazy(() =>
import('~/pages/settings/layout/SettingsLayoutPageLayoutDetail').then(
(module) => ({
default: module.SettingsLayoutPageLayoutDetail,
}),
),
);
const SettingsAdminApplicationRegistrationDetail = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetail'
@@ -774,18 +752,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.ApplicationLogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
/>
<Route
path={SettingsPath.ApplicationFrontComponentDetail}
element={<SettingsApplicationFrontComponentDetail />}
/>
<Route
path={SettingsPath.ApplicationViewDetail}
element={<SettingsLayoutViewDetail />}
/>
<Route
path={SettingsPath.ApplicationPageLayoutDetail}
element={<SettingsLayoutPageLayoutDetail />}
/>
<Route
path={SettingsPath.ApplicationRegistrationConfigVariableDetails}
element={<SettingsApplicationRegistrationConfigVariableDetail />}
@@ -39,13 +39,6 @@ export const APPLICATION_FRAGMENT = gql`
name
description
applicationId
componentName
builtComponentChecksum
universalIdentifier
isHeadless
usesSdkClient
createdAt
updatedAt
}
objects {
...ObjectMetadataFields
@@ -15,7 +15,6 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
databaseEventTriggerSettings
httpRouteTriggerSettings
applicationId
universalIdentifier
createdAt
updatedAt
}
@@ -46,9 +46,6 @@ describe('useLogicFunctionUpdateFormState', () => {
properties: {},
type: 'object',
},
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
});
});
@@ -1,11 +1,6 @@
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type CronTriggerSettings,
type DatabaseEventTriggerSettings,
type HttpRouteTriggerSettings,
} from 'twenty-shared/application';
import { type LogicFunction } from '~/generated-metadata/graphql';
import { useGetLogicFunctionSourceCode } from '@/logic-functions/hooks/useGetLogicFunctionSourceCode';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
@@ -17,9 +12,6 @@ export type LogicFunctionFormValues = {
timeoutSeconds: number;
sourceHandlerCode: string;
toolInputSchema?: object;
cronTriggerSettings: CronTriggerSettings | null;
databaseEventTriggerSettings: DatabaseEventTriggerSettings | null;
httpRouteTriggerSettings: HttpRouteTriggerSettings | null;
};
type SetLogicFunctionFormValues = Dispatch<
@@ -43,9 +35,6 @@ export const useLogicFunctionUpdateFormState = ({
sourceHandlerCode: '',
timeoutSeconds: 300,
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } =
@@ -68,11 +57,6 @@ export const useLogicFunctionUpdateFormState = ({
timeoutSeconds: logicFunction.timeoutSeconds ?? 300,
toolInputSchema:
logicFunction.toolInputSchema || DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: logicFunction.cronTriggerSettings ?? null,
databaseEventTriggerSettings:
logicFunction.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
logicFunction.httpRouteTriggerSettings ?? null,
}));
}
}, [logicFunction]);
@@ -1,55 +0,0 @@
import { getLogicFunctionTriggerLabel } from '@/logic-functions/utils/getLogicFunctionTriggerLabel';
describe('getLogicFunctionTriggerLabel', () => {
it('returns Post-install when the function matches the post-install identifier', () => {
expect(
getLogicFunctionTriggerLabel(
{ universalIdentifier: 'uid-post' },
{ postInstallUniversalIdentifier: 'uid-post' },
),
).toBe('Post-install');
});
it('returns Pre-install when the function matches the pre-install identifier', () => {
expect(
getLogicFunctionTriggerLabel(
{ universalIdentifier: 'uid-pre' },
{ preInstallUniversalIdentifier: 'uid-pre' },
),
).toBe('Pre-install');
});
it('does not match when both identifiers are undefined', () => {
expect(getLogicFunctionTriggerLabel({}, {})).toBe('');
});
it('returns AI tool when isTool is set', () => {
expect(getLogicFunctionTriggerLabel({ isTool: true })).toBe('AI tool');
});
it('returns Cron when cron settings are present', () => {
expect(getLogicFunctionTriggerLabel({ cronTriggerSettings: {} })).toBe(
'Cron',
);
});
it('returns HTTP when http settings are present', () => {
expect(getLogicFunctionTriggerLabel({ httpRouteTriggerSettings: {} })).toBe(
'HTTP',
);
});
it('returns the database event name when it exists', () => {
expect(
getLogicFunctionTriggerLabel({
databaseEventTriggerSettings: { eventName: 'person.created' },
}),
).toBe('person.created');
});
it('falls back to a generic label when the database event name is missing', () => {
expect(
getLogicFunctionTriggerLabel({ databaseEventTriggerSettings: {} }),
).toBe('Database event');
});
});
@@ -1,38 +0,0 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
type LogicFunctionLike = {
universalIdentifier?: string | null;
isTool?: boolean;
cronTriggerSettings?: unknown;
httpRouteTriggerSettings?: unknown;
databaseEventTriggerSettings?: { eventName?: string } | null;
};
export const getLogicFunctionTriggerLabel = (
lf: LogicFunctionLike,
options: {
postInstallUniversalIdentifier?: string;
preInstallUniversalIdentifier?: string;
} = {},
): string => {
if (
isDefined(lf.universalIdentifier) &&
lf.universalIdentifier === options.postInstallUniversalIdentifier
) {
return t`Post-install`;
}
if (
isDefined(lf.universalIdentifier) &&
lf.universalIdentifier === options.preInstallUniversalIdentifier
) {
return t`Pre-install`;
}
if (lf.isTool) return t`AI tool`;
if (lf.cronTriggerSettings) return t`Cron`;
if (lf.httpRouteTriggerSettings) return t`HTTP`;
if (lf.databaseEventTriggerSettings) {
return lf.databaseEventTriggerSettings.eventName ?? t`Database event`;
}
return '';
};
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateDepthRecordGqlFieldsFromRecord should generate depth one record gql fields from empty record 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`useDeleteOneRecord A. Starting from empty cache 1. Should successfully delete record and update record cache entry 1`] = `
{
@@ -1,242 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useComputeApplicationContentForLayoutAndLogic } from '@/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic';
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 APP_ID = 'test-app-id';
const wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const baseManifest = {
pageLayouts: [],
views: [],
navigationMenuItems: [],
agents: [],
skills: [],
roles: [],
} as unknown as Manifest;
describe('useComputeApplicationContentForLayoutAndLogic', () => {
describe('pageLayoutRows', () => {
it('builds rows from manifest pageLayouts and resolves the object label from workspace metadata', () => {
const manifestContent = {
...baseManifest,
objects: [],
pageLayouts: [
{
universalIdentifier: 'pl-1',
name: 'Person dashboard',
objectUniversalIdentifier: personObject.universalIdentifier,
tabs: [
{ universalIdentifier: 't1' },
{ universalIdentifier: 't2' },
],
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({ manifestContent }),
{ wrapper },
);
expect(result.current.pageLayoutRows).toHaveLength(1);
const [row] = result.current.pageLayoutRows;
expect(row.key).toBe('pl-1');
expect(row.name).toBe('Person dashboard');
expect(row.secondary).toContain(personObject.labelSingular);
expect(row.secondary).toContain('2 tabs');
expect(row.link).toBeUndefined();
});
it('exposes a link to the layout detail page when an installed app is provided', () => {
const manifestContent = {
...baseManifest,
objects: [],
pageLayouts: [
{
universalIdentifier: 'pl-1',
name: 'Layout',
tabs: [],
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({
installedApplication: { id: APP_ID, agents: [] },
manifestContent,
}),
{ wrapper },
);
expect(result.current.pageLayoutRows[0].link).toBeDefined();
});
});
describe('viewRows', () => {
it('builds rows from manifest views with type/object secondary', () => {
const manifestContent = {
...baseManifest,
objects: [],
views: [
{
universalIdentifier: 'v-1',
name: 'My table',
type: 'TABLE',
objectUniversalIdentifier: personObject.universalIdentifier,
icon: 'IconTable',
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({ manifestContent }),
{ wrapper },
);
const [row] = result.current.viewRows;
expect(row.icon).toBe('IconTable');
expect(row.secondary).toContain('Table');
expect(row.secondary).toContain(personObject.labelSingular);
});
});
describe('navigationMenuItemRows', () => {
it('falls back to a destination-derived display name when the item has no name', () => {
const manifestContent = {
...baseManifest,
objects: [],
navigationMenuItems: [
{
universalIdentifier: 'n-1',
type: 'OBJECT',
targetObjectUniversalIdentifier: personObject.universalIdentifier,
},
{ universalIdentifier: 'n-2', type: 'FOLDER' },
{
universalIdentifier: 'n-3',
type: 'LINK',
link: 'https://example.com',
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({ manifestContent }),
{ wrapper },
);
expect(result.current.navigationMenuItemRows[0].name).toBe(
personObject.labelSingular,
);
expect(result.current.navigationMenuItemRows[1].name).toBe('Folder');
expect(result.current.navigationMenuItemRows[2].name).toBe(
'https://example.com',
);
});
it('resolves PAGE_LAYOUT and VIEW destinations against the manifest', () => {
const manifestContent = {
...baseManifest,
objects: [],
pageLayouts: [{ universalIdentifier: 'pl-1', name: 'Layout A' }],
views: [{ universalIdentifier: 'v-1', name: 'View A' }],
navigationMenuItems: [
{
universalIdentifier: 'n-1',
type: 'PAGE_LAYOUT',
pageLayoutUniversalIdentifier: 'pl-1',
},
{
universalIdentifier: 'n-2',
type: 'VIEW',
viewUniversalIdentifier: 'v-1',
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({ manifestContent }),
{ wrapper },
);
expect(result.current.navigationMenuItemRows[0].secondary).toContain(
'Layout A',
);
expect(result.current.navigationMenuItemRows[1].secondary).toContain(
'View A',
);
});
});
describe('agentRows', () => {
it('uses installed agents (with link) when available, manifest agents otherwise', () => {
const installed = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({
installedApplication: {
id: APP_ID,
agents: [
{
id: 'agent-1',
label: 'Workspace Agent',
description: 'desc',
},
],
} as never,
manifestContent: baseManifest,
}),
{ wrapper },
);
expect(installed.result.current.agentRows[0].key).toBe('agent-1');
expect(installed.result.current.agentRows[0].link).toBeDefined();
const marketplace = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({
manifestContent: {
...baseManifest,
agents: [
{ universalIdentifier: 'a-uid', label: 'Manifest Agent' },
],
} as unknown as Manifest,
}),
{ wrapper },
);
expect(marketplace.result.current.agentRows[0].key).toBe('a-uid');
expect(marketplace.result.current.agentRows[0].link).toBeUndefined();
});
});
describe('skillRows and roleRows', () => {
it('builds rows from manifest skills and roles', () => {
const manifestContent = {
...baseManifest,
skills: [{ universalIdentifier: 's-1', label: 'Skill A' }],
roles: [{ universalIdentifier: 'r-1', label: 'Role A' }],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeApplicationContentForLayoutAndLogic({ manifestContent }),
{ wrapper },
);
expect(result.current.skillRows[0].name).toBe('Skill A');
expect(result.current.roleRows[0].name).toBe('Role A');
});
});
});
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { useComputeObjectAndFieldsContentForApplication } from '@/settings/applications/hooks/useComputeObjectAndFieldsContentForApplication';
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';
@@ -20,7 +20,7 @@ const wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useComputeObjectAndFieldsContentForApplication', () => {
describe('useObjectAndFieldRows', () => {
describe('with installed application', () => {
it('should return object rows for installed application objects', () => {
const installedApplication = {
@@ -37,7 +37,8 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
@@ -45,8 +46,10 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
expect(result.current.objectRows[0].name).toBe(personObject.labelPlural);
expect(result.current.objectRows[0].secondary).toMatch(/\d+ fields/);
expect(result.current.objectRows[0].labelPlural).toBe(
personObject.labelPlural,
);
expect(result.current.objectRows[0].fieldsCount).toBeGreaterThan(0);
expect(result.current.objectRows[0].link).toBeDefined();
});
@@ -65,7 +68,8 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
@@ -74,7 +78,7 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
expect(result.current.objectRows).toHaveLength(0);
});
it("should not include the app's own objects among the field rows", () => {
it('should return field group rows for fields added to other objects', () => {
const fieldBelongingToApp = companyObject.fields[0];
const installedApplication = {
@@ -91,19 +95,21 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: installedApplication.id,
installedApplication,
}),
{ wrapper },
);
const referencesOwnObject = result.current.fieldRows.some((row) =>
row.secondary?.includes(personObject.labelSingular),
// Field group rows should not include the app's own objects
const hasOwnObject = result.current.fieldGroupRows.some(
(row) => row.key === personObject.nameSingular,
);
expect(referencesOwnObject).toBe(false);
expect(hasOwnObject).toBe(false);
});
it('should exclude deny-listed objects from field rows', () => {
it('should exclude deny-listed objects from field group rows', () => {
const installedApplication = {
id: APP_ID,
objects: [],
@@ -118,27 +124,17 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
}),
{ wrapper },
);
const timelineActivityObject = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'timelineActivity',
const hasDeniedObject = result.current.fieldGroupRows.some(
(row) => row.key === 'timelineActivity' || row.key === 'favorite',
);
const favoriteObject = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'favorite',
);
const hasDeniedObjectField = result.current.fieldRows.some(
(row) =>
row.secondary?.includes(
timelineActivityObject?.labelSingular ?? '__never__',
) ||
row.secondary?.includes(favoriteObject?.labelSingular ?? '__never__'),
);
expect(hasDeniedObjectField).toBe(false);
expect(hasDeniedObject).toBe(false);
});
});
@@ -161,7 +157,8 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
@@ -169,11 +166,14 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe('customObject');
expect(result.current.objectRows[0].name).toBe('Custom Objects');
expect(result.current.objectRows[0].secondary).toBe('2 fields');
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 one row per field when the parent object lives in the manifest', () => {
it('should return field group rows grouped by object from manifest fields', () => {
const manifestContent = {
objects: [
{
@@ -189,45 +189,34 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
fields: [
{
objectUniversalIdentifier: 'custom-obj-uid',
universalIdentifier: 'field1-uid',
name: 'field1',
label: 'Field 1',
},
{
objectUniversalIdentifier: 'custom-obj-uid',
universalIdentifier: 'field2-uid',
name: 'field2',
label: 'Field 2',
},
{
objectUniversalIdentifier: 'custom-obj-uid',
universalIdentifier: 'field3-uid',
name: 'field3',
label: 'Field 3',
},
],
} as unknown as Manifest;
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldRows).toHaveLength(3);
expect(result.current.fieldRows.map((r) => r.name)).toEqual([
'Field 1',
'Field 2',
'Field 3',
]);
expect(
result.current.fieldRows.every((r) => r.secondary === 'on Custom'),
).toBe(true);
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 rows when manifest has no fields', () => {
it('should return empty field group rows when manifest has no fields', () => {
const manifestContent = {
objects: [],
fields: [],
@@ -235,25 +224,27 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: 'app-uid',
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldRows).toHaveLength(0);
expect(result.current.fieldGroupRows).toHaveLength(0);
});
it('should return empty rows when no data is provided', () => {
const { result } = renderHook(
() => useComputeObjectAndFieldsContentForApplication({}),
{
wrapper,
},
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
}),
{ wrapper },
);
expect(result.current.objectRows).toHaveLength(0);
expect(result.current.fieldRows).toHaveLength(0);
expect(result.current.fieldGroupRows).toHaveLength(0);
});
});
@@ -288,7 +279,8 @@ describe('useComputeObjectAndFieldsContentForApplication', () => {
const { result } = renderHook(
() =>
useComputeObjectAndFieldsContentForApplication({
useObjectAndFieldRows({
applicationId: APP_ID,
installedApplication,
manifestContent,
}),
@@ -1,184 +0,0 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { type Manifest } from 'twenty-shared/application';
import { SettingsPath } from 'twenty-shared/types';
import { capitalize, getSettingsPath, isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
import { type ApplicationContentRow } from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
type InstalledApplicationForContent = Pick<Application, 'agents' | 'id'>;
export const useComputeApplicationContentForLayoutAndLogic = ({
installedApplication,
manifestContent,
}: {
installedApplication?: InstalledApplicationForContent;
manifestContent?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedAppId = installedApplication?.id;
// Workspace metadata covers standard + installed-app objects; the manifest
// fallback only matters when previewing an uninstalled marketplace app.
const resolveLabel = (uid: string | undefined | null) => {
if (!isDefined(uid)) return undefined;
return (
objectMetadataItems.find((o) => o.universalIdentifier === uid)
?.labelSingular ??
manifestContent?.objects.find((o) => o.universalIdentifier === uid)
?.labelSingular
);
};
const pageLayoutRows: ApplicationContentRow[] = (
manifestContent?.pageLayouts ?? []
).map((layout) => {
const objectLabel = resolveLabel(layout.objectUniversalIdentifier);
const tabCount = layout.tabs?.length ?? 0;
const parts: string[] = [];
if (isDefined(objectLabel)) parts.push(t`for ${objectLabel}`);
if (tabCount > 0) {
parts.push(tabCount === 1 ? t`1 tab` : t`${tabCount} tabs`);
}
return {
key: layout.universalIdentifier,
name: layout.name,
secondary: parts.length > 0 ? parts.join(' · ') : undefined,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationPageLayoutDetail, {
applicationId: installedAppId,
pageLayoutUniversalIdentifier: layout.universalIdentifier,
})
: undefined,
};
});
const viewRows: ApplicationContentRow[] = (manifestContent?.views ?? []).map(
(view) => {
const objectLabel = resolveLabel(view.objectUniversalIdentifier);
const formattedType = capitalize((view.type ?? 'TABLE').toLowerCase());
return {
key: view.universalIdentifier,
name: view.name,
icon: view.icon ?? undefined,
secondary: isDefined(objectLabel)
? t`${formattedType} of ${objectLabel}`
: formattedType,
link: isDefined(installedAppId)
? getSettingsPath(SettingsPath.ApplicationViewDetail, {
applicationId: installedAppId,
viewUniversalIdentifier: view.universalIdentifier,
})
: undefined,
};
},
);
const navigationMenuItemRows: ApplicationContentRow[] = (
manifestContent?.navigationMenuItems ?? []
).map((item) => {
const destination = (() => {
switch (item.type) {
case 'FOLDER':
return { label: t`Folder`, displayName: t`Folder` };
case 'LINK': {
const link = item.link ?? t`Link`;
return { label: link, displayName: link };
}
case 'OBJECT': {
const label = resolveLabel(item.targetObjectUniversalIdentifier);
return {
label: isDefined(label) ? t`${label} list` : t`Object`,
displayName: label,
};
}
case 'PAGE_LAYOUT': {
const layout = manifestContent?.pageLayouts?.find(
(pl) =>
pl.universalIdentifier === item.pageLayoutUniversalIdentifier,
);
return {
label: isDefined(layout)
? t`${layout.name} layout`
: t`Page layout`,
displayName: layout?.name,
};
}
case 'VIEW': {
const view = manifestContent?.views?.find(
(v) => v.universalIdentifier === item.viewUniversalIdentifier,
);
return {
label: isDefined(view) ? t`${view.name} view` : t`View`,
displayName: view?.name,
};
}
case 'RECORD':
return { label: t`Record`, displayName: t`Record` };
default:
return { label: undefined, displayName: undefined };
}
})();
const displayName =
isDefined(item.name) && item.name !== ''
? item.name
: (destination.displayName ?? item.type);
return {
key: item.universalIdentifier,
name: displayName,
icon: item.icon ?? undefined,
secondary: destination.label,
};
});
const agentRows: ApplicationContentRow[] = isDefined(installedApplication)
? (installedApplication.agents ?? []).map((agent) => ({
key: agent.id,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
link: getSettingsPath(SettingsPath.AiAgentDetail, {
agentId: agent.id,
}),
}))
: (manifestContent?.agents ?? []).map((agent) => ({
key: agent.universalIdentifier,
name: agent.label,
icon: agent.icon ?? undefined,
secondary: agent.description ?? undefined,
}));
const skillRows: ApplicationContentRow[] = (
manifestContent?.skills ?? []
).map((skill) => ({
key: skill.universalIdentifier,
name: skill.label,
icon: skill.icon ?? undefined,
secondary: skill.description ?? undefined,
}));
const roleRows: ApplicationContentRow[] = (manifestContent?.roles ?? []).map(
(role) => ({
key: role.universalIdentifier,
name: role.label,
icon: role.icon ?? undefined,
secondary: role.description ?? undefined,
}),
);
return {
pageLayoutRows,
viewRows,
navigationMenuItemRows,
agentRows,
skillRows,
roleRows,
};
};
@@ -1,122 +0,0 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
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 ApplicationContentRow } from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
type InstalledApplicationForObjectAndFields = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
};
const FIELD_GROUP_DENY_LIST = new Set(['timelineActivity', 'favorite']);
export const useComputeObjectAndFieldsContentForApplication = ({
installedApplication,
manifestContent,
}: {
installedApplication?: InstalledApplicationForObjectAndFields;
manifestContent?: Manifest;
}) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const installedObjectIds = new Set(
installedApplication?.objects.map((object) => object.id),
);
const objectRows: ApplicationContentRow[] = isDefined(installedApplication)
? objectMetadataItems
.filter((item) => installedObjectIds.has(item.id))
.map((item) => {
const fieldsCount = item.fields.filter(
(f) => !isHiddenSystemField(f),
).length;
return {
key: item.nameSingular,
name: item.labelPlural,
icon: item.icon ?? undefined,
secondary: t`${fieldsCount} fields`,
link: getSettingsPath(SettingsPath.ObjectDetail, {
objectNamePlural: item.namePlural,
}),
};
})
: (manifestContent?.objects ?? []).map((appObject) => ({
key: appObject.nameSingular,
name: appObject.labelPlural,
icon: appObject.icon ?? undefined,
secondary: t`${appObject.fields.length} fields`,
}));
const fieldRows: ApplicationContentRow[] = isDefined(installedApplication)
? objectMetadataItems
.filter(
(item) =>
!installedObjectIds.has(item.id) &&
!FIELD_GROUP_DENY_LIST.has(item.nameSingular),
)
.flatMap((item) =>
item.fields
.filter((field) => field.applicationId === installedApplication.id)
.map((field) => ({
key: `${item.id}-${field.id}`,
name: field.label,
icon: field.icon ?? undefined,
secondary: t`on ${item.labelSingular}`,
link: getSettingsPath(SettingsPath.ObjectFieldEdit, {
objectNamePlural: item.namePlural,
fieldName: field.name,
}),
})),
)
: (() => {
const manifestFields = manifestContent?.fields ?? [];
const manifestObjectByUid = new Map(
(manifestContent?.objects ?? []).map((obj) => [
obj.universalIdentifier,
obj,
]),
);
return manifestFields
.map((field) => {
const appObject = manifestObjectByUid.get(
field.objectUniversalIdentifier,
);
if (isDefined(appObject)) {
return {
key: `${appObject.nameSingular}-${field.universalIdentifier}`,
name: field.label ?? field.name,
icon: field.icon ?? undefined,
secondary: t`on ${appObject.labelSingular}`,
};
}
const objectMetadataItem = objectMetadataItems.find(
(item) =>
item.universalIdentifier === field.objectUniversalIdentifier,
);
if (!isDefined(objectMetadataItem)) {
return undefined;
}
return {
key: `${objectMetadataItem.nameSingular}-${field.universalIdentifier}`,
name: field.label ?? field.name,
icon: field.icon ?? undefined,
secondary: t`on ${objectMetadataItem.labelSingular}`,
};
})
.filter(isDefined);
})();
return { objectRows, fieldRows };
};
@@ -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,10 +1,11 @@
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TextArea } from '@/ui/input/components/TextArea';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { H2Title, IconClockHour8 } from 'twenty-ui/display';
import { H2Title, IconClockHour8, IconTool } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -52,6 +53,16 @@ export const SettingsLogicFunctionNewForm = ({
onChange={onChange('description')}
readOnly={readonly}
/>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconTool}
title={t`Available as tool`}
description={t`When enabled, AI agents and workflow automations can discover and call this function`}
checked={formValues.isTool}
onChange={onChange('isTool')}
disabled={readonly}
/>
</Card>
<Card rounded>
<SettingsOptionCardContentCounter
Icon={IconClockHour8}
@@ -0,0 +1,36 @@
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { LinkChip } from 'twenty-ui/components';
import { getSettingsPath } from 'twenty-shared/utils';
import { useParams } from 'react-router-dom';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
export const SettingsLogicFunctionTabEnvironmentVariablesSection = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
return (
<Section>
<H2Title
title={t`Environment Variables`}
description={t`Accessible in your function via process.env.KEY`}
/>
<Trans>
Environment variables are defined at application level for all
functions. Please check{' '}
<LinkChip
label={t`application detail page`}
to={getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'settings',
)}
/>
.
</Trans>
</Section>
);
};
@@ -0,0 +1,66 @@
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 { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconContainer = styled.span`
align-items: center;
display: flex;
`;
const StyledIconChevronRightContainer = styled(StyledIconContainer)`
color: ${themeCssVariables.font.color.light};
`;
export const SettingsLogicFunctionsFieldItemTableRow = ({
logicFunction,
}: {
logicFunction: LogicFunctionTableRow;
}) => {
const { theme } = useContext(ThemeContext);
return (
<StyledTableRow to={logicFunction.link}>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
>
<StyledIconContainer>
<IconCode size={theme.icon.size.md} />
</StyledIconContainer>
<OverflowingTextWithTooltip text={logicFunction.name} />
</TableCell>
<TableCell
color={themeCssVariables.font.color.secondary}
gap={themeCssVariables.spacing[2]}
align={'right'}
whiteSpace="nowrap"
overflow="hidden"
>
<OverflowingTextWithTooltip text={logicFunction.trigger} />
</TableCell>
<TableCell
align="center"
padding={`0 ${themeCssVariables.spacing[1]} 0 ${themeCssVariables.spacing[2]}`}
>
{logicFunction.link && (
<StyledIconChevronRightContainer>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconChevronRightContainer>
)}
</TableCell>
</StyledTableRow>
);
};
@@ -0,0 +1,62 @@
import { SettingsLogicFunctionsFieldItemTableRow } from '@/settings/logic-functions/components/SettingsLogicFunctionsFieldItemTableRow';
import { Table } from '@/ui/layout/table/components/Table';
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 { useLingui } from '@lingui/react/macro';
import React from 'react';
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>,
) => (
<TableRow
gridTemplateColumns="300px 1fr 32px"
// oxlint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
export const SettingsLogicFunctionsTable = ({
logicFunctions,
}: {
logicFunctions: LogicFunctionTableRow[];
}) => {
const { t } = useLingui();
if (logicFunctions.length === 0) {
return null;
}
return (
<Table>
<StyledTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align={'right'}>{t`Trigger`}</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
<StyledTableBodyContainer>
<TableBody>
{logicFunctions.map((logicFunction) => (
<SettingsLogicFunctionsFieldItemTableRow
key={logicFunction.key}
logicFunction={logicFunction}
/>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
);
};
@@ -1,4 +1,5 @@
import { SettingsLogicFunctionNewForm } from '@/settings/logic-functions/components/SettingsLogicFunctionNewForm';
import { SettingsLogicFunctionTabEnvironmentVariablesSection } from '@/settings/logic-functions/components/SettingsLogicFunctionTabEnvironmentVariablesSection';
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
export const SettingsLogicFunctionSettingsTab = ({
@@ -13,10 +14,13 @@ export const SettingsLogicFunctionSettingsTab = ({
readonly?: boolean;
}) => {
return (
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly={readonly}
/>
<>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly={readonly}
/>
<SettingsLogicFunctionTabEnvironmentVariablesSection />
</>
);
};
@@ -1,34 +1,12 @@
import { LogicFunctionExecutionResult } from '@/logic-functions/components/LogicFunctionExecutionResult';
import { LogicFunctionLogs } from '@/logic-functions/components/LogicFunctionLogs';
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
import {
buildDatabaseEventPayload,
buildHttpPayload,
buildToolPayloadFromSchema,
type TriggerKind,
} from '@/settings/logic-functions/utils/getTriggerSamplePayload';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconClock,
IconDatabase,
IconPlayerPlay,
IconTool,
IconWebhook,
type IconComponent,
} from 'twenty-ui/display';
import { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { Button, CodeEditor, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type TriggerButton = {
kind: TriggerKind;
label: string;
Icon: IconComponent;
};
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
const StyledInputsContainer = styled.div`
display: flex;
@@ -41,27 +19,13 @@ const StyledCodeEditorContainer = styled.div`
flex-direction: column;
`;
const StyledTriggerButtonRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledTriggerLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
export const SettingsLogicFunctionTestTab = ({
handleExecute,
logicFunctionId,
formValues,
isTesting = false,
}: {
handleExecute: () => void;
logicFunctionId: string;
formValues: LogicFunctionFormValues;
isTesting?: boolean;
}) => {
const { t } = useLingui();
@@ -71,32 +35,6 @@ export const SettingsLogicFunctionTestTab = ({
logicFunctionId,
});
const {
httpRouteTriggerSettings,
cronTriggerSettings,
databaseEventTriggerSettings,
toolInputSchema,
isTool,
} = formValues;
const triggerButtons: TriggerButton[] = [];
if (isDefined(httpRouteTriggerSettings)) {
triggerButtons.push({ kind: 'http', label: t`HTTP`, Icon: IconWebhook });
}
if (isDefined(cronTriggerSettings)) {
triggerButtons.push({ kind: 'cron', label: t`Cron`, Icon: IconClock });
}
if (isDefined(databaseEventTriggerSettings)) {
triggerButtons.push({
kind: 'databaseEvent',
label: t`Database event`,
Icon: IconDatabase,
});
}
if (isTool) {
triggerButtons.push({ kind: 'tool', label: t`AI tool`, Icon: IconTool });
}
const onChange = (value: string) => {
try {
updateLogicFunctionInput(JSON.parse(value));
@@ -105,50 +43,13 @@ export const SettingsLogicFunctionTestTab = ({
}
};
const fillSamplePayload = (kind: TriggerKind) => {
const payload = (() => {
switch (kind) {
case 'http':
return isDefined(httpRouteTriggerSettings)
? buildHttpPayload(httpRouteTriggerSettings)
: {};
case 'cron':
return {};
case 'databaseEvent':
return isDefined(databaseEventTriggerSettings)
? buildDatabaseEventPayload(databaseEventTriggerSettings)
: {};
case 'tool':
return buildToolPayloadFromSchema(toolInputSchema);
}
})();
updateLogicFunctionInput(payload);
};
return (
<Section>
<H2Title
title={t`Test your function`}
description={t`Insert a JSON input, then press "Run Function".`}
description={t`Insert a JSON input, then press "Run" to test your function.`}
/>
<StyledInputsContainer>
{triggerButtons.length > 0 && (
<div>
<StyledTriggerLabel>{t`Fill with sample input from`}</StyledTriggerLabel>
<StyledTriggerButtonRow>
{triggerButtons.map((trigger) => (
<Button
key={trigger.kind}
Icon={trigger.Icon}
title={trigger.label}
variant="secondary"
size="small"
onClick={() => fillSamplePayload(trigger.kind)}
/>
))}
</StyledTriggerButtonRow>
</div>
)}
<StyledCodeEditorContainer>
<CoreEditorHeader
title={t`Input`}
@@ -1,93 +1,155 @@
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
import { SettingsLogicFunctionCronTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionCronTriggerSection';
import { SettingsLogicFunctionDatabaseEventTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionDatabaseEventTriggerSection';
import { SettingsLogicFunctionHttpTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionHttpTriggerSection';
import { SettingsLogicFunctionToolTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionToolTriggerSection';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { SettingsDatabaseEventsForm } from '@/settings/components/SettingsDatabaseEventsForm';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { isDefined } from 'twenty-shared/utils';
import { Callout, IconInfoCircle } from 'twenty-ui/display';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { Tag } from 'twenty-ui/components';
import { type LogicFunction } from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const ROUTE_TRIGGER_GRID_TEMPLATE_COLUMNS = '1fr 120px 120px';
const StyledRouteTriggerTableHeaderRowWrapper = styled.div`
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledEmptyState = styled.div`
background-color: ${themeCssVariables.background.secondary};
border: 1px dashed ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
padding: ${themeCssVariables.spacing[4]};
height: 160px;
justify-content: center;
text-align: center;
`;
const StyledCalloutWrapper = styled.div`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
export const SettingsLogicFunctionTriggersTab = ({
formValues,
onChange,
readonly = false,
applicationName,
logicFunction,
}: {
formValues: LogicFunctionFormValues;
onChange: <TKey extends keyof LogicFunctionFormValues>(
key: TKey,
) => (value: LogicFunctionFormValues[TKey]) => void;
readonly?: boolean;
applicationName?: string;
logicFunction: LogicFunction;
}) => {
const { t } = useLingui();
const hasAnyTrigger =
isDefined(formValues.httpRouteTriggerSettings) ||
isDefined(formValues.cronTriggerSettings) ||
isDefined(formValues.databaseEventTriggerSettings) ||
formValues.isTool;
const cronTrigger = logicFunction.cronTriggerSettings;
if (readonly && !hasAnyTrigger) {
return isDefined(applicationName) ? (
<StyledCalloutWrapper>
<Callout
variant="info"
Icon={IconInfoCircle}
title={t`Bundled with ${applicationName}`}
description={t`This function has no trigger configured, so it can only be invoked from the Test tab or by other functions.`}
const routeTrigger = logicFunction.httpRouteTriggerSettings;
const databaseEventTriggerSettings =
logicFunction.databaseEventTriggerSettings;
let databaseEventTrigger = undefined;
if (isDefined(databaseEventTriggerSettings)) {
const [object, action]: [string, string] =
databaseEventTriggerSettings.eventName.split('.');
databaseEventTrigger = {
object,
action,
updatedFields: databaseEventTriggerSettings.updatedFields,
};
}
const hasNoTriggers = !cronTrigger && !routeTrigger && !databaseEventTrigger;
if (hasNoTriggers) {
return (
<Section>
<H2Title
title={t`Triggers`}
description={t`Configure when this function should be executed`}
/>
</StyledCalloutWrapper>
) : (
<StyledEmptyState>
{t`No trigger is configured for this function.`}
</StyledEmptyState>
<StyledEmptyState>
{t`No triggers configured for this function.`}
</StyledEmptyState>
</Section>
);
}
return (
<>
<SettingsLogicFunctionHttpTriggerSection
value={formValues.httpRouteTriggerSettings}
onChange={onChange('httpRouteTriggerSettings')}
readonly={readonly}
/>
<SettingsLogicFunctionCronTriggerSection
value={formValues.cronTriggerSettings}
onChange={onChange('cronTriggerSettings')}
readonly={readonly}
/>
<SettingsLogicFunctionDatabaseEventTriggerSection
value={formValues.databaseEventTriggerSettings}
onChange={onChange('databaseEventTriggerSettings')}
readonly={readonly}
/>
<SettingsLogicFunctionToolTriggerSection
isTool={formValues.isTool}
toolInputSchema={formValues.toolInputSchema}
onChange={onChange('isTool')}
readonly={readonly}
/>
{!readonly && !hasAnyTrigger && (
<StyledEmptyState>
{t`No trigger is enabled. Toggle one of the options above to choose how this function gets invoked.`}
</StyledEmptyState>
{isDefined(databaseEventTrigger) && (
<Section>
<H2Title
title={t`Database event`}
description={t`Select the events that should trigger the function`}
/>
<SettingsDatabaseEventsForm
events={[databaseEventTrigger]}
disabled
/>
</Section>
)}
{isDefined(cronTrigger) && (
<Section>
<H2Title
title={t`Cron`}
description={t`Triggers the function at regular intervals`}
/>
<FormTextFieldInput
label={t`Expression`}
placeholder="0 */1 * * *"
hint={t`Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]`}
onChange={() => {}}
readonly
defaultValue={cronTrigger.pattern}
/>
</Section>
)}
{isDefined(routeTrigger) && (
<Section>
<H2Title
title={t`Http`}
description={t`Triggers the function with Http request`}
/>
<Table>
<StyledRouteTriggerTableHeaderRowWrapper>
<TableRow
gridTemplateColumns={ROUTE_TRIGGER_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Path`}</TableHeader>
<TableHeader>{t`Method`}</TableHeader>
<TableHeader>{t`Auth Required`}</TableHeader>
</TableRow>
</StyledRouteTriggerTableHeaderRowWrapper>
<TableRow gridTemplateColumns={ROUTE_TRIGGER_GRID_TEMPLATE_COLUMNS}>
<TableCell
color={themeCssVariables.font.color.tertiary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<OverflowingTextWithTooltip
text={`${REACT_APP_SERVER_BASE_URL}/s${routeTrigger.path}`}
/>
</TableCell>
<TableCell
color={themeCssVariables.font.color.tertiary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
{routeTrigger.httpMethod}
</TableCell>
<TableCell
color={themeCssVariables.font.color.tertiary}
gap={themeCssVariables.spacing[2]}
overflow="hidden"
>
<Tag
text={routeTrigger.isAuthRequired ? t`True` : t`False`}
color={routeTrigger.isAuthRequired ? 'green' : 'orange'}
weight="medium"
/>
</TableCell>
</TableRow>
</Table>
</Section>
)}
</>
);
@@ -1,67 +0,0 @@
import { SettingsLogicFunctionTriggerPayloadFormat } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat';
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type CronTriggerSettings } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const DEFAULT_CRON_SETTINGS: CronTriggerSettings = {
pattern: '0 */1 * * *',
};
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
margin-top: ${themeCssVariables.spacing[2]};
`;
type SettingsLogicFunctionCronTriggerSectionProps = {
value: CronTriggerSettings | null;
onChange: (value: CronTriggerSettings | null) => void;
readonly: boolean;
};
export const SettingsLogicFunctionCronTriggerSection = ({
value,
onChange,
readonly,
}: SettingsLogicFunctionCronTriggerSectionProps) => {
const { t } = useLingui();
return (
<SettingsLogicFunctionTriggerSection
title={t`Cron`}
description={t`Triggers the function at regular intervals`}
enabled={isDefined(value)}
onEnabledChange={(checked) =>
onChange(checked ? DEFAULT_CRON_SETTINGS : null)
}
readonly={readonly}
>
{isDefined(value) && (
<>
<SettingsTextInput
instanceId="logic-function-cron-trigger-pattern"
label={t`Expression`}
placeholder="0 */1 * * *"
value={value.pattern}
onChange={(newPattern: string) =>
onChange({ ...value, pattern: newPattern })
}
readOnly={readonly}
fullWidth
/>
<StyledHint>
{t`Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]`}
</StyledHint>
<SettingsLogicFunctionTriggerPayloadFormat
payload={{}}
hint={t`Cron triggers pass no payload — the handler is called with an empty object.`}
/>
</>
)}
</SettingsLogicFunctionTriggerSection>
);
};
@@ -1,75 +0,0 @@
import { SettingsDatabaseEventsForm } from '@/settings/components/SettingsDatabaseEventsForm';
import { SettingsLogicFunctionTriggerPayloadFormat } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat';
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { buildDatabaseEventPayload } from '@/settings/logic-functions/utils/getTriggerSamplePayload';
import { useLingui } from '@lingui/react/macro';
import { type DatabaseEventTriggerSettings } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
const DEFAULT_DATABASE_EVENT_SETTINGS: DatabaseEventTriggerSettings = {
eventName: '*.created',
};
type SettingsLogicFunctionDatabaseEventTriggerSectionProps = {
value: DatabaseEventTriggerSettings | null;
onChange: (value: DatabaseEventTriggerSettings | null) => void;
readonly: boolean;
};
export const SettingsLogicFunctionDatabaseEventTriggerSection = ({
value,
onChange,
readonly,
}: SettingsLogicFunctionDatabaseEventTriggerSectionProps) => {
const { t } = useLingui();
const [object = '', action = 'created'] = value?.eventName.split('.') ?? [];
const updateEventNamePart = ({
field,
fieldValue,
}: {
field: 'object' | 'action';
fieldValue: string | null;
}) => {
if (!isDefined(value)) return;
const nextObject = field === 'object' ? (fieldValue ?? '') : object;
const nextAction = field === 'action' ? (fieldValue ?? action) : action;
onChange({ ...value, eventName: `${nextObject}.${nextAction}` });
};
return (
<SettingsLogicFunctionTriggerSection
title={t`Database event`}
description={t`Triggers the function when a record changes`}
enabled={isDefined(value)}
onEnabledChange={(checked) =>
onChange(checked ? DEFAULT_DATABASE_EVENT_SETTINGS : null)
}
readonly={readonly}
>
{isDefined(value) && (
<>
<SettingsDatabaseEventsForm
events={[
{
object: object || null,
action,
updatedFields: value.updatedFields,
},
]}
updateOperation={(_, field, fieldValue) =>
updateEventNamePart({ field, fieldValue })
}
removeOperation={() => onChange(null)}
disabled={readonly}
/>
<SettingsLogicFunctionTriggerPayloadFormat
payload={buildDatabaseEventPayload(value)}
hint={t`Your handler receives this event object. "after" holds the new state, "before" the previous one (null for created), and "updatedFields" lists the field names that changed on update.`}
/>
</>
)}
</SettingsLogicFunctionTriggerSection>
);
};
@@ -1,157 +0,0 @@
import { SettingsLogicFunctionTriggerPayloadFormat } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerPayloadFormat';
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { buildHttpPayload } from '@/settings/logic-functions/utils/getTriggerSamplePayload';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { type HttpRouteTriggerSettings } from 'twenty-shared/application';
import { HTTPMethod } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconCopy,
IconHttpDelete,
IconHttpGet,
IconHttpPatch,
IconHttpPost,
IconHttpPut,
type IconComponent,
} from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const HTTP_METHOD_OPTIONS: Array<{
label: string;
value: HTTPMethod;
Icon: IconComponent;
}> = [
{ label: 'GET', value: HTTPMethod.GET, Icon: IconHttpGet },
{ label: 'POST', value: HTTPMethod.POST, Icon: IconHttpPost },
{ label: 'PUT', value: HTTPMethod.PUT, Icon: IconHttpPut },
{ label: 'PATCH', value: HTTPMethod.PATCH, Icon: IconHttpPatch },
{ label: 'DELETE', value: HTTPMethod.DELETE, Icon: IconHttpDelete },
];
const DEFAULT_HTTP_SETTINGS: HttpRouteTriggerSettings = {
path: '',
httpMethod: HTTPMethod.POST,
isAuthRequired: false,
};
const StyledFields = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
`;
const StyledAuthRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[3]};
`;
const StyledAuthLabel = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
`;
type SettingsLogicFunctionHttpTriggerSectionProps = {
value: HttpRouteTriggerSettings | null;
onChange: (value: HttpRouteTriggerSettings | null) => void;
readonly: boolean;
};
export const SettingsLogicFunctionHttpTriggerSection = ({
value,
onChange,
readonly,
}: SettingsLogicFunctionHttpTriggerSectionProps) => {
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { copyToClipboard } = useCopyToClipboard();
const updateField = <TKey extends keyof HttpRouteTriggerSettings>(
key: TKey,
fieldValue: HttpRouteTriggerSettings[TKey],
) => {
if (!isDefined(value)) {
return;
}
onChange({ ...value, [key]: fieldValue });
};
const fullUrl = isDefined(value)
? `${REACT_APP_SERVER_BASE_URL}/s${value.path}`
: '';
return (
<SettingsLogicFunctionTriggerSection
title={t`HTTP`}
description={t`Triggers the function with an HTTP request`}
enabled={isDefined(value)}
onEnabledChange={(checked) =>
onChange(checked ? DEFAULT_HTTP_SETTINGS : null)
}
readonly={readonly}
>
{isDefined(value) && (
<StyledFields>
<Select
dropdownId="logic-function-http-trigger-method"
label={t`Method`}
fullWidth
disabled={readonly}
value={value.httpMethod as HTTPMethod}
options={HTTP_METHOD_OPTIONS}
onChange={(newMethod) => updateField('httpMethod', newMethod)}
dropdownOffset={{ y: 4 }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<SettingsTextInput
instanceId="logic-function-http-trigger-path"
label={t`Path`}
placeholder="/my-route"
value={value.path}
onChange={(newPath: string) => updateField('path', newPath)}
readOnly={readonly}
fullWidth
/>
<SettingsTextInput
instanceId="logic-function-http-trigger-url"
label={t`Live URL`}
value={fullUrl}
onChange={() => {}}
readOnly
fullWidth
RightIcon={IconCopy}
onRightIconClick={() =>
copyToClipboard(fullUrl, t`URL copied to clipboard`)
}
/>
<StyledAuthRow>
<Toggle
value={value.isAuthRequired}
onChange={(checked) => updateField('isAuthRequired', checked)}
disabled={readonly}
toggleSize="small"
color={theme.color.blue}
/>
<StyledAuthLabel>{t`Require authentication`}</StyledAuthLabel>
</StyledAuthRow>
<SettingsLogicFunctionTriggerPayloadFormat
payload={buildHttpPayload(value)}
hint={
value.httpMethod === HTTPMethod.GET
? t`Your handler receives this object. The body is empty because GET requests carry no payload.`
: t`Your handler receives this object. The body holds the parsed JSON sent by the client.`
}
/>
</StyledFields>
)}
</SettingsLogicFunctionTriggerSection>
);
};
@@ -1,48 +0,0 @@
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { SettingsToolParameterTable } from '~/pages/settings/ai/components/SettingsToolParameterTable';
type ToolInputSchema = {
properties?: Record<string, unknown>;
required?: string[];
};
type SettingsLogicFunctionToolTriggerSectionProps = {
isTool: boolean;
toolInputSchema?: object;
onChange: (value: boolean) => void;
readonly: boolean;
};
export const SettingsLogicFunctionToolTriggerSection = ({
isTool,
toolInputSchema,
onChange,
readonly,
}: SettingsLogicFunctionToolTriggerSectionProps) => {
const { t } = useLingui();
const schema = (toolInputSchema as ToolInputSchema | undefined) ?? {};
const schemaProperties = isDefined(schema.properties)
? (schema.properties as Record<
string,
{ type?: string; description?: string; format?: string }
>)
: {};
return (
<SettingsLogicFunctionTriggerSection
title={t`AI tool`}
description={t`Triggers the function when called by an AI agent or workflow`}
enabled={isTool}
onEnabledChange={onChange}
readonly={readonly}
>
<SettingsToolParameterTable
schemaProperties={schemaProperties}
requiredFields={schema.required}
/>
</SettingsLogicFunctionTriggerSection>
);
};
@@ -1,45 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { CodeEditor } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
margin-top: ${themeCssVariables.spacing[4]};
`;
const StyledLabel = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledHint = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
`;
export const SettingsLogicFunctionTriggerPayloadFormat = ({
payload,
hint,
}: {
payload: object;
hint?: string;
}) => {
const { t } = useLingui();
return (
<StyledContainer>
<StyledLabel>{t`Sample input`}</StyledLabel>
<CodeEditor
value={JSON.stringify(payload, null, 2)}
language="json"
height={140}
options={{ readOnly: true }}
/>
{hint !== undefined && <StyledHint>{hint}</StyledHint>}
</StyledContainer>
);
};
@@ -1,58 +0,0 @@
import { styled } from '@linaria/react';
import { useContext, type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[3]};
justify-content: space-between;
margin-bottom: ${themeCssVariables.spacing[4]};
`;
type SettingsLogicFunctionTriggerSectionProps = {
title: string;
description: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
readonly: boolean;
children?: ReactNode;
};
// Common scaffolding for the four trigger toggles on a logic function. Hides
// the section entirely when an installed app function doesn't use this trigger
// (read-only + disabled = nothing to show).
export const SettingsLogicFunctionTriggerSection = ({
title,
description,
enabled,
onEnabledChange,
readonly,
children,
}: SettingsLogicFunctionTriggerSectionProps) => {
const { theme } = useContext(ThemeContext);
if (readonly && !enabled) {
return null;
}
return (
<Section>
<StyledHeader>
<H2Title title={title} description={description} />
{!readonly && (
<Toggle
value={enabled}
onChange={onEnabledChange}
toggleSize="small"
color={theme.color.blue}
/>
)}
</StyledHeader>
{enabled && children}
</Section>
);
};
@@ -1,97 +0,0 @@
import {
buildDatabaseEventPayload,
buildHttpPayload,
buildToolPayloadFromSchema,
} from '@/settings/logic-functions/utils/getTriggerSamplePayload';
import { HTTPMethod } from 'twenty-shared/types';
describe('buildToolPayloadFromSchema', () => {
it('returns an empty object when no schema is provided', () => {
expect(buildToolPayloadFromSchema()).toEqual({});
expect(buildToolPayloadFromSchema({})).toEqual({});
});
it('uses defaults when present', () => {
expect(
buildToolPayloadFromSchema({
properties: {
first: { type: 'string', default: 'hello' },
second: { type: 'number', default: 42 },
},
}),
).toEqual({ first: 'hello', second: 42 });
});
it('falls back to type-specific sample values', () => {
expect(
buildToolPayloadFromSchema({
properties: {
a: { type: 'string' },
b: { type: 'number' },
c: { type: 'integer' },
d: { type: 'boolean' },
e: { type: 'array' },
f: { type: 'object' },
g: {},
},
}),
).toEqual({ a: '', b: 0, c: 0, d: false, e: [], f: {}, g: null });
});
});
describe('buildHttpPayload', () => {
it('omits the body for GET requests', () => {
expect(
buildHttpPayload({
path: '/hello',
httpMethod: HTTPMethod.GET,
isAuthRequired: false,
}),
).toMatchObject({
body: null,
requestContext: { http: { method: HTTPMethod.GET, path: '/s/hello' } },
});
});
it('includes an empty body for non-GET requests', () => {
expect(
buildHttpPayload({
path: '/hello',
httpMethod: HTTPMethod.POST,
isAuthRequired: false,
}),
).toMatchObject({
body: {},
requestContext: { http: { method: HTTPMethod.POST, path: '/s/hello' } },
});
});
});
describe('buildDatabaseEventPayload', () => {
it('returns null before for created events', () => {
expect(
buildDatabaseEventPayload({ eventName: 'person.created' }),
).toMatchObject({
name: 'person.created',
objectMetadata: { nameSingular: 'person' },
properties: { after: {}, before: null, updatedFields: [] },
});
});
it('returns an empty before for non-created events', () => {
expect(
buildDatabaseEventPayload({ eventName: 'person.updated' }),
).toMatchObject({
properties: { after: {}, before: {}, updatedFields: [] },
});
});
it('threads updatedFields through', () => {
expect(
buildDatabaseEventPayload({
eventName: 'person.updated',
updatedFields: ['name'],
}),
).toMatchObject({ properties: { updatedFields: ['name'] } });
});
});
@@ -1,77 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { HTTPMethod } from 'twenty-shared/types';
import {
type DatabaseEventTriggerSettings,
type HttpRouteTriggerSettings,
} from 'twenty-shared/application';
export type TriggerKind = 'http' | 'cron' | 'databaseEvent' | 'tool';
type ToolInputSchemaShape = {
properties?: Record<string, { type?: string; default?: unknown }>;
};
const sampleValueForType = (type?: string): unknown => {
switch (type) {
case 'string':
return '';
case 'number':
case 'integer':
return 0;
case 'boolean':
return false;
case 'array':
return [];
case 'object':
return {};
default:
return null;
}
};
export const buildToolPayloadFromSchema = (schema?: object): object => {
const properties = (schema as ToolInputSchemaShape | undefined)?.properties;
if (!isDefined(properties)) {
return {};
}
const payload: Record<string, unknown> = {};
for (const [key, prop] of Object.entries(properties)) {
payload[key] = isDefined(prop.default)
? prop.default
: sampleValueForType(prop.type);
}
return payload;
};
export const buildHttpPayload = (
settings: HttpRouteTriggerSettings,
): object => {
return {
headers: {},
queryStringParameters: {},
pathParameters: {},
body: settings.httpMethod === HTTPMethod.GET ? null : {},
isBase64Encoded: false,
requestContext: {
http: {
method: settings.httpMethod,
path: `/s${settings.path}`,
},
},
};
};
export const buildDatabaseEventPayload = (
settings: DatabaseEventTriggerSettings,
): object => {
const [object, action] = settings.eventName.split('.');
return {
name: settings.eventName,
objectMetadata: { nameSingular: object },
properties: {
after: {},
before: action === 'created' ? null : {},
updatedFields: settings.updatedFields ?? [],
},
};
};
@@ -1,110 +0,0 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
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 { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconEye, IconSettings } from 'twenty-ui/display';
import { FindOneApplicationDocument } from '~/generated-metadata/graphql';
import { SettingsApplicationFrontComponentPreviewTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentPreviewTab';
import { SettingsApplicationFrontComponentSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationFrontComponentSettingsTab';
const FRONT_COMPONENT_DETAIL_ID = 'application-front-component-detail';
export const SettingsApplicationFrontComponentDetail = () => {
const { applicationId = '', frontComponentId = '' } = useParams<{
applicationId: string;
frontComponentId: string;
}>();
const { data, loading } = useQuery(FindOneApplicationDocument, {
variables: { id: applicationId },
skip: !applicationId,
});
const application = data?.findOneApplication;
const frontComponent = application?.frontComponents?.find(
(fc) => fc.id === frontComponentId,
);
const instanceId = `${FRONT_COMPONENT_DETAIL_ID}-${frontComponentId}`;
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
instanceId,
);
const tabs = [
{ id: 'preview', title: t`Preview`, Icon: IconEye },
{ id: 'settings', title: t`Settings`, Icon: IconSettings },
];
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
const breadcrumbLinks = [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: application?.name ?? '', href: applicationContentHref },
{ children: t`Front components`, href: applicationContentHref },
{ children: frontComponent?.name ?? '' },
];
const renderActiveTabContent = () => {
if (!isDefined(frontComponent)) {
return <SettingsSectionSkeletonLoader />;
}
const resolvedTabId = activeTabId ?? 'preview';
switch (resolvedTabId) {
case 'preview':
return (
<SettingsApplicationFrontComponentPreviewTab
frontComponentId={frontComponent.id}
isHeadless={frontComponent.isHeadless}
/>
);
case 'settings':
return (
<SettingsApplicationFrontComponentSettingsTab
description={frontComponent.description}
componentName={frontComponent.componentName}
universalIdentifier={frontComponent.universalIdentifier}
builtComponentChecksum={frontComponent.builtComponentChecksum}
isHeadless={frontComponent.isHeadless}
usesSdkClient={frontComponent.usesSdkClient}
createdAt={frontComponent.createdAt}
updatedAt={frontComponent.updatedAt}
/>
);
default:
return null;
}
};
return (
<SubMenuTopBarContainer
title={frontComponent?.name ?? t`Front component`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={instanceId} />
{loading ? <SettingsSectionSkeletonLoader /> : renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,83 +0,0 @@
import {
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronRight,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export type ApplicationContentRow = {
key: string;
name: string;
icon?: string;
secondary?: string;
link?: string;
};
const GRID_TEMPLATE_COLUMNS = '1fr auto 32px';
export const SettingsApplicationContentSubtable = ({
title,
rows,
}: {
title: string;
rows: ApplicationContentRow[];
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
if (rows.length === 0) {
return null;
}
return (
<TableSection title={title}>
{rows.map((row) => {
const Icon = getIcon(row.icon);
return (
<TableRow
key={row.key}
gridAutoColumns={GRID_TEMPLATE_COLUMNS}
to={row.link}
>
<StyledNameTableCell minWidth="0" overflow="hidden">
{isDefined(Icon) && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
<OverflowingTextWithTooltip text={row.name} />
</StyledNameTableCell>
<TableCell
align="right"
color={themeCssVariables.font.color.secondary}
minWidth="0"
overflow="hidden"
whiteSpace="nowrap"
>
{isDefined(row.secondary) && (
<OverflowingTextWithTooltip text={row.secondary} />
)}
</TableCell>
<StyledActionTableCell>
{isDefined(row.link) && (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
)}
</StyledActionTableCell>
</TableRow>
);
})}
</TableSection>
);
};
@@ -0,0 +1,129 @@
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SETTINGS_OBJECT_TABLE_COLUMN_WIDTH } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { H2Title } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTableRow';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
export type ApplicationDataTableRow = {
key: string;
labelPlural: string;
icon?: string;
fieldsCount: number;
link?: string;
tagItem: {
isCustom?: boolean;
isRemote?: boolean;
applicationId?: string | null;
};
};
const MAIN_ROW_GRID_COLUMNS = `180px 1fr ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`;
const StyledEmptyHeaderContainer = styled.div`
> div {
min-width: 0;
}
`;
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
export const SettingsApplicationDataTable = ({
objectRows,
fieldGroupRows,
}: {
objectRows: ApplicationDataTableRow[];
fieldGroupRows: ApplicationDataTableRow[];
}) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredObjectRows = useMemo(() => {
const normalizedSearch = normalizeSearchText(searchTerm);
if (normalizedSearch === '') {
return objectRows;
}
return objectRows.filter((row) =>
normalizeSearchText(row.labelPlural).includes(normalizedSearch),
);
}, [objectRows, searchTerm]);
const filteredFieldGroupRows = useMemo(() => {
const normalizedSearch = normalizeSearchText(searchTerm);
if (normalizedSearch === '') {
return fieldGroupRows;
}
return fieldGroupRows.filter((row) =>
normalizeSearchText(row.labelPlural).includes(normalizedSearch),
);
}, [fieldGroupRows, searchTerm]);
const shouldDisplayObjects = filteredObjectRows.length > 0;
const shouldDisplayFields = filteredFieldGroupRows.length > 0;
const hasSearchTerm = searchTerm.trim().length > 0;
const hasNoResults =
hasSearchTerm && !shouldDisplayObjects && !shouldDisplayFields;
if (objectRows.length === 0 && fieldGroupRows.length === 0) {
return null;
}
return (
<Section>
<H2Title
title={t`Data`}
description={t`Objects and fields managed by this app`}
/>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search an object...`}
value={searchTerm}
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
{hasNoResults ? (
<SettingsEmptyPlaceholder>{t`No object found`}</SettingsEmptyPlaceholder>
) : (
<Table>
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`App`}</TableHeader>
<TableHeader align="right">{t`Fields`}</TableHeader>
<StyledEmptyHeaderContainer>
<TableHeader />
</StyledEmptyHeaderContainer>
</TableRow>
{shouldDisplayObjects && (
<TableSection title={t`Objects`}>
{filteredObjectRows.map((row) => (
<SettingsApplicationDataTableRow key={row.key} row={row} />
))}
</TableSection>
)}
{shouldDisplayFields && (
<TableSection title={t`Fields`}>
{filteredFieldGroupRows.map((row) => (
<SettingsApplicationDataTableRow key={row.key} row={row} />
))}
</TableSection>
)}
</Table>
)}
</Section>
);
};
@@ -0,0 +1,69 @@
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import {
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronRight, useIcons } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
const MAIN_ROW_GRID_COLUMNS = '180px 1fr 98.7px 36px';
const StyledNameContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
`;
const StyledNameLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsApplicationDataTableRow = ({
row,
}: {
row: ApplicationDataTableRow;
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const Icon = getIcon(row.icon);
return (
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS} to={row.link}>
<StyledNameTableCell minWidth="0" overflow="hidden">
{isDefined(Icon) && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
<StyledNameContainer>
<StyledNameLabel title={row.labelPlural}>
{row.labelPlural}
</StyledNameLabel>
</StyledNameContainer>
</StyledNameTableCell>
<TableCell minWidth="0" overflow="hidden">
<SettingsItemTypeTag item={row.tagItem} />
</TableCell>
<TableCell align="right">{row.fieldsCount}</TableCell>
<StyledActionTableCell>
{row.link && (
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
)}
</StyledActionTableCell>
</TableRow>
);
};
@@ -0,0 +1,60 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { t } from '@lingui/core/macro';
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type ApplicationNameDescriptionTableRow = {
key: string;
name: string;
description?: string | null;
};
export const SettingsApplicationNameDescriptionTable = ({
title,
description,
sectionTitle,
items,
}: {
title: string;
description: string;
sectionTitle: string;
items: ApplicationNameDescriptionTableRow[];
}) => {
if (items.length === 0) {
return null;
}
return (
<Section>
<H2Title title={title} description={description} />
<Table>
<TableRow gridAutoColumns="180px 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Description`}</TableHeader>
</TableRow>
<TableSection title={sectionTitle}>
{items.map((item) => (
<TableRow key={item.key} gridAutoColumns="180px 1fr">
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
minWidth="0"
overflow="hidden"
>
<OverflowingTextWithTooltip text={item.name} />
</TableCell>
<TableCell minWidth="0" overflow="hidden">
<OverflowingTextWithTooltip text={item.description ?? ''} />
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
);
};
@@ -1,32 +1,28 @@
import { getLogicFunctionTriggerLabel } from '@/logic-functions/utils/getLogicFunctionTriggerLabel';
import { useComputeApplicationContentForLayoutAndLogic } from '@/settings/applications/hooks/useComputeApplicationContentForLayoutAndLogic';
import { useComputeObjectAndFieldsContentForApplication } from '@/settings/applications/hooks/useComputeObjectAndFieldsContentForApplication';
import { Table } from '@/ui/layout/table/components/Table';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
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 { 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 { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { type Application } from '~/generated-metadata/graphql';
import { SettingsApplicationDataTable } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
import {
type ApplicationContentRow,
SettingsApplicationContentSubtable,
} from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
type ApplicationNameDescriptionTableRow,
SettingsApplicationNameDescriptionTable,
} from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable';
type InstalledApplicationForContentTab = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
frontComponents?: {
id: string;
name: string;
description?: string | null;
}[];
frontComponents?: { name: string; description?: string | null }[];
};
type SettingsApplicationDetailContentTabProps = {
@@ -35,200 +31,89 @@ type SettingsApplicationDetailContentTabProps = {
manifestContent?: Manifest;
};
const filterRows = (rows: ApplicationContentRow[], normalizedSearch: string) =>
normalizedSearch === ''
? rows
: rows.filter(
(row) =>
normalizeSearchText(row.name).includes(normalizedSearch) ||
(isDefined(row.secondary) &&
normalizeSearchText(row.secondary).includes(normalizedSearch)),
);
export const SettingsApplicationDetailContentTab = ({
applicationId,
installedApplication,
manifestContent,
}: SettingsApplicationDetailContentTabProps) => {
const { t } = useLingui();
const { objectRows, fieldRows } =
useComputeObjectAndFieldsContentForApplication({
installedApplication,
manifestContent,
});
const {
pageLayoutRows,
viewRows,
navigationMenuItemRows,
agentRows,
skillRows,
roleRows,
} = useComputeApplicationContentForLayoutAndLogic({
const { objectRows, fieldGroupRows } = useObjectAndFieldRows({
applicationId,
installedApplication,
manifestContent,
});
const lifecycleOptions = {
postInstallUniversalIdentifier:
manifestContent?.application?.postInstallLogicFunction
?.universalIdentifier,
preInstallUniversalIdentifier:
manifestContent?.application?.preInstallLogicFunction
?.universalIdentifier,
};
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 logicFunctionRows: ApplicationContentRow[] = isDefined(
installedApplication,
)
? (installedApplication.logicFunctions ?? []).map((lf) => ({
if (isDefined(installedApplication)) {
return (installedApplication.logicFunctions ?? []).map((lf) => ({
key: lf.id,
name: lf.name,
secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions),
trigger: computeTrigger(lf),
link: getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: lf.id,
}),
}))
: (manifestContent?.logicFunctions ?? []).map((lf) => ({
key: lf.universalIdentifier,
name: lf.name ?? lf.universalIdentifier,
secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions),
}));
}
const frontComponentRows: ApplicationContentRow[] = isDefined(
installedApplication,
)
? (installedApplication.frontComponents ?? []).map((fc) => ({
key: fc.id,
name: fc.name,
secondary: fc.description ?? undefined,
link: getSettingsPath(SettingsPath.ApplicationFrontComponentDetail, {
applicationId,
frontComponentId: fc.id,
}),
}))
: (manifestContent?.frontComponents ?? []).map((fc) => ({
return (manifestContent?.logicFunctions ?? []).map((lf) => ({
key: lf.universalIdentifier,
name: lf.name ?? lf.universalIdentifier,
trigger: computeTrigger(lf),
}));
}, [installedApplication, manifestContent?.logicFunctions, applicationId]);
const frontComponentRows =
useMemo((): ApplicationNameDescriptionTableRow[] => {
if (isDefined(installedApplication)) {
return (installedApplication.frontComponents ?? []).map((fc) => ({
key: fc.name,
name: fc.name,
description: fc.description,
}));
}
return (manifestContent?.frontComponents ?? []).map((fc) => ({
key: fc.universalIdentifier,
name: fc.name ?? fc.universalIdentifier,
secondary: fc.description ?? undefined,
description: fc.description,
}));
const [searchTerm, setSearchTerm] = useState('');
const normalizedSearch = normalizeSearchText(searchTerm);
const filtered = {
objects: filterRows(objectRows, normalizedSearch),
fields: filterRows(fieldRows, normalizedSearch),
pageLayouts: filterRows(pageLayoutRows, normalizedSearch),
views: filterRows(viewRows, normalizedSearch),
navigation: filterRows(navigationMenuItemRows, normalizedSearch),
frontComponents: filterRows(frontComponentRows, normalizedSearch),
logicFunctions: filterRows(logicFunctionRows, normalizedSearch),
agents: filterRows(agentRows, normalizedSearch),
skills: filterRows(skillRows, normalizedSearch),
roles: filterRows(roleRows, normalizedSearch),
};
const hasData = filtered.objects.length > 0 || filtered.fields.length > 0;
const hasLayout =
filtered.pageLayouts.length > 0 ||
filtered.views.length > 0 ||
filtered.navigation.length > 0 ||
filtered.frontComponents.length > 0;
const hasLogic =
filtered.logicFunctions.length > 0 ||
filtered.agents.length > 0 ||
filtered.skills.length > 0 ||
filtered.roles.length > 0;
if (!hasData && !hasLayout && !hasLogic && normalizedSearch === '') {
return null;
}
}, [installedApplication, manifestContent?.frontComponents]);
return (
<>
<Section>
<SearchInput
placeholder={t`Search...`}
value={searchTerm}
onChange={setSearchTerm}
/>
</Section>
{hasData && (
<Section>
<H2Title
title={t`Data`}
description={t`Schema this app contributes to your workspace`}
/>
<Table>
<SettingsApplicationContentSubtable
title={t`Objects`}
rows={filtered.objects}
/>
<SettingsApplicationContentSubtable
title={t`Fields added to other objects`}
rows={filtered.fields}
/>
</Table>
</Section>
)}
{hasLayout && (
<Section>
<H2Title
title={t`Layout`}
description={t`How records, pages, and navigation are displayed`}
/>
<Table>
<SettingsApplicationContentSubtable
title={t`Page layouts`}
rows={filtered.pageLayouts}
/>
<SettingsApplicationContentSubtable
title={t`Views`}
rows={filtered.views}
/>
<SettingsApplicationContentSubtable
title={t`Navigation menu items`}
rows={filtered.navigation}
/>
<SettingsApplicationContentSubtable
title={t`Front components`}
rows={filtered.frontComponents}
/>
</Table>
</Section>
)}
{hasLogic && (
<SettingsApplicationDataTable
objectRows={objectRows}
fieldGroupRows={fieldGroupRows}
/>
{logicFunctionRows.length > 0 && (
<Section>
<H2Title
title={t`Logic`}
description={t`Automation, AI, and access this app provides`}
description={t`Logic functions powering this app`}
/>
<Table>
<SettingsApplicationContentSubtable
title={t`Logic functions`}
rows={filtered.logicFunctions}
/>
<SettingsApplicationContentSubtable
title={t`Agents`}
rows={filtered.agents}
/>
<SettingsApplicationContentSubtable
title={t`Skills`}
rows={filtered.skills}
/>
<SettingsApplicationContentSubtable
title={t`Roles`}
rows={filtered.roles}
/>
</Table>
<SettingsLogicFunctionsTable logicFunctions={logicFunctionRows} />
</Section>
)}
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponentRows}
/>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More