Redesign application content tab + logic function settings; add Layout detail pages (#20056)

## Summary

Iterative redesign of two related areas in settings, plus a new
`pages/settings/layout/` folder for read-only entity detail pages.

### Application content tab

- **Grouped into three sections** — Data / Layout / Logic — each with
one H2 + multiple `TableSection`-wrapped sub-tables (mirrors the
role-permissions pattern). Replaces six per-category table/row
components with one uniform `<SettingsApplicationContentSubtable>` +
`ApplicationContentRow` shape (net **−~700 lines** across the refactor).
- **All 10 row categories now clickable** for installed apps:
- Objects / Fields / Logic functions / Front components → existing
detail pages
  - Agents → existing `AiAgentDetail`
- Skills → existing `AiSkillDetail` (looked up by `Skill.applicationId +
name`)
- Roles → existing `RoleDetail` (looked up by
`Role.universalIdentifier`)
- Views / Page layouts / Navigation menu items → **new** detail pages
(see below)
- **Lifecycle hooks visible** — `pre-install` / `post-install` logic
functions are surfaced in the Trigger column instead of appearing as
empty/misconfigured.

### Logic function settings (Triggers + Test tabs)

- Triggers tab is now editable (HTTP / Cron / Database event / AI tool)
with a `<SettingsLogicFunctionTriggerSection>` wrapper that owns the
toggle, header, and read-only short-circuit.
- HTTP section gets a Live URL field with copy-to-clipboard.
- Each section shows a **Sample input** preview (the JSON the function
will receive) using the same payload builders the Test tab uses.
- Test tab: **Simulate trigger** buttons that prefill the JSON input
from the configured trigger's schema. Replaces an unclickable `<Select>`
(which auto-disables when there's only one option — the typical case).
- Read-only behavior for installed-app functions: explicit `<Callout>`
notice when there's no trigger; trigger sections render as disabled
controls when there is one.
- Removed the empty Environment Variables section from the Settings tab
(it just told the user to go elsewhere).

### New `pages/settings/layout/` folder

Three new app-scoped detail pages so users can drill into entities the
GraphQL `Application` type doesn't expose by id (keyed by manifest
`universalIdentifier`):

- `ApplicationViewDetail` — type, object, visibility + Fields / Filters
/ Sorts subsections (field UIDs resolved to readable labels via
`useFieldLabelByUid`)
- `ApplicationPageLayoutDetail` — type, object + per-tab subsections
listing widgets
- `ApplicationNavigationMenuItemDetail` — type, destination (resolved),
icon, color, position

Each page reads from the marketplace manifest the parent app page
already loads (no extra queries). Folder set up so a future "Layout"
settings tab can grow here (analogous to the existing `data-model/`
folder under the Data tab).

### Other consistency fixes

- Breadcrumbs on every app-scoped entity detail page now include a
category crumb so users know what they're looking at: `Workspace /
Applications / Timely / Navigation menu items / Time entry`.
- Title fallback for nav menu items uses the resolved destination
(`"Time entry"`) instead of the raw enum (`"OBJECT"`).
- New shared utils: `getNavigationMenuItemDestination`,
`resolveManifestObjectLabel`, `getLogicFunctionTriggerLabel`,
`<MonoText>`.

## Backend changes

Only one minor schema-shape change (additive): added `applicationId` to
the `SkillFields` GraphQL fragment and `universalIdentifier` to the
`RoleFragment` so the new lookups have what they need. Generated
metadata schema patched in-tree to match — regenerate with `nx run
twenty-front:graphql:generate --configuration=metadata` if it drifts.

## Test plan

- [ ] Application content tab on an installed app shows the 3 grouped
sections; rows in each section are clickable
- [ ] Click an Object → existing object detail page
- [ ] Click a Field → existing field-edit page
- [ ] Click an Agent / Skill / Role → existing detail page
- [ ] Click a View / Page layout / Navigation menu item → new read-only
detail page; subsections (Fields/Filters/Sorts for views, per-tab
widgets for page layouts) populate correctly
- [ ] Breadcrumbs on every entity detail page have 5 crumbs ending in
`<Category> / <Entity name>`
- [ ] Logic function Triggers tab: toggle each trigger type on/off, see
the Sample input preview update; for installed apps, sections render as
read-only
- [ ] Test tab: each "Simulate trigger" button prefills the JSON editor
with the matching payload shape
- [ ] Functions list: a function configured as `post-install` shows
"Post-install" in the Trigger column

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-04-28 16:08:15 +02:00
committed by GitHub
parent 8998009805
commit 8f362186ce
52 changed files with 2770 additions and 972 deletions
+2 -2
View File
@@ -61,8 +61,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 47.9,
lines: 46,
statements: 47.3,
lines: 45.9,
functions: 39.5,
},
},
File diff suppressed because one or more lines are too long
@@ -180,6 +180,28 @@ 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'
@@ -752,6 +774,18 @@ 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,6 +39,13 @@ export const APPLICATION_FRAGMENT = gql`
name
description
applicationId
componentName
builtComponentChecksum
universalIdentifier
isHeadless
usesSdkClient
createdAt
updatedAt
}
objects {
...ObjectMetadataFields
@@ -15,6 +15,7 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
databaseEventTriggerSettings
httpRouteTriggerSettings
applicationId
universalIdentifier
createdAt
updatedAt
}
@@ -46,6 +46,9 @@ describe('useLogicFunctionUpdateFormState', () => {
properties: {},
type: 'object',
},
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
});
});
@@ -1,6 +1,11 @@
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';
@@ -12,6 +17,9 @@ export type LogicFunctionFormValues = {
timeoutSeconds: number;
sourceHandlerCode: string;
toolInputSchema?: object;
cronTriggerSettings: CronTriggerSettings | null;
databaseEventTriggerSettings: DatabaseEventTriggerSettings | null;
httpRouteTriggerSettings: HttpRouteTriggerSettings | null;
};
type SetLogicFunctionFormValues = Dispatch<
@@ -35,6 +43,9 @@ export const useLogicFunctionUpdateFormState = ({
sourceHandlerCode: '',
timeoutSeconds: 300,
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
});
const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } =
@@ -57,6 +68,11 @@ 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]);
@@ -0,0 +1,55 @@
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');
});
});
@@ -0,0 +1,38 @@
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://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateDepthRecordGqlFieldsFromObject should generate depth one record gql fields from object 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`generateDepthRecordGqlFieldsFromRecord should generate depth one record gql fields from empty record 1`] = `
{
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`useDeleteOneRecord A. Starting from empty cache 1. Should successfully delete record and update record cache entry 1`] = `
{
@@ -0,0 +1,242 @@
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 { useObjectAndFieldRows } from '@/settings/applications/hooks/useObjectAndFieldRows';
import { useComputeObjectAndFieldsContentForApplication } from '@/settings/applications/hooks/useComputeObjectAndFieldsContentForApplication';
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('useObjectAndFieldRows', () => {
describe('useComputeObjectAndFieldsContentForApplication', () => {
describe('with installed application', () => {
it('should return object rows for installed application objects', () => {
const installedApplication = {
@@ -37,8 +37,7 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
useComputeObjectAndFieldsContentForApplication({
installedApplication,
}),
{ wrapper },
@@ -46,10 +45,8 @@ describe('useObjectAndFieldRows', () => {
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe(personObject.nameSingular);
expect(result.current.objectRows[0].labelPlural).toBe(
personObject.labelPlural,
);
expect(result.current.objectRows[0].fieldsCount).toBeGreaterThan(0);
expect(result.current.objectRows[0].name).toBe(personObject.labelPlural);
expect(result.current.objectRows[0].secondary).toMatch(/\d+ fields/);
expect(result.current.objectRows[0].link).toBeDefined();
});
@@ -68,8 +65,7 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
useComputeObjectAndFieldsContentForApplication({
installedApplication,
}),
{ wrapper },
@@ -78,7 +74,7 @@ describe('useObjectAndFieldRows', () => {
expect(result.current.objectRows).toHaveLength(0);
});
it('should return field group rows for fields added to other objects', () => {
it("should not include the app's own objects among the field rows", () => {
const fieldBelongingToApp = companyObject.fields[0];
const installedApplication = {
@@ -95,21 +91,19 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: installedApplication.id,
useComputeObjectAndFieldsContentForApplication({
installedApplication,
}),
{ wrapper },
);
// Field group rows should not include the app's own objects
const hasOwnObject = result.current.fieldGroupRows.some(
(row) => row.key === personObject.nameSingular,
const referencesOwnObject = result.current.fieldRows.some((row) =>
row.secondary?.includes(personObject.labelSingular),
);
expect(hasOwnObject).toBe(false);
expect(referencesOwnObject).toBe(false);
});
it('should exclude deny-listed objects from field group rows', () => {
it('should exclude deny-listed objects from field rows', () => {
const installedApplication = {
id: APP_ID,
objects: [],
@@ -124,17 +118,27 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
useComputeObjectAndFieldsContentForApplication({
installedApplication,
}),
{ wrapper },
);
const hasDeniedObject = result.current.fieldGroupRows.some(
(row) => row.key === 'timelineActivity' || row.key === 'favorite',
const timelineActivityObject = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'timelineActivity',
);
expect(hasDeniedObject).toBe(false);
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);
});
});
@@ -157,8 +161,7 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
useComputeObjectAndFieldsContentForApplication({
manifestContent,
}),
{ wrapper },
@@ -166,14 +169,11 @@ describe('useObjectAndFieldRows', () => {
expect(result.current.objectRows).toHaveLength(1);
expect(result.current.objectRows[0].key).toBe('customObject');
expect(result.current.objectRows[0].labelPlural).toBe('Custom Objects');
expect(result.current.objectRows[0].fieldsCount).toBe(2);
expect(result.current.objectRows[0].tagItem.applicationId).toBe(
'app-uid',
);
expect(result.current.objectRows[0].name).toBe('Custom Objects');
expect(result.current.objectRows[0].secondary).toBe('2 fields');
});
it('should return field group rows grouped by object from manifest fields', () => {
it('should return one row per field when the parent object lives in the manifest', () => {
const manifestContent = {
objects: [
{
@@ -189,34 +189,45 @@ describe('useObjectAndFieldRows', () => {
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(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
useComputeObjectAndFieldsContentForApplication({
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldGroupRows).toHaveLength(1);
expect(result.current.fieldGroupRows[0].key).toBe('customObj');
expect(result.current.fieldGroupRows[0].fieldsCount).toBe(3);
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);
});
it('should return empty field group rows when manifest has no fields', () => {
it('should return empty field rows when manifest has no fields', () => {
const manifestContent = {
objects: [],
fields: [],
@@ -224,27 +235,25 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
useComputeObjectAndFieldsContentForApplication({
manifestContent,
}),
{ wrapper },
);
expect(result.current.fieldGroupRows).toHaveLength(0);
expect(result.current.fieldRows).toHaveLength(0);
});
it('should return empty rows when no data is provided', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: 'app-uid',
}),
{ wrapper },
() => useComputeObjectAndFieldsContentForApplication({}),
{
wrapper,
},
);
expect(result.current.objectRows).toHaveLength(0);
expect(result.current.fieldGroupRows).toHaveLength(0);
expect(result.current.fieldRows).toHaveLength(0);
});
});
@@ -279,8 +288,7 @@ describe('useObjectAndFieldRows', () => {
const { result } = renderHook(
() =>
useObjectAndFieldRows({
applicationId: APP_ID,
useComputeObjectAndFieldsContentForApplication({
installedApplication,
manifestContent,
}),
@@ -0,0 +1,184 @@
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,
};
};
@@ -0,0 +1,122 @@
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 };
};
@@ -1,179 +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 { 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,11 +1,10 @@
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, IconTool } from 'twenty-ui/display';
import { H2Title, IconClockHour8 } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -53,16 +52,6 @@ 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}
@@ -1,36 +0,0 @@
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>
);
};
@@ -1,66 +0,0 @@
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>
);
};
@@ -1,62 +0,0 @@
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,5 +1,4 @@
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 = ({
@@ -14,13 +13,10 @@ export const SettingsLogicFunctionSettingsTab = ({
readonly?: boolean;
}) => {
return (
<>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly={readonly}
/>
<SettingsLogicFunctionTabEnvironmentVariablesSection />
</>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly={readonly}
/>
);
};
@@ -1,12 +1,34 @@
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 { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconClock,
IconDatabase,
IconPlayerPlay,
IconTool,
IconWebhook,
type IconComponent,
} 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';
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
type TriggerButton = {
kind: TriggerKind;
label: string;
Icon: IconComponent;
};
const StyledInputsContainer = styled.div`
display: flex;
@@ -19,13 +41,27 @@ 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();
@@ -35,6 +71,32 @@ 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));
@@ -43,13 +105,50 @@ 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" to test your function.`}
description={t`Insert a JSON input, then press "Run 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,155 +1,93 @@
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 { 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 { 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 { 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 { Callout, IconInfoCircle } from 'twenty-ui/display';
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`
align-items: center;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
background-color: ${themeCssVariables.background.secondary};
border: 1px dashed ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
height: 160px;
justify-content: center;
padding: ${themeCssVariables.spacing[4]};
text-align: center;
`;
const StyledCalloutWrapper = styled.div`
margin-bottom: ${themeCssVariables.spacing[6]};
`;
export const SettingsLogicFunctionTriggersTab = ({
logicFunction,
formValues,
onChange,
readonly = false,
applicationName,
}: {
logicFunction: LogicFunction;
formValues: LogicFunctionFormValues;
onChange: <TKey extends keyof LogicFunctionFormValues>(
key: TKey,
) => (value: LogicFunctionFormValues[TKey]) => void;
readonly?: boolean;
applicationName?: string;
}) => {
const { t } = useLingui();
const cronTrigger = logicFunction.cronTriggerSettings;
const hasAnyTrigger =
isDefined(formValues.httpRouteTriggerSettings) ||
isDefined(formValues.cronTriggerSettings) ||
isDefined(formValues.databaseEventTriggerSettings) ||
formValues.isTool;
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`}
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.`}
/>
<StyledEmptyState>
{t`No triggers configured for this function.`}
</StyledEmptyState>
</Section>
</StyledCalloutWrapper>
) : (
<StyledEmptyState>
{t`No trigger is configured for this function.`}
</StyledEmptyState>
);
}
return (
<>
{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>
<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>
)}
</>
);
@@ -0,0 +1,67 @@
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>
);
};
@@ -0,0 +1,75 @@
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>
);
};
@@ -0,0 +1,157 @@
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>
);
};
@@ -0,0 +1,48 @@
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>
);
};
@@ -0,0 +1,45 @@
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>
);
};
@@ -0,0 +1,58 @@
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>
);
};
@@ -0,0 +1,97 @@
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'] } });
});
});
@@ -0,0 +1,77 @@
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 ?? [],
},
};
};
@@ -0,0 +1,110 @@
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>
);
};
@@ -0,0 +1,83 @@
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>
);
};
@@ -1,129 +0,0 @@
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>
);
};
@@ -1,69 +0,0 @@
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>
);
};
@@ -1,60 +0,0 @@
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,28 +1,32 @@
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 { 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 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 ApplicationNameDescriptionTableRow,
SettingsApplicationNameDescriptionTable,
} from '~/pages/settings/applications/components/SettingsApplicationNameDescriptionTable';
type ApplicationContentRow,
SettingsApplicationContentSubtable,
} from '~/pages/settings/applications/components/SettingsApplicationContentSubtable';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
type InstalledApplicationForContentTab = Omit<
Application,
'objects' | 'universalIdentifier' | 'frontComponents'
> & {
objects: { id: string }[];
frontComponents?: { name: string; description?: string | null }[];
frontComponents?: {
id: string;
name: string;
description?: string | null;
}[];
};
type SettingsApplicationDetailContentTabProps = {
@@ -31,89 +35,200 @@ 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 { objectRows, fieldGroupRows } = useObjectAndFieldRows({
applicationId,
const { t } = useLingui();
const { objectRows, fieldRows } =
useComputeObjectAndFieldsContentForApplication({
installedApplication,
manifestContent,
});
const {
pageLayoutRows,
viewRows,
navigationMenuItemRows,
agentRows,
skillRows,
roleRows,
} = useComputeApplicationContentForLayoutAndLogic({
installedApplication,
manifestContent,
});
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 lifecycleOptions = {
postInstallUniversalIdentifier:
manifestContent?.application?.postInstallLogicFunction
?.universalIdentifier,
preInstallUniversalIdentifier:
manifestContent?.application?.preInstallLogicFunction
?.universalIdentifier,
};
if (isDefined(installedApplication)) {
return (installedApplication.logicFunctions ?? []).map((lf) => ({
const logicFunctionRows: ApplicationContentRow[] = isDefined(
installedApplication,
)
? (installedApplication.logicFunctions ?? []).map((lf) => ({
key: lf.id,
name: lf.name,
trigger: computeTrigger(lf),
secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions),
link: getSettingsPath(SettingsPath.ApplicationLogicFunctionDetail, {
applicationId,
logicFunctionId: lf.id,
}),
}))
: (manifestContent?.logicFunctions ?? []).map((lf) => ({
key: lf.universalIdentifier,
name: lf.name ?? lf.universalIdentifier,
secondary: getLogicFunctionTriggerLabel(lf, lifecycleOptions),
}));
}
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) => ({
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) => ({
key: fc.universalIdentifier,
name: fc.name ?? fc.universalIdentifier,
description: fc.description,
secondary: fc.description ?? undefined,
}));
}, [installedApplication, manifestContent?.frontComponents]);
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;
}
return (
<>
<SettingsApplicationDataTable
objectRows={objectRows}
fieldGroupRows={fieldGroupRows}
/>
{logicFunctionRows.length > 0 && (
<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 && (
<Section>
<H2Title
title={t`Logic`}
description={t`Logic functions powering this app`}
description={t`Automation, AI, and access this app provides`}
/>
<SettingsLogicFunctionsTable logicFunctions={logicFunctionRows} />
<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>
</Section>
)}
<SettingsApplicationNameDescriptionTable
title={t`Front components`}
description={t`UI components provided by this app`}
sectionTitle={t`Front components`}
items={frontComponentRows}
/>
</>
);
};
@@ -0,0 +1,75 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Suspense, lazy } from 'react';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
),
);
const StyledPreviewFrame = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
height: 600px;
overflow: auto;
width: 100%;
`;
const StyledHeadlessNotice = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.secondary};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
height: 100%;
justify-content: center;
padding: ${themeCssVariables.spacing[6]};
text-align: center;
width: 100%;
`;
const StyledHeadlessTitle = styled.span`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledRendererContainer = styled.div`
flex: 1;
min-height: 0;
min-width: 0;
`;
type SettingsApplicationFrontComponentPreviewTabProps = {
frontComponentId: string;
isHeadless: boolean;
};
export const SettingsApplicationFrontComponentPreviewTab = ({
frontComponentId,
isHeadless,
}: SettingsApplicationFrontComponentPreviewTabProps) => {
return (
<Section>
<StyledPreviewFrame>
{isHeadless ? (
<StyledHeadlessNotice>
<StyledHeadlessTitle>{t`Headless component`}</StyledHeadlessTitle>
<span>{t`This component runs without a UI and renders nothing here.`}</span>
</StyledHeadlessNotice>
) : (
<StyledRendererContainer>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</Suspense>
</StyledRendererContainer>
)}
</StyledPreviewFrame>
</Section>
);
};
@@ -0,0 +1,139 @@
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 { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsApplicationFrontComponentSettingsTabProps = {
description?: string | null;
componentName: string;
universalIdentifier?: string | null;
builtComponentChecksum: string;
isHeadless: boolean;
usesSdkClient: boolean;
createdAt: string;
updatedAt: string;
};
const StyledDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.5;
white-space: pre-wrap;
`;
const StyledMonoText = styled.span`
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.code.font.family}, monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const formatDateTime = (isoString: string): string => {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) {
return isoString;
}
return date.toLocaleString();
};
const GRID_TEMPLATE = '220px 1fr';
export const SettingsApplicationFrontComponentSettingsTab = ({
description,
componentName,
universalIdentifier,
builtComponentChecksum,
isHeadless,
usesSdkClient,
createdAt,
updatedAt,
}: SettingsApplicationFrontComponentSettingsTabProps) => {
const trimmedDescription = description?.trim();
const detailRows: { key: string; label: string; value: ReactNode }[] = [
{
key: 'componentName',
label: t`Component name`,
value: <StyledMonoText>{componentName}</StyledMonoText>,
},
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: (
<StyledMonoText>{universalIdentifier ?? t`Not set`}</StyledMonoText>
),
},
{
key: 'isHeadless',
label: t`Headless`,
value: isHeadless ? t`Yes` : t`No`,
},
{
key: 'usesSdkClient',
label: t`Uses SDK client`,
value: usesSdkClient ? t`Yes` : t`No`,
},
{
key: 'builtComponentChecksum',
label: t`Build checksum`,
value: <StyledMonoText>{builtComponentChecksum}</StyledMonoText>,
},
{
key: 'createdAt',
label: t`Created`,
value: formatDateTime(createdAt),
},
{
key: 'updatedAt',
label: t`Updated`,
value: formatDateTime(updatedAt),
},
];
return (
<>
{trimmedDescription !== undefined && trimmedDescription.length > 0 && (
<Section>
<H2Title
title={t`About`}
description={t`Description provided by the application`}
/>
<StyledDescription>{trimmedDescription}</StyledDescription>
</Section>
)}
<Section>
<H2Title
title={t`Details`}
description={t`Build and runtime metadata for this component`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableSection title={t`Front component`}>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableSection>
</Table>
</Section>
</>
);
};
@@ -15,13 +15,10 @@ import {
type ObjectManifest,
type RoleManifest,
} from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { findObjectNameByUniversalIdentifier } from '~/pages/settings/applications/utils/findObjectNameByUniversalIdentifier';
type SettingsApplicationPermissionsTabProps = {
defaultRoleId?: string | null;
marketplaceAppDefaultRole?: RoleManifest;
@@ -38,8 +35,16 @@ const resolvePermissionIds = (
const objectUniversalIdToIdMap: Record<string, string> = {};
const fieldUniversalIdToIdMap: Record<string, string> = {};
const allObjectUniversalIds = new Set<string>();
const objectsByUid = new Map(
objectMetadataItems.map((item) => [item.universalIdentifier, item]),
);
const fieldsByUid = new Map(
objectMetadataItems.flatMap((item) =>
item.fields.map((field) => [field.universalIdentifier, field] as const),
),
);
const allObjectUniversalIds = new Set<string>();
for (const permission of defaultRole.objectPermissions ?? []) {
allObjectUniversalIds.add(permission.objectUniversalIdentifier);
}
@@ -48,51 +53,17 @@ const resolvePermissionIds = (
}
for (const universalId of allObjectUniversalIds) {
const standardObjectName = findObjectNameByUniversalIdentifier(universalId);
if (isDefined(standardObjectName)) {
const workspaceObject = objectMetadataItems.find(
(item) => item.nameSingular === standardObjectName,
);
if (isDefined(workspaceObject)) {
objectUniversalIdToIdMap[universalId] = workspaceObject.id;
}
const workspaceObject = objectsByUid.get(universalId);
if (isDefined(workspaceObject)) {
objectUniversalIdToIdMap[universalId] = workspaceObject.id;
}
}
for (const permission of defaultRole.fieldPermissions ?? []) {
const objectName = findObjectNameByUniversalIdentifier(
permission.objectUniversalIdentifier,
);
if (isDefined(objectName)) {
const standardObject =
STANDARD_OBJECTS[objectName as keyof typeof STANDARD_OBJECTS];
if (isDefined(standardObject)) {
for (const [fieldName, fieldDef] of Object.entries(
standardObject.fields,
)) {
if (
(fieldDef as { universalIdentifier: string })
.universalIdentifier === permission.fieldUniversalIdentifier
) {
const workspaceObject = objectMetadataItems.find(
(item) => item.nameSingular === objectName,
);
const workspaceField = workspaceObject?.fields.find(
(field) => field.name === fieldName,
);
if (isDefined(workspaceField)) {
fieldUniversalIdToIdMap[permission.fieldUniversalIdentifier] =
workspaceField.id;
}
break;
}
}
}
const workspaceField = fieldsByUid.get(permission.fieldUniversalIdentifier);
if (isDefined(workspaceField)) {
fieldUniversalIdToIdMap[permission.fieldUniversalIdentifier] =
workspaceField.id;
}
}
@@ -1,13 +0,0 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
export const findObjectNameByUniversalIdentifier = (
universalIdentifier: string,
): string | undefined => {
for (const [objectName, objectConfig] of Object.entries(STANDARD_OBJECTS)) {
if (objectConfig.universalIdentifier === universalIdentifier) {
return objectName;
}
}
return undefined;
};
@@ -0,0 +1,109 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
type DetailRow,
SettingsLayoutDetailScaffold,
} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold';
import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable';
export const SettingsLayoutPageLayoutDetail = () => {
const { applicationId = '', pageLayoutUniversalIdentifier = '' } = useParams<{
applicationId: string;
pageLayoutUniversalIdentifier: string;
}>();
const { application, manifest, isLoading } =
useApplicationManifest(applicationId);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const findObjectLabel = (uid: string | undefined) =>
isDefined(uid)
? objectMetadataItems.find((o) => o.universalIdentifier === uid)
?.labelSingular
: undefined;
const pageLayout = manifest?.pageLayouts?.find(
(pl) => pl.universalIdentifier === pageLayoutUniversalIdentifier,
);
const objectLabel = isDefined(pageLayout)
? findObjectLabel(pageLayout.objectUniversalIdentifier)
: undefined;
const detailRows: DetailRow[] = isDefined(pageLayout)
? [
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: pageLayout.universalIdentifier,
},
{ key: 'type', label: t`Type`, value: pageLayout.type ?? t`Default` },
{
key: 'object',
label: t`Object`,
value: objectLabel ?? pageLayout.objectUniversalIdentifier,
},
]
: [];
const sortedTabs = [...(pageLayout?.tabs ?? [])].sort(
(a, b) => a.position - b.position,
);
return (
<SettingsLayoutDetailScaffold
applicationId={applicationId}
applicationName={application?.name}
entityName={pageLayout?.name ?? t`Page layout`}
entityTypeLabel={t`page layout`}
categoryLabel={t`Page layouts`}
detailRows={detailRows}
isLoading={isLoading}
>
{sortedTabs.map((tab, index) => {
const widgets = tab.widgets ?? [];
const tabNumber = index + 1;
const descriptionParts: string[] = [];
if (isDefined(tab.layoutMode)) {
descriptionParts.push(t`Layout mode: ${tab.layoutMode}`);
}
if (widgets.length > 0) {
descriptionParts.push(
widgets.length === 1 ? t`1 widget` : t`${widgets.length} widgets`,
);
}
const description =
descriptionParts.length > 0
? descriptionParts.join(' · ')
: t`Empty tab`;
return (
<SettingsLayoutItemTable
key={tab.universalIdentifier}
title={t`Tab ${tabNumber}: ${tab.title}`}
description={description}
columns={[
{ key: 'title', label: t`Widget` },
{ key: 'type', label: t`Type`, width: '160px' },
{ key: 'object', label: t`Object`, width: '180px' },
]}
rows={widgets.map((widget) => ({
key: widget.universalIdentifier,
cells: [
widget.title,
widget.type,
findObjectLabel(widget.objectUniversalIdentifier) ?? '—',
],
}))}
/>
);
})}
</SettingsLayoutDetailScaffold>
);
};
@@ -0,0 +1,144 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
type DetailRow,
SettingsLayoutDetailScaffold,
} from '~/pages/settings/layout/components/SettingsLayoutDetailScaffold';
import { SettingsLayoutItemTable } from '~/pages/settings/layout/components/SettingsLayoutItemTable';
const formatFilterValue = (value: unknown): string => {
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
};
export const SettingsLayoutViewDetail = () => {
const { applicationId = '', viewUniversalIdentifier = '' } = useParams<{
applicationId: string;
viewUniversalIdentifier: string;
}>();
const { application, manifest, isLoading } =
useApplicationManifest(applicationId);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const view = manifest?.views?.find(
(v) => v.universalIdentifier === viewUniversalIdentifier,
);
const objectLabel = isDefined(view)
? objectMetadataItems.find(
(o) => o.universalIdentifier === view.objectUniversalIdentifier,
)?.labelSingular
: undefined;
const resolveFieldLabel = (uid: string): string =>
flattenedFieldMetadataItems.find((f) => f.universalIdentifier === uid)
?.label ?? uid;
const detailRows: DetailRow[] = isDefined(view)
? [
{
key: 'universalIdentifier',
label: t`Universal identifier`,
value: view.universalIdentifier,
},
{ key: 'type', label: t`Type`, value: view.type ?? t`Table` },
{
key: 'object',
label: t`Object`,
value: objectLabel ?? view.objectUniversalIdentifier,
},
{ key: 'icon', label: t`Icon`, value: view.icon ?? t`Not set` },
{
key: 'visibility',
label: t`Visibility`,
value: view.visibility ?? t`Default`,
},
{
key: 'openRecordIn',
label: t`Open records in`,
value: view.openRecordIn ?? t`Default`,
},
]
: [];
const sortedFields = [...(view?.fields ?? [])].sort(
(a, b) => a.position - b.position,
);
return (
<SettingsLayoutDetailScaffold
applicationId={applicationId}
applicationName={application?.name}
entityName={view?.name ?? t`View`}
entityTypeLabel={t`view`}
categoryLabel={t`Views`}
detailRows={detailRows}
isLoading={isLoading}
>
<SettingsLayoutItemTable
title={t`Fields`}
description={t`Columns shown in this view, in display order`}
columns={[
{ key: 'position', label: t`#`, width: '40px', align: 'right' },
{ key: 'field', label: t`Field` },
{ key: 'visible', label: t`Visible`, width: '80px' },
{ key: 'size', label: t`Size`, width: '80px', align: 'right' },
]}
rows={sortedFields.map((field) => ({
key: field.universalIdentifier,
cells: [
field.position,
resolveFieldLabel(field.fieldMetadataUniversalIdentifier),
field.isVisible === false ? t`Hidden` : t`Yes`,
field.size ?? '—',
],
}))}
/>
<SettingsLayoutItemTable
title={t`Filters`}
description={t`Conditions applied to records before they appear in this view`}
columns={[
{ key: 'field', label: t`Field` },
{ key: 'operand', label: t`Operand`, width: '160px' },
{ key: 'value', label: t`Value` },
]}
rows={(view?.filters ?? []).map((filter) => ({
key: filter.universalIdentifier,
cells: [
resolveFieldLabel(filter.fieldMetadataUniversalIdentifier),
filter.operand,
formatFilterValue(filter.value),
],
}))}
/>
<SettingsLayoutItemTable
title={t`Sorts`}
description={t`Order in which records are displayed`}
columns={[
{ key: 'field', label: t`Field` },
{ key: 'direction', label: t`Direction`, width: '120px' },
]}
rows={(view?.sorts ?? []).map((sort) => ({
key: sort.universalIdentifier,
cells: [
resolveFieldLabel(sort.fieldMetadataUniversalIdentifier),
sort.direction,
],
}))}
/>
</SettingsLayoutDetailScaffold>
);
};
@@ -0,0 +1,116 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
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 { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export type DetailRow = { key: string; label: string; value: ReactNode };
const StyledDescription = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
line-height: 1.5;
white-space: pre-wrap;
`;
const GRID_TEMPLATE = '220px 1fr';
export const SettingsLayoutDetailScaffold = ({
applicationId,
applicationName,
entityName,
entityTypeLabel,
categoryLabel,
description,
detailRows,
isLoading,
children,
}: {
applicationId: string;
applicationName: string | undefined;
entityName: string;
entityTypeLabel: string;
categoryLabel: string;
description?: string | null;
detailRows: DetailRow[];
isLoading: boolean;
children?: ReactNode;
}) => {
const trimmedDescription = description?.trim();
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: applicationName ?? '', href: applicationContentHref },
{ children: categoryLabel, href: applicationContentHref },
{ children: entityName },
];
return (
<SubMenuTopBarContainer title={entityName} links={breadcrumbLinks}>
<SettingsPageContainer>
{isLoading ? (
<SettingsSectionSkeletonLoader />
) : (
<>
{isDefined(trimmedDescription) && trimmedDescription.length > 0 && (
<Section>
<H2Title
title={t`About`}
description={t`Description provided by the application`}
/>
<StyledDescription>{trimmedDescription}</StyledDescription>
</Section>
)}
<Section>
<H2Title
title={t`Details`}
description={t`Read-only ${entityTypeLabel} definition shipped by this app`}
/>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE}>
<TableHeader>{t`Property`}</TableHeader>
<TableHeader>{t`Value`}</TableHeader>
</TableRow>
<TableBody>
{detailRows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={GRID_TEMPLATE}>
<TableCell color={themeCssVariables.font.color.secondary}>
{row.label}
</TableCell>
<TableCell minWidth="0" overflow="hidden">
{row.value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Section>
{children}
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,71 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
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 { type ReactNode } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type Column = {
key: string;
label: string;
align?: 'left' | 'right' | 'center';
width?: string;
};
type Row = {
key: string;
cells: ReactNode[];
};
export const SettingsLayoutItemTable = ({
title,
description,
columns,
rows,
}: {
title: string;
description?: string;
columns: Column[];
rows: Row[];
}) => {
if (rows.length === 0) {
return null;
}
const gridTemplate = columns.map((c) => c.width ?? '1fr').join(' ');
return (
<Section>
<H2Title title={title} description={description} />
<Table>
<TableRow gridTemplateColumns={gridTemplate}>
{columns.map((col) => (
<TableHeader key={col.key} align={col.align ?? 'left'}>
{col.label}
</TableHeader>
))}
</TableRow>
<TableBody>
{rows.map((row) => (
<TableRow key={row.key} gridTemplateColumns={gridTemplate}>
{row.cells.map((cell, index) => (
<TableCell
key={columns[index]?.key ?? index}
align={columns[index]?.align ?? 'left'}
color={themeCssVariables.font.color.primary}
minWidth="0"
overflow="hidden"
>
{cell}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</Section>
);
};
@@ -0,0 +1,66 @@
import { renderHook, waitFor } from '@testing-library/react';
import { type MockedResponse } from '@apollo/client/testing';
import { useApplicationManifest } from '~/pages/settings/layout/hooks/useApplicationManifest';
import {
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const APP_ID = 'app-1';
const APP_UID = 'uid-1';
const findOneApplicationMock = (
application: { id: string; universalIdentifier: string; name: string } | null,
): MockedResponse => ({
request: {
query: FindOneApplicationDocument,
variables: { id: APP_ID },
},
result: { data: { findOneApplication: application } },
});
const findMarketplaceAppDetailMock = (
manifest: object | null,
): MockedResponse => ({
request: {
query: FindMarketplaceAppDetailDocument,
variables: { universalIdentifier: APP_UID },
},
result: {
data: {
findMarketplaceAppDetail: manifest === null ? null : { manifest },
},
},
});
describe('useApplicationManifest', () => {
it('skips both queries when applicationId is empty', () => {
const wrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const { result } = renderHook(() => useApplicationManifest(''), {
wrapper,
});
expect(result.current.application).toBeUndefined();
expect(result.current.manifest).toBeUndefined();
});
it('exposes an undefined manifest when findMarketplaceAppDetail returns null', async () => {
const wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [
findOneApplicationMock({
id: APP_ID,
universalIdentifier: APP_UID,
name: 'My App',
}),
findMarketplaceAppDetailMock(null),
],
});
const { result } = renderHook(() => useApplicationManifest(APP_ID), {
wrapper,
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.manifest).toBeUndefined();
});
});
@@ -0,0 +1,42 @@
import { useQuery } from '@apollo/client/react';
import { type Manifest } from 'twenty-shared/application';
import {
FindMarketplaceAppDetailDocument,
FindOneApplicationDocument,
} from '~/generated-metadata/graphql';
// Loads the app + its marketplace manifest. Both queries are cache-first since
// the application detail page already issues them.
export const useApplicationManifest = (applicationId: string) => {
const { data: appData, loading: appLoading } = useQuery(
FindOneApplicationDocument,
{
variables: { id: applicationId },
fetchPolicy: 'cache-first',
skip: !applicationId,
},
);
const application = appData?.findOneApplication;
const { data: detailData, loading: detailLoading } = useQuery(
FindMarketplaceAppDetailDocument,
{
variables: {
universalIdentifier: application?.universalIdentifier ?? '',
},
fetchPolicy: 'cache-first',
skip: !application?.universalIdentifier,
},
);
const manifest = detailData?.findMarketplaceAppDetail?.manifest as
| Manifest
| undefined;
return {
application,
manifest,
isLoading: appLoading || detailLoading,
};
};
@@ -89,28 +89,27 @@ export const SettingsLogicFunctionDetail = () => {
const isTestTab = activeTabId === 'test';
const breadcrumbLinks = isDefined(applicationId)
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{
children: `${applicationName}`,
href: getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'content',
),
},
{ children: `${logicFunction?.name}` },
]
? (() => {
const applicationContentHref = getSettingsPath(
SettingsPath.ApplicationDetail,
{ applicationId },
undefined,
'content',
);
return [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: applicationName ?? '', href: applicationContentHref },
{ children: t`Logic functions`, href: applicationContentHref },
{ children: logicFunction?.name ?? '' },
];
})()
: [
{
children: t`Workspace`,
@@ -120,7 +119,8 @@ export const SettingsLogicFunctionDetail = () => {
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{ children: `${logicFunction?.name}` },
{ children: t`Logic functions` },
{ children: logicFunction?.name ?? '' },
];
const files = [
@@ -154,8 +154,13 @@ export const SettingsLogicFunctionDetail = () => {
isTesting={isExecuting}
/>
)}
{isTriggersTab && logicFunction && (
<SettingsLogicFunctionTriggersTab logicFunction={logicFunction} />
{isTriggersTab && (
<SettingsLogicFunctionTriggersTab
formValues={formValues}
onChange={onChange}
readonly={isReadonly}
applicationName={applicationName}
/>
)}
{isSettingsTab && (
<SettingsLogicFunctionSettingsTab
@@ -168,6 +173,7 @@ export const SettingsLogicFunctionDetail = () => {
<SettingsLogicFunctionTestTab
handleExecute={executeLogicFunction}
logicFunctionId={logicFunctionId}
formValues={formValues}
isTesting={isExecuting}
/>
)}
@@ -43,6 +43,9 @@ export enum SettingsPath {
Applications = 'applications',
ApplicationDetail = 'applications/:applicationId',
ApplicationLogicFunctionDetail = 'applications/:applicationId/logicFunctions/:logicFunctionId',
ApplicationFrontComponentDetail = 'applications/:applicationId/frontComponents/:frontComponentId',
ApplicationViewDetail = 'applications/:applicationId/views/:viewUniversalIdentifier',
ApplicationPageLayoutDetail = 'applications/:applicationId/pageLayouts/:pageLayoutUniversalIdentifier',
AvailableApplicationDetail = 'applications/available/:availableApplicationId',
ApplicationRegistrationDetail = 'applications/registrations/:applicationRegistrationId',
ApplicationRegistrationConfigVariableDetails = 'applications/registrations/:applicationRegistrationId/config-variables/:variableKey',
@@ -228,8 +228,11 @@ export {
IconHome,
IconHourglassHigh,
IconHours24,
IconHttpDelete,
IconHttpGet,
IconHttpPatch,
IconHttpPost,
IconHttpPut,
IconId,
IconInbox,
IconInfoCircle,
+3
View File
@@ -307,8 +307,11 @@ export {
IconHome,
IconHourglassHigh,
IconHours24,
IconHttpDelete,
IconHttpGet,
IconHttpPatch,
IconHttpPost,
IconHttpPut,
IconId,
IconInbox,
IconInfoCircle,