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