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

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