Serverless function UI (#6388)

https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=36235-120877

Did not do the file manager part. A Function is defined using one unique
file at the moment

Feature protected by featureFlag `IS_FUNCTION_SETTINGS_ENABLED`

## Demo


https://github.com/user-attachments/assets/0acb8291-47b4-4521-a6fa-a88b9198609b
This commit is contained in:
martmull
2024-07-29 13:03:09 +02:00
committed by GitHub
parent 936279f895
commit 00fea17920
100 changed files with 2283 additions and 121 deletions

View File

@ -0,0 +1,41 @@
import { H2Title } from 'twenty-ui';
import { Section } from '@/ui/layout/section/components/Section';
import { TextInput } from '@/ui/input/components/TextInput';
import { TextArea } from '@/ui/input/components/TextArea';
import styled from '@emotion/styled';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsServerlessFunctionNewForm = ({
formValues,
onChange,
}: {
formValues: ServerlessFunctionNewFormValues;
onChange: (key: string) => (value: string) => void;
}) => {
return (
<Section>
<H2Title title="About" description="Name and set your function" />
<StyledInputsContainer>
<TextInput
placeholder="Name"
fullWidth
focused
value={formValues.name}
onChange={onChange('name')}
/>
<TextArea
placeholder="Description"
minRows={4}
value={formValues.description}
onChange={onChange('description')}
/>
</StyledInputsContainer>
</Section>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ServerlessFunction } from '~/generated-metadata/graphql';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { IconChevronRight } from 'twenty-ui';
import { useTheme } from '@emotion/react';
export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconTableCell = styled(TableCell)`
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const SettingsServerlessFunctionsFieldItemTableRow = ({
serverlessFunction,
to,
}: {
serverlessFunction: ServerlessFunction;
to: string;
}) => {
const theme = useTheme();
return (
<StyledApisFieldTableRow to={to}>
<StyledNameTableCell>{serverlessFunction.name}</StyledNameTableCell>
<StyledNameTableCell>{serverlessFunction.runtime}</StyledNameTableCell>
<StyledIconTableCell>
<StyledIconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconTableCell>
</StyledApisFieldTableRow>
);
};

View File

@ -0,0 +1,51 @@
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { SettingsServerlessFunctionsFieldItemTableRow } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsFieldItemTableRow';
import { ServerlessFunction } from '~/generated-metadata/graphql';
import { SettingsServerlessFunctionsTableEmpty } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
const StyledTableRow = styled(TableRow)`
grid-template-columns: 312px 132px 68px;
`;
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const SettingsServerlessFunctionsTable = () => {
const { serverlessFunctions } = useGetManyServerlessFunctions();
return (
<>
{serverlessFunctions.length ? (
<Table>
<StyledTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Runtime</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
<StyledTableBody>
{serverlessFunctions.map(
(serverlessFunction: ServerlessFunction) => (
<SettingsServerlessFunctionsFieldItemTableRow
key={serverlessFunction.id}
serverlessFunction={serverlessFunction}
to={getSettingsPagePath(SettingsPath.ServerlessFunctions, {
id: serverlessFunction.id,
})}
/>
),
)}
</StyledTableBody>
</Table>
) : (
<SettingsServerlessFunctionsTableEmpty />
)}
</>
);
};

View File

@ -0,0 +1,43 @@
import {
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import { IconPlus } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import styled from '@emotion/styled';
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={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
/>
</AnimatedPlaceholderEmptyContainer>
</StyledEmptyFunctionsContainer>
);
};

View File

@ -0,0 +1,60 @@
import { H2Title, IconPlayerPlay } from 'twenty-ui';
import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor';
import { Section } from '@/ui/layout/section/components/Section';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { Button } from '@/ui/input/button/components/Button';
import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader';
import styled from '@emotion/styled';
import { TabList } from '@/ui/layout/tab/components/TabList';
const StyledTabList = styled(TabList)`
border-bottom: none;
`;
export const SettingsServerlessFunctionCodeEditorTab = ({
formValues,
handleExecute,
onChange,
}: {
formValues: ServerlessFunctionFormValues;
handleExecute: () => void;
onChange: (key: string) => (value: string) => void;
}) => {
const HeaderButton = (
<Button
title="Test"
variant="primary"
accent="blue"
size="small"
Icon={IconPlayerPlay}
onClick={handleExecute}
/>
);
const TAB_LIST_COMPONENT_ID = 'serverless-function-editor';
const HeaderTabList = (
<StyledTabList
tabListId={TAB_LIST_COMPONENT_ID}
tabs={[{ id: 'index.ts', title: 'index.ts' }]}
/>
);
const Header = (
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[HeaderButton]} />
);
return (
<Section>
<H2Title
title="Code your function"
description="Write your function (in typescript) below"
/>
<CodeEditor
value={formValues.code}
onChange={onChange('code')}
header={Header}
/>
</Section>
);
};

View File

@ -0,0 +1,62 @@
import { H2Title } from 'twenty-ui';
import { Section } from '@/ui/layout/section/components/Section';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { Button } from '@/ui/input/button/components/Button';
import { useState } from 'react';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { useNavigate } from 'react-router-dom';
export const SettingsServerlessFunctionSettingsTab = ({
formValues,
serverlessFunctionId,
onChange,
}: {
formValues: ServerlessFunctionFormValues;
serverlessFunctionId: string;
onChange: (key: string) => (value: string) => void;
}) => {
const navigate = useNavigate();
const [isDeleteFunctionModalOpen, setIsDeleteFunctionModalOpen] =
useState(false);
const { deleteOneServerlessFunction } = useDeleteOneServerlessFunction();
const deleteFunction = async () => {
await deleteOneServerlessFunction({ id: serverlessFunctionId });
navigate('/settings/functions');
};
return (
<>
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}
/>
<Section>
<H2Title title="Danger zone" description="Delete this function" />
<Button
accent="danger"
onClick={() => setIsDeleteFunctionModalOpen(true)}
variant="secondary"
size="small"
title="Delete function"
/>
</Section>
<ConfirmationModal
confirmationValue={formValues.name}
confirmationPlaceholder={formValues.name}
isOpen={isDeleteFunctionModalOpen}
setIsOpen={setIsDeleteFunctionModalOpen}
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}
deleteButtonText="Delete function"
/>
</>
);
};

View File

@ -0,0 +1,81 @@
import { H2Title, IconPlayerPlay } from 'twenty-ui';
import { Section } from '@/ui/layout/section/components/Section';
import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor';
import styled from '@emotion/styled';
import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader';
import { Button } from '@/ui/input/button/components/Button';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { useRecoilState, useRecoilValue } from 'recoil';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
const StyledInputsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsServerlessFunctionTestTab = ({
handleExecute,
}: {
handleExecute: () => void;
}) => {
const settingsServerlessFunctionCodeEditorOutputParams = useRecoilValue(
settingsServerlessFunctionCodeEditorOutputParamsState,
);
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
const [settingsServerlessFunctionInput, setSettingsServerlessFunctionInput] =
useRecoilState(settingsServerlessFunctionInputState);
const InputHeaderButton = (
<Button
title="Run Function"
variant="primary"
accent="blue"
size="small"
Icon={IconPlayerPlay}
onClick={handleExecute}
/>
);
const InputHeader = (
<CoreEditorHeader title={'Input'} rightNodes={[InputHeaderButton]} />
);
const OutputHeaderButton = (
<LightCopyIconButton copyText={settingsServerlessFunctionOutput} />
);
const OutputHeader = (
<CoreEditorHeader title={'Output'} rightNodes={[OutputHeaderButton]} />
);
return (
<Section>
<H2Title
title="Test your function"
description='Insert a JSON input, then press "Run" to test your function.'
/>
<StyledInputsContainer>
<CodeEditor
value={settingsServerlessFunctionInput}
height={200}
onChange={setSettingsServerlessFunctionInput}
language={'json'}
header={InputHeader}
/>
<CodeEditor
value={settingsServerlessFunctionOutput}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
language={settingsServerlessFunctionCodeEditorOutputParams.language}
options={{ readOnly: true, domReadOnly: true }}
header={OutputHeader}
/>
</StyledInputsContainer>
</Section>
);
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
export const SettingsServerlessFunctionTestTabEffect = () => {
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState(
settingsServerlessFunctionCodeEditorOutputParamsState,
);
try {
JSON.parse(settingsServerlessFunctionOutput);
setSettingsServerlessFunctionCodeEditorOutputParams({
language: 'json',
height: 300,
});
} catch {
return <></>;
}
return <></>;
};