Webhook wip (#6371)

This PR introduces the following changes:
- Add the ability to filter webhooks by objectSingularName and Actions
- Refactor SettingsWebhookDetails edition to not use react-hook-form
(which will be deprecated on the whole project)
- Updating the tests with a complex set of mock (we just need to fix ~30
of them now :D)

<img width="1053" alt="image"
src="https://github.com/user-attachments/assets/4e56d972-f129-4789-8d1c-4b5797a8ffd7">
This commit is contained in:
Charles Bochet 2024-08-05 23:14:29 +02:00 committed by GitHub
parent 48f4e41148
commit 8373dfdc26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 14156 additions and 12287 deletions

View File

@ -28,12 +28,15 @@ describe('filterOutInvalidTimelineActivities', () => {
] as TimelineActivity[];
const mainObjectMetadataItem = {
nameSingular: 'objectNameSingular',
namePlural: 'objectNamePlural',
fields: [{ name: 'field1' }, { name: 'field2' }, { name: 'field3' }],
} as ObjectMetadataItem;
const filteredEvents = filterOutInvalidTimelineActivities(
events,
mainObjectMetadataItem,
'objectNameSingular',
[mainObjectMetadataItem],
);
expect(filteredEvents).toEqual([
@ -85,7 +88,8 @@ describe('filterOutInvalidTimelineActivities', () => {
const filteredEvents = filterOutInvalidTimelineActivities(
events,
mainObjectMetadataItem,
'objectNameSingular',
[mainObjectMetadataItem],
);
expect(filteredEvents).toEqual([]);
@ -109,7 +113,8 @@ describe('filterOutInvalidTimelineActivities', () => {
const filteredEvents = filterOutInvalidTimelineActivities(
events,
mainObjectMetadataItem,
'objectNameSingular',
[mainObjectMetadataItem],
);
expect(filteredEvents).toEqual(events);

View File

@ -1,10 +1,10 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import {
phoneFieldDefinition,
ratingfieldDefinition,
ratingFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
@ -28,7 +28,7 @@ const getWrapper =
</FieldContext.Provider>
);
const RatingWrapper = getWrapper(ratingfieldDefinition);
const RatingWrapper = getWrapper(ratingFieldDefinition);
const PhoneWrapper = getWrapper(phoneFieldDefinition);
describe('useIsFieldInputOnly', () => {

View File

@ -1,5 +1,5 @@
import { Controller, useFormContext } from 'react-hook-form';
import omit from 'lodash.omit';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';

View File

@ -42,9 +42,7 @@ describe('getFieldPreviewValue', () => {
// Then
expect(result).toBe(2000);
expect(result).toBe(
getSettingsFieldTypeConfig(FieldMetadataType.Number)?.defaultValue,
);
expect(result).toBe(getSettingsFieldTypeConfig(FieldMetadataType.Number));
});
it('returns null if the field is supported in Settings but has no pre-configured placeholder defaultValue', () => {

View File

@ -4,7 +4,6 @@ import { Link } from 'react-router-dom';
const StyledUndecoratedLink = styled(Link)`
text-decoration: none;
width: 100%;
`;
type UndecoratedLinkProps = {

View File

@ -1,3 +1,9 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconSettings, IconTrash } from 'twenty-ui';
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';
@ -5,68 +11,82 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { Button } from '@/ui/input/button/components/Button';
import { Select } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import { H2Title, IconSettings, IconTrash } from 'twenty-ui';
type SettingsDevelopersWebhooksDetailForm = {
description?: string;
};
const StyledFilterRow = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDevelopersWebhooksDetail = () => {
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
useState(false);
const { objectMetadataItems } = useObjectMetadataItems();
const navigate = useNavigate();
const { webhookId = '' } = useParams();
const { enqueueSnackBar } = useSnackBar();
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] =
useState(false);
const [description, setDescription] = useState<string>('');
const [operationObjectSingularName, setOperationObjectSingularName] =
useState<string>('');
const [operationAction, setOperationAction] = useState('');
const [isDirty, setIsDirty] = useState<boolean>(false);
const { record: webhookData } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
objectRecordId: webhookId,
onCompleted: (data) => {
setDescription(data?.description ?? '');
setOperationObjectSingularName(data?.operation.split('.')[0] ?? '');
setOperationAction(data?.operation.split('.')[1] ?? '');
setIsDirty(false);
},
});
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const deleteWebhook = () => {
deleteOneWebhook(webhookId);
navigate('/settings/developers');
};
const formConfig = useForm<SettingsDevelopersWebhooksDetailForm>();
const { isDirty, isValid, isSubmitting } = formConfig.formState;
const canSave = isDirty && isValid && !isSubmitting;
const fieldTypeOptions = [
{ value: '*', label: 'All Objects' },
...objectMetadataItems.map((item) => ({
value: item.nameSingular,
label: item.labelSingular,
})),
];
const handleSave = async (
formValues: SettingsDevelopersWebhooksDetailForm,
) => {
try {
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: formValues,
});
navigate('/settings/developers');
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
const { updateOneRecord } = useUpdateOneRecord<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const handleSave = async () => {
setIsDirty(false);
await updateOneRecord({
idToUpdate: webhookId,
updateOneRecordInput: {
operation: `${operationObjectSingularName}.${operationAction}`,
description: description,
},
});
navigate('/settings/developers');
};
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}>
<>
{webhookData?.targetUrl && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
@ -78,9 +98,11 @@ export const SettingsDevelopersWebhooksDetail = () => {
]}
/>
<SaveAndCancelButtons
onCancel={() => navigate(`/settings/developers`)}
onSave={formConfig.handleSubmit(handleSave)}
isSaveDisabled={!canSave}
isSaveDisabled={!isDirty}
onCancel={() => {
navigate('/settings/developers');
}}
onSave={handleSave}
/>
</SettingsHeaderContainer>
<Section>
@ -100,20 +122,49 @@ export const SettingsDevelopersWebhooksDetail = () => {
title="Description"
description="An optional description"
/>
<Controller
name="description"
control={formConfig.control}
defaultValue={webhookData?.description ?? null}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder="Write a description"
minRows={4}
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
/>
)}
<TextArea
placeholder="Write a description"
minRows={4}
value={description}
onChange={(description) => {
setDescription(description);
setIsDirty(true);
}}
/>
</Section>
<Section>
<H2Title
title="Filters"
description="Select the event you wish to send to this endpoint"
/>
<StyledFilterRow>
<Select
fullWidth
dropdownId="object-webhook-type-select"
value={operationObjectSingularName}
onChange={(objectSingularName) => {
setIsDirty(true);
setOperationObjectSingularName(objectSingularName);
}}
options={fieldTypeOptions}
/>
<Select
fullWidth
dropdownId="operation-webhook-type-select"
value={operationAction}
onChange={(operationAction) => {
setIsDirty(true);
setOperationAction(operationAction);
}}
options={[
{ value: '*', label: 'All Actions' },
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' },
]}
/>
</StyledFilterRow>
</Section>
<Section>
<H2Title
title="Danger zone"
@ -145,6 +196,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
</SettingsPageContainer>
</SubMenuTopBarContainer>
)}
</FormProvider>
</>
);
};

View File

@ -3,7 +3,6 @@ import {
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
@ -16,6 +15,7 @@ import {
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service';
@ -34,10 +34,8 @@ describe('TokenService', () => {
providers: [
TokenService,
{
provide: JwtService,
useValue: {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
},
provide: JwtWrapperService,
useValue: {},
},
{
provide: JwtAuthStrategy,

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
@ -22,7 +22,7 @@ describe('FileService', () => {
useValue: {},
},
{
provide: TokenService,
provide: JwtWrapperService,
useValue: {},
},
],

View File

@ -89,7 +89,7 @@ export class WorkspaceMigrationRunnerService {
await queryRunner.commitTransaction();
} catch (error) {
this.logger.error('Error executing migration', error);
console.error('Error executing migration', error);
await queryRunner.rollbackTransaction();
throw error;
} finally {