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
@@ -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>
);
};