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

View File

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

View File

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

View File

@ -42,9 +42,7 @@ describe('getFieldPreviewValue', () => {
// Then // Then
expect(result).toBe(2000); expect(result).toBe(2000);
expect(result).toBe( expect(result).toBe(getSettingsFieldTypeConfig(FieldMetadataType.Number));
getSettingsFieldTypeConfig(FieldMetadataType.Number)?.defaultValue,
);
}); });
it('returns null if the field is supported in Settings but has no pre-configured placeholder defaultValue', () => { 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)` const StyledUndecoratedLink = styled(Link)`
text-decoration: none; text-decoration: none;
width: 100%;
`; `;
type UndecoratedLinkProps = { 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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; 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 { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Button } from '@/ui/input/button/components/Button'; import { Button } from '@/ui/input/button/components/Button';
import { Select } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea'; import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; 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 = { const StyledFilterRow = styled.div`
description?: string; display: flex;
}; flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDevelopersWebhooksDetail = () => { export const SettingsDevelopersWebhooksDetail = () => {
const [isDeleteWebhookModalOpen, setIsDeleteWebhookModalOpen] = const { objectMetadataItems } = useObjectMetadataItems();
useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { webhookId = '' } = useParams(); 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({ const { record: webhookData } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook, objectNameSingular: CoreObjectNameSingular.Webhook,
objectRecordId: webhookId, objectRecordId: webhookId,
onCompleted: (data) => {
setDescription(data?.description ?? '');
setOperationObjectSingularName(data?.operation.split('.')[0] ?? '');
setOperationAction(data?.operation.split('.')[1] ?? '');
setIsDirty(false);
},
}); });
const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({ const { deleteOneRecord: deleteOneWebhook } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook, objectNameSingular: CoreObjectNameSingular.Webhook,
}); });
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const deleteWebhook = () => { const deleteWebhook = () => {
deleteOneWebhook(webhookId); deleteOneWebhook(webhookId);
navigate('/settings/developers'); navigate('/settings/developers');
}; };
const formConfig = useForm<SettingsDevelopersWebhooksDetailForm>();
const { isDirty, isValid, isSubmitting } = formConfig.formState; const fieldTypeOptions = [
const canSave = isDirty && isValid && !isSubmitting; { value: '*', label: 'All Objects' },
...objectMetadataItems.map((item) => ({
value: item.nameSingular,
label: item.labelSingular,
})),
];
const handleSave = async ( const { updateOneRecord } = useUpdateOneRecord<Webhook>({
formValues: SettingsDevelopersWebhooksDetailForm, objectNameSingular: CoreObjectNameSingular.Webhook,
) => { });
try {
await updateOneRecord({ const handleSave = async () => {
idToUpdate: webhookId, setIsDirty(false);
updateOneRecordInput: formValues, await updateOneRecord({
}); idToUpdate: webhookId,
navigate('/settings/developers'); updateOneRecordInput: {
} catch (error) { operation: `${operationObjectSingularName}.${operationAction}`,
enqueueSnackBar((error as Error).message, { description: description,
variant: SnackBarVariant.Error, },
}); });
} navigate('/settings/developers');
}; };
return ( return (
// eslint-disable-next-line react/jsx-props-no-spreading <>
<FormProvider {...formConfig}>
{webhookData?.targetUrl && ( {webhookData?.targetUrl && (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer> <SettingsPageContainer>
@ -78,9 +98,11 @@ export const SettingsDevelopersWebhooksDetail = () => {
]} ]}
/> />
<SaveAndCancelButtons <SaveAndCancelButtons
onCancel={() => navigate(`/settings/developers`)} isSaveDisabled={!isDirty}
onSave={formConfig.handleSubmit(handleSave)} onCancel={() => {
isSaveDisabled={!canSave} navigate('/settings/developers');
}}
onSave={handleSave}
/> />
</SettingsHeaderContainer> </SettingsHeaderContainer>
<Section> <Section>
@ -100,20 +122,49 @@ export const SettingsDevelopersWebhooksDetail = () => {
title="Description" title="Description"
description="An optional description" description="An optional description"
/> />
<Controller <TextArea
name="description" placeholder="Write a description"
control={formConfig.control} minRows={4}
defaultValue={webhookData?.description ?? null} value={description}
render={({ field: { onChange, value } }) => ( onChange={(description) => {
<TextArea setDescription(description);
placeholder="Write a description" setIsDirty(true);
minRows={4} }}
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
/>
)}
/> />
</Section> </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> <Section>
<H2Title <H2Title
title="Danger zone" title="Danger zone"
@ -145,6 +196,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
</SettingsPageContainer> </SettingsPageContainer>
</SubMenuTopBarContainer> </SubMenuTopBarContainer>
)} )}
</FormProvider> </>
); );
}; };

View File

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

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; 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 { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
@ -22,7 +22,7 @@ describe('FileService', () => {
useValue: {}, useValue: {},
}, },
{ {
provide: TokenService, provide: JwtWrapperService,
useValue: {}, useValue: {},
}, },
], ],

View File

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