6654 serverless functions add a deploy button disable deploy when autosave (#6715)

- improvements on serverless function behavior (autosave performances,
deploy on execution only)
- add versioning to serverless functions
- add a publish endpoint to create a new version of a serverless
function
  - add deploy and reset to lastVersion button in the settings section:
<img width="736" alt="image"
src="https://github.com/user-attachments/assets/2001f8d2-07a4-4f79-84dd-ec74b6f301d3">
This commit is contained in:
martmull
2024-08-23 12:06:03 +02:00
committed by GitHub
parent 7ca091faa5
commit 6f9aa1e870
42 changed files with 850 additions and 269 deletions

View File

@ -58,7 +58,9 @@ export const useRestoreManyRecords = ({
// TODO: fix optimistic effect
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`;
const findManyQueryName = `FindMany${capitalize(
objectMetadataItem.namePlural,
)}`;
const restoredRecordsResponse = await apolloClient.mutate({
mutation: restoreManyRecordsMutation,

View File

@ -1,4 +1,4 @@
import { H2Title, IconPlayerPlay } from 'twenty-ui';
import { H2Title, IconPlayerPlay, IconGitCommit, IconRestore } 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';
@ -14,13 +14,23 @@ const StyledTabList = styled(TabList)`
export const SettingsServerlessFunctionCodeEditorTab = ({
formValues,
handleExecute,
handlePublish,
handleReset,
resetDisabled,
publishDisabled,
onChange,
setIsCodeValid,
}: {
formValues: ServerlessFunctionFormValues;
handleExecute: () => void;
handlePublish: () => void;
handleReset: () => void;
resetDisabled: boolean;
publishDisabled: boolean;
onChange: (key: string) => (value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
}) => {
const HeaderButton = (
const TestButton = (
<Button
title="Test"
variant="primary"
@ -30,6 +40,26 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
onClick={handleExecute}
/>
);
const PublishButton = (
<Button
title="Publish"
variant="secondary"
size="small"
Icon={IconGitCommit}
onClick={handlePublish}
disabled={publishDisabled}
/>
);
const ResetButton = (
<Button
title="Reset"
variant="secondary"
size="small"
Icon={IconRestore}
onClick={handleReset}
disabled={resetDisabled}
/>
);
const TAB_LIST_COMPONENT_ID = 'serverless-function-editor';
@ -41,7 +71,10 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
);
const Header = (
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[HeaderButton]} />
<CoreEditorHeader
leftNodes={[HeaderTabList]}
rightNodes={[ResetButton, PublishButton, TestButton]}
/>
);
return (
@ -53,6 +86,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
<CodeEditor
value={formValues.code}
onChange={onChange('code')}
setIsCodeValid={setIsCodeValid}
header={Header}
/>
</Section>

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
DEFAULT_OUTPUT_VALUE,
@ -13,11 +13,16 @@ export const SettingsServerlessFunctionTestTabEffect = () => {
const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState(
settingsServerlessFunctionCodeEditorOutputParamsState,
);
if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) {
setSettingsServerlessFunctionCodeEditorOutputParams({
language: 'json',
height: 300,
});
}
useEffect(() => {
if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) {
setSettingsServerlessFunctionCodeEditorOutputParams({
language: 'json',
height: 300,
});
}
}, [
settingsServerlessFunctionOutput.data,
setSettingsServerlessFunctionCodeEditorOutputParams,
]);
return <></>;
};

View File

@ -6,9 +6,9 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
name
description
sourceCodeHash
sourceCodeFullPath
runtime
syncStatus
latestVersion
createdAt
updatedAt
}

View File

@ -1,8 +1,10 @@
import { gql } from '@apollo/client';
export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql`
mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {
executeOneServerlessFunction(id: $id, payload: $payload) {
mutation ExecuteOneServerlessFunction(
$input: ExecuteServerlessFunctionInput!
) {
executeOneServerlessFunction(input: $input) {
data
duration
status

View File

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

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE = gql`
query FindOneServerlessFunctionSourceCode(
$input: GetServerlessFunctionSourceCodeInput!
) {
getServerlessFunctionSourceCode(input: $input)
}
`;

View File

@ -9,6 +9,13 @@ jest.mock(
}),
);
jest.mock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode',
() => ({
useGetOneServerlessFunctionSourceCode: jest.fn(),
}),
);
describe('useServerlessFunctionUpdateFormState', () => {
test('should return a form', () => {
const serverlessFunctionId = 'serverlessFunctionId';
@ -20,6 +27,14 @@ describe('useServerlessFunctionUpdateFormState', () => {
serverlessFunction: { sourceCodeFullPath: undefined },
},
);
const useGetOneServerlessFunctionSourceCodeMock = jest.requireMock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode',
);
useGetOneServerlessFunctionSourceCodeMock.useGetOneServerlessFunctionSourceCode.mockReturnValue(
{
code: 'export const handler = () => {}',
},
);
const { result } = renderHook(
() => useServerlessFunctionUpdateFormState(serverlessFunctionId),
{

View File

@ -1,13 +1,13 @@
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';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
export const useDeleteOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
@ -26,7 +26,9 @@ export const useDeleteOneServerlessFunction = () => {
input,
},
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_SERVERLESS_FUNCTIONS) ?? ''],
refetchQueries: [
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
],
});
};

View File

@ -2,6 +2,7 @@ import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetada
import { ApolloClient, useMutation } from '@apollo/client';
import { EXECUTE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/executeOneServerlessFunction';
import {
ExecuteServerlessFunctionInput,
ExecuteOneServerlessFunctionMutation,
ExecuteOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
@ -16,13 +17,11 @@ export const useExecuteOneServerlessFunction = () => {
});
const executeOneServerlessFunction = async (
id: string,
payload: object = {},
input: ExecuteServerlessFunctionInput,
) => {
return await mutate({
variables: {
id,
payload,
input,
},
});
};

View File

@ -0,0 +1,30 @@
import { useQuery } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
import {
FindOneServerlessFunctionSourceCodeQuery,
FindOneServerlessFunctionSourceCodeQueryVariables,
} from '~/generated-metadata/graphql';
export const useGetOneServerlessFunctionSourceCode = ({
id,
version,
onCompleted,
}: {
id: string;
version: string;
onCompleted?: (data: FindOneServerlessFunctionSourceCodeQuery) => void;
}) => {
const apolloMetadataClient = useApolloMetadataClient();
const { data, loading } = useQuery<
FindOneServerlessFunctionSourceCodeQuery,
FindOneServerlessFunctionSourceCodeQueryVariables
>(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE, {
client: apolloMetadataClient ?? undefined,
variables: {
input: { id, version },
},
onCompleted,
});
return { code: data?.getServerlessFunctionSourceCode, loading };
};

View File

@ -0,0 +1,36 @@
import { ApolloClient, useMutation } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { PUBLISH_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/publishOneServerlessFunction';
import {
PublishServerlessFunctionInput,
PublishOneServerlessFunctionMutation,
PublishOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
export const usePublishOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
PublishOneServerlessFunctionMutation,
PublishOneServerlessFunctionMutationVariables
>(PUBLISH_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const publishOneServerlessFunction = async (
input: PublishServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
awaitRefetchQueries: true,
refetchQueries: [
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
],
});
};
return { publishOneServerlessFunction };
};

View File

@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
import { isDefined } from '~/utils/isDefined';
import { Dispatch, SetStateAction, useState } from 'react';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
export type ServerlessFunctionNewFormValues = {
name: string;
@ -28,30 +28,21 @@ export const useServerlessFunctionUpdateFormState = (
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]);
useGetOneServerlessFunctionSourceCode({
id: serverlessFunctionId,
version: 'draft',
onCompleted: (data: FindOneServerlessFunctionSourceCodeQuery) => {
const newState = {
code: data?.getServerlessFunctionSourceCode || '',
name: serverlessFunction?.name || '',
description: serverlessFunction?.description || '',
};
setFormValues((prevState) => ({
...prevState,
...newState,
}));
},
});
return [formValues, setFormValues];
};

View File

@ -1,5 +1,5 @@
import Editor, { Monaco, EditorProps } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { editor, MarkerSeverity } from 'monaco-editor';
import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -24,17 +24,20 @@ const StyledEditor = styled(Editor)`
type CodeEditorProps = Omit<EditorProps, 'onChange'> & {
header: React.ReactNode;
onChange?: (value: string) => void;
setIsCodeValid?: (isCodeValid: boolean) => void;
};
export const CodeEditor = ({
value = DEFAULT_CODE,
onChange,
setIsCodeValid,
language = 'typescript',
height = 500,
height = 450,
options = undefined,
header,
}: CodeEditorProps) => {
const theme = useTheme();
const handleEditorDidMount = (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
@ -42,6 +45,17 @@ export const CodeEditor = ({
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme');
};
const handleEditorValidation = (markers: editor.IMarker[]) => {
for (const marker of markers) {
if (marker.severity === MarkerSeverity.Error) {
setIsCodeValid?.(false);
return;
}
}
setIsCodeValid?.(true);
};
useEffect(() => {
const style = document.createElement('style');
style.innerHTML = `
@ -63,6 +77,7 @@ export const CodeEditor = ({
value={value}
onMount={handleEditorDidMount}
onChange={(value?: string) => value && onChange?.(value)}
onValidate={handleEditorValidation}
options={{
...options,
overviewRulerLanes: 0,