diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
index 31f7dc2b9..61beb1c9b 100644
--- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
+++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
@@ -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={}
/>
- }
- />
}
diff --git a/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts
new file mode 100644
index 000000000..d436ba897
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/developers/hooks/useWebhookUpdateForm.ts
@@ -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({
+ targetUrl: '',
+ description: '',
+ operations: [],
+ secret: '',
+ });
+
+ const [isTargetUrlValid, setIsTargetUrlValid] = useState(true);
+
+ const { updateOneRecord } = useUpdateOneRecord({
+ 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) => {
+ if (isDefined(data?.targetUrl)) {
+ const trimmedUrl = data.targetUrl.trim();
+ setIsTargetUrlValid(isValidUrl(trimmedUrl));
+ }
+ };
+
+ const updateWebhook = async (data: Partial) => {
+ 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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts
index 9d152805e..30863792d 100644
--- a/packages/twenty-front/src/modules/types/SettingsPath.ts
+++ b/packages/twenty-front/src/modules/types/SettingsPath.ts
@@ -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',
diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
index 91a1ad09e..1498316a8 100644
--- a/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/page/components/SubMenuTopBarContainer.tsx
@@ -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 = ({
- {title && {title}}
+ {(title || reserveTitleSpace) && (
+
+ {title}
+
+ )}
{children}
diff --git a/packages/twenty-front/src/pages/settings/developers/SettingsDevelopers.tsx b/packages/twenty-front/src/pages/settings/developers/SettingsDevelopers.tsx
index 78bcfa295..b3858e301 100644
--- a/packages/twenty-front/src/pages/settings/developers/SettingsDevelopers.tsx
+++ b/packages/twenty-front/src/pages/settings/developers/SettingsDevelopers.tsx
@@ -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({
+ objectNameSingular: CoreObjectNameSingular.Webhook,
+ });
+
+ const createNewWebhook = async () => {
+ const newWebhook = await createOneWebhook({
+ targetUrl: '',
+ operations: ['*.*'],
+ });
+ if (!newWebhook) {
+ return;
+ }
+ navigate(SettingsPath.DevelopersNewWebhookDetail, {
+ webhookId: newWebhook.id,
+ });
+ };
return (
{
title={t`Create Webhook`}
size="small"
variant="secondary"
- to={getSettingsPath(SettingsPath.DevelopersNewWebhook)}
+ onClick={createNewWebhook}
/>
diff --git a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksNew.stories.tsx b/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksNew.stories.tsx
deleted file mode 100644
index 430349183..000000000
--- a/packages/twenty-front/src/pages/settings/developers/__stories__/webhooks/SettingsDevelopersWebhooksNew.stories.tsx
+++ /dev/null
@@ -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 = {
- title: 'Pages/Settings/Developers/Webhooks/SettingsDevelopersWebhooksNew',
- component: SettingsDevelopersWebhooksNew,
- decorators: [PageDecorator],
- args: { routePath: '/settings/developers' },
- parameters: {
- msw: graphqlMocks,
- },
-};
-
-export default meta;
-
-export type Story = StoryObj;
-
-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',
- );
- },
-};
diff --git a/packages/twenty-front/src/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookDetail.tsx b/packages/twenty-front/src/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookDetail.tsx
index 489705908..dccedc335 100644
--- a/packages/twenty-front/src/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookDetail.tsx
+++ b/packages/twenty-front/src/pages/settings/developers/webhooks/components/SettingsDevelopersWebhookDetail.tsx
@@ -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('');
- const [operations, setOperations] = useState([
- WEBHOOK_EMPTY_OPERATION,
- ]);
- const [secret, setSecret] = useState('');
- const [isDirty, setIsDirty] = useState(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({
- 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 (
{
},
{ children: t`Webhook` },
]}
- actionButton={
- {
- navigate(SettingsPath.Developers);
- }}
- onSave={handleSave}
- />
- }
>
@@ -240,9 +136,13 @@ export const SettingsDevelopersWebhooksDetail = () => {
/>
{
+ updateWebhook({ targetUrl });
+ }}
+ error={!isTargetUrlValid ? t`Please enter a valid URL` : undefined}
fullWidth
+ autoFocus={formData.targetUrl.trim() === ''}
/>
@@ -253,10 +153,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
@@ -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) => (