refactor: Webhooks (#12487)
Closes #12303 ### What’s Changed - Replace auto‐save with explicit Save / Cancel Webhook forms now use manual “Save” and “Cancel” buttons instead of the old debounced auto‐save/update. - Separate “New” and “Detail” routes Two dedicated paths `/settings/webhooks/new` for creation and /`settings/webhooks/:webhookId` for editing, making the UX clearer. - URL hint & normalization If a user omits the http(s):// scheme, we display a “Will be saved as https://…” hint and automatically default to HTTPS. - Centralized validation with Zod Introduced a `webhookFormSchema` for client‐side URL, operations, and secret validation. - Storybook coverage Added stories for both “New Webhook” and “Webhook Detail” - Unit tests Added tests for the new `useWebhookForm` hook
This commit is contained in:
@ -0,0 +1,287 @@
|
||||
import { Controller, FormProvider } from 'react-hook-form';
|
||||
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
|
||||
import { useWebhookForm } from '@/settings/developers/hooks/useWebhookForm';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import styled from '@emotion/styled';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
getUrlHostnameOrThrow,
|
||||
isDefined,
|
||||
isValidUrl,
|
||||
} from 'twenty-shared/utils';
|
||||
import {
|
||||
H2Title,
|
||||
IconBox,
|
||||
IconNorthStar,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
useIcons,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button, IconButton, SelectOption } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||
|
||||
const OBJECT_DROPDOWN_WIDTH = 340;
|
||||
const ACTION_DROPDOWN_WIDTH = 140;
|
||||
const OBJECT_MOBILE_WIDTH = 150;
|
||||
const ACTION_MOBILE_WIDTH = 140;
|
||||
|
||||
const StyledFilterRow = styled.div<{ isMobile: boolean }>`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ isMobile }) =>
|
||||
isMobile
|
||||
? `${OBJECT_MOBILE_WIDTH}px ${ACTION_MOBILE_WIDTH}px auto`
|
||||
: `${OBJECT_DROPDOWN_WIDTH}px ${ACTION_DROPDOWN_WIDTH}px auto`};
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledPlaceholder = styled.div`
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
width: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const DELETE_WEBHOOK_MODAL_ID = 'delete-webhook-modal';
|
||||
|
||||
type SettingsDevelopersWebhookFormProps = {
|
||||
webhookId?: string;
|
||||
mode: WebhookFormMode;
|
||||
};
|
||||
|
||||
export const SettingsDevelopersWebhookForm = ({
|
||||
webhookId,
|
||||
mode,
|
||||
}: SettingsDevelopersWebhookFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigateSettings();
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const isMobile = useIsMobile();
|
||||
const { getIcon } = useIcons();
|
||||
const { openModal } = useModal();
|
||||
const {
|
||||
formConfig,
|
||||
loading,
|
||||
canSave,
|
||||
handleSave,
|
||||
updateOperation,
|
||||
removeOperation,
|
||||
deleteWebhook,
|
||||
isCreationMode,
|
||||
error,
|
||||
} = useWebhookForm({ webhookId, mode });
|
||||
|
||||
const getTitle = () => {
|
||||
if (isCreationMode) {
|
||||
return 'New Webhook';
|
||||
}
|
||||
|
||||
const targetUrl = formConfig.watch('targetUrl');
|
||||
if (isDefined(targetUrl) && isValidUrl(targetUrl.trim())) {
|
||||
return getUrlHostnameOrThrow(targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if ((loading && !isCreationMode) || isDefined(error)) {
|
||||
return <SettingsSkeletonLoader />;
|
||||
}
|
||||
|
||||
const objectOptions: SelectOption<string>[] = [
|
||||
{ label: 'All Objects', value: '*', Icon: IconNorthStar },
|
||||
...objectMetadataItems.map((item) => ({
|
||||
label: item.labelPlural,
|
||||
value: item.nameSingular,
|
||||
Icon: getIcon(item.icon),
|
||||
})),
|
||||
];
|
||||
|
||||
const actionOptions: SelectOption<string>[] = [
|
||||
{ label: 'All', value: '*', Icon: IconNorthStar },
|
||||
{ label: 'Created', value: 'created', Icon: IconPlus },
|
||||
{ label: 'Updated', value: 'updated', Icon: IconBox },
|
||||
{ label: 'Deleted', value: 'deleted', Icon: IconTrash },
|
||||
];
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<FormProvider {...formConfig}>
|
||||
<SubMenuTopBarContainer
|
||||
title={getTitle()}
|
||||
reserveTitleSpace
|
||||
links={[
|
||||
{
|
||||
children: t`Workspace`,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: t`Webhooks`,
|
||||
href: getSettingsPath(SettingsPath.Webhooks),
|
||||
},
|
||||
{ children: isCreationMode ? t`New` : getTitle() },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
isCancelDisabled={formConfig.formState.isSubmitting}
|
||||
onCancel={() => navigate(SettingsPath.Webhooks)}
|
||||
onSave={formConfig.handleSubmit(handleSave)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Endpoint URL`}
|
||||
description={t`We will send POST requests to this endpoint for every new event`}
|
||||
/>
|
||||
<Controller
|
||||
name="targetUrl"
|
||||
control={formConfig.control}
|
||||
render={({
|
||||
field: { onChange, value },
|
||||
fieldState: { error },
|
||||
}) => {
|
||||
return (
|
||||
<TextInput
|
||||
placeholder={t`https://example.com/webhook`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error?.message}
|
||||
fullWidth
|
||||
autoFocus={isCreationMode}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Description`}
|
||||
description={t`An optional description`}
|
||||
/>
|
||||
<Controller
|
||||
name="description"
|
||||
control={formConfig.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextArea
|
||||
placeholder={t`Write a description`}
|
||||
minRows={4}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Filters`}
|
||||
description={t`Select the events you wish to send to this endpoint`}
|
||||
/>
|
||||
<Controller
|
||||
name="operations"
|
||||
control={formConfig.control}
|
||||
render={({ field: { value } }) => (
|
||||
<>
|
||||
{value.map((operation, index) => (
|
||||
<StyledFilterRow key={index} isMobile={isMobile}>
|
||||
<Select
|
||||
dropdownId={`object-webhook-type-select-${index}`}
|
||||
value={operation.object}
|
||||
options={objectOptions}
|
||||
onChange={(newValue) =>
|
||||
updateOperation(index, 'object', newValue)
|
||||
}
|
||||
fullWidth
|
||||
emptyOption={{ label: 'Object', value: null }}
|
||||
/>
|
||||
<Select
|
||||
dropdownId={`operation-webhook-type-select-${index}`}
|
||||
value={operation.action}
|
||||
options={actionOptions}
|
||||
onChange={(newValue) =>
|
||||
updateOperation(index, 'action', newValue)
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
{isDefined(operation.object) ? (
|
||||
<IconButton
|
||||
Icon={IconTrash}
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
onClick={() => removeOperation(index)}
|
||||
/>
|
||||
) : (
|
||||
<StyledPlaceholder />
|
||||
)}
|
||||
</StyledFilterRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Secret`}
|
||||
description={t`Optional secret used to compute the HMAC signature for webhook payloads`}
|
||||
/>
|
||||
<Controller
|
||||
name="secret"
|
||||
control={formConfig.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t`Secret (optional)`}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
{!isCreationMode && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Danger zone`}
|
||||
description={t`Delete this webhook`}
|
||||
/>
|
||||
<Button
|
||||
accent="danger"
|
||||
variant="secondary"
|
||||
title={t`Delete`}
|
||||
Icon={IconTrash}
|
||||
onClick={() => openModal(DELETE_WEBHOOK_MODAL_ID)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
{!isCreationMode && (
|
||||
<ConfirmationModal
|
||||
confirmationPlaceholder={t`yes`}
|
||||
confirmationValue={t`yes`}
|
||||
modalId={DELETE_WEBHOOK_MODAL_ID}
|
||||
title={t`Delete webhook`}
|
||||
subtitle={
|
||||
<Trans>
|
||||
Please type "yes" to confirm you want to delete this webhook.
|
||||
</Trans>
|
||||
}
|
||||
onConfirmClick={deleteWebhook}
|
||||
confirmButtonText={t`Delete`}
|
||||
/>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { expect, within } from '@storybook/test';
|
||||
|
||||
import { SettingsDevelopersWebhookForm } from '@/settings/developers/components/SettingsDevelopersWebhookForm';
|
||||
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
const meta: Meta<typeof SettingsDevelopersWebhookForm> = {
|
||||
title: 'Modules/Settings/Developers/Components/SettingsDevelopersWebhookForm',
|
||||
component: SettingsDevelopersWebhookForm,
|
||||
decorators: [
|
||||
ComponentDecorator,
|
||||
I18nFrontDecorator,
|
||||
RouterDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
],
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsDevelopersWebhookForm>;
|
||||
|
||||
export const CreateMode: Story = {
|
||||
args: {
|
||||
mode: WebhookFormMode.Create,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('New Webhook', undefined, { timeout: 10000 });
|
||||
await canvas.findByPlaceholderText('https://example.com/webhook');
|
||||
await canvas.findByPlaceholderText('Write a description');
|
||||
|
||||
expect(canvas.queryByText('Danger zone')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EditMode: Story = {
|
||||
args: {
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId: '1234',
|
||||
},
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByDisplayValue('https://example.com/webhook', undefined, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await canvas.findByDisplayValue('A Sample Description');
|
||||
|
||||
await canvas.findByText('Danger zone');
|
||||
await canvas.findByText('Delete this webhook');
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export enum WebhookFormMode {
|
||||
Create = 'create',
|
||||
Edit = 'edit',
|
||||
}
|
||||
@ -0,0 +1,352 @@
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
|
||||
import { useWebhookForm } from '../useWebhookForm';
|
||||
|
||||
// Mock dependencies
|
||||
const mockNavigateSettings = jest.fn();
|
||||
const mockEnqueueSnackBar = jest.fn();
|
||||
const mockCreateOneRecord = jest.fn();
|
||||
const mockUpdateOneRecord = jest.fn();
|
||||
const mockDeleteOneRecord = jest.fn();
|
||||
|
||||
jest.mock('~/hooks/useNavigateSettings', () => ({
|
||||
useNavigateSettings: () => mockNavigateSettings,
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({
|
||||
enqueueSnackBar: mockEnqueueSnackBar,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
|
||||
useCreateOneRecord: () => ({
|
||||
createOneRecord: mockCreateOneRecord,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
|
||||
useUpdateOneRecord: () => ({
|
||||
updateOneRecord: mockUpdateOneRecord,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({
|
||||
useDeleteOneRecord: () => ({
|
||||
deleteOneRecord: mockDeleteOneRecord,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
|
||||
useFindOneRecord: () => ({
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider addTypename={false}>
|
||||
<RecoilRoot>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
</MockedProvider>
|
||||
);
|
||||
|
||||
describe('useWebhookForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Create Mode', () => {
|
||||
it('should initialize with default values in create mode', () => {
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.isCreationMode).toBe(true);
|
||||
expect(result.current.formConfig.getValues()).toEqual({
|
||||
targetUrl: '',
|
||||
description: '',
|
||||
operations: [{ object: '*', action: '*' }],
|
||||
secret: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle webhook creation successfully', async () => {
|
||||
const mockCreatedWebhook = {
|
||||
id: 'new-webhook-id',
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
};
|
||||
mockCreateOneRecord.mockResolvedValue(mockCreatedWebhook);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const formData = {
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: [{ object: 'person', action: 'created' }],
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
await result.current.handleSave(formData);
|
||||
|
||||
expect(mockCreateOneRecord).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: ['person.created'],
|
||||
secret: 'test-secret',
|
||||
});
|
||||
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
|
||||
'Webhook https://test.com/webhook created successfully',
|
||||
{ variant: 'success' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle creation errors', async () => {
|
||||
const error = new Error('Creation failed');
|
||||
mockCreateOneRecord.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const formData = {
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: [{ object: 'person', action: 'created' }],
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
await result.current.handleSave(formData);
|
||||
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Creation failed', {
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean and format operations correctly', async () => {
|
||||
mockCreateOneRecord.mockResolvedValue({ id: 'test-id' });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const formData = {
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: [
|
||||
{ object: 'person', action: 'created' },
|
||||
{ object: 'person', action: 'created' },
|
||||
{ object: 'company', action: 'updated' },
|
||||
{ object: null, action: 'test' },
|
||||
],
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
await result.current.handleSave(formData);
|
||||
|
||||
expect(mockCreateOneRecord).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: ['person.created', 'company.updated'],
|
||||
secret: 'test-secret',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
const webhookId = 'test-webhook-id';
|
||||
|
||||
it('should initialize correctly in edit mode', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWebhookForm({
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.isCreationMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle webhook update successfully', async () => {
|
||||
mockUpdateOneRecord.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWebhookForm({
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const formData = {
|
||||
targetUrl: 'https://updated.com/webhook',
|
||||
description: 'Updated webhook',
|
||||
operations: [{ object: 'person', action: 'updated' }],
|
||||
secret: 'updated-secret',
|
||||
};
|
||||
|
||||
await result.current.handleSave(formData);
|
||||
|
||||
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
|
||||
idToUpdate: webhookId,
|
||||
updateOneRecordInput: {
|
||||
targetUrl: 'https://updated.com/webhook',
|
||||
description: 'Updated webhook',
|
||||
operations: ['person.updated'],
|
||||
secret: 'updated-secret',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle update errors', async () => {
|
||||
const error = new Error('Update failed');
|
||||
mockUpdateOneRecord.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWebhookForm({
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const formData = {
|
||||
targetUrl: 'https://test.com/webhook',
|
||||
description: 'Test webhook',
|
||||
operations: [{ object: 'person', action: 'created' }],
|
||||
secret: 'test-secret',
|
||||
};
|
||||
|
||||
await result.current.handleSave(formData);
|
||||
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Update failed', {
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operation Management', () => {
|
||||
it('should update operations correctly', () => {
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
result.current.updateOperation(0, 'object', 'person');
|
||||
|
||||
const operations = result.current.formConfig.getValues('operations');
|
||||
expect(operations[0].object).toBe('person');
|
||||
});
|
||||
|
||||
it('should remove operations correctly', () => {
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
result.current.formConfig.setValue('operations', [
|
||||
{ object: 'person', action: 'created' },
|
||||
{ object: 'company', action: 'updated' },
|
||||
]);
|
||||
|
||||
const initialOperations =
|
||||
result.current.formConfig.getValues('operations');
|
||||
const initialCount = initialOperations.length;
|
||||
|
||||
result.current.removeOperation(0);
|
||||
|
||||
const updatedOperations =
|
||||
result.current.formConfig.getValues('operations');
|
||||
expect(updatedOperations.length).toBeLessThanOrEqual(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Deletion', () => {
|
||||
const webhookId = 'test-webhook-id';
|
||||
|
||||
it('should delete webhook successfully', async () => {
|
||||
mockDeleteOneRecord.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWebhookForm({
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
await result.current.deleteWebhook();
|
||||
|
||||
expect(mockDeleteOneRecord).toHaveBeenCalledWith(webhookId);
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
|
||||
'Webhook deleted successfully',
|
||||
{ variant: 'success' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle deletion without webhookId', async () => {
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
await result.current.deleteWebhook();
|
||||
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
|
||||
'Webhook ID is required for deletion',
|
||||
{ variant: 'error' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle deletion errors', async () => {
|
||||
const error = new Error('Deletion failed');
|
||||
mockDeleteOneRecord.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWebhookForm({
|
||||
mode: WebhookFormMode.Edit,
|
||||
webhookId,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
await result.current.deleteWebhook();
|
||||
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith('Deletion failed', {
|
||||
variant: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should validate canSave property', () => {
|
||||
const { result } = renderHook(
|
||||
() => useWebhookForm({ mode: WebhookFormMode.Create }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// Initially canSave should be false (form is not valid)
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,256 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
|
||||
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
|
||||
import {
|
||||
webhookFormSchema,
|
||||
WebhookFormValues,
|
||||
} from '@/settings/developers/validation-schemas/webhookFormSchema';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
|
||||
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
|
||||
|
||||
type UseWebhookFormProps = {
|
||||
webhookId?: string;
|
||||
mode: WebhookFormMode;
|
||||
};
|
||||
|
||||
export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
|
||||
const navigate = useNavigateSettings();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const isCreationMode = mode === WebhookFormMode.Create;
|
||||
|
||||
const { createOneRecord } = useCreateOneRecord<Webhook>({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const formConfig = useForm<WebhookFormValues>({
|
||||
mode: isCreationMode ? 'onSubmit' : 'onTouched',
|
||||
resolver: zodResolver(webhookFormSchema),
|
||||
defaultValues: {
|
||||
targetUrl: '',
|
||||
description: '',
|
||||
operations: [
|
||||
{
|
||||
object: '*',
|
||||
action: '*',
|
||||
},
|
||||
],
|
||||
secret: '',
|
||||
},
|
||||
});
|
||||
|
||||
const addEmptyOperationIfNecessary = (
|
||||
newOperations: WebhookOperationType[],
|
||||
): WebhookOperationType[] => {
|
||||
if (
|
||||
!newOperations.some((op) => op.object === '*' && op.action === '*') &&
|
||||
!newOperations.some((op) => op.object === null)
|
||||
) {
|
||||
return [...newOperations, WEBHOOK_EMPTY_OPERATION];
|
||||
}
|
||||
return newOperations;
|
||||
};
|
||||
|
||||
const cleanAndFormatOperations = (operations: WebhookOperationType[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
operations
|
||||
.filter((op) => isDefined(op.object) && isDefined(op.action))
|
||||
.map((op) => `${op.object}.${op.action}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const { loading, error } = useFindOneRecord({
|
||||
skip: isCreationMode,
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
objectRecordId: webhookId || '',
|
||||
onCompleted: (data) => {
|
||||
if (!data) return;
|
||||
|
||||
const baseOperations = data?.operations
|
||||
? data.operations.map((op: string) => {
|
||||
const [object, action] = op.split('.');
|
||||
return { object, action };
|
||||
})
|
||||
: data?.operation
|
||||
? [
|
||||
{
|
||||
object: data.operation.split('.')[0],
|
||||
action: data.operation.split('.')[1],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const operations = addEmptyOperationIfNecessary(baseOperations);
|
||||
|
||||
formConfig.reset({
|
||||
targetUrl: data.targetUrl || '',
|
||||
description: data.description || '',
|
||||
operations,
|
||||
secret: data.secret || '',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { isDirty, isValid, isSubmitting } = formConfig.formState;
|
||||
const canSave = isCreationMode
|
||||
? isValid && !isSubmitting
|
||||
: isDirty && isValid && !isSubmitting;
|
||||
|
||||
const handleCreate = async (formValues: WebhookFormValues) => {
|
||||
try {
|
||||
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
|
||||
|
||||
const webhookData = {
|
||||
targetUrl: formValues.targetUrl.trim(),
|
||||
operations: cleanedOperations,
|
||||
description: formValues.description,
|
||||
secret: formValues.secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await createOneRecord({
|
||||
id: v4(),
|
||||
...webhookData,
|
||||
});
|
||||
|
||||
enqueueSnackBar(
|
||||
`Webhook ${createdWebhook?.targetUrl} created successfully`,
|
||||
{
|
||||
variant: SnackBarVariant.Success,
|
||||
},
|
||||
);
|
||||
|
||||
navigate(
|
||||
createdWebhook ? SettingsPath.WebhookDetail : SettingsPath.Webhooks,
|
||||
createdWebhook ? { webhookId: createdWebhook.id } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (formValues: WebhookFormValues) => {
|
||||
if (!webhookId) {
|
||||
enqueueSnackBar('Webhook ID is required for updates', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
|
||||
|
||||
const webhookData = {
|
||||
targetUrl: formValues.targetUrl.trim(),
|
||||
operations: cleanedOperations,
|
||||
description: formValues.description,
|
||||
secret: formValues.secret,
|
||||
};
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: webhookId,
|
||||
updateOneRecordInput: webhookData,
|
||||
});
|
||||
|
||||
formConfig.reset(formValues);
|
||||
|
||||
enqueueSnackBar(`Webhook ${webhookData.targetUrl} updated successfully`, {
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = isCreationMode ? handleCreate : handleUpdate;
|
||||
|
||||
const updateOperation = (
|
||||
index: number,
|
||||
field: 'object' | 'action',
|
||||
value: string | null,
|
||||
) => {
|
||||
const currentOperations = formConfig.getValues('operations');
|
||||
const newOperations = [...currentOperations];
|
||||
|
||||
newOperations[index] = {
|
||||
...newOperations[index],
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
formConfig.setValue(
|
||||
'operations',
|
||||
addEmptyOperationIfNecessary(newOperations),
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const removeOperation = (index: number) => {
|
||||
const currentOperations = formConfig.getValues('operations');
|
||||
const newOperations = currentOperations.filter((_, i) => i !== index);
|
||||
|
||||
formConfig.setValue(
|
||||
'operations',
|
||||
addEmptyOperationIfNecessary(newOperations),
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const deleteWebhook = async () => {
|
||||
if (!webhookId) {
|
||||
enqueueSnackBar('Webhook ID is required for deletion', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteOneWebhook(webhookId);
|
||||
enqueueSnackBar('Webhook deleted successfully', {
|
||||
variant: SnackBarVariant.Success,
|
||||
});
|
||||
|
||||
navigate(SettingsPath.Webhooks);
|
||||
} catch (error) {
|
||||
enqueueSnackBar((error as Error).message, {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formConfig,
|
||||
loading,
|
||||
canSave,
|
||||
handleSave,
|
||||
updateOperation,
|
||||
removeOperation,
|
||||
deleteWebhook,
|
||||
isCreationMode,
|
||||
error,
|
||||
};
|
||||
};
|
||||
@ -1,218 +0,0 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useState } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
|
||||
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
|
||||
import {
|
||||
getUrlHostnameOrThrow,
|
||||
isDefined,
|
||||
isValidUrl,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type WebhookFormData = {
|
||||
targetUrl: string;
|
||||
description?: string;
|
||||
operations: WebhookOperationType[];
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
export const useWebhookUpdateForm = ({
|
||||
webhookId,
|
||||
isCreationMode,
|
||||
}: {
|
||||
webhookId: string;
|
||||
isCreationMode: boolean;
|
||||
}) => {
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const [isCreated, setIsCreated] = useState(!isCreationMode);
|
||||
const [loading, setLoading] = useState(!isCreationMode);
|
||||
const [title, setTitle] = useState(isCreationMode ? 'New Webhook' : '');
|
||||
|
||||
const [formData, setFormData] = useState<WebhookFormData>({
|
||||
targetUrl: '',
|
||||
description: '',
|
||||
operations: [
|
||||
{
|
||||
object: '*',
|
||||
action: '*',
|
||||
},
|
||||
],
|
||||
secret: '',
|
||||
});
|
||||
|
||||
const [isTargetUrlValid, setIsTargetUrlValid] = useState(true);
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const { createOneRecord } = useCreateOneRecord<Webhook>({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const addEmptyOperationIfNecessary = (
|
||||
newOperations: WebhookOperationType[],
|
||||
) => {
|
||||
if (
|
||||
!newOperations.some((op) => op.object === '*' && op.action === '*') &&
|
||||
!newOperations.some((op) => op.object === null)
|
||||
) {
|
||||
return [...newOperations, WEBHOOK_EMPTY_OPERATION];
|
||||
}
|
||||
return newOperations;
|
||||
};
|
||||
|
||||
const cleanAndFormatOperations = (operations: WebhookOperationType[]) => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
operations
|
||||
.filter((op) => isDefined(op.object) && isDefined(op.action))
|
||||
.map((op) => `${op.object}.${op.action}`),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = useDebouncedCallback(async () => {
|
||||
const cleanedOperations = cleanAndFormatOperations(formData.operations);
|
||||
|
||||
const webhookData = {
|
||||
...(isTargetUrlValid && { targetUrl: formData.targetUrl.trim() }),
|
||||
operations: cleanedOperations,
|
||||
description: formData.description,
|
||||
secret: formData.secret,
|
||||
};
|
||||
|
||||
if (!isCreated) {
|
||||
await createOneRecord({ id: webhookId, ...webhookData });
|
||||
setIsCreated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: webhookId,
|
||||
updateOneRecordInput: {
|
||||
...(isTargetUrlValid && { targetUrl: formData.targetUrl.trim() }),
|
||||
operations: cleanedOperations,
|
||||
description: formData.description,
|
||||
secret: formData.secret,
|
||||
},
|
||||
});
|
||||
}, 300);
|
||||
|
||||
const isFormValidAndSetErrors = () => {
|
||||
const { targetUrl } = formData;
|
||||
|
||||
if (isDefined(targetUrl)) {
|
||||
const trimmedUrl = targetUrl.trim();
|
||||
const isValid = isValidUrl(trimmedUrl);
|
||||
|
||||
if (!isValid) {
|
||||
setIsTargetUrlValid(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsTargetUrlValid(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateWebhook = async (data: Partial<WebhookFormData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
|
||||
if (!isFormValidAndSetErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(data?.targetUrl)) {
|
||||
setTitle(getUrlHostnameOrThrow(data.targetUrl) || 'New Webhook');
|
||||
}
|
||||
|
||||
await handleSave();
|
||||
};
|
||||
|
||||
const updateOperation = async (
|
||||
index: number,
|
||||
field: 'object' | 'action',
|
||||
value: string | null,
|
||||
) => {
|
||||
const newOperations = [...formData.operations];
|
||||
|
||||
newOperations[index] = {
|
||||
...newOperations[index],
|
||||
[field]: value,
|
||||
};
|
||||
|
||||
await updateWebhook({
|
||||
operations: addEmptyOperationIfNecessary(newOperations),
|
||||
});
|
||||
};
|
||||
|
||||
const removeOperation = async (index: number) => {
|
||||
const newOperations = formData.operations.filter((_, i) => i !== index);
|
||||
await updateWebhook({
|
||||
operations: addEmptyOperationIfNecessary(newOperations),
|
||||
});
|
||||
};
|
||||
|
||||
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
});
|
||||
|
||||
const deleteWebhook = async () => {
|
||||
await deleteOneWebhook(webhookId);
|
||||
navigate(SettingsPath.Webhooks);
|
||||
};
|
||||
|
||||
useFindOneRecord({
|
||||
skip: isCreationMode,
|
||||
objectNameSingular: CoreObjectNameSingular.Webhook,
|
||||
objectRecordId: webhookId,
|
||||
onCompleted: (data) => {
|
||||
const baseOperations = data?.operations
|
||||
? data.operations.map((op: string) => {
|
||||
const [object, action] = op.split('.');
|
||||
return { object, action };
|
||||
})
|
||||
: data?.operation
|
||||
? [
|
||||
{
|
||||
object: data.operation.split('.')[0],
|
||||
action: data.operation.split('.')[1],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const operations = addEmptyOperationIfNecessary(baseOperations);
|
||||
setFormData({
|
||||
targetUrl: data.targetUrl,
|
||||
description: data.description,
|
||||
operations,
|
||||
secret: data.secret,
|
||||
});
|
||||
if (isValidUrl(data.targetUrl)) {
|
||||
setTitle(getUrlHostnameOrThrow(data.targetUrl));
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
formData,
|
||||
title,
|
||||
isTargetUrlValid,
|
||||
updateWebhook,
|
||||
updateOperation,
|
||||
removeOperation,
|
||||
deleteWebhook,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { isValidUrl } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const webhookFormSchema = z.object({
|
||||
targetUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'URL is required')
|
||||
.refine((url) => isValidUrl(url), {
|
||||
message: 'Please enter a valid URL',
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
operations: z
|
||||
.array(
|
||||
z.object({
|
||||
object: z.string().nullable(),
|
||||
action: z.string(),
|
||||
}),
|
||||
)
|
||||
.min(1, 'At least one operation is required')
|
||||
.refine(
|
||||
(operations) =>
|
||||
operations.some((op) => op.object !== null && op.action !== null),
|
||||
{
|
||||
message: 'At least one complete operation is required',
|
||||
},
|
||||
),
|
||||
secret: z.string().optional(),
|
||||
});
|
||||
|
||||
export type WebhookFormValues = z.infer<typeof webhookFormSchema>;
|
||||
Reference in New Issue
Block a user