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

@ -82,7 +82,10 @@ import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace';
import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers';
import { Tasks } from '~/pages/tasks/Tasks';
import { getPageTitleFromPath } from '~/utils/title-utils';
import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions';
import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { SettingsServerlessFunctionDetailWrapper } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper';
const ProvidersThatNeedRouterContext = () => {
const { pathname } = useLocation();
@ -130,6 +133,7 @@ const ProvidersThatNeedRouterContext = () => {
const createRouter = (
isBillingEnabled?: boolean,
isCRMMigrationEnabled?: boolean,
isServerlessFunctionSettingsEnabled?: boolean,
) =>
createBrowserRouter(
createRoutesFromElements(
@ -256,6 +260,22 @@ const createRouter = (
</Routes>
}
/>
{isServerlessFunctionSettingsEnabled && (
<>
<Route
path={SettingsPath.ServerlessFunctions}
element={<SettingsServerlessFunctions />}
/>
<Route
path={SettingsPath.NewServerlessFunction}
element={<SettingsServerlessFunctionsNew />}
/>
<Route
path={SettingsPath.ServerlessFunctionDetail}
element={<SettingsServerlessFunctionDetailWrapper />}
/>
</>
)}
<Route
path={SettingsPath.Integrations}
element={<SettingsIntegrations />}
@ -304,10 +324,17 @@ const createRouter = (
export const App = () => {
const billing = useRecoilValue(billingState);
const isCRMMigrationEnabled = useIsFeatureEnabled('IS_CRM_MIGRATION_ENABLED');
const isServerlessFunctionSettingsEnabled = useIsFeatureEnabled(
'IS_FUNCTION_SETTINGS_ENABLED',
);
return (
<RouterProvider
router={createRouter(billing?.isBillingEnabled, isCRMMigrationEnabled)}
router={createRouter(
billing?.isBillingEnabled,
isCRMMigrationEnabled,
isServerlessFunctionSettingsEnabled,
)}
/>
);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -692,6 +692,19 @@ export type Sentry = {
release?: Maybe<Scalars['String']>;
};
export type ServerlessFunction = {
__typename?: 'ServerlessFunction';
createdAt: Scalars['DateTime'];
description: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
runtime: Scalars['String'];
sourceCodeFullPath: Scalars['String'];
sourceCodeHash: Scalars['String'];
syncStatus: ServerlessFunctionSyncStatus;
updatedAt: Scalars['DateTime'];
};
export type ServerlessFunctionConnection = {
__typename?: 'ServerlessFunctionConnection';
/** Array of edges. */
@ -700,6 +713,14 @@ export type ServerlessFunctionConnection = {
pageInfo: PageInfo;
};
export type ServerlessFunctionEdge = {
__typename?: 'ServerlessFunctionEdge';
/** Cursor for this node. */
cursor: Scalars['ConnectionCursor'];
/** The node containing the ServerlessFunction */
node: ServerlessFunction;
};
export type ServerlessFunctionExecutionResult = {
__typename?: 'ServerlessFunctionExecutionResult';
/** Execution result in JSON format */
@ -1090,24 +1111,6 @@ export type RelationEdge = {
node: Relation;
};
export type ServerlessFunction = {
__typename?: 'serverlessFunction';
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
name: Scalars['String'];
sourceCodeHash: Scalars['String'];
syncStatus: ServerlessFunctionSyncStatus;
updatedAt: Scalars['DateTime'];
};
export type ServerlessFunctionEdge = {
__typename?: 'serverlessFunctionEdge';
/** Cursor for this node. */
cursor: Scalars['ConnectionCursor'];
/** The node containing the serverlessFunction */
node: ServerlessFunction;
};
export type TimelineCalendarEventFragmentFragment = { __typename?: 'TimelineCalendarEvent', id: any, title: string, description: string, location: string, startsAt: string, endsAt: string, isFullDay: boolean, visibility: CalendarChannelVisibility, participants: Array<{ __typename?: 'TimelineCalendarEventParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> };
export type TimelineCalendarEventParticipantFragmentFragment = { __typename?: 'TimelineCalendarEventParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string };

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';

View File

@ -18,6 +18,7 @@ import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGenerateApiKeyTokenMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { Key } from 'ts-key-enum';
export const SettingsDevelopersApiKeysNew = () => {
const [generateOneApiKeyToken] = useGenerateApiKeyTokenMutation();
@ -85,7 +86,7 @@ export const SettingsDevelopersApiKeysNew = () => {
placeholder="E.g. backoffice integration"
value={formValues.name}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === Key.Enter) {
handleSave();
}
}}

View File

@ -0,0 +1,20 @@
import { useResetRecoilState } from 'recoil';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
export const ResetServerlessFunctionStatesEffect = () => {
const resetSettingsServerlessFunctionInput = useResetRecoilState(
settingsServerlessFunctionInputState,
);
const resetSettingsServerlessFunctionOutput = useResetRecoilState(
settingsServerlessFunctionOutputState,
);
const resetSettingsServerlessFunctionCodeEditorOutputParamsState =
useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState);
resetSettingsServerlessFunctionInput();
resetSettingsServerlessFunctionOutput();
resetSettingsServerlessFunctionCodeEditorOutputParamsState();
return <></>;
};

View File

@ -0,0 +1,156 @@
import { useParams } from 'react-router-dom';
import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { useDebouncedCallback } from 'use-debounce';
import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const { activeTabIdState, setActiveTabId } = useTabList(
TAB_LIST_COMPONENT_ID,
);
const activeTabId = useRecoilValue(activeTabIdState);
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const [formValues, setFormValues] =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const setSettingsServerlessFunctionOutput = useSetRecoilState(
settingsServerlessFunctionOutputState,
);
const settingsServerlessFunctionInput = useRecoilValue(
settingsServerlessFunctionInputState,
);
const save = async () => {
try {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleSave = useDebouncedCallback(save, 500);
const onChange = (key: string) => {
return async (value: string | undefined) => {
setFormValues((prevState) => ({
...prevState,
[key]: value,
}));
await handleSave();
};
};
const handleExecute = async () => {
await handleSave();
try {
const result = await executeOneServerlessFunction(
serverlessFunctionId,
JSON.parse(settingsServerlessFunctionInput),
);
setSettingsServerlessFunctionOutput(
JSON.stringify(
result?.data?.executeOneServerlessFunction?.result,
null,
4,
),
);
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while executing function',
{
variant: SnackBarVariant.Error,
},
);
setSettingsServerlessFunctionOutput(JSON.stringify(err, null, 4));
}
setActiveTabId('test');
};
const tabs = [
{ id: 'editor', title: 'Editor', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconTestPipe },
{ id: 'settings', title: 'Settings', Icon: IconSettings },
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'editor':
return (
<SettingsServerlessFunctionCodeEditorTab
formValues={formValues}
handleExecute={handleExecute}
onChange={onChange}
/>
);
case 'test':
return (
<>
<SettingsServerlessFunctionTestTabEffect />
<SettingsServerlessFunctionTestTab handleExecute={handleExecute} />
</>
);
case 'settings':
return (
<SettingsServerlessFunctionSettingsTab
formValues={formValues}
serverlessFunctionId={serverlessFunctionId}
onChange={onChange}
/>
);
default:
return <></>;
}
};
return (
formValues.name && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: `${formValues.name}` },
]}
/>
</SettingsHeaderContainer>
<Section>
<TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={tabs} />
</Section>
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
)
);
};

View File

@ -0,0 +1,11 @@
import { ResetServerlessFunctionStatesEffect } from '~/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect';
import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
export const SettingsServerlessFunctionDetailWrapper = () => {
return (
<>
<ResetServerlessFunctionStatesEffect />
<SettingsServerlessFunctionDetail />
</>
);
};

View File

@ -0,0 +1,36 @@
import { IconPlus, IconSettings } from 'twenty-ui';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Button } from '@/ui/input/button/components/Button';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
export const SettingsServerlessFunctions = () => {
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb links={[{ children: 'Functions' }]} />
<UndecoratedLink
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
>
<Button
Icon={IconPlus}
title="New Function"
accent="blue"
size="small"
/>
</UndecoratedLink>
</SettingsHeaderContainer>
<Section>
<SettingsServerlessFunctionsTable />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,81 @@
import { IconSettings } from 'twenty-ui';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { useNavigate } from 'react-router-dom';
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { isDefined } from '~/utils/isDefined';
import { useState } from 'react';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
export const SettingsServerlessFunctionsNew = () => {
const navigate = useNavigate();
const [formValues, setFormValues] = useState<ServerlessFunctionNewFormValues>(
{
name: '',
description: '',
},
);
const { createOneServerlessFunction } = useCreateOneServerlessFunction();
const handleSave = async () => {
const newServerlessFunction = await createOneServerlessFunction({
name: formValues.name,
description: formValues.description,
code: DEFAULT_CODE,
});
if (!isDefined(newServerlessFunction?.data)) {
return;
}
navigate(
getSettingsPagePath(SettingsPath.ServerlessFunctions, {
id: newServerlessFunction.data.createOneServerlessFunction.id,
}),
);
};
const onChange = (key: string) => {
return (value: string | undefined) => {
setFormValues((prevState) => ({
...prevState,
[key]: value,
}));
};
};
const canSave = !!formValues.name && createOneServerlessFunction;
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<SettingsHeaderContainer>
<Breadcrumb
links={[
{ children: 'Functions', href: '/settings/functions' },
{ children: 'New' },
]}
/>
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate('/settings/functions');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -0,0 +1,69 @@
import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphql, http, HttpResponse } from 'msw';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
import { within } from '@storybook/test';
import { sleep } from '~/utils/sleep';
const SOURCE_CODE_FULL_PATH =
'serverless-function/20202020-1c25-4d02-bf25-6aeccf7ea419/adb4bd21-7670-4c81-9f74-1fc196fe87ea/source.ts';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionDetail',
component: SettingsServerlessFunctionDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/function/',
routeParams: {
':serverlessFunctionId': 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
},
},
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('GetOneServerlessFunction', () => {
return HttpResponse.json({
data: {
serverlessFunction: {
__typename: 'ServerlessFunction',
id: 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
name: 'Serverless Function Name',
description: '',
syncStatus: 'READY',
runtime: 'nodejs18.x',
sourceCodeFullPath: SOURCE_CODE_FULL_PATH,
sourceCodeHash: '42d2734b3dc8a7b45a16803ed7f417bc',
updatedAt: '2024-02-24T10:23:10.673Z',
createdAt: '2024-02-24T10:23:10.673Z',
},
},
});
}),
http.get(
getImageAbsoluteURIOrBase64(SOURCE_CODE_FULL_PATH) || '',
() => {
return HttpResponse.text(DEFAULT_CODE);
},
),
],
},
},
};
export default meta;
export type Story = StoryObj<typeof SettingsServerlessFunctionDetail>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(100);
await canvas.findByText('Code your function');
},
};

View File

@ -0,0 +1,32 @@
import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { sleep } from '~/utils/sleep';
import { within } from '@storybook/test';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctions',
component: SettingsServerlessFunctions,
decorators: [PageDecorator],
args: { routePath: '/settings/functions' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsServerlessFunctions>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(100);
await canvas.findByText('Functions');
await canvas.findByText('Add your first Function');
},
};

View File

@ -0,0 +1,37 @@
import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { Meta, StoryObj } from '@storybook/react';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { userEvent, within } from '@storybook/test';
import { sleep } from '~/utils/sleep';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionsNew',
component: SettingsServerlessFunctionsNew,
decorators: [PageDecorator],
args: { routePath: '/settings/functions/new' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsServerlessFunctionsNew>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await sleep(100);
await canvas.findByText('Functions');
await canvas.findByText('New');
const input = await canvas.findByPlaceholderText('Name');
await userEvent.type(input, 'Function Name');
const saveButton = await canvas.findByText('Save');
await userEvent.click(saveButton);
},
};

View File

@ -0,0 +1,9 @@
import { sortByAscString } from '~/utils/array/sortByAscString';
describe('sortByAscString', () => {
test('should sort properly', () => {
expect(sortByAscString('a', 'b')).toEqual(-1);
expect(sortByAscString('b', 'a')).toEqual(1);
expect(sortByAscString('a', 'a')).toEqual(0);
});
});

View File

@ -0,0 +1,10 @@
import { getFileAbsoluteURI } from '../getFileAbsoluteURI';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
describe('getFileAbsoluteURI', () => {
test('should return absolute uri', () => {
expect(getFileAbsoluteURI('foo')).toEqual(
`${REACT_APP_SERVER_BASE_URL}/files/foo`,
);
});
});

View File

@ -0,0 +1,5 @@
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const getFileAbsoluteURI = (fileUrl?: string) => {
return `${REACT_APP_SERVER_BASE_URL}/files/${fileUrl}`;
};

View File

@ -10,6 +10,7 @@ export enum SettingsPageTitles {
Members = 'Members - Settings',
Developers = 'Developers - Settings',
Integration = 'Integrations - Settings',
ServerlessFunctions = 'Functions - Settings',
General = 'General - Settings',
Default = 'Settings',
}
@ -21,6 +22,7 @@ enum SettingsPathPrefixes {
Objects = `${AppBasePath.Settings}/${SettingsPath.Objects}`,
Members = `${AppBasePath.Settings}/${SettingsPath.WorkspaceMembersPage}`,
Developers = `${AppBasePath.Settings}/${SettingsPath.Developers}`,
ServerlessFunctions = `${AppBasePath.Settings}/${SettingsPath.ServerlessFunctions}`,
Integration = `${AppBasePath.Settings}/${SettingsPath.Integrations}`,
General = `${AppBasePath.Settings}/${SettingsPath.Workspace}`,
}
@ -63,6 +65,8 @@ export const getPageTitleFromPath = (pathname: string): string => {
return SettingsPageTitles.Objects;
case SettingsPathPrefixes.Developers:
return SettingsPageTitles.Developers;
case SettingsPathPrefixes.ServerlessFunctions:
return SettingsPageTitles.ServerlessFunctions;
case SettingsPathPrefixes.Integration:
return SettingsPageTitles.Integration;
case SettingsPathPrefixes.General: