Serverless function UI (#6388)

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

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

Feature protected by featureFlag `IS_FUNCTION_SETTINGS_ENABLED`

## Demo


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

View File

@@ -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',
],

View File

@@ -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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import {
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import { IconPlus } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button';
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import styled from '@emotion/styled';
const StyledEmptyFunctionsContainer = styled.div`
height: 60vh;
`;
export const SettingsServerlessFunctionsTableEmpty = () => {
return (
<StyledEmptyFunctionsContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="emptyFunctions" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Add your first Function
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
Add your first Function to get started
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconPlus}
title="New function"
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
/>
</AnimatedPlaceholderEmptyContainer>
</StyledEmptyFunctionsContainer>
);
};

View File

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

View File

@@ -0,0 +1,62 @@
import { H2Title } from 'twenty-ui';
import { Section } from '@/ui/layout/section/components/Section';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { Button } from '@/ui/input/button/components/Button';
import { useState } from 'react';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction';
import { useNavigate } from 'react-router-dom';
export const SettingsServerlessFunctionSettingsTab = ({
formValues,
serverlessFunctionId,
onChange,
}: {
formValues: ServerlessFunctionFormValues;
serverlessFunctionId: string;
onChange: (key: string) => (value: string) => void;
}) => {
const navigate = useNavigate();
const [isDeleteFunctionModalOpen, setIsDeleteFunctionModalOpen] =
useState(false);
const { deleteOneServerlessFunction } = useDeleteOneServerlessFunction();
const deleteFunction = async () => {
await deleteOneServerlessFunction({ id: serverlessFunctionId });
navigate('/settings/functions');
};
return (
<>
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}
/>
<Section>
<H2Title title="Danger zone" description="Delete this function" />
<Button
accent="danger"
onClick={() => setIsDeleteFunctionModalOpen(true)}
variant="secondary"
size="small"
title="Delete function"
/>
</Section>
<ConfirmationModal
confirmationValue={formValues.name}
confirmationPlaceholder={formValues.name}
isOpen={isDeleteFunctionModalOpen}
setIsOpen={setIsDeleteFunctionModalOpen}
title="Function Deletion"
subtitle={
<>
This action cannot be undone. This will permanently delete your
function. <br /> Please type in the function name to confirm.
</>
}
onConfirmClick={deleteFunction}
deleteButtonText="Delete function"
/>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -50,6 +50,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKeys.IsFunctionSettingsEnabled,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKeys.IsWorkflowEnabled,
workspaceId: workspaceId,

View File

@@ -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"`,
);
}
}

View File

@@ -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',
}

View File

@@ -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`);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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)

View File

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

View File

@@ -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,

View File

@@ -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',
}

View File

@@ -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: [

View File

@@ -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,
),

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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,
},
);

View File

@@ -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,

View 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,
},
};

View 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,
},
};

View File

@@ -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,
},
};

View File

@@ -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,
},
};

View File

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

View File

@@ -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"