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

@ -23,13 +23,13 @@ import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDraw
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { FileFolder, useUploadFileMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { getFileType } from '../files/utils/getFileType';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
@ -127,9 +127,7 @@ export const ActivityBodyEditor = ({
if (!result?.data?.uploadFile) {
throw new Error("Couldn't upload Image");
}
const imageUrl =
REACT_APP_SERVER_BASE_URL + '/files/' + result?.data?.uploadFile;
return imageUrl;
return getFileAbsoluteURI(result.data.uploadFile);
};
const handlePersistBody = useCallback(

View File

@ -13,8 +13,8 @@ import {
FieldContext,
GenericFieldContextType,
} from '@/object-record/record-field/contexts/FieldContext';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { formatToHumanReadableDate } from '~/utils/date-utils';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
const StyledRow = styled.div`
align-items: center;
@ -76,7 +76,7 @@ export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
<StyledLeftContent>
<AttachmentIcon attachmentType={attachment.type} />
<StyledLink
href={REACT_APP_SERVER_BASE_URL + '/files/' + attachment.fullPath}
href={getFileAbsoluteURI(attachment.fullPath)}
target="__blank"
>
{attachment.name}

View File

@ -1,7 +1,7 @@
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
export const downloadFile = (fullPath: string, fileName: string) => {
fetch(REACT_APP_SERVER_BASE_URL + '/files/' + fullPath)
fetch(getFileAbsoluteURI(fullPath))
.then((resp) =>
resp.status === 200
? resp.blob()

View File

@ -13,6 +13,7 @@ import {
IconSettings,
IconUserCircle,
IconUsers,
IconFunction,
} from 'twenty-ui';
import { useAuth } from '@/auth/hooks/useAuth';
@ -29,6 +30,9 @@ export const SettingsNavigationDrawerItems = () => {
const { signOut } = useAuth();
const billing = useRecoilValue(billingState);
const isFunctionSettingsEnabled = useIsFeatureEnabled(
'IS_FUNCTION_SETTINGS_ENABLED',
);
const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED');
return (
@ -99,6 +103,13 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.Developers}
Icon={IconCode}
/>
{isFunctionSettingsEnabled && (
<SettingsNavigationDrawerItem
label="Functions"
path={SettingsPath.ServerlessFunctions}
Icon={IconFunction}
/>
)}
<SettingsNavigationDrawerItem
label="Integrations"
path={SettingsPath.Integrations}

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 <></>;
};

View File

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const SERVERLESS_FUNCTION_FRAGMENT = gql`
fragment ServerlessFunctionFields on ServerlessFunction {
id
name
description
sourceCodeHash
sourceCodeFullPath
runtime
syncStatus
createdAt
updatedAt
}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const CREATE_ONE_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
mutation CreateOneServerlessFunctionItem(
$input: CreateServerlessFunctionInput!
) {
createOneServerlessFunction(input: $input) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const DELETE_ONE_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {
deleteOneServerlessFunction(input: $input) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql`
mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {
executeOneServerlessFunction(id: $id, payload: $payload) {
result
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const UPDATE_ONE_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {
updateOneServerlessFunction(input: $input) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const FIND_MANY_SERVERLESS_FUNCTIONS = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
query GetManyServerlessFunctions {
serverlessFunctions(paging: { first: 100 }) {
edges {
node {
...ServerlessFunctionFields
}
}
}
}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const FIND_ONE_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
query GetOneServerlessFunction($id: UUID!) {
serverlessFunction(id: $id) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,34 @@
import { renderHook } from '@testing-library/react';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { RecoilRoot } from 'recoil';
jest.mock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunction',
() => ({
useGetOneServerlessFunction: jest.fn(),
}),
);
describe('useServerlessFunctionUpdateFormState', () => {
test('should return a form', () => {
const serverlessFunctionId = 'serverlessFunctionId';
const useGetOneServerlessFunctionMock = jest.requireMock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunction',
);
useGetOneServerlessFunctionMock.useGetOneServerlessFunction.mockReturnValue(
{
serverlessFunction: { sourceCodeFullPath: undefined },
},
);
const { result } = renderHook(
() => useServerlessFunctionUpdateFormState(serverlessFunctionId),
{
wrapper: RecoilRoot,
},
);
const [formValues] = result.current;
expect(formValues).toEqual({ name: '', description: '', code: '' });
});
});

View File

@ -0,0 +1,35 @@
import { ApolloClient, useMutation } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import {
CreateServerlessFunctionInput,
CreateOneServerlessFunctionItemMutation,
CreateOneServerlessFunctionItemMutationVariables,
} from '~/generated-metadata/graphql';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
import { CREATE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/createOneServerlessFunction';
export const useCreateOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
CreateOneServerlessFunctionItemMutation,
CreateOneServerlessFunctionItemMutationVariables
>(CREATE_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const createOneServerlessFunction = async (
input: CreateServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_SERVERLESS_FUNCTIONS) ?? ''],
});
};
return { createOneServerlessFunction };
};

View File

@ -0,0 +1,34 @@
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { ApolloClient, useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
import { DELETE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction';
import {
DeleteServerlessFunctionInput,
DeleteOneServerlessFunctionMutation,
DeleteOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
export const useDeleteOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
DeleteOneServerlessFunctionMutation,
DeleteOneServerlessFunctionMutationVariables
>(DELETE_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const deleteOneServerlessFunction = async (
input: DeleteServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_SERVERLESS_FUNCTIONS) ?? ''],
});
};
return { deleteOneServerlessFunction };
};

View File

@ -0,0 +1,30 @@
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { ApolloClient, useMutation } from '@apollo/client';
import { EXECUTE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/executeOneServerlessFunction';
import {
ExecuteOneServerlessFunctionMutation,
ExecuteOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
export const useExecuteOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
ExecuteOneServerlessFunctionMutation,
ExecuteOneServerlessFunctionMutationVariables
>(EXECUTE_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const executeOneServerlessFunction = async (
id: string,
payload: object = {},
) => {
return await mutate({
variables: {
id,
payload,
},
});
};
return { executeOneServerlessFunction };
};

View File

@ -0,0 +1,21 @@
import { useQuery } from '@apollo/client';
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import {
GetManyServerlessFunctionsQuery,
GetManyServerlessFunctionsQueryVariables,
} from '~/generated-metadata/graphql';
export const useGetManyServerlessFunctions = () => {
const apolloMetadataClient = useApolloMetadataClient();
const { data } = useQuery<
GetManyServerlessFunctionsQuery,
GetManyServerlessFunctionsQueryVariables
>(FIND_MANY_SERVERLESS_FUNCTIONS, {
client: apolloMetadataClient ?? undefined,
});
return {
serverlessFunctions:
data?.serverlessFunctions?.edges.map(({ node }) => node) || [],
};
};

View File

@ -0,0 +1,23 @@
import { useQuery } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction';
import {
GetOneServerlessFunctionQuery,
GetOneServerlessFunctionQueryVariables,
} from '~/generated-metadata/graphql';
export const useGetOneServerlessFunction = (id: string) => {
const apolloMetadataClient = useApolloMetadataClient();
const { data } = useQuery<
GetOneServerlessFunctionQuery,
GetOneServerlessFunctionQueryVariables
>(FIND_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? undefined,
variables: {
id,
},
});
return {
serverlessFunction: data?.serverlessFunction || null,
};
};

View File

@ -0,0 +1,57 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
import { isDefined } from '~/utils/isDefined';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
export type ServerlessFunctionNewFormValues = {
name: string;
description: string;
};
export type ServerlessFunctionFormValues = ServerlessFunctionNewFormValues & {
code: string;
};
type SetServerlessFunctionFormValues = Dispatch<
SetStateAction<ServerlessFunctionFormValues>
>;
export const useServerlessFunctionUpdateFormState = (
serverlessFunctionId: string,
): [ServerlessFunctionFormValues, SetServerlessFunctionFormValues] => {
const [formValues, setFormValues] = useState<ServerlessFunctionFormValues>({
name: '',
description: '',
code: '',
});
const { serverlessFunction } =
useGetOneServerlessFunction(serverlessFunctionId);
useEffect(() => {
const getFileContent = async () => {
const resp = await fetch(
getFileAbsoluteURI(serverlessFunction?.sourceCodeFullPath),
);
if (resp.status !== 200) {
throw new Error('Network response was not ok');
} else {
const result = await resp.text();
const newState = {
code: result,
name: serverlessFunction?.name || '',
description: serverlessFunction?.description || '',
};
setFormValues((prevState) => ({
...prevState,
...newState,
}));
}
};
if (isDefined(serverlessFunction?.sourceCodeFullPath)) {
getFileContent();
}
}, [serverlessFunction, setFormValues]);
return [formValues, setFormValues];
};

View File

@ -0,0 +1,34 @@
import { ApolloClient, useMutation } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { UPDATE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/updateOneServerlessFunction';
import {
UpdateServerlessFunctionInput,
UpdateOneServerlessFunctionMutation,
UpdateOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
export const useUpdateOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
UpdateOneServerlessFunctionMutation,
UpdateOneServerlessFunctionMutationVariables
>(UPDATE_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const updateOneServerlessFunction = async (
input: UpdateServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_SERVERLESS_FUNCTIONS) ?? ''],
});
};
return { updateOneServerlessFunction };
};

View File

@ -0,0 +1,7 @@
import { createState } from 'twenty-ui';
export const settingsServerlessFunctionCodeEditorOutputParamsState =
createState<{ language: string; height: number }>({
key: 'settingsServerlessFunctionCodeEditorOutputParamsState',
defaultValue: { language: 'plaintext', height: 64 },
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const settingsServerlessFunctionInputState = createState<string>({
key: 'settingsServerlessFunctionInputState',
defaultValue: '{}',
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const settingsServerlessFunctionOutputState = createState<string>({
key: 'settingsServerlessFunctionOutputState',
defaultValue: 'Enter an input above then press "run Function"',
});

View File

@ -0,0 +1,15 @@
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
describe('getSettingsPagePath', () => {
test('should compute page path', () => {
expect(getSettingsPagePath(SettingsPath.ServerlessFunctions)).toEqual(
'/settings/functions',
);
});
test('should compute page path with id', () => {
expect(
getSettingsPagePath(SettingsPath.ServerlessFunctions, { id: 'id' }),
).toEqual('/settings/functions/id');
});
});

View File

@ -2,6 +2,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { isDefined } from '~/utils/isDefined';
type PathParams = {
id?: string;
objectSlug?: string;
};
@ -15,5 +16,9 @@ export const getSettingsPagePath = <Path extends SettingsPath>(
resultPath = resultPath.replace(':objectSlug', params.objectSlug);
}
if (isDefined(params?.id)) {
resultPath = `${resultPath}/${params?.id}`;
}
return resultPath;
};

View File

@ -16,10 +16,13 @@ export enum SettingsPath {
ObjectNewFieldStep2 = 'objects/:objectSlug/new-field/step-2',
ObjectFieldEdit = 'objects/:objectSlug/:fieldSlug',
NewObject = 'objects/new',
NewServerlessFunction = 'functions/new',
ServerlessFunctionDetail = 'functions/:serverlessFunctionId',
WorkspaceMembersPage = 'workspace-members',
Workspace = 'workspace',
CRMMigration = 'crm-migration',
Developers = 'developers',
ServerlessFunctions = 'functions',
DevelopersNewApiKey = 'api-keys/new',
DevelopersApiKeyDetail = 'api-keys/:apiKeyId',
Integrations = 'integrations',

View File

@ -0,0 +1,80 @@
import Editor, { Monaco, EditorProps } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect } from 'react';
export const DEFAULT_CODE = `export const handler = async (
event: object,
context: object
): Promise<object> => {
// Your code here
return {};
}
`;
const StyledEditor = styled(Editor)`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top: none;
border-radius: 0 0 ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm};
`;
type CodeEditorProps = Omit<EditorProps, 'onChange'> & {
header: React.ReactNode;
onChange?: (value: string) => void;
};
export const CodeEditor = ({
value = DEFAULT_CODE,
onChange,
language = 'typescript',
height = 500,
options = undefined,
header,
}: CodeEditorProps) => {
const theme = useTheme();
const handleEditorDidMount = (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
};
useEffect(() => {
const style = document.createElement('style');
style.innerHTML = `
.monaco-editor .margin .line-numbers {
font-weight: bold;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
return (
<div>
{header}
<StyledEditor
height={height}
language={language}
value={value}
onMount={handleEditorDidMount}
onChange={(value?: string) => value && onChange?.(value)}
options={{
...options,
overviewRulerLanes: 0,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
minimap: {
enabled: false,
},
}}
/>
</div>
);
};

View File

@ -0,0 +1,51 @@
import styled from '@emotion/styled';
const StyledEditorHeader = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.tertiary};
font-weight: ${({ theme }) => theme.font.weight.medium};
display: flex;
height: ${({ theme }) => theme.spacing(10)};
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
justify-content: space-between;
`;
const StyledElementContainer = styled.div`
align-content: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
export type CoreEditorHeaderProps = {
title?: string;
leftNodes?: React.ReactNode[];
rightNodes?: React.ReactNode[];
};
export const CoreEditorHeader = ({
title,
leftNodes,
rightNodes,
}: CoreEditorHeaderProps) => {
return (
<StyledEditorHeader>
<StyledElementContainer>
{leftNodes &&
leftNodes.map((leftButton, index) => {
return <div key={`left-${index}`}>{leftButton}</div>;
})}
{title}
</StyledElementContainer>
<StyledElementContainer>
{rightNodes &&
rightNodes.map((rightButton, index) => {
return <div key={`right-${index}`}>{rightButton}</div>;
})}
</StyledElementContainer>
</StyledEditorHeader>
);
};

View File

@ -0,0 +1,33 @@
import { editor } from 'monaco-editor';
import { ThemeType } from 'twenty-ui';
export const codeEditorTheme = (theme: ThemeType) => {
return {
base: 'vs' as editor.BuiltinTheme,
inherit: true,
rules: [
{
token: '',
foreground: theme.code.text.gray,
fontStyle: 'bold',
},
{ token: 'keyword', foreground: theme.code.text.sky },
{
token: 'delimiter',
foreground: theme.code.text.gray,
},
{ token: 'string', foreground: theme.code.text.pink },
{
token: 'comment',
foreground: theme.code.text.orange,
},
],
colors: {
'editor.background': theme.background.secondary,
'editorCursor.foreground': theme.font.color.primary,
'editorLineNumber.foreground': theme.font.color.extraLight,
'editorLineNumber.activeForeground': theme.font.color.light,
'editor.lineHighlightBackground': theme.background.tertiary,
},
};
};

View File

@ -29,7 +29,6 @@ const StyledTextArea = styled(TextareaAutosize)`
line-height: 16px;
overflow: auto;
padding: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(3)};
resize: none;
width: 100%;

View File

@ -8,6 +8,7 @@ export const BACKGROUND: Record<string, string> = {
emptyTimeline: '/images/placeholders/background/empty_timeline_bg.png',
loadingMessages: '/images/placeholders/background/loading_messages_bg.png',
loadingAccounts: '/images/placeholders/background/loading_accounts_bg.png',
emptyFunctions: '/images/placeholders/background/empty_functions_bg.png',
emptyInbox: '/images/placeholders/background/empty_inbox_bg.png',
error404: '/images/placeholders/background/404_bg.png',
error500: '/images/placeholders/background/500_bg.png',

View File

@ -11,4 +11,5 @@ export const DARK_BACKGROUND: Record<string, string> = {
error500: '/images/placeholders/dark-background/500_bg.png',
loadingMessages: '/images/placeholders/background/loading_messages_bg.png',
loadingAccounts: '/images/placeholders/background/loading_accounts_bg.png',
emptyFunctions: '/images/placeholders/dark-background/empty_functions_bg.png',
};

View File

@ -11,4 +11,5 @@ export const DARK_MOVING_IMAGE: Record<string, string> = {
error500: '/images/placeholders/dark-moving-image/500.png',
loadingMessages: '/images/placeholders/moving-image/loading_messages.png',
loadingAccounts: '/images/placeholders/moving-image/loading_accounts.png',
emptyFunctions: '/images/placeholders/dark-moving-image/empty_functions.png',
};

View File

@ -8,6 +8,7 @@ export const MOVING_IMAGE: Record<string, string> = {
emptyTimeline: '/images/placeholders/moving-image/empty_timeline.png',
loadingMessages: '/images/placeholders/moving-image/loading_messages.png',
loadingAccounts: '/images/placeholders/moving-image/loading_accounts.png',
emptyFunctions: '/images/placeholders/moving-image/empty_functions.png',
emptyInbox: '/images/placeholders/moving-image/empty_inbox.png',
error404: '/images/placeholders/moving-image/404.png',
error500: '/images/placeholders/moving-image/500.png',

View File

@ -22,6 +22,7 @@ type TabListProps = {
tabListId: string;
tabs: SingleTabProps[];
loading?: boolean;
className?: string;
};
const StyledContainer = styled.div`
@ -34,7 +35,12 @@ const StyledContainer = styled.div`
user-select: none;
`;
export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
export const TabList = ({
tabs,
tabListId,
loading,
className,
}: TabListProps) => {
const initialActiveTabId = tabs.find((tab) => !tab.hide)?.id || '';
const { activeTabIdState, setActiveTabId } = useTabList(tabListId);
@ -48,7 +54,7 @@ export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
return (
<TabListScope tabListScopeId={tabListId}>
<ScrollWrapper hideY>
<StyledContainer>
<StyledContainer className={className}>
{tabs
.filter((tab) => !tab.hide)
.map((tab) => (

View File

@ -5,5 +5,6 @@ export type FeatureFlagKey =
| 'IS_AIRTABLE_INTEGRATION_ENABLED'
| 'IS_POSTGRESQL_INTEGRATION_ENABLED'
| 'IS_STRIPE_INTEGRATION_ENABLED'
| 'IS_FUNCTION_SETTINGS_ENABLED'
| 'IS_COPILOT_ENABLED'
| 'IS_CRM_MIGRATION_ENABLED';