mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 11:52:28 +00:00
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:
@@ -3,6 +3,7 @@ module.exports = {
|
||||
documents: [
|
||||
'./src/modules/databases/graphql/**/*.ts',
|
||||
'./src/modules/object-metadata/graphql/*.ts',
|
||||
'./src/modules/settings/serverless-functions/graphql/**/*.ts',
|
||||
'./src/modules/object-record/graphql/*.tsx',
|
||||
'./src/modules/metadata/graphql/*.ts',
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ module.exports = {
|
||||
'!./src/modules/databases/**',
|
||||
'!./src/modules/object-metadata/**',
|
||||
'!./src/modules/object-record/**',
|
||||
'!./src/modules/settings/serverless-functions/**',
|
||||
'./src/modules/**/*.tsx',
|
||||
'./src/modules/**/*.ts',
|
||||
'!./src/**/*.test.tsx',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 <></>;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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) || [],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const settingsServerlessFunctionCodeEditorOutputParamsState =
|
||||
createState<{ language: string; height: number }>({
|
||||
key: 'settingsServerlessFunctionCodeEditorOutputParamsState',
|
||||
defaultValue: { language: 'plaintext', height: 64 },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui';
|
||||
|
||||
export const settingsServerlessFunctionInputState = createState<string>({
|
||||
key: 'settingsServerlessFunctionInputState',
|
||||
defaultValue: '{}',
|
||||
});
|
||||
@@ -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"',
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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%;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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 <></>;
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch",
|
||||
"@langchain/mistralai": "^0.0.24",
|
||||
"@langchain/openai": "^0.1.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nestjs/cache-manager": "^2.2.1",
|
||||
"@nestjs/devtools-integration": "^0.1.6",
|
||||
"@nestjs/graphql": "patch:@nestjs/graphql@12.1.1#./patches/@nestjs+graphql+12.1.1.patch",
|
||||
@@ -34,6 +35,7 @@
|
||||
"lodash.omitby": "^4.6.0",
|
||||
"lodash.uniq": "^4.5.0",
|
||||
"lodash.uniqby": "^4.7.0",
|
||||
"monaco-editor": "^0.50.0",
|
||||
"passport": "^0.7.0",
|
||||
"psl": "^1.9.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
|
||||
@@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKeys.IsFunctionSettingsEnabled,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKeys.IsWorkflowEnabled,
|
||||
workspaceId: workspaceId,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRuntimeColumnToServerlessFunction1721309629608
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddRuntimeColumnToServerlessFunction1721309629608';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."serverlessFunction" ADD "runtime" character varying NOT NULL DEFAULT 'nodejs18.x'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."serverlessFunction" ADD "description" character varying`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."serverlessFunction" ADD "sourceCodeFullPath" character varying NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "runtime"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export enum FeatureFlagKeys {
|
||||
IsMessagingAliasFetchingEnabled = 'IS_MESSAGING_ALIAS_FETCHING_ENABLED',
|
||||
IsGoogleCalendarSyncV2Enabled = 'IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED',
|
||||
IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED',
|
||||
IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED',
|
||||
IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED',
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ export const checkFilePath = (filePath: string): string => {
|
||||
throw new BadRequestException(`Folder ${folder} is not allowed`);
|
||||
}
|
||||
|
||||
if (size && !settings.storage.imageCropSizes[folder]?.includes(size)) {
|
||||
if (
|
||||
folder !== kebabCase(FileFolder.ServerlessFunction) &&
|
||||
size &&
|
||||
!settings.storage.imageCropSizes[folder]?.includes(size)
|
||||
) {
|
||||
throw new BadRequestException(`Size ${size} is not allowed`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface StorageDriver {
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void>;
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable>;
|
||||
write(params: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
|
||||
@@ -42,6 +42,19 @@ export class LocalDriver implements StorageDriver {
|
||||
await fs.writeFile(filePath, params.file);
|
||||
}
|
||||
|
||||
async delete(params: {
|
||||
folderPath: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
const filePath = join(
|
||||
`${this.options.storagePath}/`,
|
||||
params.folderPath,
|
||||
params.filename || '',
|
||||
);
|
||||
|
||||
await fs.rm(filePath, { recursive: true });
|
||||
}
|
||||
|
||||
async read(params: {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
CreateBucketCommandInput,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommandInput,
|
||||
ListObjectsV2Command,
|
||||
NotFound,
|
||||
PutObjectCommand,
|
||||
S3,
|
||||
@@ -53,6 +56,57 @@ export class S3Driver implements StorageDriver {
|
||||
await this.s3Client.send(command);
|
||||
}
|
||||
|
||||
private async emptyS3Directory(folderPath) {
|
||||
const listParams = {
|
||||
Bucket: this.bucketName,
|
||||
Prefix: folderPath,
|
||||
};
|
||||
|
||||
const listObjectsCommand = new ListObjectsV2Command(listParams);
|
||||
const listedObjects = await this.s3Client.send(listObjectsCommand);
|
||||
|
||||
if (listedObjects.Contents?.length === 0) return;
|
||||
|
||||
const deleteParams = {
|
||||
Bucket: this.bucketName,
|
||||
Delete: {
|
||||
Objects: listedObjects.Contents?.map(({ Key }) => {
|
||||
return { Key };
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const deleteObjectCommand = new DeleteObjectsCommand(deleteParams);
|
||||
|
||||
await this.s3Client.send(deleteObjectCommand);
|
||||
|
||||
if (listedObjects.IsTruncated) {
|
||||
await this.emptyS3Directory(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(params: {
|
||||
folderPath: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
if (params.filename) {
|
||||
const deleteCommand = new DeleteObjectCommand({
|
||||
Key: `${params.folderPath}/${params.filename}`,
|
||||
Bucket: this.bucketName,
|
||||
});
|
||||
|
||||
await this.s3Client.send(deleteCommand);
|
||||
} else {
|
||||
await this.emptyS3Directory(params.folderPath);
|
||||
const deleteEmptyFolderCommand = new DeleteObjectCommand({
|
||||
Key: `${params.folderPath}`,
|
||||
Bucket: this.bucketName,
|
||||
});
|
||||
|
||||
await this.s3Client.send(deleteEmptyFolderCommand);
|
||||
}
|
||||
}
|
||||
|
||||
async read(params: {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
|
||||
@@ -10,6 +10,10 @@ import { StorageDriver } from './drivers/interfaces/storage-driver.interface';
|
||||
export class FileStorageService implements StorageDriver {
|
||||
constructor(@Inject(STORAGE_DRIVER) private driver: StorageDriver) {}
|
||||
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void> {
|
||||
return this.driver.delete(params);
|
||||
}
|
||||
|
||||
write(params: {
|
||||
file: string | Buffer | Uint8Array;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
|
||||
export interface ServerlessDriver {
|
||||
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
|
||||
build(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
|
||||
execute(
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
Lambda,
|
||||
LambdaClientConfig,
|
||||
InvokeCommand,
|
||||
GetFunctionCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
DeleteFunctionCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand';
|
||||
import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand';
|
||||
|
||||
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
|
||||
|
||||
@@ -42,6 +46,18 @@ export class LambdaDriver
|
||||
this.buildDirectoryManagerService = options.buildDirectoryManagerService;
|
||||
}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity) {
|
||||
try {
|
||||
const deleteFunctionCommand = new DeleteFunctionCommand({
|
||||
FunctionName: serverlessFunction.id,
|
||||
});
|
||||
|
||||
await this.lambdaClient.send(deleteFunctionCommand);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
const javascriptCode = await this.getCompiledCode(
|
||||
serverlessFunction,
|
||||
@@ -59,21 +75,44 @@ export class LambdaDriver
|
||||
|
||||
await createZipFile(sourceTemporaryDir, lambdaZipPath);
|
||||
|
||||
const params: CreateFunctionCommandInput = {
|
||||
Code: {
|
||||
let existingFunction = true;
|
||||
|
||||
try {
|
||||
const getFunctionCommand = new GetFunctionCommand({
|
||||
FunctionName: serverlessFunction.id,
|
||||
});
|
||||
|
||||
await this.lambdaClient.send(getFunctionCommand);
|
||||
} catch {
|
||||
existingFunction = false;
|
||||
}
|
||||
|
||||
if (!existingFunction) {
|
||||
const params: CreateFunctionCommandInput = {
|
||||
Code: {
|
||||
ZipFile: await fs.promises.readFile(lambdaZipPath),
|
||||
},
|
||||
FunctionName: serverlessFunction.id,
|
||||
Handler: lambdaHandler,
|
||||
Role: this.lambdaRole,
|
||||
Runtime: serverlessFunction.runtime,
|
||||
Description: 'Lambda function to run user script',
|
||||
Timeout: 900,
|
||||
};
|
||||
|
||||
const command = new CreateFunctionCommand(params);
|
||||
|
||||
await this.lambdaClient.send(command);
|
||||
} else {
|
||||
const params: UpdateFunctionCodeCommandInput = {
|
||||
ZipFile: await fs.promises.readFile(lambdaZipPath),
|
||||
},
|
||||
FunctionName: serverlessFunction.id,
|
||||
Handler: lambdaHandler,
|
||||
Role: this.lambdaRole,
|
||||
Runtime: 'nodejs18.x',
|
||||
Description: 'Lambda function to run user script',
|
||||
Timeout: 900,
|
||||
};
|
||||
FunctionName: serverlessFunction.id,
|
||||
};
|
||||
|
||||
const command = new CreateFunctionCommand(params);
|
||||
const command = new UpdateFunctionCodeCommand(params);
|
||||
|
||||
await this.lambdaClient.send(command);
|
||||
await this.lambdaClient.send(command);
|
||||
}
|
||||
|
||||
await this.buildDirectoryManagerService.clean();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export class LocalDriver
|
||||
this.fileStorageService = options.fileStorageService;
|
||||
}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity) {
|
||||
await this.fileStorageService.delete({
|
||||
folderPath: this.getFolderPath(serverlessFunction),
|
||||
});
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
const javascriptCode = await this.getCompiledCode(
|
||||
serverlessFunction,
|
||||
@@ -57,8 +63,16 @@ export class LocalDriver
|
||||
const modifiedContent = `
|
||||
process.on('message', async (message) => {
|
||||
const { event, context } = message;
|
||||
const result = await handler(event, context);
|
||||
process.send(result);
|
||||
try {
|
||||
const result = await handler(event, context);
|
||||
process.send(result);
|
||||
} catch (error) {
|
||||
process.send({
|
||||
errorType: error.name,
|
||||
errorMessage: error.message,
|
||||
stackTrace: error.stack.split('\\n').filter((line) => line.trim() !== ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
${fileContent}
|
||||
@@ -67,7 +81,7 @@ export class LocalDriver
|
||||
await fs.writeFile(tmpFilePath, modifiedContent);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = fork(tmpFilePath);
|
||||
const child = fork(tmpFilePath, { silent: true });
|
||||
|
||||
child.on('message', (message: object) => {
|
||||
resolve(message);
|
||||
@@ -75,6 +89,32 @@ export class LocalDriver
|
||||
fs.unlink(tmpFilePath);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const stackTrace = data
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '');
|
||||
const errorTrace = stackTrace.filter((line) =>
|
||||
line.includes('Error: '),
|
||||
)?.[0];
|
||||
|
||||
let errorType = 'Unknown';
|
||||
let errorMessage = '';
|
||||
|
||||
if (errorTrace) {
|
||||
errorType = errorTrace.split(':')[0];
|
||||
errorMessage = errorTrace.split(': ')[1];
|
||||
}
|
||||
|
||||
resolve({
|
||||
errorType,
|
||||
errorMessage,
|
||||
stackTrace: stackTrace,
|
||||
});
|
||||
child.kill();
|
||||
fs.unlink(tmpFilePath);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
child.kill();
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
|
||||
export class ServerlessService implements ServerlessDriver {
|
||||
constructor(@Inject(SERVERLESS_DRIVER) private driver: ServerlessDriver) {}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity): Promise<void> {
|
||||
return this.driver.delete(serverlessFunction);
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity): Promise<void> {
|
||||
return this.driver.build(serverlessFunction);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionFromFileInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionInput extends CreateServerlessFunctionFromFileInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
code: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ID, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
@InputType()
|
||||
export class DeleteServerlessFunctionInput {
|
||||
@IDField(() => ID, { description: 'The id of the function.' })
|
||||
id!: string;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class ExecuteServerlessFunctionInput {
|
||||
@Field({ description: 'Name of the serverless function to execute' })
|
||||
@Field(() => UUIDScalarType, {
|
||||
description: 'Id of the serverless function to execute',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@Field(() => graphqlTypeJson, {
|
||||
description: 'Payload in JSON format',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IsObject } from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
@ObjectType('ServerlessFunctionExecutionResult')
|
||||
export class ServerlessFunctionExecutionResultDTO {
|
||||
export class ServerlessFunctionExecutionResultDto {
|
||||
@IsObject()
|
||||
@Field(() => graphqlTypeJson, {
|
||||
description: 'Execution result in JSON format',
|
||||
@@ -26,7 +26,7 @@ registerEnumType(ServerlessFunctionSyncStatus, {
|
||||
description: 'SyncStatus of the serverlessFunction',
|
||||
});
|
||||
|
||||
@ObjectType('serverlessFunction')
|
||||
@ObjectType('ServerlessFunction')
|
||||
@Authorize({
|
||||
authorize: (context: any) => ({
|
||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||
@@ -47,11 +47,25 @@ export class ServerlessFunctionDto {
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
sourceCodeHash: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
sourceCodeFullPath: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
runtime: string;
|
||||
|
||||
@IsEnum(ServerlessFunctionSyncStatus)
|
||||
@IsNotEmpty()
|
||||
@Field(() => ServerlessFunctionSyncStatus)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpdateServerlessFunctionInput {
|
||||
@Field(() => UUIDScalarType, {
|
||||
description: 'Id of the serverless function to execute',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
code: string;
|
||||
}
|
||||
@@ -12,6 +12,10 @@ export enum ServerlessFunctionSyncStatus {
|
||||
READY = 'READY',
|
||||
}
|
||||
|
||||
export enum ServerlessFunctionRuntime {
|
||||
NODE18 = 'nodejs18.x',
|
||||
}
|
||||
|
||||
@Entity('serverlessFunction')
|
||||
@Unique('IndexOnNameAndWorkspaceIdUnique', ['name', 'workspaceId'])
|
||||
export class ServerlessFunctionEntity {
|
||||
@@ -21,9 +25,18 @@ export class ServerlessFunctionEntity {
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
sourceCodeHash: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
sourceCodeFullPath: string;
|
||||
|
||||
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
|
||||
runtime: ServerlessFunctionRuntime;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
default: ServerlessFunctionSyncStatus.NOT_READY,
|
||||
|
||||
@@ -9,6 +9,7 @@ export class ServerlessFunctionException extends CustomException {
|
||||
|
||||
export enum ServerlessFunctionExceptionCode {
|
||||
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
|
||||
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
|
||||
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
|
||||
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
NestjsQueryGraphQLModule,
|
||||
@@ -14,6 +15,7 @@ import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverle
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
||||
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +26,7 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
|
||||
[ServerlessFunctionEntity],
|
||||
'metadata',
|
||||
),
|
||||
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
|
||||
],
|
||||
services: [ServerlessFunctionService],
|
||||
resolvers: [
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FileUpload, GraphQLUpload } from 'graphql-upload';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
||||
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
|
||||
@@ -9,45 +11,136 @@ import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serv
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
import { ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o';
|
||||
import { ServerlessFunctionExecutionResultDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
|
||||
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
|
||||
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
|
||||
import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input';
|
||||
import {
|
||||
FeatureFlagEntity,
|
||||
FeatureFlagKeys,
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
ServerlessFunctionException,
|
||||
ServerlessFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver()
|
||||
export class ServerlessFunctionResolver {
|
||||
constructor(
|
||||
private readonly serverlessFunctionService: ServerlessFunctionService,
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
) {}
|
||||
|
||||
async checkFeatureFlag(workspaceId: string) {
|
||||
const isFunctionSettingsEnabled =
|
||||
await this.featureFlagRepository.findOneBy({
|
||||
workspaceId,
|
||||
key: FeatureFlagKeys.IsFunctionSettingsEnabled,
|
||||
value: true,
|
||||
});
|
||||
|
||||
if (!isFunctionSettingsEnabled) {
|
||||
throw new ServerlessFunctionException(
|
||||
`IS_FUNCTION_SETTINGS_ENABLED feature flag is not set to true for this workspace`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunction(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
file: FileUpload,
|
||||
@Args('name', { type: () => String }) name: string,
|
||||
async deleteOneServerlessFunction(
|
||||
@Args('input') input: DeleteServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
return await this.serverlessFunctionService.createOne(
|
||||
name,
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.deleteOneServerlessFunction(
|
||||
input.id,
|
||||
workspaceId,
|
||||
file,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionExecutionResultDTO)
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async updateOneServerlessFunction(
|
||||
@Args('input')
|
||||
input: UpdateServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.updateOneServerlessFunction(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunction(
|
||||
@Args('input')
|
||||
input: CreateServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.createOneServerlessFunction(
|
||||
{
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
},
|
||||
input.code,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunctionFromFile(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
file: FileUpload,
|
||||
@Args('input')
|
||||
input: CreateServerlessFunctionFromFileInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.createOneServerlessFunction(
|
||||
input,
|
||||
file,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionExecutionResultDto)
|
||||
async executeOneServerlessFunction(
|
||||
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
const { name, payload } = executeServerlessFunctionInput;
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
const { id, payload } = executeServerlessFunctionInput;
|
||||
|
||||
return {
|
||||
result: await this.serverlessFunctionService.executeOne(
|
||||
name,
|
||||
id,
|
||||
workspaceId,
|
||||
payload,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from 'path';
|
||||
|
||||
import { FileUpload } from 'graphql-upload';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
@@ -21,24 +23,28 @@ import { readFileContent } from 'src/engine/integrations/file-storage/utils/read
|
||||
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
|
||||
import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
|
||||
|
||||
@Injectable()
|
||||
export class ServerlessFunctionService {
|
||||
export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> {
|
||||
constructor(
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly serverlessService: ServerlessService,
|
||||
@InjectRepository(ServerlessFunctionEntity, 'metadata')
|
||||
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
|
||||
) {}
|
||||
) {
|
||||
super(serverlessFunctionRepository);
|
||||
}
|
||||
|
||||
async executeOne(
|
||||
name: string,
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
payload: object | undefined = undefined,
|
||||
) {
|
||||
const functionToExecute = await this.serverlessFunctionRepository.findOne({
|
||||
where: {
|
||||
name,
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
@@ -62,14 +68,82 @@ export class ServerlessFunctionService {
|
||||
return this.serverlessService.execute(functionToExecute, payload);
|
||||
}
|
||||
|
||||
async createOne(
|
||||
name: string,
|
||||
async deleteOneServerlessFunction(id: string, workspaceId: string) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!existingServerlessFunction) {
|
||||
throw new ServerlessFunctionException(
|
||||
`Function does not exist`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await super.deleteOne(id);
|
||||
|
||||
await this.serverlessService.delete(existingServerlessFunction);
|
||||
|
||||
return existingServerlessFunction;
|
||||
}
|
||||
|
||||
async updateOneServerlessFunction(
|
||||
serverlessFunctionInput: UpdateServerlessFunctionInput,
|
||||
workspaceId: string,
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { name, workspaceId },
|
||||
where: { id: serverlessFunctionInput.id, workspaceId },
|
||||
});
|
||||
|
||||
if (!existingServerlessFunction) {
|
||||
throw new ServerlessFunctionException(
|
||||
`Function does not exist`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const codeHasChanged =
|
||||
serverlessFunctionCreateHash(serverlessFunctionInput.code) !==
|
||||
existingServerlessFunction.sourceCodeHash;
|
||||
|
||||
await super.updateOne(existingServerlessFunction.id, {
|
||||
name: serverlessFunctionInput.name,
|
||||
description: serverlessFunctionInput.description,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(
|
||||
serverlessFunctionInput.code,
|
||||
),
|
||||
});
|
||||
|
||||
if (codeHasChanged) {
|
||||
const fileFolder = join(
|
||||
FileFolder.ServerlessFunction,
|
||||
workspaceId,
|
||||
existingServerlessFunction.id,
|
||||
);
|
||||
|
||||
await this.fileStorageService.write({
|
||||
file: serverlessFunctionInput.code,
|
||||
name: SOURCE_FILE_NAME,
|
||||
mimeType: undefined,
|
||||
folder: fileFolder,
|
||||
});
|
||||
|
||||
await this.serverlessService.build(existingServerlessFunction);
|
||||
}
|
||||
|
||||
return await this.findById(existingServerlessFunction.id);
|
||||
}
|
||||
|
||||
async createOneServerlessFunction(
|
||||
serverlessFunctionInput: CreateServerlessFunctionFromFileInput,
|
||||
code: FileUpload | string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { name: serverlessFunctionInput.name, workspaceId },
|
||||
});
|
||||
|
||||
if (existingServerlessFunction) {
|
||||
@@ -79,34 +153,44 @@ export class ServerlessFunctionService {
|
||||
);
|
||||
}
|
||||
|
||||
const typescriptCode = await readFileContent(createReadStream());
|
||||
let typescriptCode: string;
|
||||
|
||||
const serverlessFunction = await this.serverlessFunctionRepository.save({
|
||||
name,
|
||||
workspaceId,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
|
||||
});
|
||||
if (typeof code === 'string') {
|
||||
typescriptCode = code;
|
||||
} else {
|
||||
typescriptCode = await readFileContent(code.createReadStream());
|
||||
}
|
||||
|
||||
const serverlessFunctionId = v4();
|
||||
|
||||
const fileFolder = join(
|
||||
FileFolder.ServerlessFunction,
|
||||
workspaceId,
|
||||
serverlessFunction.id,
|
||||
serverlessFunctionId,
|
||||
);
|
||||
|
||||
const sourceCodeFullPath = fileFolder + '/' + SOURCE_FILE_NAME;
|
||||
|
||||
const serverlessFunction = await super.createOne({
|
||||
...serverlessFunctionInput,
|
||||
id: serverlessFunctionId,
|
||||
workspaceId,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
|
||||
sourceCodeFullPath,
|
||||
});
|
||||
|
||||
await this.fileStorageService.write({
|
||||
file: typescriptCode,
|
||||
name: SOURCE_FILE_NAME,
|
||||
mimeType: mimetype,
|
||||
mimeType: undefined,
|
||||
folder: fileFolder,
|
||||
});
|
||||
|
||||
await this.serverlessService.build(serverlessFunction);
|
||||
await this.serverlessFunctionRepository.update(serverlessFunction.id, {
|
||||
await super.updateOne(serverlessFunctionId, {
|
||||
syncStatus: ServerlessFunctionSyncStatus.READY,
|
||||
});
|
||||
|
||||
return await this.serverlessFunctionRepository.findOneByOrFail({
|
||||
id: serverlessFunction.id,
|
||||
});
|
||||
return await this.findById(serverlessFunctionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
|
||||
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
|
||||
throw new ConflictError(error.message);
|
||||
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_READY:
|
||||
case ServerlessFunctionExceptionCode.FEATURE_FLAG_INVALID:
|
||||
throw new ForbiddenError(error.message);
|
||||
default:
|
||||
throw new InternalServerError(error.message);
|
||||
|
||||
@@ -62,6 +62,7 @@ export class AddStandardIdCommand extends CommandRunner {
|
||||
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
|
||||
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
|
||||
IS_FREE_ACCESS_ENABLED: false,
|
||||
IS_FUNCTION_SETTINGS_ENABLED: false,
|
||||
IS_WORKFLOW_ENABLED: false,
|
||||
},
|
||||
);
|
||||
@@ -81,6 +82,7 @@ export class AddStandardIdCommand extends CommandRunner {
|
||||
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
|
||||
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
|
||||
IS_FREE_ACCESS_ENABLED: false,
|
||||
IS_FUNCTION_SETTINGS_ENABLED: false,
|
||||
IS_WORKFLOW_ENABLED: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ export {
|
||||
IconFilter,
|
||||
IconFilterOff,
|
||||
IconFocusCentered,
|
||||
IconFunction,
|
||||
IconForbid,
|
||||
IconGripVertical,
|
||||
IconH1,
|
||||
@@ -127,6 +128,7 @@ export {
|
||||
IconPhone,
|
||||
IconPhoto,
|
||||
IconPilcrow,
|
||||
IconPlayerPlay,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconPresentation,
|
||||
@@ -153,6 +155,7 @@ export {
|
||||
IconTags,
|
||||
IconTarget,
|
||||
IconTargetArrow,
|
||||
IconTestPipe,
|
||||
IconTextSize,
|
||||
IconTimelineEvent,
|
||||
IconTrash,
|
||||
|
||||
10
packages/twenty-ui/src/theme/constants/CodeDark.ts
Normal file
10
packages/twenty-ui/src/theme/constants/CodeDark.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { COLOR } from './Colors';
|
||||
|
||||
export const CODE_DARK = {
|
||||
text: {
|
||||
gray: COLOR.gray50,
|
||||
sky: COLOR.sky50,
|
||||
pink: COLOR.pink50,
|
||||
orange: COLOR.orange40,
|
||||
},
|
||||
};
|
||||
10
packages/twenty-ui/src/theme/constants/CodeLight.ts
Normal file
10
packages/twenty-ui/src/theme/constants/CodeLight.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { COLOR } from './Colors';
|
||||
|
||||
export const CODE_LIGHT = {
|
||||
text: {
|
||||
gray: COLOR.gray50,
|
||||
sky: COLOR.sky50,
|
||||
pink: COLOR.pink50,
|
||||
orange: COLOR.orange40,
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { BOX_SHADOW_DARK } from './BoxShadowDark';
|
||||
import { FONT_DARK } from './FontDark';
|
||||
import { TAG_DARK } from './TagDark';
|
||||
import { THEME_COMMON } from './ThemeCommon';
|
||||
import { CODE_DARK } from './CodeDark';
|
||||
|
||||
export const THEME_DARK: ThemeType = {
|
||||
...THEME_COMMON,
|
||||
@@ -22,5 +23,6 @@ export const THEME_DARK: ThemeType = {
|
||||
name: 'dark',
|
||||
snackBar: SNACK_BAR_DARK,
|
||||
tag: TAG_DARK,
|
||||
code: CODE_DARK,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { BOX_SHADOW_LIGHT } from './BoxShadowLight';
|
||||
import { FONT_LIGHT } from './FontLight';
|
||||
import { TAG_LIGHT } from './TagLight';
|
||||
import { THEME_COMMON } from './ThemeCommon';
|
||||
import { CODE_LIGHT } from './CodeLight';
|
||||
|
||||
export const THEME_LIGHT = {
|
||||
...THEME_COMMON,
|
||||
@@ -21,5 +22,6 @@ export const THEME_LIGHT = {
|
||||
name: 'light',
|
||||
snackBar: SNACK_BAR_LIGHT,
|
||||
tag: TAG_LIGHT,
|
||||
code: CODE_LIGHT,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export * from './constants/BorderDark';
|
||||
export * from './constants/BorderLight';
|
||||
export * from './constants/BoxShadowDark';
|
||||
export * from './constants/BoxShadowLight';
|
||||
export * from './constants/CodeDark';
|
||||
export * from './constants/CodeLight';
|
||||
export * from './constants/Colors';
|
||||
export * from './constants/FontCommon';
|
||||
export * from './constants/FontDark';
|
||||
|
||||
40
yarn.lock
40
yarn.lock
@@ -8671,6 +8671,30 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@monaco-editor/loader@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "@monaco-editor/loader@npm:1.4.0"
|
||||
dependencies:
|
||||
state-local: "npm:^1.0.6"
|
||||
peerDependencies:
|
||||
monaco-editor: ">= 0.21.0 < 1"
|
||||
checksum: 10c0/68938350adf2f42363a801d87f5d00c87d397d4cba7041141af10a9216bd35c85209b4723a26d56cb32e68eef61471deda2a450f8892891118fbdce7fa1d987d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@monaco-editor/react@npm:^4.6.0":
|
||||
version: 4.6.0
|
||||
resolution: "@monaco-editor/react@npm:4.6.0"
|
||||
dependencies:
|
||||
"@monaco-editor/loader": "npm:^1.4.0"
|
||||
peerDependencies:
|
||||
monaco-editor: ">= 0.25.0 < 1"
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/231e9a9b66a530db326f6732de0ebffcce6b79dcfaf4948923d78b9a3d5e2a04b7a06e1f85bbbca45a5ae15c107a124e4c5c46cabadc20a498fb5f2d05f7f379
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@motionone/animation@npm:^10.12.0":
|
||||
version: 10.16.3
|
||||
resolution: "@motionone/animation@npm:10.16.3"
|
||||
@@ -39375,6 +39399,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"monaco-editor@npm:^0.50.0":
|
||||
version: 0.50.0
|
||||
resolution: "monaco-editor@npm:0.50.0"
|
||||
checksum: 10c0/79189c926c2fc1e3a3b9118e80911599bf18108018fe176c7b47a27b4856b544129f9a59c9a5c321d154d6a30a8d9c231684246e9382f4f18329a548d11cb4d6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mri@npm:^1.1.0":
|
||||
version: 1.2.0
|
||||
resolution: "mri@npm:1.2.0"
|
||||
@@ -47161,6 +47192,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"state-local@npm:^1.0.6":
|
||||
version: 1.0.7
|
||||
resolution: "state-local@npm:1.0.7"
|
||||
checksum: 10c0/8dc7daeac71844452fafb514a6d6b6f40d7e2b33df398309ea1c7b3948d6110c57f112b7196500a10c54fdde40291488c52c875575670fb5c819602deca48bd9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"static-browser-server@npm:1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "static-browser-server@npm:1.0.3"
|
||||
@@ -49095,6 +49133,7 @@ __metadata:
|
||||
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga-nestjs-npm-2.1.0-cb509e6047.patch"
|
||||
"@langchain/mistralai": "npm:^0.0.24"
|
||||
"@langchain/openai": "npm:^0.1.3"
|
||||
"@monaco-editor/react": "npm:^4.6.0"
|
||||
"@nestjs/cache-manager": "npm:^2.2.1"
|
||||
"@nestjs/cli": "npm:10.3.0"
|
||||
"@nestjs/devtools-integration": "npm:^0.1.6"
|
||||
@@ -49125,6 +49164,7 @@ __metadata:
|
||||
lodash.omitby: "npm:^4.6.0"
|
||||
lodash.uniq: "npm:^4.5.0"
|
||||
lodash.uniqby: "npm:^4.7.0"
|
||||
monaco-editor: "npm:^0.50.0"
|
||||
passport: "npm:^0.7.0"
|
||||
psl: "npm:^1.9.0"
|
||||
rimraf: "npm:^5.0.5"
|
||||
|
||||
Reference in New Issue
Block a user