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