Simplify webhook creation flow (#10107)

## Before


https://github.com/user-attachments/assets/6bc61970-f0e2-4826-bf95-2b0c9fff5113


## After
- no new webhook form anymore
- autosave on update


https://github.com/user-attachments/assets/c7a304ec-76f5-4c2b-ac5e-7a846bd7f23b

@Bonapara ok for you?
This commit is contained in:
martmull
2025-02-10 16:48:51 +01:00
committed by GitHub
parent f733307517
commit c07f43fcb1
8 changed files with 224 additions and 295 deletions

View File

@ -62,14 +62,6 @@ const SettingsDevelopersApiKeysNew = lazy(() =>
})),
);
const SettingsDevelopersWebhooksNew = lazy(() =>
import(
'~/pages/settings/developers/webhooks/components/SettingsDevelopersWebhooksNew'
).then((module) => ({
default: module.SettingsDevelopersWebhooksNew,
})),
);
const Releases = lazy(() =>
import('~/pages/settings/Releases').then((module) => ({
default: module.Releases,
@ -327,10 +319,6 @@ export const SettingsRoutes = ({
path={SettingsPath.DevelopersApiKeyDetail}
element={<SettingsDevelopersApiKeyDetail />}
/>
<Route
path={SettingsPath.DevelopersNewWebhook}
element={<SettingsDevelopersWebhooksNew />}
/>
<Route
path={SettingsPath.DevelopersNewWebhookDetail}
element={<SettingsDevelopersWebhooksDetail />}

View File

@ -0,0 +1,159 @@
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useState } from 'react';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { useDebouncedCallback } from 'use-debounce';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
import { isDefined } from 'twenty-shared';
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { SettingsPath } from '@/types/SettingsPath';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isValidUrl } from '~/utils/url/isValidUrl';
type WebhookFormData = {
targetUrl: string;
description?: string;
operations: WebhookOperationType[];
secret?: string;
};
export const useWebhookUpdateForm = ({ webhookId }: { webhookId: string }) => {
const navigate = useNavigateSettings();
const [loading, setLoading] = useState(true);
const [formData, setFormData] = useState<WebhookFormData>({
targetUrl: '',
description: '',
operations: [],
secret: '',
});
const [isTargetUrlValid, setIsTargetUrlValid] = useState(true);
const { updateOneRecord } = useUpdateOneRecord<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;
};
useFindOneRecord({
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,
});
setLoading(false);
},
});
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);
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: {
...(isTargetUrlValid && { targetUrl: formData.targetUrl.trim() }),
operations: cleanedOperations,
description: formData.description,
secret: formData.secret,
},
});
}, 300);
const validateData = (data: Partial<WebhookFormData>) => {
if (isDefined(data?.targetUrl)) {
const trimmedUrl = data.targetUrl.trim();
setIsTargetUrlValid(isValidUrl(trimmedUrl));
}
};
const updateWebhook = async (data: Partial<WebhookFormData>) => {
validateData(data);
setFormData((prev) => ({ ...prev, ...data }));
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.Developers);
};
return {
formData,
isTargetUrlValid,
updateWebhook,
updateOperation,
removeOperation,
deleteWebhook,
loading,
};
};

View File

@ -30,7 +30,6 @@ export enum SettingsPath {
Security = 'security',
NewSSOIdentityProvider = 'security/sso/new',
EditSSOIdentityProvider = 'security/sso/:identityProviderId',
DevelopersNewWebhook = 'developers/webhooks/new',
DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId',
Releases = 'releases',
AdminPanel = 'admin-panel',

View File

@ -11,6 +11,7 @@ import { PageHeader } from './PageHeader';
type SubMenuTopBarContainerProps = {
children: JSX.Element | JSX.Element[];
title?: string | JSX.Element;
reserveTitleSpace?: boolean;
actionButton?: ReactNode;
className?: string;
links: BreadcrumbProps['links'];
@ -22,17 +23,20 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledTitle = styled.h3`
const StyledTitle = styled.h3<{ reserveTitleSpace?: boolean }>`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: 1.2;
margin: ${({ theme }) => theme.spacing(8, 8, 2)};
min-height: ${({ theme, reserveTitleSpace }) =>
reserveTitleSpace ? theme.spacing(5) : 'none'};
`;
export const SubMenuTopBarContainer = ({
children,
title,
reserveTitleSpace,
actionButton,
className,
links,
@ -44,7 +48,11 @@ export const SubMenuTopBarContainer = ({
</PageHeader>
<PageBody>
<InformationBannerWrapper />
{title && <StyledTitle>{title}</StyledTitle>}
{(title || reserveTitleSpace) && (
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
{title}
</StyledTitle>
)}
{children}
</PageBody>
</StyledContainer>

View File

@ -9,6 +9,10 @@ import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledButtonContainer = styled.div`
display: flex;
@ -28,7 +32,24 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
export const SettingsDevelopers = () => {
const isMobile = useIsMobile();
const navigate = useNavigateSettings();
const { t } = useLingui();
const { createOneRecord: createOneWebhook } = useCreateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const createNewWebhook = async () => {
const newWebhook = await createOneWebhook({
targetUrl: '',
operations: ['*.*'],
});
if (!newWebhook) {
return;
}
navigate(SettingsPath.DevelopersNewWebhookDetail, {
webhookId: newWebhook.id,
});
};
return (
<SubMenuTopBarContainer
@ -72,7 +93,7 @@ export const SettingsDevelopers = () => {
title={t`Create Webhook`}
size="small"
variant="secondary"
to={getSettingsPath(SettingsPath.DevelopersNewWebhook)}
onClick={createNewWebhook}
/>
</StyledButtonContainer>
</Section>

View File

@ -1,32 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { SettingsDevelopersWebhooksNew } from '~/pages/settings/developers/webhooks/components/SettingsDevelopersWebhooksNew';
import {
PageDecorator,
PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const meta: Meta<PageDecoratorArgs> = {
title: 'Pages/Settings/Developers/Webhooks/SettingsDevelopersWebhooksNew',
component: SettingsDevelopersWebhooksNew,
decorators: [PageDecorator],
args: { routePath: '/settings/developers' },
parameters: {
msw: graphqlMocks,
},
};
export default meta;
export type Story = StoryObj<typeof SettingsDevelopersWebhooksNew>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText(
'We will send POST requests to this endpoint for every new event',
);
},
};

View File

@ -20,13 +20,7 @@ import { AnalyticsGraphEffect } from '@/analytics/components/AnalyticsGraphEffec
import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext';
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { SettingsPath } from '@/types/SettingsPath';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
@ -36,12 +30,9 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated/graphql';
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 { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { useWebhookUpdateForm } from '@/settings/developers/hooks/useWebhookUpdateForm';
const OBJECT_DROPDOWN_WIDTH = 340;
const ACTION_DROPDOWN_WIDTH = 140;
@ -68,54 +59,29 @@ export const SettingsDevelopersWebhooksDetail = () => {
const { t } = useLingui();
const { objectMetadataItems } = useObjectMetadataItems();
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
const isMobile = useIsMobile();
const navigate = useNavigateSettings();
const { getIcon } = useIcons();
const { webhookId = '' } = useParams();
const {
formData,
loading,
isTargetUrlValid,
updateWebhook,
updateOperation,
removeOperation,
deleteWebhook,
} = useWebhookUpdateForm({
webhookId,
});
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
useState(false);
const [description, setDescription] = useState<string>('');
const [operations, setOperations] = useState<WebhookOperationType[]>([
WEBHOOK_EMPTY_OPERATION,
]);
const [secret, setSecret] = useState<string>('');
const [isDirty, setIsDirty] = useState<boolean>(false);
const { getIcon } = useIcons();
const { record: webhookData } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
objectRecordId: webhookId,
onCompleted: (data) => {
setDescription(data?.description ?? '');
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],
},
]
: [];
setOperations(addEmptyOperationIfNecessary(baseOperations));
setSecret(data?.secret ?? '');
setIsDirty(false);
},
});
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const deleteWebhook = () => {
deleteOneWebhook(webhookId);
navigate(SettingsPath.Developers);
};
const isAnalyticsV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsAnalyticsV2Enabled,
@ -140,69 +106,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
{ value: 'deleted', label: 'Deleted', Icon: IconTrash },
];
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
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 = async () => {
const cleanedOperations = cleanAndFormatOperations(operations);
setIsDirty(false);
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: {
operations: cleanedOperations,
description: description,
secret: secret,
},
});
navigate(SettingsPath.Developers);
};
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 updateOperation = (
index: number,
field: 'object' | 'action',
value: string | null,
) => {
const newOperations = [...operations];
newOperations[index] = {
...newOperations[index],
[field]: value,
};
setOperations(addEmptyOperationIfNecessary(newOperations));
setIsDirty(true);
};
const removeOperation = (index: number) => {
const newOperations = operations.filter((_, i) => i !== index);
setOperations(addEmptyOperationIfNecessary(newOperations));
setIsDirty(true);
};
if (!webhookData?.targetUrl) {
if (loading || !formData) {
return <></>;
}
@ -210,7 +114,8 @@ export const SettingsDevelopersWebhooksDetail = () => {
return (
<SubMenuTopBarContainer
title={webhookData.targetUrl}
title={formData.targetUrl}
reserveTitleSpace
links={[
{
children: t`Workspace`,
@ -222,15 +127,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
},
{ children: t`Webhook` },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isDirty}
onCancel={() => {
navigate(SettingsPath.Developers);
}}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
@ -240,9 +136,13 @@ export const SettingsDevelopersWebhooksDetail = () => {
/>
<TextInput
placeholder={t`URL`}
value={webhookData.targetUrl}
disabled
value={formData.targetUrl}
onChange={(targetUrl) => {
updateWebhook({ targetUrl });
}}
error={!isTargetUrlValid ? t`Please enter a valid URL` : undefined}
fullWidth
autoFocus={formData.targetUrl.trim() === ''}
/>
</Section>
<Section>
@ -253,10 +153,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
<TextArea
placeholder={t`Write a description`}
minRows={4}
value={description}
value={formData.description}
onChange={(description) => {
setDescription(description);
setIsDirty(true);
updateWebhook({ description });
}}
/>
</Section>
@ -265,7 +164,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
title={t`Filters`}
description={t`Select the events you wish to send to this endpoint`}
/>
{operations.map((operation, index) => (
{formData.operations.map((operation, index) => (
<StyledFilterRow isMobile={isMobile} key={index}>
<Select
withSearchInput
@ -293,7 +192,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
options={actionOptions}
/>
{index < operations.length - 1 ? (
{index < formData.operations.length - 1 ? (
<IconButton
onClick={() => removeOperation(index)}
variant="tertiary"
@ -314,10 +213,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
<TextInput
type="password"
placeholder="Write a secret"
value={secret}
value={formData.secret}
onChange={(secret: string) => {
setSecret(secret.trim());
setIsDirty(true);
updateWebhook({ secret: secret.trim() });
}}
fullWidth
/>

View File

@ -1,112 +0,0 @@
import { useState } from 'react';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { SettingsPath } from '@/types/SettingsPath';
import { TextInput } from '@/ui/input/components/TextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared';
import { H2Title, Section } from 'twenty-ui';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isValidUrl } from '~/utils/url/isValidUrl';
export const SettingsDevelopersWebhooksNew = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const [formValues, setFormValues] = useState<{
targetUrl: string;
operations: string[];
}>({
targetUrl: '',
operations: ['*.*'],
});
const [isTargetUrlValid, setIsTargetUrlValid] = useState(true);
const { createOneRecord: createOneWebhook } = useCreateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const handleValidate = async (value: string) => {
const trimmedUrl = value.trim();
setIsTargetUrlValid(isValidUrl(trimmedUrl));
};
const handleSave = async () => {
const newWebhook = await createOneWebhook?.(formValues);
if (!newWebhook) {
return;
}
navigate(SettingsPath.DevelopersNewWebhookDetail, {
webhookId: newWebhook.id,
});
};
const canSave =
!!formValues.targetUrl && isTargetUrlValid && isDefined(createOneWebhook);
// TODO: refactor use useScopedHotkeys
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && canSave) {
handleSave();
}
};
const handleChange = (value: string) => {
setFormValues((prevState) => ({
...prevState,
targetUrl: value,
}));
handleValidate(value);
};
return (
<SubMenuTopBarContainer
title="New Webhook"
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Developers`,
href: getSettingsPath(SettingsPath.Developers),
},
{ children: t`New Webhook` },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
onCancel={() => {
navigate(SettingsPath.Developers);
}}
onSave={handleSave}
/>
}
>
<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={formValues.targetUrl}
error={!isTargetUrlValid ? t`Please enter a valid URL` : undefined}
onKeyDown={handleKeyDown}
onChange={handleChange}
fullWidth
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};