refactor: Webhooks (#12487)

Closes #12303

### What’s Changed
- Replace auto‐save with explicit Save / Cancel
Webhook forms now use manual “Save” and “Cancel” buttons instead of the
old debounced auto‐save/update.

- Separate “New” and “Detail” routes
Two dedicated paths `/settings/webhooks/new` for creation and
/`settings/webhooks/:webhookId` for editing, making the UX clearer.

- URL hint & normalization
If a user omits the http(s):// scheme, we display a “Will be saved as
https://…” hint and automatically default to HTTPS.

- Centralized validation with Zod
Introduced a `webhookFormSchema` for client‐side URL, operations, and
secret validation.

- Storybook coverage
Added stories for both “New Webhook” and “Webhook Detail”

- Unit tests
Added tests for the new `useWebhookForm` hook
This commit is contained in:
nitin
2025-06-13 11:07:25 +05:30
committed by GitHub
parent b160871227
commit 3d57c90e04
89 changed files with 3465 additions and 1679 deletions
@@ -0,0 +1,256 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import {
webhookFormSchema,
WebhookFormValues,
} from '@/settings/developers/validation-schemas/webhookFormSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
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';
type UseWebhookFormProps = {
webhookId?: string;
mode: WebhookFormMode;
};
export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
const navigate = useNavigateSettings();
const { enqueueSnackBar } = useSnackBar();
const isCreationMode = mode === WebhookFormMode.Create;
const { createOneRecord } = useCreateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const formConfig = useForm<WebhookFormValues>({
mode: isCreationMode ? 'onSubmit' : 'onTouched',
resolver: zodResolver(webhookFormSchema),
defaultValues: {
targetUrl: '',
description: '',
operations: [
{
object: '*',
action: '*',
},
],
secret: '',
},
});
const addEmptyOperationIfNecessary = (
newOperations: WebhookOperationType[],
): WebhookOperationType[] => {
if (
!newOperations.some((op) => op.object === '*' && op.action === '*') &&
!newOperations.some((op) => op.object === null)
) {
return [...newOperations, WEBHOOK_EMPTY_OPERATION];
}
return newOperations;
};
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 { loading, error } = useFindOneRecord({
skip: isCreationMode,
objectNameSingular: CoreObjectNameSingular.Webhook,
objectRecordId: webhookId || '',
onCompleted: (data) => {
if (!data) return;
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);
formConfig.reset({
targetUrl: data.targetUrl || '',
description: data.description || '',
operations,
secret: data.secret || '',
});
},
});
const { isDirty, isValid, isSubmitting } = formConfig.formState;
const canSave = isCreationMode
? isValid && !isSubmitting
: isDirty && isValid && !isSubmitting;
const handleCreate = async (formValues: WebhookFormValues) => {
try {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
const webhookData = {
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
const createdWebhook = await createOneRecord({
id: v4(),
...webhookData,
});
enqueueSnackBar(
`Webhook ${createdWebhook?.targetUrl} created successfully`,
{
variant: SnackBarVariant.Success,
},
);
navigate(
createdWebhook ? SettingsPath.WebhookDetail : SettingsPath.Webhooks,
createdWebhook ? { webhookId: createdWebhook.id } : undefined,
);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
const handleUpdate = async (formValues: WebhookFormValues) => {
if (!webhookId) {
enqueueSnackBar('Webhook ID is required for updates', {
variant: SnackBarVariant.Error,
});
return;
}
try {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
const webhookData = {
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: webhookData,
});
formConfig.reset(formValues);
enqueueSnackBar(`Webhook ${webhookData.targetUrl} updated successfully`, {
variant: SnackBarVariant.Success,
});
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
const handleSave = isCreationMode ? handleCreate : handleUpdate;
const updateOperation = (
index: number,
field: 'object' | 'action',
value: string | null,
) => {
const currentOperations = formConfig.getValues('operations');
const newOperations = [...currentOperations];
newOperations[index] = {
...newOperations[index],
[field]: value,
};
formConfig.setValue(
'operations',
addEmptyOperationIfNecessary(newOperations),
{ shouldDirty: true, shouldValidate: true },
);
};
const removeOperation = (index: number) => {
const currentOperations = formConfig.getValues('operations');
const newOperations = currentOperations.filter((_, i) => i !== index);
formConfig.setValue(
'operations',
addEmptyOperationIfNecessary(newOperations),
{ shouldDirty: true, shouldValidate: true },
);
};
const deleteWebhook = async () => {
if (!webhookId) {
enqueueSnackBar('Webhook ID is required for deletion', {
variant: SnackBarVariant.Error,
});
return;
}
try {
await deleteOneWebhook(webhookId);
enqueueSnackBar('Webhook deleted successfully', {
variant: SnackBarVariant.Success,
});
navigate(SettingsPath.Webhooks);
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
return {
formConfig,
loading,
canSave,
handleSave,
updateOperation,
removeOperation,
deleteWebhook,
isCreationMode,
error,
};
};