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,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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user