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,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { SettingsDevelopersWebhookNew } from '~/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookNew';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhookNew',
component: SettingsDevelopersWebhookNew,
decorators: [PageDecorator],
args: {
routePath: '/settings/webhooks/new',
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersWebhookNew>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('New Webhook', undefined, { timeout: 10000 });
await canvas.findByText(
'We will send POST requests to this endpoint for every new event',
);
await canvas.findByPlaceholderText('https://example.com/webhook');
},
};

View File

@ -1,49 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';
import { SettingsDevelopersWebhooksDetail } from '~/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookDetail';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsDevelopersWebhookDetail } from '../../webhooks/components/SettingsDevelopersWebhookDetail';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhooksDetail',
component: SettingsDevelopersWebhooksDetail,
title: 'Pages/Settings/Webhooks/SettingsDevelopersWebhookDetail',
component: SettingsDevelopersWebhookDetail,
decorators: [PageDecorator],
args: {
routePath: '/settings/webhooks/:webhookId',
routeParams: { ':webhookId': '1234' },
},
parameters: {
msw: {
handlers: [
graphql.query('FindOneWebhook', () => {
return HttpResponse.json({
data: {
webhook: {
id: '1',
createdAt: '2021-08-27T12:00:00Z',
targetUrl: 'https://example.com/webhook',
description: 'A Sample Description',
updatedAt: '2021-08-27T12:00:00Z',
operation: 'created',
__typename: 'Webhook',
},
},
});
}),
graphqlMocks.handlers,
],
},
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersWebhooksDetail>;
export type Story = StoryObj<typeof SettingsDevelopersWebhookDetail>;
export const Default: Story = {
play: async ({ canvasElement }) => {
@ -53,6 +33,6 @@ export const Default: Story = {
undefined,
{ timeout: 10000 },
);
await canvas.findByText('Delete this integration');
await canvas.findByText('Delete this webhook');
},
};

View File

@ -1,248 +1,15 @@
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { useWebhookUpdateForm } from '@/settings/developers/hooks/useWebhookUpdateForm';
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 { Trans, useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconBox,
IconNorthStar,
IconPlus,
IconRefresh,
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { Button, IconButton, SelectOption } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
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;
import { SettingsDevelopersWebhookForm } from '@/settings/developers/components/SettingsDevelopersWebhookForm';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
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';
export const SettingsDevelopersWebhooksDetail = () => {
const { t } = useLingui();
const { objectMetadataItems } = useObjectMetadataItems();
const isMobile = useIsMobile();
const { getIcon } = useIcons();
const { webhookId = '' } = useParams();
const [searchParams] = useSearchParams();
const isCreationMode = isDefined(searchParams.get('creationMode'));
const {
formData,
title,
loading,
isTargetUrlValid,
updateWebhook,
updateOperation,
removeOperation,
deleteWebhook,
} = useWebhookUpdateForm({
webhookId,
isCreationMode,
});
const { openModal } = useModal();
const fieldTypeOptions: SelectOption<string>[] = useMemo(
() => [
{ value: '*', label: t`All Objects`, Icon: IconNorthStar },
...objectMetadataItems.map((item) => ({
value: item.nameSingular,
label: item.labelPlural,
Icon: getIcon(item.icon),
})),
],
[objectMetadataItems, getIcon, t],
);
const actionOptions: SelectOption<string>[] = [
{ value: '*', label: t`All Actions`, Icon: IconNorthStar },
{ value: 'created', label: t`Created`, Icon: IconPlus },
{ value: 'updated', label: t`Updated`, Icon: IconRefresh },
{ value: 'deleted', label: t`Deleted`, Icon: IconTrash },
];
if (loading || !formData) {
return <SettingsSkeletonLoader />;
}
const confirmationText = t`yes`;
export const SettingsDevelopersWebhookDetail = () => {
const { webhookId } = useParams();
return (
<SubMenuTopBarContainer
title={title}
reserveTitleSpace
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Webhooks`,
href: getSettingsPath(SettingsPath.Webhooks),
},
{ children: title },
]}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Endpoint URL`}
description={t`We will send POST requests to this endpoint for every new event`}
/>
<TextInput
placeholder={t`URL`}
value={formData.targetUrl}
onChange={(targetUrl) => {
updateWebhook({ targetUrl });
}}
error={!isTargetUrlValid ? t`Please enter a valid URL` : undefined}
fullWidth
autoFocus={formData.targetUrl.trim() === ''}
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`An optional description`}
/>
<TextArea
placeholder={t`Write a description`}
minRows={4}
value={formData.description}
onChange={(description) => {
updateWebhook({ description });
}}
/>
</Section>
<Section>
<H2Title
title={t`Filters`}
description={t`Select the events you wish to send to this endpoint`}
/>
{formData.operations.map((operation, index) => (
<StyledFilterRow isMobile={isMobile} key={index}>
<Select
withSearchInput
dropdownWidth={
isMobile ? OBJECT_MOBILE_WIDTH : OBJECT_DROPDOWN_WIDTH
}
dropdownId={`object-webhook-type-select-${index}`}
value={operation.object}
onChange={(object) => updateOperation(index, 'object', object)}
options={fieldTypeOptions}
emptyOption={{
value: null,
label: t`Choose an object`,
Icon: IconBox,
}}
/>
<Select
dropdownWidth={
isMobile ? ACTION_MOBILE_WIDTH : ACTION_DROPDOWN_WIDTH
}
dropdownId={`operation-webhook-type-select-${index}`}
value={operation.action}
onChange={(action) => updateOperation(index, 'action', action)}
options={actionOptions}
/>
{index < formData.operations.length - 1 ? (
<IconButton
onClick={() => removeOperation(index)}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
) : (
<StyledPlaceholder />
)}
</StyledFilterRow>
))}
</Section>
<Section>
<H2Title
title={t`Secret`}
description={t`Optional: Define a secret string that we will include in every webhook. Use this to authenticate and verify the webhook upon receipt.`}
/>
<TextInput
type="password"
placeholder={t`Write a secret`}
value={formData.secret}
onChange={(secret: string) => {
updateWebhook({ secret: secret.trim() });
}}
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Delete this integration`}
/>
<Button
accent="danger"
variant="secondary"
title={t`Delete`}
Icon={IconTrash}
onClick={() => openModal(DELETE_WEBHOOK_MODAL_ID)}
/>
<ConfirmationModal
confirmationPlaceholder={confirmationText}
confirmationValue={confirmationText}
modalId={DELETE_WEBHOOK_MODAL_ID}
title={t`Delete webhook`}
subtitle={
<Trans>
Please type {confirmationText} to confirm you want to delete
this webhook.
</Trans>
}
onConfirmClick={deleteWebhook}
confirmButtonText={t`Delete webhook`}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<SettingsDevelopersWebhookForm
mode={WebhookFormMode.Edit}
webhookId={webhookId}
/>
);
};

View File

@ -0,0 +1,6 @@
import { SettingsDevelopersWebhookForm } from '@/settings/developers/components/SettingsDevelopersWebhookForm';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
export const SettingsDevelopersWebhookNew = () => {
return <SettingsDevelopersWebhookForm mode={WebhookFormMode.Create} />;
};

View File

@ -9,7 +9,6 @@ import { H2Title, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { v4 } from 'uuid';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledButtonContainer = styled.div`
@ -57,15 +56,7 @@ export const SettingsWebhooks = () => {
title={t`Create Webhook`}
size="small"
variant="secondary"
to={getSettingsPath(
SettingsPath.WebhookDetail,
{
webhookId: v4(),
},
{
creationMode: true,
},
)}
to={getSettingsPath(SettingsPath.NewWebhook)}
/>
</StyledButtonContainer>
</Section>