1518 extensibility front add an application section in settings (#15056)

Protected by IS_APPLICATION_ENABLED featureFlag

Add `Application` section in settings

<img width="301" height="137" alt="image"
src="https://github.com/user-attachments/assets/ee53bdd2-36f6-45c6-8646-17b1e08abf00"
/>


A `settings/applications` route listing all installed applications

<img width="661" height="428" alt="image"
src="https://github.com/user-attachments/assets/69d534c4-4e9e-452a-a3d9-ded0223bb457"
/>

Introduce a new Tag for application managed items

<img width="885" height="759" alt="image"
src="https://github.com/user-attachments/assets/19767be5-61e5-4bd2-a51d-54ed9bfb1923"
/>



A `settings/applications/<application_id>` details setting page listing
all objects, serverlessFunctions and agents created by the application:

<img width="917" height="778" alt="image"
src="https://github.com/user-attachments/assets/7fc056a6-1d73-4242-b2eb-6f8955d8597d"
/>

A `settings/applications/<application_id>/<serverless_function_id>`

<img width="905" height="652" alt="image"
src="https://github.com/user-attachments/assets/56ca0021-26bf-42cb-9abf-34879f16050a"
/>

Add trigger tab in serverless function details (readonly for now)

<img width="899" height="724" alt="image"
src="https://github.com/user-attachments/assets/5eeefa35-f2a4-4fd8-a640-7b5c5891f226"
/>

Set object, serverless and agent setting detail pages readonly for
managed items
<img width="1075" height="859" alt="image"
src="https://github.com/user-attachments/assets/57c73d69-4980-47a2-b752-8dc5ab494530"
/>
<img width="648" height="582" alt="image"
src="https://github.com/user-attachments/assets/5ad5f3f7-3bc3-4e40-870a-4981c6492524"
/>
<img width="982" height="692" alt="image"
src="https://github.com/user-attachments/assets/7ad756c4-5d33-4a0a-9eb8-416c040362b9"
/>
<img width="1077" height="647" alt="image"
src="https://github.com/user-attachments/assets/e086b9f5-4062-4d10-82a9-4023de3cad3f"
/>
This commit is contained in:
martmull
2025-10-15 17:13:29 +02:00
committed by GitHub
parent c0ed246a03
commit b16ab1b7c9
109 changed files with 2464 additions and 1357 deletions
@@ -1,7 +1,7 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { type EditorProps, type Monaco } from '@monaco-editor/react';
import dotenv from 'dotenv';
import { type editor, MarkerSeverity } from 'monaco-editor';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { useParams } from 'react-router-dom';
import { isDefined } from 'twenty-shared/utils';
@@ -20,14 +20,12 @@ type SettingsServerlessFunctionCodeEditorProps = Omit<
currentFilePath: string;
files: File[];
onChange: (value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
};
export const SettingsServerlessFunctionCodeEditor = ({
currentFilePath,
files,
onChange,
setIsCodeValid,
height = 450,
options = undefined,
}: SettingsServerlessFunctionCodeEditorProps) => {
@@ -106,16 +104,6 @@ export const SettingsServerlessFunctionCodeEditor = ({
}
};
const handleEditorValidation = (markers: editor.IMarker[]) => {
for (const marker of markers) {
if (marker.severity === MarkerSeverity.Error) {
setIsCodeValid?.(false);
return;
}
}
setIsCodeValid?.(true);
};
return (
isDefined(currentFile) &&
isDefined(availablePackages) && (
@@ -125,7 +113,6 @@ export const SettingsServerlessFunctionCodeEditor = ({
language={currentFile.language}
onMount={handleEditorDidMount}
onChange={onChange}
onValidate={handleEditorValidation}
options={options}
variant="with-header"
/>
@@ -14,9 +14,11 @@ const StyledInputsContainer = styled.div`
export const SettingsServerlessFunctionNewForm = ({
formValues,
onChange,
readonly = false,
}: {
formValues: ServerlessFunctionNewFormValues;
onChange: (key: string) => (value: string) => void;
readonly?: boolean;
}) => {
const descriptionTextAreaId = `${formValues.name}-description`;
const nameTextInputId = `${formValues.name}-name`;
@@ -32,6 +34,7 @@ export const SettingsServerlessFunctionNewForm = ({
autoFocusOnMount
value={formValues.name}
onChange={onChange('name')}
readOnly={readonly}
/>
<TextArea
textAreaId={descriptionTextAreaId}
@@ -39,6 +42,7 @@ export const SettingsServerlessFunctionNewForm = ({
minRows={4}
value={formValues.description}
onChange={onChange('description')}
readOnly={readonly}
/>
</StyledInputsContainer>
</Section>
@@ -1,7 +1,4 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsServerlessFunctionsFieldItemTableRow } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsFieldItemTableRow';
import { SettingsServerlessFunctionsTableEmpty } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -10,6 +7,8 @@ import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { type ServerlessFunction } from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
@@ -19,37 +18,41 @@ const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const SettingsServerlessFunctionsTable = () => {
const { serverlessFunctions } = useGetManyServerlessFunctions();
export const SettingsServerlessFunctionsTable = ({
serverlessFunctions,
}: {
serverlessFunctions: ServerlessFunction[];
}) => {
const { applicationId = '' } = useParams();
const { t } = useLingui();
if (serverlessFunctions.length === 0) {
return null;
}
return (
<>
{serverlessFunctions.length ? (
<SettingsPageContainer>
<Table>
<StyledTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Runtime</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
<StyledTableBody>
{serverlessFunctions.map(
(serverlessFunction: ServerlessFunction) => (
<SettingsServerlessFunctionsFieldItemTableRow
key={serverlessFunction.id}
serverlessFunction={serverlessFunction}
to={getSettingsPath(SettingsPath.ServerlessFunctions, {
id: serverlessFunction.id,
})}
/>
),
)}
</StyledTableBody>
</Table>
</SettingsPageContainer>
) : (
<SettingsServerlessFunctionsTableEmpty />
)}
</>
<Table>
<StyledTableRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>Runtime</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
<StyledTableBody>
{serverlessFunctions.map((serverlessFunction: ServerlessFunction) => (
<SettingsServerlessFunctionsFieldItemTableRow
key={serverlessFunction.id}
serverlessFunction={serverlessFunction}
to={getSettingsPath(
SettingsPath.ApplicationServerlessFunctionDetail,
{
applicationId,
serverlessFunctionId: serverlessFunction.id,
},
)}
/>
))}
</StyledTableBody>
</Table>
);
};
@@ -1,43 +0,0 @@
import styled from '@emotion/styled';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
const StyledEmptyFunctionsContainer = styled.div`
height: 60vh;
`;
export const SettingsServerlessFunctionsTableEmpty = () => {
return (
<StyledEmptyFunctionsContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyFunctions" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Add your first Function
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
Add your first Function to get started
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconPlus}
title="New function"
to={getSettingsPath(SettingsPath.NewServerlessFunction)}
/>
</AnimatedPlaceholderEmptyContainer>
</StyledEmptyFunctionsContainer>
);
};
@@ -7,12 +7,7 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import {
H2Title,
IconGitCommit,
IconPlayerPlay,
IconRestore,
} from 'twenty-ui/display';
import { H2Title, IconPlayerPlay } from 'twenty-ui/display';
import { Button, CoreEditorHeader } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -23,21 +18,13 @@ const StyledTabList = styled(TabList)`
export const SettingsServerlessFunctionCodeEditorTab = ({
files,
handleExecute,
handlePublish,
handleReset,
resetDisabled,
publishDisabled,
onChange,
setIsCodeValid,
isTesting = false,
}: {
files: File[];
handleExecute: () => void;
handlePublish: () => void;
handleReset: () => void;
resetDisabled: boolean;
publishDisabled: boolean;
onChange: (filePath: string, value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
isTesting?: boolean;
}) => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -50,29 +37,10 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
accent="blue"
size="small"
Icon={IconPlayerPlay}
disabled={isTesting}
onClick={handleExecute}
/>
);
const PublishButton = (
<Button
title="Publish"
variant="secondary"
size="small"
Icon={IconGitCommit}
onClick={handlePublish}
disabled={publishDisabled}
/>
);
const ResetButton = (
<Button
title="Reset"
variant="secondary"
size="small"
Icon={IconRestore}
onClick={handleReset}
disabled={resetDisabled}
/>
);
const HeaderTabList = (
<StyledTabList
@@ -91,16 +59,18 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
title="Code your function"
description="Write your function (in typescript) below"
/>
<CoreEditorHeader
leftNodes={[HeaderTabList]}
rightNodes={[ResetButton, PublishButton, TestButton]}
/>
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[TestButton]} />
{activeTabId && (
<SettingsServerlessFunctionCodeEditor
files={files}
currentFilePath={activeTabId}
onChange={(newCodeValue) => onChange(activeTabId, newCodeValue)}
setIsCodeValid={setIsCodeValid}
options={{
readOnly: true,
readOnlyMessage: {
value: 'Managed serverless functions are not editable',
},
}}
/>
)}
</Section>
@@ -1,70 +1,28 @@
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const DELETE_FUNCTION_MODAL_ID = 'delete-function-modal';
export const SettingsServerlessFunctionSettingsTab = ({
formValues,
serverlessFunctionId,
onChange,
onCodeChange,
serverlessFunctionId,
}: {
formValues: ServerlessFunctionFormValues;
serverlessFunctionId: string;
onChange: (key: string) => (value: string) => void;
onCodeChange: (filePath: string, value: string) => void;
}) => {
const navigate = useNavigateSettings();
const { openModal } = useModal();
const { deleteOneServerlessFunction } = useDeleteOneServerlessFunction();
const deleteFunction = async () => {
await deleteOneServerlessFunction({ id: serverlessFunctionId });
navigate(SettingsPath.ServerlessFunctions);
};
return (
<>
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly
/>
<SettingsServerlessFunctionTabEnvironmentVariablesSection
formValues={formValues}
onCodeChange={onCodeChange}
/>
<Section>
<H2Title title="Danger zone" description="Delete this function" />
<Button
accent="danger"
onClick={() => openModal(DELETE_FUNCTION_MODAL_ID)}
variant="secondary"
size="small"
title="Delete function"
/>
</Section>
<ConfirmationModal
confirmationValue={formValues.name}
confirmationPlaceholder={formValues.name}
modalId={DELETE_FUNCTION_MODAL_ID}
title="Function Deletion"
subtitle={
<>
This action cannot be undone. This will permanently delete your
function. <br /> Please type in the function name to confirm.
</>
}
onConfirmClick={deleteFunction}
confirmButtonText="Delete function"
serverlessFunctionId={serverlessFunctionId}
/>
</>
);
@@ -1,18 +1,18 @@
import { SettingsServerlessFunctionTabEnvironmentVariableTableRow } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
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 '@emotion/styled';
import dotenv from 'dotenv';
import { useMemo, useState } from 'react';
import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { v4 } from 'uuid';
import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState';
import { useRecoilState } from 'recoil';
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
@@ -39,24 +39,18 @@ const StyledTableRow = styled(TableRow)`
export type EnvironmentVariable = { id: string; key: string; value: string };
export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({
formValues,
onCodeChange,
serverlessFunctionId,
}: {
formValues: ServerlessFunctionFormValues;
serverlessFunctionId: string;
onCodeChange: (filePath: string, value: string) => void;
}) => {
const environmentVariables = formValues.code?.['.env']
? dotenv.parse(formValues.code['.env'])
: {};
const environmentVariablesList = Object.entries(environmentVariables).map(
([key, value]) => ({ id: v4(), key, value }),
);
const [searchTerm, setSearchTerm] = useState('');
const [newEnvVarAdded, setNewEnvVarAdded] = useState(false);
const [envVariables, setEnvVariables] = useState<EnvironmentVariable[]>(
environmentVariablesList,
const [envVariables, setEnvVariables] = useRecoilState(
serverlessFunctionEnvVarFamilyState(serverlessFunctionId),
);
const filteredEnvVariable = useMemo(() => {
return envVariables.filter(
({ key, value }) =>
@@ -68,11 +62,28 @@ export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({
const getFormattedEnvironmentVariables = (
newEnvVariables: EnvironmentVariable[],
) => {
return newEnvVariables.reduce(
(acc, { key, value }) =>
key.length > 0 && value.length > 0 ? `${acc}\n${key}=${value}` : acc,
'',
);
return [...newEnvVariables]
.reverse()
.reduce(
(acc, { key, value }) =>
key.length > 0 && value.length > 0 ? `${key}=${value}\n${acc}` : acc,
'',
);
};
const onEnvVarChange = (newEnvVariable: EnvironmentVariable) => {
const newEnvVariables: EnvironmentVariable[] = [];
for (const envVariable of envVariables) {
if (envVariable.id === newEnvVariable.id) {
newEnvVariables.push(newEnvVariable);
} else if (envVariable.key !== newEnvVariable.key) {
newEnvVariables.push(envVariable);
}
}
setEnvVariables(newEnvVariables);
onCodeChange('.env', getFormattedEnvironmentVariables(newEnvVariables));
};
return (
@@ -101,24 +112,7 @@ export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({
key={envVariable.id}
envVariable={envVariable}
initialEditMode={newEnvVarAdded && envVariable.value === ''}
onChange={(newEnvVariable) => {
const newEnvVariables = envVariables.reduce(
(acc, { id, key }) => {
if (id === newEnvVariable.id) {
acc.push(newEnvVariable);
} else if (key !== newEnvVariable.key) {
acc.push(envVariable);
}
return acc;
},
[] as EnvironmentVariable[],
);
setEnvVariables(newEnvVariables);
onCodeChange(
'.env',
getFormattedEnvironmentVariables(newEnvVariables),
);
}}
onChange={onEnvVarChange}
onDelete={() => {
const newEnvVariables = envVariables.filter(
({ id }) => id !== envVariable.id,
@@ -21,9 +21,11 @@ const StyledCodeEditorContainer = styled.div`
export const SettingsServerlessFunctionTestTab = ({
handleExecute,
serverlessFunctionId,
isTesting = false,
}: {
handleExecute: () => void;
serverlessFunctionId: string;
isTesting?: boolean;
}) => {
const { t } = useLingui();
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
@@ -54,6 +56,7 @@ export const SettingsServerlessFunctionTestTab = ({
size="small"
Icon={IconPlayerPlay}
onClick={handleExecute}
disabled={isTesting}
/>,
]}
/>
@@ -67,6 +70,7 @@ export const SettingsServerlessFunctionTestTab = ({
</StyledCodeEditorContainer>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
isTesting={isTesting}
/>
</StyledInputsContainer>
</Section>
@@ -0,0 +1,113 @@
import { H2Title, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type ServerlessFunction } from '~/generated/graphql';
import { useLingui } from '@lingui/react/macro';
import { SettingsDatabaseEventsForm } from '@/settings/components/SettingsDatabaseEventsForm';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { Tag } from 'twenty-ui/components';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
export const StyledRouteTriggerTableRow = styled(TableRow)`
grid-template-columns: 1fr 120px 120px;
`;
const StyledTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
overflow: hidden;
`;
const StyledRouteTriggerTableHeaderRow = styled(StyledRouteTriggerTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsServerlessFunctionTriggersTab = ({
serverlessFunction,
}: {
serverlessFunction: ServerlessFunction;
}) => {
const { t } = useLingui();
const databaseEventTriggers = serverlessFunction.databaseEventTriggers ?? [];
const cronTriggers = serverlessFunction.cronTriggers ?? [];
const routeTriggers = serverlessFunction.routeTriggers ?? [];
const databaseEvents = databaseEventTriggers?.map((event) => {
const [object, action]: [string, string] =
event.settings.eventName.split('.');
return { object, action };
});
return (
<>
{databaseEvents.length > 0 && (
<Section>
<H2Title
title={t`Database event`}
description={t`Select the events that should trigger the function`}
/>
<SettingsDatabaseEventsForm events={databaseEvents} disabled />
</Section>
)}
{cronTriggers.length > 0 && (
<Section>
<H2Title
title={t`Cron`}
description={t`Triggers the function at regular intervals`}
/>
{cronTriggers.map((cronTrigger, index) => (
<FormTextFieldInput
key={index}
label={t`Expression`}
placeholder="0 */1 * * *"
hint={t`Format: [Minute] [Hour] [Day of Month] [Month] [Day of Week]`}
onChange={() => {}}
readonly
defaultValue={cronTrigger.settings.pattern}
/>
))}
</Section>
)}
{routeTriggers.length > 0 && (
<Section>
<H2Title
title={t`Http`}
description={t`Triggers the function with Http request`}
/>
<Table>
<StyledRouteTriggerTableHeaderRow>
<TableHeader>{t`Path`}</TableHeader>
<TableHeader>{t`Method`}</TableHeader>
<TableHeader>{t`Auth Required`}</TableHeader>
</StyledRouteTriggerTableHeaderRow>
{routeTriggers.map((routeTrigger, index) => (
<StyledRouteTriggerTableRow key={index}>
<StyledTableCell>
<OverflowingTextWithTooltip text={routeTrigger.path} />
</StyledTableCell>
<StyledTableCell>{routeTrigger.httpMethod}</StyledTableCell>
<StyledTableCell>
<Tag
text={routeTrigger.isAuthRequired ? 'True' : 'False'}
color={routeTrigger.isAuthRequired ? 'green' : 'orange'}
weight="medium"
/>
</StyledTableCell>
</StyledRouteTriggerTableRow>
))}
</Table>
</Section>
)}
</>
);
};