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 = () => {