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:
nitin
2025-06-13 11:07:25 +05:30
committed by GitHub
parent b160871227
commit 3d57c90e04
89 changed files with 3465 additions and 1679 deletions

View File

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

View File

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

View File

@ -0,0 +1,4 @@
export enum WebhookFormMode {
Create = 'create',
Edit = 'edit',
}

View File

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

View File

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

View File

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

View File

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