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:
@@ -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 />}
|
||||
|
||||
+7
@@ -39,6 +39,13 @@ export const APPLICATION_FRAGMENT = gql`
|
||||
name
|
||||
description
|
||||
applicationId
|
||||
componentName
|
||||
builtComponentChecksum
|
||||
universalIdentifier
|
||||
isHeadless
|
||||
usesSdkClient
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
objects {
|
||||
...ObjectMetadataFields
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
|
||||
databaseEventTriggerSettings
|
||||
httpRouteTriggerSettings
|
||||
applicationId
|
||||
universalIdentifier
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
+3
@@ -46,6 +46,9 @@ describe('useLogicFunctionUpdateFormState', () => {
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
cronTriggerSettings: null,
|
||||
databaseEventTriggerSettings: null,
|
||||
httpRouteTriggerSettings: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+16
@@ -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]);
|
||||
|
||||
+55
@@ -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');
|
||||
});
|
||||
});
|
||||
+38
@@ -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
-1
@@ -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
-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
-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`] = `
|
||||
{
|
||||
|
||||
+242
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
-48
@@ -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,
|
||||
}),
|
||||
+184
@@ -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,
|
||||
};
|
||||
};
|
||||
+122
@@ -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 };
|
||||
};
|
||||
-179
@@ -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
-12
@@ -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}
|
||||
|
||||
-36
@@ -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>
|
||||
);
|
||||
};
|
||||
-66
@@ -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>
|
||||
);
|
||||
};
|
||||
-62
@@ -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>
|
||||
);
|
||||
};
|
||||
+5
-9
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+102
-3
@@ -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`}
|
||||
|
||||
+68
-130
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+67
@@ -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>
|
||||
);
|
||||
};
|
||||
+75
@@ -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>
|
||||
);
|
||||
};
|
||||
+157
@@ -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>
|
||||
);
|
||||
};
|
||||
+48
@@ -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>
|
||||
);
|
||||
};
|
||||
+45
@@ -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>
|
||||
);
|
||||
};
|
||||
+58
@@ -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>
|
||||
);
|
||||
};
|
||||
+97
@@ -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'] } });
|
||||
});
|
||||
});
|
||||
+77
@@ -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 ?? [],
|
||||
},
|
||||
};
|
||||
};
|
||||
+110
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
@@ -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>
|
||||
);
|
||||
};
|
||||
-129
@@ -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>
|
||||
);
|
||||
};
|
||||
-69
@@ -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>
|
||||
);
|
||||
};
|
||||
-60
@@ -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>
|
||||
);
|
||||
};
|
||||
+180
-65
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+75
@@ -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>
|
||||
);
|
||||
};
|
||||
+139
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+16
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-13
@@ -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>
|
||||
);
|
||||
};
|
||||
+116
@@ -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>
|
||||
);
|
||||
};
|
||||
+71
@@ -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>
|
||||
);
|
||||
};
|
||||
+66
@@ -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,
|
||||
};
|
||||
};
|
||||
+31
-25
@@ -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,
|
||||
|
||||
@@ -307,8 +307,11 @@ export {
|
||||
IconHome,
|
||||
IconHourglassHigh,
|
||||
IconHours24,
|
||||
IconHttpDelete,
|
||||
IconHttpGet,
|
||||
IconHttpPatch,
|
||||
IconHttpPost,
|
||||
IconHttpPut,
|
||||
IconId,
|
||||
IconInbox,
|
||||
IconInfoCircle,
|
||||
|
||||
Reference in New Issue
Block a user