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(() =>
|
const Releases = lazy(() =>
|
||||||
import('~/pages/settings/Releases').then((module) => ({
|
import('~/pages/settings/Releases').then((module) => ({
|
||||||
default: module.Releases,
|
default: module.Releases,
|
||||||
@ -327,10 +319,6 @@ export const SettingsRoutes = ({
|
|||||||
path={SettingsPath.DevelopersApiKeyDetail}
|
path={SettingsPath.DevelopersApiKeyDetail}
|
||||||
element={<SettingsDevelopersApiKeyDetail />}
|
element={<SettingsDevelopersApiKeyDetail />}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path={SettingsPath.DevelopersNewWebhook}
|
|
||||||
element={<SettingsDevelopersWebhooksNew />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path={SettingsPath.DevelopersNewWebhookDetail}
|
path={SettingsPath.DevelopersNewWebhookDetail}
|
||||||
element={<SettingsDevelopersWebhooksDetail />}
|
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',
|
Security = 'security',
|
||||||
NewSSOIdentityProvider = 'security/sso/new',
|
NewSSOIdentityProvider = 'security/sso/new',
|
||||||
EditSSOIdentityProvider = 'security/sso/:identityProviderId',
|
EditSSOIdentityProvider = 'security/sso/:identityProviderId',
|
||||||
DevelopersNewWebhook = 'developers/webhooks/new',
|
|
||||||
DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId',
|
DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId',
|
||||||
Releases = 'releases',
|
Releases = 'releases',
|
||||||
AdminPanel = 'admin-panel',
|
AdminPanel = 'admin-panel',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { PageHeader } from './PageHeader';
|
|||||||
type SubMenuTopBarContainerProps = {
|
type SubMenuTopBarContainerProps = {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
title?: string | JSX.Element;
|
title?: string | JSX.Element;
|
||||||
|
reserveTitleSpace?: boolean;
|
||||||
actionButton?: ReactNode;
|
actionButton?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
links: BreadcrumbProps['links'];
|
links: BreadcrumbProps['links'];
|
||||||
@ -22,17 +23,20 @@ const StyledContainer = styled.div`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledTitle = styled.h3`
|
const StyledTitle = styled.h3<{ reserveTitleSpace?: boolean }>`
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
font-size: ${({ theme }) => theme.font.size.lg};
|
font-size: ${({ theme }) => theme.font.size.lg};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: ${({ theme }) => theme.spacing(8, 8, 2)};
|
margin: ${({ theme }) => theme.spacing(8, 8, 2)};
|
||||||
|
min-height: ${({ theme, reserveTitleSpace }) =>
|
||||||
|
reserveTitleSpace ? theme.spacing(5) : 'none'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const SubMenuTopBarContainer = ({
|
export const SubMenuTopBarContainer = ({
|
||||||
children,
|
children,
|
||||||
title,
|
title,
|
||||||
|
reserveTitleSpace,
|
||||||
actionButton,
|
actionButton,
|
||||||
className,
|
className,
|
||||||
links,
|
links,
|
||||||
@ -44,7 +48,11 @@ export const SubMenuTopBarContainer = ({
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<InformationBannerWrapper />
|
<InformationBannerWrapper />
|
||||||
{title && <StyledTitle>{title}</StyledTitle>}
|
{(title || reserveTitleSpace) && (
|
||||||
|
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
|
||||||
|
{title}
|
||||||
|
</StyledTitle>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import styled from '@emotion/styled';
|
|||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
import { Button, H2Title, IconPlus, MOBILE_VIEWPORT, Section } from 'twenty-ui';
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
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`
|
const StyledButtonContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -28,7 +32,24 @@ const StyledContainer = styled.div<{ isMobile: boolean }>`
|
|||||||
|
|
||||||
export const SettingsDevelopers = () => {
|
export const SettingsDevelopers = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const navigate = useNavigateSettings();
|
||||||
const { t } = useLingui();
|
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 (
|
return (
|
||||||
<SubMenuTopBarContainer
|
<SubMenuTopBarContainer
|
||||||
@ -72,7 +93,7 @@ export const SettingsDevelopers = () => {
|
|||||||
title={t`Create Webhook`}
|
title={t`Create Webhook`}
|
||||||
size="small"
|
size="small"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
to={getSettingsPath(SettingsPath.DevelopersNewWebhook)}
|
onClick={createNewWebhook}
|
||||||
/>
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -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',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -20,13 +20,7 @@ import { AnalyticsGraphEffect } from '@/analytics/components/AnalyticsGraphEffec
|
|||||||
import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext';
|
import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext';
|
||||||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
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 { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
|
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||||
import { TextArea } from '@/ui/input/components/TextArea';
|
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 { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { isDefined } from 'twenty-shared';
|
|
||||||
import { FeatureFlagKey } from '~/generated/graphql';
|
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 { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
import { useWebhookUpdateForm } from '@/settings/developers/hooks/useWebhookUpdateForm';
|
||||||
|
|
||||||
const OBJECT_DROPDOWN_WIDTH = 340;
|
const OBJECT_DROPDOWN_WIDTH = 340;
|
||||||
const ACTION_DROPDOWN_WIDTH = 140;
|
const ACTION_DROPDOWN_WIDTH = 140;
|
||||||
@ -68,54 +59,29 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { objectMetadataItems } = useObjectMetadataItems();
|
const { objectMetadataItems } = useObjectMetadataItems();
|
||||||
|
|
||||||
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
|
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigateSettings();
|
|
||||||
|
const { getIcon } = useIcons();
|
||||||
|
|
||||||
const { webhookId = '' } = useParams();
|
const { webhookId = '' } = useParams();
|
||||||
|
|
||||||
|
const {
|
||||||
|
formData,
|
||||||
|
loading,
|
||||||
|
isTargetUrlValid,
|
||||||
|
updateWebhook,
|
||||||
|
updateOperation,
|
||||||
|
removeOperation,
|
||||||
|
deleteWebhook,
|
||||||
|
} = useWebhookUpdateForm({
|
||||||
|
webhookId,
|
||||||
|
});
|
||||||
|
|
||||||
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
|
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
|
||||||
useState(false);
|
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(
|
const isAnalyticsV2Enabled = useIsFeatureEnabled(
|
||||||
FeatureFlagKey.IsAnalyticsV2Enabled,
|
FeatureFlagKey.IsAnalyticsV2Enabled,
|
||||||
@ -140,69 +106,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
{ value: 'deleted', label: 'Deleted', Icon: IconTrash },
|
{ value: 'deleted', label: 'Deleted', Icon: IconTrash },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
|
if (loading || !formData) {
|
||||||
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) {
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,7 +114,8 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SubMenuTopBarContainer
|
<SubMenuTopBarContainer
|
||||||
title={webhookData.targetUrl}
|
title={formData.targetUrl}
|
||||||
|
reserveTitleSpace
|
||||||
links={[
|
links={[
|
||||||
{
|
{
|
||||||
children: t`Workspace`,
|
children: t`Workspace`,
|
||||||
@ -222,15 +127,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
},
|
},
|
||||||
{ children: t`Webhook` },
|
{ children: t`Webhook` },
|
||||||
]}
|
]}
|
||||||
actionButton={
|
|
||||||
<SaveAndCancelButtons
|
|
||||||
isSaveDisabled={!isDirty}
|
|
||||||
onCancel={() => {
|
|
||||||
navigate(SettingsPath.Developers);
|
|
||||||
}}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<Section>
|
<Section>
|
||||||
@ -240,9 +136,13 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder={t`URL`}
|
placeholder={t`URL`}
|
||||||
value={webhookData.targetUrl}
|
value={formData.targetUrl}
|
||||||
disabled
|
onChange={(targetUrl) => {
|
||||||
|
updateWebhook({ targetUrl });
|
||||||
|
}}
|
||||||
|
error={!isTargetUrlValid ? t`Please enter a valid URL` : undefined}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
autoFocus={formData.targetUrl.trim() === ''}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
@ -253,10 +153,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
<TextArea
|
<TextArea
|
||||||
placeholder={t`Write a description`}
|
placeholder={t`Write a description`}
|
||||||
minRows={4}
|
minRows={4}
|
||||||
value={description}
|
value={formData.description}
|
||||||
onChange={(description) => {
|
onChange={(description) => {
|
||||||
setDescription(description);
|
updateWebhook({ description });
|
||||||
setIsDirty(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
@ -265,7 +164,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
title={t`Filters`}
|
title={t`Filters`}
|
||||||
description={t`Select the events you wish to send to this endpoint`}
|
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}>
|
<StyledFilterRow isMobile={isMobile} key={index}>
|
||||||
<Select
|
<Select
|
||||||
withSearchInput
|
withSearchInput
|
||||||
@ -293,7 +192,7 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
options={actionOptions}
|
options={actionOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{index < operations.length - 1 ? (
|
{index < formData.operations.length - 1 ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => removeOperation(index)}
|
onClick={() => removeOperation(index)}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@ -314,10 +213,9 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
<TextInput
|
<TextInput
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Write a secret"
|
placeholder="Write a secret"
|
||||||
value={secret}
|
value={formData.secret}
|
||||||
onChange={(secret: string) => {
|
onChange={(secret: string) => {
|
||||||
setSecret(secret.trim());
|
updateWebhook({ secret: secret.trim() });
|
||||||
setIsDirty(true);
|
|
||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user