mirror of
https://github.com/twentyhq/twenty.git
synced 2024-11-23 05:53:31 +03:00
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:
parent
48f4e41148
commit
8373dfdc26
@ -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);
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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', () => {
|
||||||
|
@ -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';
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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 = {
|
||||||
|
@ -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 {
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsDirty(false);
|
||||||
await updateOneRecord({
|
await updateOneRecord({
|
||||||
idToUpdate: webhookId,
|
idToUpdate: webhookId,
|
||||||
updateOneRecordInput: formValues,
|
updateOneRecordInput: {
|
||||||
|
operation: `${operationObjectSingularName}.${operationAction}`,
|
||||||
|
description: description,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
navigate('/settings/developers');
|
navigate('/settings/developers');
|
||||||
} catch (error) {
|
|
||||||
enqueueSnackBar((error as Error).message, {
|
|
||||||
variant: SnackBarVariant.Error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,19 +122,48 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
title="Description"
|
title="Description"
|
||||||
description="An optional description"
|
description="An optional description"
|
||||||
/>
|
/>
|
||||||
<Controller
|
|
||||||
name="description"
|
|
||||||
control={formConfig.control}
|
|
||||||
defaultValue={webhookData?.description ?? null}
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<TextArea
|
<TextArea
|
||||||
placeholder="Write a description"
|
placeholder="Write a description"
|
||||||
minRows={4}
|
minRows={4}
|
||||||
value={value ?? undefined}
|
value={description}
|
||||||
onChange={(nextValue) => onChange(nextValue ?? null)}
|
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>
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title
|
<H2Title
|
||||||
@ -145,6 +196,6 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
</SettingsPageContainer>
|
</SettingsPageContainer>
|
||||||
</SubMenuTopBarContainer>
|
</SubMenuTopBarContainer>
|
||||||
)}
|
)}
|
||||||
</FormProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user