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:
@ -0,0 +1,20 @@
|
||||
import { useResetRecoilState } from 'recoil';
|
||||
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
|
||||
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
|
||||
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
|
||||
|
||||
export const ResetServerlessFunctionStatesEffect = () => {
|
||||
const resetSettingsServerlessFunctionInput = useResetRecoilState(
|
||||
settingsServerlessFunctionInputState,
|
||||
);
|
||||
const resetSettingsServerlessFunctionOutput = useResetRecoilState(
|
||||
settingsServerlessFunctionOutputState,
|
||||
);
|
||||
const resetSettingsServerlessFunctionCodeEditorOutputParamsState =
|
||||
useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState);
|
||||
|
||||
resetSettingsServerlessFunctionInput();
|
||||
resetSettingsServerlessFunctionOutput();
|
||||
resetSettingsServerlessFunctionCodeEditorOutputParamsState();
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,156 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { IconCode, IconSettings, IconTestPipe } from 'twenty-ui';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { TabList } from '@/ui/layout/tab/components/TabList';
|
||||
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
|
||||
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
|
||||
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
|
||||
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
|
||||
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTabEffect';
|
||||
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
|
||||
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
|
||||
|
||||
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
|
||||
|
||||
export const SettingsServerlessFunctionDetail = () => {
|
||||
const { serverlessFunctionId = '' } = useParams();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const { activeTabIdState, setActiveTabId } = useTabList(
|
||||
TAB_LIST_COMPONENT_ID,
|
||||
);
|
||||
const activeTabId = useRecoilValue(activeTabIdState);
|
||||
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
|
||||
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
|
||||
const [formValues, setFormValues] =
|
||||
useServerlessFunctionUpdateFormState(serverlessFunctionId);
|
||||
const setSettingsServerlessFunctionOutput = useSetRecoilState(
|
||||
settingsServerlessFunctionOutputState,
|
||||
);
|
||||
const settingsServerlessFunctionInput = useRecoilValue(
|
||||
settingsServerlessFunctionInputState,
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await updateOneServerlessFunction({
|
||||
id: serverlessFunctionId,
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
code: formValues.code,
|
||||
});
|
||||
} catch (err) {
|
||||
enqueueSnackBar(
|
||||
(err as Error)?.message || 'An error occurred while updating function',
|
||||
{
|
||||
variant: SnackBarVariant.Error,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = useDebouncedCallback(save, 500);
|
||||
|
||||
const onChange = (key: string) => {
|
||||
return async (value: string | undefined) => {
|
||||
setFormValues((prevState) => ({
|
||||
...prevState,
|
||||
[key]: value,
|
||||
}));
|
||||
await handleSave();
|
||||
};
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
await handleSave();
|
||||
try {
|
||||
const result = await executeOneServerlessFunction(
|
||||
serverlessFunctionId,
|
||||
JSON.parse(settingsServerlessFunctionInput),
|
||||
);
|
||||
setSettingsServerlessFunctionOutput(
|
||||
JSON.stringify(
|
||||
result?.data?.executeOneServerlessFunction?.result,
|
||||
null,
|
||||
4,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
enqueueSnackBar(
|
||||
(err as Error)?.message || 'An error occurred while executing function',
|
||||
{
|
||||
variant: SnackBarVariant.Error,
|
||||
},
|
||||
);
|
||||
setSettingsServerlessFunctionOutput(JSON.stringify(err, null, 4));
|
||||
}
|
||||
setActiveTabId('test');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'editor', title: 'Editor', Icon: IconCode },
|
||||
{ id: 'test', title: 'Test', Icon: IconTestPipe },
|
||||
{ id: 'settings', title: 'Settings', Icon: IconSettings },
|
||||
];
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
switch (activeTabId) {
|
||||
case 'editor':
|
||||
return (
|
||||
<SettingsServerlessFunctionCodeEditorTab
|
||||
formValues={formValues}
|
||||
handleExecute={handleExecute}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'test':
|
||||
return (
|
||||
<>
|
||||
<SettingsServerlessFunctionTestTabEffect />
|
||||
<SettingsServerlessFunctionTestTab handleExecute={handleExecute} />
|
||||
</>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<SettingsServerlessFunctionSettingsTab
|
||||
formValues={formValues}
|
||||
serverlessFunctionId={serverlessFunctionId}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
formValues.name && (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<SettingsPageContainer>
|
||||
<SettingsHeaderContainer>
|
||||
<Breadcrumb
|
||||
links={[
|
||||
{ children: 'Functions', href: '/settings/functions' },
|
||||
{ children: `${formValues.name}` },
|
||||
]}
|
||||
/>
|
||||
</SettingsHeaderContainer>
|
||||
<Section>
|
||||
<TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={tabs} />
|
||||
</Section>
|
||||
{renderActiveTabContent()}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
)
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { ResetServerlessFunctionStatesEffect } from '~/pages/settings/serverless-functions/ResetServerlessFunctionStatesEffect';
|
||||
import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
|
||||
|
||||
export const SettingsServerlessFunctionDetailWrapper = () => {
|
||||
return (
|
||||
<>
|
||||
<ResetServerlessFunctionStatesEffect />
|
||||
<SettingsServerlessFunctionDetail />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
import { IconPlus, IconSettings } from 'twenty-ui';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
|
||||
import { Section } from '@/ui/layout/section/components/Section';
|
||||
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
|
||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
|
||||
|
||||
export const SettingsServerlessFunctions = () => {
|
||||
return (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<SettingsPageContainer>
|
||||
<SettingsHeaderContainer>
|
||||
<Breadcrumb links={[{ children: 'Functions' }]} />
|
||||
<UndecoratedLink
|
||||
to={getSettingsPagePath(SettingsPath.NewServerlessFunction)}
|
||||
>
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title="New Function"
|
||||
accent="blue"
|
||||
size="small"
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</SettingsHeaderContainer>
|
||||
<Section>
|
||||
<SettingsServerlessFunctionsTable />
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,81 @@
|
||||
import { IconSettings } from 'twenty-ui';
|
||||
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useCreateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useCreateOneServerlessFunction';
|
||||
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
|
||||
import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
|
||||
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
import { useState } from 'react';
|
||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
|
||||
export const SettingsServerlessFunctionsNew = () => {
|
||||
const navigate = useNavigate();
|
||||
const [formValues, setFormValues] = useState<ServerlessFunctionNewFormValues>(
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
);
|
||||
|
||||
const { createOneServerlessFunction } = useCreateOneServerlessFunction();
|
||||
const handleSave = async () => {
|
||||
const newServerlessFunction = await createOneServerlessFunction({
|
||||
name: formValues.name,
|
||||
description: formValues.description,
|
||||
code: DEFAULT_CODE,
|
||||
});
|
||||
|
||||
if (!isDefined(newServerlessFunction?.data)) {
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
getSettingsPagePath(SettingsPath.ServerlessFunctions, {
|
||||
id: newServerlessFunction.data.createOneServerlessFunction.id,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onChange = (key: string) => {
|
||||
return (value: string | undefined) => {
|
||||
setFormValues((prevState) => ({
|
||||
...prevState,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
const canSave = !!formValues.name && createOneServerlessFunction;
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<SettingsPageContainer>
|
||||
<SettingsHeaderContainer>
|
||||
<Breadcrumb
|
||||
links={[
|
||||
{ children: 'Functions', href: '/settings/functions' },
|
||||
{ children: 'New' },
|
||||
]}
|
||||
/>
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
onCancel={() => {
|
||||
navigate('/settings/functions');
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</SettingsHeaderContainer>
|
||||
<SettingsServerlessFunctionNewForm
|
||||
formValues={formValues}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,69 @@
|
||||
import { SettingsServerlessFunctionDetail } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetail';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import {
|
||||
PageDecorator,
|
||||
PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { graphql, http, HttpResponse } from 'msw';
|
||||
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
|
||||
import { DEFAULT_CODE } from '@/ui/input/code-editor/components/CodeEditor';
|
||||
import { within } from '@storybook/test';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
const SOURCE_CODE_FULL_PATH =
|
||||
'serverless-function/20202020-1c25-4d02-bf25-6aeccf7ea419/adb4bd21-7670-4c81-9f74-1fc196fe87ea/source.ts';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionDetail',
|
||||
component: SettingsServerlessFunctionDetail,
|
||||
decorators: [PageDecorator],
|
||||
args: {
|
||||
routePath: '/settings/function/',
|
||||
routeParams: {
|
||||
':serverlessFunctionId': 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
...graphqlMocks.handlers,
|
||||
graphql.query('GetOneServerlessFunction', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
serverlessFunction: {
|
||||
__typename: 'ServerlessFunction',
|
||||
id: 'adb4bd21-7670-4c81-9f74-1fc196fe87ea',
|
||||
name: 'Serverless Function Name',
|
||||
description: '',
|
||||
syncStatus: 'READY',
|
||||
runtime: 'nodejs18.x',
|
||||
sourceCodeFullPath: SOURCE_CODE_FULL_PATH,
|
||||
sourceCodeHash: '42d2734b3dc8a7b45a16803ed7f417bc',
|
||||
updatedAt: '2024-02-24T10:23:10.673Z',
|
||||
createdAt: '2024-02-24T10:23:10.673Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.get(
|
||||
getImageAbsoluteURIOrBase64(SOURCE_CODE_FULL_PATH) || '',
|
||||
() => {
|
||||
return HttpResponse.text(DEFAULT_CODE);
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsServerlessFunctionDetail>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await sleep(100);
|
||||
await canvas.findByText('Code your function');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import {
|
||||
PageDecorator,
|
||||
PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
import { within } from '@storybook/test';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctions',
|
||||
component: SettingsServerlessFunctions,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: '/settings/functions' },
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsServerlessFunctions>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await sleep(100);
|
||||
|
||||
await canvas.findByText('Functions');
|
||||
await canvas.findByText('Add your first Function');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import {
|
||||
PageDecorator,
|
||||
PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/ServerlessFunctions/SettingsServerlessFunctionsNew',
|
||||
component: SettingsServerlessFunctionsNew,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: '/settings/functions/new' },
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsServerlessFunctionsNew>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await sleep(100);
|
||||
await canvas.findByText('Functions');
|
||||
await canvas.findByText('New');
|
||||
|
||||
const input = await canvas.findByPlaceholderText('Name');
|
||||
await userEvent.type(input, 'Function Name');
|
||||
const saveButton = await canvas.findByText('Save');
|
||||
|
||||
await userEvent.click(saveButton);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user