Api keys and webhook migration to core (#13011)

TODO: check Zapier trigger records work as expected

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2025-07-09 20:33:54 +05:30
committed by GitHub
parent 18792f9f74
commit 484c267aa6
113 changed files with 4563 additions and 1060 deletions

View File

@ -2,11 +2,9 @@ import styled from '@emotion/styled';
import { useCallback, useEffect } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { TextInput } from '@/ui/input/components/TextInput';
import { isDefined } from 'twenty-shared/utils';
import { useUpdateApiKeyMutation } from '~/generated-metadata/graphql';
const StyledComboInputContainer = styled.div`
display: flex;
@ -29,9 +27,7 @@ export const ApiKeyNameInput = ({
disabled,
onNameUpdate,
}: ApiKeyNameInputProps) => {
const { updateOneRecord: updateApiKey } = useUpdateOneRecord<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
});
const [updateApiKey] = useUpdateApiKeyMutation();
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -43,10 +39,18 @@ export const ApiKeyNameInput = ({
if (!apiKeyName) {
return;
}
await updateApiKey({
idToUpdate: apiKeyId,
updateOneRecordInput: { name },
const { data: updatedApiKeyData } = await updateApiKey({
variables: {
input: {
id: apiKeyId,
name,
},
},
});
const updatedApiKey = updatedApiKeyData?.updateApiKey;
if (isDefined(updatedApiKey)) {
onNameUpdate?.(updatedApiKey.name);
}
}, 500),
[updateApiKey, onNameUpdate],
);

View File

@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import {
@ -9,6 +9,7 @@ import {
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { ApiKey } from '~/generated-metadata/graphql';
export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 312px auto 28px;
@ -34,27 +35,28 @@ const StyledIconChevronRight = styled(IconChevronRight)`
`;
export const SettingsApiKeysFieldItemTableRow = ({
fieldItem,
apiKey,
to,
}: {
fieldItem: ApiFieldItem;
apiKey: Pick<ApiKey, 'id' | 'name' | 'expiresAt' | 'revokedAt'>;
to: string;
}) => {
const theme = useTheme();
const formattedExpiration = formatExpiration(apiKey.expiresAt || null);
return (
<StyledApisFieldTableRow to={to}>
<StyledNameTableCell>
<OverflowingTextWithTooltip text={fieldItem.name} />
<OverflowingTextWithTooltip text={apiKey.name} />
</StyledNameTableCell>
<TableCell
color={
fieldItem.expiration === 'Expired'
formattedExpiration === 'Expired'
? theme.font.color.danger
: theme.font.color.tertiary
}
>
{fieldItem.expiration}
{formattedExpiration}
</TableCell>
<StyledIconTableCell>
<StyledIconChevronRight

View File

@ -1,9 +1,4 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { formatExpirations } from '@/settings/developers/utils/formatExpiration';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
@ -12,6 +7,7 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { useGetApiKeysQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)`
@ -33,10 +29,9 @@ const StyledTableRow = styled(TableRow)`
`;
export const SettingsApiKeysTable = () => {
const { records: apiKeys } = useFindManyRecords<ApiKey>({
objectNameSingular: CoreObjectNameSingular.ApiKey,
filter: { revokedAt: { is: 'NULL' } },
});
const { data: apiKeysData } = useGetApiKeysQuery();
const apiKeys = apiKeysData?.apiKeys;
return (
<Table>
@ -49,14 +44,14 @@ export const SettingsApiKeysTable = () => {
</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
{!!apiKeys.length && (
{!!apiKeys?.length && (
<StyledTableBody>
{formatExpirations(apiKeys).map((fieldItem) => (
{apiKeys.map((apiKey) => (
<SettingsApiKeysFieldItemTableRow
key={fieldItem.id}
fieldItem={fieldItem as ApiFieldItem}
key={apiKey.id}
apiKey={apiKey}
to={getSettingsPath(SettingsPath.ApiKeyDetail, {
apiKeyId: fieldItem.id,
apiKeyId: apiKey.id,
})}
/>
))}

View File

@ -79,7 +79,7 @@ export const SettingsDevelopersWebhookForm = ({
handleSave,
updateOperation,
removeOperation,
deleteWebhook,
handleDelete,
isCreationMode,
error,
} = useWebhookForm({ webhookId, mode });
@ -285,7 +285,7 @@ export const SettingsDevelopersWebhookForm = ({
Please type "yes" to confirm you want to delete this webhook.
</Trans>
}
onConfirmClick={deleteWebhook}
onConfirmClick={handleDelete}
confirmButtonText={t`Delete`}
/>
)}

View File

@ -1,11 +1,11 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { getUrlHostnameOrThrow, isValidUrl } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { Webhook } from '~/generated-metadata/graphql';
export const StyledApisFieldTableRow = styled(TableRow)`
grid-template-columns: 1fr 28px;
@ -28,10 +28,13 @@ const StyledIconChevronRight = styled(IconChevronRight)`
`;
export const SettingsDevelopersWebhookTableRow = ({
fieldItem,
webhook,
to,
}: {
fieldItem: Webhook;
webhook: Pick<
Webhook,
'id' | 'targetUrl' | 'operations' | 'description' | 'secret'
>;
to: string;
}) => {
const theme = useTheme();
@ -39,9 +42,9 @@ export const SettingsDevelopersWebhookTableRow = ({
return (
<StyledApisFieldTableRow to={to}>
<StyledUrlTableCell>
{isValidUrl(fieldItem.targetUrl)
? getUrlHostnameOrThrow(fieldItem.targetUrl)
: fieldItem.targetUrl}
{isValidUrl(webhook.targetUrl)
? getUrlHostnameOrThrow(webhook.targetUrl)
: webhook.targetUrl}
</StyledUrlTableCell>
<StyledIconTableCell>
<StyledIconChevronRight

View File

@ -1,14 +1,12 @@
import styled from '@emotion/styled';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
import { SettingsPath } from '@/types/SettingsPath';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useGetWebhooksQuery } from '~/generated-metadata/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledTableBody = styled(TableBody)`
@ -22,9 +20,9 @@ const StyledTableRow = styled(TableRow)`
`;
export const SettingsWebhooksTable = () => {
const { records: webhooks } = useFindManyRecords<Webhook>({
objectNameSingular: CoreObjectNameSingular.Webhook,
});
const { data: webhooksData } = useGetWebhooksQuery();
const webhooks = webhooksData?.webhooks;
return (
<Table>
@ -32,12 +30,12 @@ export const SettingsWebhooksTable = () => {
<TableHeader>URL</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
{!!webhooks.length && (
{!!webhooks?.length && (
<StyledTableBody>
{webhooks.map((webhookFieldItem) => (
<SettingsDevelopersWebhookTableRow
key={webhookFieldItem.id}
fieldItem={webhookFieldItem}
webhook={webhookFieldItem}
to={getSettingsPath(SettingsPath.WebhookDetail, {
webhookId: webhookFieldItem.id,
})}

View File

@ -1,19 +1,20 @@
import { Meta, StoryObj } from '@storybook/react';
import { SettingsApiKeysFieldItemTableRow } from '@/settings/developers/components/SettingsApiKeysFieldItemTableRow';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof SettingsApiKeysFieldItemTableRow> = {
title: 'Modules/Settings/Developers/ApiKeys/SettingsApiKeysFieldItemTableRow',
component: SettingsApiKeysFieldItemTableRow,
decorators: [ComponentDecorator],
decorators: [ComponentDecorator, RouterDecorator],
args: {
fieldItem: {
apiKey: {
id: '3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
name: 'Zapier Api Key',
type: 'internal',
expiration: 'In 3 days',
expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days from now
revokedAt: null,
},
to: '/settings/developers/api-keys/3f4a42e8-b81f-4f8c-9c20-1602e6b34791',
},
};

View File

@ -35,7 +35,7 @@ export const CreateMode: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('New Webhook', undefined, { timeout: 10000 });
await canvas.findByText('New Webhook', undefined, { timeout: 3000 });
await canvas.findByPlaceholderText('https://example.com/webhook');
await canvas.findByPlaceholderText('Write a description');
@ -48,15 +48,21 @@ export const EditMode: Story = {
mode: WebhookFormMode.Edit,
webhookId: '1234',
},
parameters: {
msw: graphqlMocks,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByDisplayValue('https://example.com/webhook', undefined, {
timeout: 10000,
});
await canvas.findByDisplayValue('A Sample Description');
await canvas.findByDisplayValue(
'https://api.slackbot.io/webhooks/twenty',
undefined,
{
timeout: 3000,
},
);
await canvas.findByDisplayValue('Slack notifications for lead updates');
const allObjectsLabels = await canvas.findAllByText('All Objects');
expect(allObjectsLabels).toHaveLength(2);
await canvas.findByText('Created');
await canvas.findByText('Updated');
await canvas.findByText('Danger zone');
await canvas.findByText('Delete this webhook');

View File

@ -0,0 +1,10 @@
import gql from 'graphql-tag';
export const API_KEY_FRAGMENT = gql`
fragment ApiKeyFragment on ApiKey {
id
name
expiresAt
revokedAt
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export const WEBHOOK_FRAGMENT = gql`
fragment WebhookFragment on Webhook {
id
targetUrl
operations
description
secret
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const CREATE_API_KEY = gql`
mutation CreateApiKey($input: CreateApiKeyDTO!) {
createApiKey(input: $input) {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const CREATE_WEBHOOK = gql`
mutation CreateWebhook($input: CreateWebhookDTO!) {
createWebhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag';
export const DELETE_WEBHOOK = gql`
mutation DeleteWebhook($input: DeleteWebhookDTO!) {
deleteWebhook(input: $input)
}
`;

View File

@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const REVOKE_API_KEY = gql`
mutation RevokeApiKey($input: RevokeApiKeyDTO!) {
revokeApiKey(input: $input) {
id
}
}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const UPDATE_API_KEY = gql`
mutation UpdateApiKey($input: UpdateApiKeyDTO!) {
updateApiKey(input: $input) {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const UPDATE_WEBHOOK = gql`
mutation UpdateWebhook($input: UpdateWebhookDTO!) {
updateWebhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const GET_API_KEY = gql`
query GetApiKey($input: GetApiKeyDTO!) {
apiKey(input: $input) {
...ApiKeyFragment
createdAt
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { API_KEY_FRAGMENT } from '../fragments/apiKeyFragment';
export const GET_API_KEYS = gql`
query GetApiKeys {
apiKeys {
...ApiKeyFragment
}
}
${API_KEY_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const GET_WEBHOOK = gql`
query GetWebhook($input: GetWebhookDTO!) {
webhook(input: $input) {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
import { WEBHOOK_FRAGMENT } from '../fragments/webhookFragment';
export const GET_WEBHOOKS = gql`
query GetWebhooks {
webhooks {
...WebhookFragment
}
}
${WEBHOOK_FRAGMENT}
`;

View File

@ -5,16 +5,15 @@ import { MemoryRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
import { WebhookFormMode } from '@/settings/developers/constants/WebhookFormMode';
import { ApolloError } from '@apollo/client';
import { CREATE_WEBHOOK } from '@/settings/developers/graphql/mutations/createWebhook';
import { DELETE_WEBHOOK } from '@/settings/developers/graphql/mutations/deleteWebhook';
import { UPDATE_WEBHOOK } from '@/settings/developers/graphql/mutations/updateWebhook';
import { GET_WEBHOOK } from '@/settings/developers/graphql/queries/getWebhook';
import { useWebhookForm } from '../useWebhookForm';
// Mock dependencies
const mockNavigateSettings = jest.fn();
const mockEnqueueSuccessSnackBar = jest.fn();
const mockEnqueueErrorSnackBar = jest.fn();
const mockCreateOneRecord = jest.fn();
const mockUpdateOneRecord = jest.fn();
const mockDeleteOneRecord = jest.fn();
jest.mock('~/hooks/useNavigateSettings', () => ({
useNavigateSettings: () => mockNavigateSettings,
@ -27,32 +26,108 @@ jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
}),
}));
jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({
useCreateOneRecord: () => ({
createOneRecord: mockCreateOneRecord,
}),
}));
const createMockWebhookData = (overrides = {}) => ({
id: 'test-webhook-id',
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
...overrides,
});
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: mockUpdateOneRecord,
}),
}));
const createSuccessfulCreateMock = (webhookData = {}) => ({
request: {
query: CREATE_WEBHOOK,
variables: {
input: {
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
...webhookData,
},
},
},
result: {
data: {
createWebhook: createMockWebhookData(webhookData),
},
},
});
jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({
useDeleteOneRecord: () => ({
deleteOneRecord: mockDeleteOneRecord,
}),
}));
const createSuccessfulUpdateMock = (webhookId: string, webhookData = {}) => ({
request: {
query: UPDATE_WEBHOOK,
variables: {
input: {
id: webhookId,
targetUrl: 'https://updated.com/webhook',
operations: ['person.updated'],
description: 'Updated webhook',
secret: 'updated-secret',
...webhookData,
},
},
},
result: {
data: {
updateWebhook: createMockWebhookData({
id: webhookId,
targetUrl: 'https://updated.com/webhook',
operations: ['person.updated'],
description: 'Updated webhook',
secret: 'updated-secret',
...webhookData,
}),
},
},
});
jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
useFindOneRecord: () => ({
loading: false,
}),
}));
const createSuccessfulDeleteMock = (webhookId: string) => ({
request: {
query: DELETE_WEBHOOK,
variables: {
input: {
id: webhookId,
},
},
},
result: {
data: {
deleteWebhook: {
id: webhookId,
},
},
},
});
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider addTypename={false}>
const createGetWebhookMock = (webhookId: string, webhookData = {}) => ({
request: {
query: GET_WEBHOOK,
variables: {
input: {
id: webhookId,
},
},
},
result: {
data: {
webhook: createMockWebhookData({
id: webhookId,
...webhookData,
}),
},
},
});
const Wrapper = ({
children,
mocks = [],
}: {
children: ReactNode;
mocks?: any[];
}) => (
<MockedProvider mocks={mocks} addTypename={false}>
<RecoilRoot>
<MemoryRouter>{children}</MemoryRouter>
</RecoilRoot>
@ -68,7 +143,7 @@ describe('useWebhookForm', () => {
it('should initialize with default values in create mode', () => {
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{ wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
);
expect(result.current.isCreationMode).toBe(true);
@ -81,15 +156,15 @@ describe('useWebhookForm', () => {
});
it('should handle webhook creation successfully', async () => {
const mockCreatedWebhook = {
id: 'new-webhook-id',
targetUrl: 'https://test.com/webhook',
};
mockCreateOneRecord.mockResolvedValue(mockCreatedWebhook);
const mocks = [createSuccessfulCreateMock()];
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
const formData = {
@ -103,28 +178,36 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData);
});
expect(mockCreateOneRecord).toHaveBeenCalledWith({
id: expect.any(String),
targetUrl: 'https://test.com/webhook',
description: 'Test webhook',
operations: ['person.created'],
secret: 'test-secret',
});
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook https://test.com/webhook created successfully',
});
});
it('should handle creation errors', async () => {
const error = new ApolloError({
graphQLErrors: [{ message: 'Creation failed' }],
});
mockCreateOneRecord.mockRejectedValue(error);
const errorMock = {
request: {
query: CREATE_WEBHOOK,
variables: {
input: {
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
},
error: new Error('Creation failed'),
};
const mocks = [errorMock];
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
const formData = {
@ -139,16 +222,24 @@ describe('useWebhookForm', () => {
});
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error,
apolloError: expect.any(Error),
});
});
it('should clean and format operations correctly', async () => {
mockCreateOneRecord.mockResolvedValue({ id: 'test-id' });
const mocks = [
createSuccessfulCreateMock({
operations: ['person.created', 'company.updated'],
}),
];
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
const formData = {
@ -167,12 +258,8 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData);
});
expect(mockCreateOneRecord).toHaveBeenCalledWith({
id: expect.any(String),
targetUrl: 'https://test.com/webhook',
description: 'Test webhook',
operations: ['person.created', 'company.updated'],
secret: 'test-secret',
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook https://test.com/webhook created successfully',
});
});
});
@ -181,20 +268,29 @@ describe('useWebhookForm', () => {
const webhookId = 'test-webhook-id';
it('should initialize correctly in edit mode', () => {
const mocks = [createGetWebhookMock(webhookId)];
const { result } = renderHook(
() =>
useWebhookForm({
mode: WebhookFormMode.Edit,
webhookId,
}),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
expect(result.current.isCreationMode).toBe(false);
});
it('should handle webhook update successfully', async () => {
mockUpdateOneRecord.mockResolvedValue({});
const mocks = [
createGetWebhookMock(webhookId),
createSuccessfulUpdateMock(webhookId),
];
const { result } = renderHook(
() =>
@ -202,7 +298,11 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit,
webhookId,
}),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
const formData = {
@ -216,22 +316,30 @@ describe('useWebhookForm', () => {
await result.current.handleSave(formData);
});
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: webhookId,
updateOneRecordInput: {
targetUrl: 'https://updated.com/webhook',
description: 'Updated webhook',
operations: ['person.updated'],
secret: 'updated-secret',
},
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook https://updated.com/webhook updated successfully',
});
});
it('should handle update errors', async () => {
const error = new ApolloError({
graphQLErrors: [{ message: 'Update failed' }],
});
mockUpdateOneRecord.mockRejectedValue(error);
const getWebhookMock = createGetWebhookMock(webhookId);
const updateErrorMock = {
request: {
query: UPDATE_WEBHOOK,
variables: {
input: {
id: webhookId,
targetUrl: 'https://test.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
},
error: new Error('Update failed'),
};
const mocks = [getWebhookMock, updateErrorMock];
const { result } = renderHook(
() =>
@ -239,7 +347,11 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit,
webhookId,
}),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
const formData = {
@ -254,7 +366,7 @@ describe('useWebhookForm', () => {
});
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error,
apolloError: expect.any(Error),
});
});
});
@ -263,7 +375,7 @@ describe('useWebhookForm', () => {
it('should update operations correctly', () => {
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{ wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
);
act(() => {
@ -277,7 +389,7 @@ describe('useWebhookForm', () => {
it('should remove operations correctly', () => {
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{ wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
);
act(() => {
@ -305,7 +417,10 @@ describe('useWebhookForm', () => {
const webhookId = 'test-webhook-id';
it('should delete webhook successfully', async () => {
mockDeleteOneRecord.mockResolvedValue({});
const mocks = [
createGetWebhookMock(webhookId),
createSuccessfulDeleteMock(webhookId),
];
const { result } = renderHook(
() =>
@ -313,14 +428,17 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit,
webhookId,
}),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
await act(async () => {
await result.current.deleteWebhook();
await result.current.handleDelete();
});
expect(mockDeleteOneRecord).toHaveBeenCalledWith(webhookId);
expect(mockEnqueueSuccessSnackBar).toHaveBeenCalledWith({
message: 'Webhook deleted successfully',
});
@ -329,11 +447,11 @@ describe('useWebhookForm', () => {
it('should handle deletion without webhookId', async () => {
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{ wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
);
await act(async () => {
await result.current.deleteWebhook();
await result.current.handleDelete();
});
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
@ -342,10 +460,19 @@ describe('useWebhookForm', () => {
});
it('should handle deletion errors', async () => {
const error = new ApolloError({
graphQLErrors: [{ message: 'Deletion failed' }],
});
mockDeleteOneRecord.mockRejectedValue(error);
const errorMock = {
request: {
query: DELETE_WEBHOOK,
variables: {
input: {
id: webhookId,
},
},
},
error: new Error('Deletion failed'),
};
const mocks = [createGetWebhookMock(webhookId), errorMock];
const { result } = renderHook(
() =>
@ -353,15 +480,19 @@ describe('useWebhookForm', () => {
mode: WebhookFormMode.Edit,
webhookId,
}),
{ wrapper: Wrapper },
{
wrapper: ({ children }) => (
<Wrapper mocks={mocks}>{children}</Wrapper>
),
},
);
await act(async () => {
await result.current.deleteWebhook();
await result.current.handleDelete();
});
expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({
apolloError: error,
apolloError: expect.any(Error),
});
});
});
@ -370,7 +501,7 @@ describe('useWebhookForm', () => {
it('should validate canSave property', () => {
const { result } = renderHook(
() => useWebhookForm({ mode: WebhookFormMode.Create }),
{ wrapper: Wrapper },
{ wrapper: ({ children }) => <Wrapper>{children}</Wrapper> },
);
// Initially canSave should be false (form is not valid)

View File

@ -1,13 +1,13 @@
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 { addEmptyOperationIfNecessary } from '@/settings/developers/utils/addEmptyOperationIfNecessary';
import {
createWebhookCreateInput,
createWebhookUpdateInput,
} from '@/settings/developers/utils/createWebhookInput';
import { parseOperationsFromStrings } from '@/settings/developers/utils/parseOperationsFromStrings';
import {
webhookFormSchema,
WebhookFormValues,
@ -16,100 +16,66 @@ import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
useCreateWebhookMutation,
useDeleteWebhookMutation,
useGetWebhookQuery,
useUpdateWebhookMutation,
} from '~/generated-metadata/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';
type UseWebhookFormProps = {
webhookId?: string;
mode: WebhookFormMode;
};
const DEFAULT_FORM_VALUES: WebhookFormValues = {
targetUrl: '',
description: '',
operations: [{ object: '*', action: '*' }],
secret: '',
};
export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = 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 [createWebhook] = useCreateWebhookMutation();
const [updateWebhook] = useUpdateWebhookMutation();
const [deleteWebhook] = useDeleteWebhookMutation();
const formConfig = useForm<WebhookFormValues>({
mode: isCreationMode ? 'onSubmit' : 'onTouched',
resolver: zodResolver(webhookFormSchema),
defaultValues: {
targetUrl: '',
description: '',
operations: [
{
object: '*',
action: '*',
},
],
secret: '',
},
defaultValues: DEFAULT_FORM_VALUES,
});
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 || '',
const { loading, error } = useGetWebhookQuery({
skip: isCreationMode || !webhookId,
variables: {
input: { id: webhookId || '' },
},
onCompleted: (data) => {
if (!data) return;
const webhook = data.webhook;
if (!webhook) 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 baseOperations = webhook?.operations?.length
? parseOperationsFromStrings(webhook.operations)
: [];
const operations = addEmptyOperationIfNecessary(baseOperations);
formConfig.reset({
targetUrl: data.targetUrl || '',
description: data.description || '',
targetUrl: webhook.targetUrl || '',
description: webhook.description || '',
operations,
secret: data.secret || '',
secret: webhook.secret || '',
});
},
onError: () => {
enqueueErrorSnackBar({
message: t`Failed to load webhook`,
});
},
});
@ -121,19 +87,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
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,
});
const input = createWebhookCreateInput(formValues);
const { data } = await createWebhook({ variables: { input } });
const createdWebhook = data?.createWebhook;
const targetUrl = createdWebhook?.targetUrl
? `${createdWebhook?.targetUrl}`
@ -163,23 +119,15 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
}
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,
});
const input = createWebhookUpdateInput(formValues, webhookId);
const { data } = await updateWebhook({ variables: { input } });
const updatedWebhook = data?.updateWebhook;
formConfig.reset(formValues);
const targetUrl = webhookData.targetUrl ? `${webhookData.targetUrl}` : '';
const targetUrl = updatedWebhook?.targetUrl
? `${updatedWebhook.targetUrl}`
: '';
enqueueSuccessSnackBar({
message: t`Webhook ${targetUrl} updated successfully`,
@ -224,7 +172,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
);
};
const deleteWebhook = async () => {
const handleDelete = async () => {
if (!webhookId) {
enqueueErrorSnackBar({
message: t`Webhook ID is required for deletion`,
@ -233,7 +181,9 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
}
try {
await deleteOneWebhook(webhookId);
await deleteWebhook({
variables: { input: { id: webhookId } },
});
enqueueSuccessSnackBar({
message: t`Webhook deleted successfully`,
});
@ -253,7 +203,7 @@ export const useWebhookForm = ({ webhookId, mode }: UseWebhookFormProps) => {
handleSave,
updateOperation,
removeOperation,
deleteWebhook,
handleDelete,
isCreationMode,
error,
};

View File

@ -1,6 +0,0 @@
export type ApiFieldItem = {
id: string;
name: string;
type: 'internal' | 'published';
expiration: string;
};

View File

@ -1,10 +0,0 @@
export type ApiKey = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
expiresAt: string;
revokedAt: string | null;
__typename: 'ApiKey';
};

View File

@ -1,8 +0,0 @@
export type Webhook = {
id: string;
targetUrl: string;
description?: string;
operations: string[];
secret?: string;
__typename: 'Webhook';
};

View File

@ -0,0 +1,67 @@
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
import { addEmptyOperationIfNecessary } from '../addEmptyOperationIfNecessary';
describe('addEmptyOperationIfNecessary', () => {
it('should add empty operation when no wildcard or null object operations exist', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
WEBHOOK_EMPTY_OPERATION,
]);
});
it('should not add empty operation when wildcard operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
]);
});
it('should not add empty operation when null object operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
]);
});
it('should handle empty array by adding empty operation', () => {
const operations: WebhookOperationType[] = [];
const result = addEmptyOperationIfNecessary(operations);
expect(result).toEqual([WEBHOOK_EMPTY_OPERATION]);
});
it('should not modify original array', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
];
const originalLength = operations.length;
addEmptyOperationIfNecessary(operations);
expect(operations.length).toBe(originalLength);
});
});

View File

@ -0,0 +1,47 @@
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
import { cleanAndFormatOperations } from '../cleanAndFormatOperations';
describe('cleanAndFormatOperations', () => {
it('should filter out operations with null object values', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: null, action: 'test' },
{ object: 'person', action: 'updated' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['person.created', 'person.updated']);
});
it('should remove duplicate operations', () => {
const operations: WebhookOperationType[] = [
{ object: 'person', action: 'created' },
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['person.created', 'company.updated']);
});
it('should handle empty array', () => {
const operations: WebhookOperationType[] = [];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual([]);
});
it('should handle wildcard operations', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
];
const result = cleanAndFormatOperations(operations);
expect(result).toEqual(['*.*', 'person.created']);
});
});

View File

@ -0,0 +1,71 @@
import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema';
import {
createWebhookCreateInput,
createWebhookUpdateInput,
} from '../createWebhookInput';
describe('createWebhookInput', () => {
const mockFormValues: WebhookFormValues = {
targetUrl: ' https://test.com/webhook ',
description: 'Test webhook',
operations: [
{ object: 'person', action: 'created' },
{ object: 'person', action: 'created' }, // duplicate
{ object: 'company', action: 'updated' },
{ object: null, action: 'test' }, // should be filtered out
],
secret: 'test-secret',
};
describe('createWebhookCreateInput', () => {
it('should create input for webhook creation', () => {
const result = createWebhookCreateInput(mockFormValues);
expect(result).toEqual({
targetUrl: 'https://test.com/webhook',
operations: ['person.created', 'company.updated'],
description: 'Test webhook',
secret: 'test-secret',
});
});
it('should trim targetUrl', () => {
const formValues: WebhookFormValues = {
...mockFormValues,
targetUrl: ' https://example.com ',
};
const result = createWebhookCreateInput(formValues);
expect(result.targetUrl).toBe('https://example.com');
});
});
describe('createWebhookUpdateInput', () => {
it('should create input for webhook update with id', () => {
const webhookId = 'test-webhook-id';
const result = createWebhookUpdateInput(mockFormValues, webhookId);
expect(result).toEqual({
id: 'test-webhook-id',
targetUrl: 'https://test.com/webhook',
operations: ['person.created', 'company.updated'],
description: 'Test webhook',
secret: 'test-secret',
});
});
it('should trim targetUrl and include id', () => {
const formValues: WebhookFormValues = {
...mockFormValues,
targetUrl: ' https://example.com ',
};
const webhookId = 'test-webhook-id';
const result = createWebhookUpdateInput(formValues, webhookId);
expect(result.targetUrl).toBe('https://example.com');
expect(result.id).toBe('test-webhook-id');
});
});
});

View File

@ -0,0 +1,42 @@
import { parseOperationsFromStrings } from '../parseOperationsFromStrings';
describe('parseOperationsFromStrings', () => {
it('should parse operation strings into object/action pairs', () => {
const operations = ['person.created', 'company.updated', 'lead.deleted'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([
{ object: 'person', action: 'created' },
{ object: 'company', action: 'updated' },
{ object: 'lead', action: 'deleted' },
]);
});
it('should handle wildcard operations', () => {
const operations = ['*.*', 'person.created'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
]);
});
it('should handle empty array', () => {
const operations: string[] = [];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([]);
});
it('should handle operations with multiple dots by taking first two parts', () => {
const operations = ['person.created.test'];
const result = parseOperationsFromStrings(operations);
expect(result).toEqual([{ object: 'person', action: 'created' }]);
});
});

View File

@ -0,0 +1,14 @@
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export 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;
};

View File

@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export const cleanAndFormatOperations = (
operations: WebhookOperationType[],
) => {
return Array.from(
new Set(
operations
.filter((op) => isDefined(op.object) && isDefined(op.action))
.map((op) => `${op.object}.${op.action}`),
),
);
};

View File

@ -0,0 +1,28 @@
import { WebhookFormValues } from '@/settings/developers/validation-schemas/webhookFormSchema';
import { cleanAndFormatOperations } from './cleanAndFormatOperations';
export const createWebhookCreateInput = (formValues: WebhookFormValues) => {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
return {
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
};
export const createWebhookUpdateInput = (
formValues: WebhookFormValues,
webhookId: string,
) => {
const cleanedOperations = cleanAndFormatOperations(formValues.operations);
return {
id: webhookId,
targetUrl: formValues.targetUrl.trim(),
operations: cleanedOperations,
description: formValues.description,
secret: formValues.secret,
};
};

View File

@ -2,8 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { DateTime } from 'luxon';
import { NEVER_EXPIRE_DELTA_IN_YEARS } from '@/settings/developers/constants/NeverExpireDeltaInYears';
import { ApiFieldItem } from '@/settings/developers/types/api-key/ApiFieldItem';
import { ApiKey } from '@/settings/developers/types/api-key/ApiKey';
import { beautifyDateDiff } from '~/utils/date-utils';
export const doesNeverExpire = (expiresAt: string) => {
@ -28,16 +26,3 @@ export const formatExpiration = (
}
return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`;
};
export const formatExpirations = (
apiKeys: Array<Pick<ApiKey, 'id' | 'name' | 'expiresAt'>>,
): ApiFieldItem[] => {
return apiKeys.map(({ id, name, expiresAt }) => {
return {
id,
name,
expiration: formatExpiration(expiresAt || null),
type: 'internal',
};
});
};

View File

@ -0,0 +1,10 @@
import { WebhookOperationType } from '~/pages/settings/developers/webhooks/types/WebhookOperationsType';
export const parseOperationsFromStrings = (
operations: string[],
): WebhookOperationType[] => {
return operations.map((op: string) => {
const [object, action] = op.split('.');
return { object, action };
});
};