From 9f5accbc17cf431758641bce58ec6cbfb0e064fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 16 Feb 2026 18:20:27 +0100 Subject: [PATCH 1/4] feat(webhooks): implement webhook settings page with modal --- .../$organizationId/settings/webhook.tsx | 3 +- .../organizations/feature/src/index.ts | 1 + .../settings-webhook.spec.tsx | 144 +++++++++++++ .../lib/settings-webhook/settings-webhook.tsx | 198 ++++++++++++++++++ .../webhook-crud-modal-feature.spec.tsx | 52 +++-- .../webhook-crud-modal-feature.tsx} | 92 +++++++- ...age-organization-webhooks-feature.spec.tsx | 60 ------ .../page-organization-webhooks-feature.tsx | 81 ------- .../webhook-crud-modal-feature.tsx | 78 ------- .../page-organization-webhooks.spec.tsx | 75 ------- .../page-organization-webhooks.tsx | 132 ------------ .../webhook-crud-modal.spec.tsx | 71 ------- 12 files changed, 459 insertions(+), 528 deletions(-) create mode 100644 libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx create mode 100644 libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx rename libs/{pages/settings/src/lib/feature/page-organization-webhooks-feature => domains/organizations/feature/src/lib/settings-webhook}/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx (88%) rename libs/{pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx => domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx} (70%) delete mode 100644 libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.spec.tsx delete mode 100644 libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.tsx delete mode 100644 libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx delete mode 100644 libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.spec.tsx delete mode 100644 libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.tsx delete mode 100644 libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.spec.tsx diff --git a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/webhook.tsx b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/webhook.tsx index d8efb02d832..bce220469a0 100644 --- a/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/webhook.tsx +++ b/apps/console-v5/src/routes/_authenticated/organization/$organizationId/settings/webhook.tsx @@ -1,9 +1,10 @@ import { createFileRoute } from '@tanstack/react-router' +import { SettingsWebhook } from '@qovery/domains/organizations/feature' export const Route = createFileRoute('/_authenticated/organization/$organizationId/settings/webhook')({ component: RouteComponent, }) function RouteComponent() { - return
Hello "/_authenticated/organization/$organizationId/settings/webhook"!
+ return } diff --git a/libs/domains/organizations/feature/src/index.ts b/libs/domains/organizations/feature/src/index.ts index f88d67e0a66..aa1de4208bd 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -91,3 +91,4 @@ export * from './lib/free-trial-banner/free-trial-banner' export * from './lib/settings-general/settings-general' export * from './lib/settings-labels-annotations/settings-labels-annotations' export * from './lib/settings-container-registries/settings-container-registries' +export * from './lib/settings-webhook/settings-webhook' diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx new file mode 100644 index 00000000000..37d9ad75f43 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx @@ -0,0 +1,144 @@ +import { webhookFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' +import * as sharedUi from '@qovery/shared/ui' +import * as useDeleteWebhookHook from '../hooks/use-delete-webhook/use-delete-webhook' +import * as useEditWebhookHook from '../hooks/use-edit-webhook/use-edit-webhook' +import * as useWebhooksHook from '../hooks/use-webhooks/use-webhooks' +import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' +import { SettingsWebhook } from './settings-webhook' + +jest.mock('@tanstack/react-router', () => ({ + ...jest.requireActual('@tanstack/react-router'), + useParams: () => ({ organizationId: '1' }), +})) + +const useWebhooksMockSpy = jest.spyOn(useWebhooksHook, 'useWebhooks') as jest.Mock +const useEditWebhookMockSpy = jest.spyOn(useEditWebhookHook, 'useEditWebhook') as jest.Mock +const useDeleteWebhookMockSpy = jest.spyOn(useDeleteWebhookHook, 'useDeleteWebhook') as jest.Mock +const useModalMockSpy = jest.spyOn(sharedUi, 'useModal') as jest.Mock +const useModalConfirmationMockSpy = jest.spyOn(sharedUi, 'useModalConfirmation') as jest.Mock + +describe('SettingsWebhook', () => { + let openModalMock: jest.Mock + let closeModalMock: jest.Mock + let openModalConfirmationMock: jest.Mock + let editWebhookMock: jest.Mock + let deleteWebhookMock: jest.Mock + + const mockWebhooks = webhookFactoryMock(3) + + beforeEach(() => { + openModalMock = jest.fn() + closeModalMock = jest.fn() + openModalConfirmationMock = jest.fn() + editWebhookMock = jest.fn().mockResolvedValue(undefined) + deleteWebhookMock = jest.fn().mockResolvedValue(undefined) + + useModalMockSpy.mockReturnValue({ + openModal: openModalMock, + closeModal: closeModalMock, + enableAlertClickOutside: jest.fn(), + }) + useModalConfirmationMockSpy.mockReturnValue({ + openModalConfirmation: openModalConfirmationMock, + }) + useWebhooksMockSpy.mockReturnValue({ + data: mockWebhooks, + isLoading: false, + }) + useEditWebhookMockSpy.mockReturnValue({ + mutateAsync: editWebhookMock, + }) + useDeleteWebhookMockSpy.mockReturnValue({ + mutateAsync: deleteWebhookMock, + }) + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('should render webhooks with actions', () => { + renderWithProviders() + + const rows = screen.getAllByTestId('webhook-row') + expect(rows).toHaveLength(3) + + if (mockWebhooks[0].target_url) within(rows[0]).getByText(mockWebhooks[0].target_url) + within(rows[0]).getByTestId('edit-webhook') + within(rows[0]).getByTestId('input-toggle') + within(rows[0]).getByTestId('delete-webhook') + }) + + it('should open create modal on click on add new', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Add new' })) + + expect(openModalMock).toHaveBeenCalled() + + const modalProps = openModalMock.mock.calls[0][0] + expect(modalProps.content.type).toBe(WebhookCrudModalFeature) + expect(modalProps.content.props.organizationId).toBe('1') + expect(modalProps.content.props.webhook).toBeUndefined() + }) + + it('should open edit modal on click on cog', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getAllByTestId('edit-webhook')[0]) + + expect(openModalMock).toHaveBeenCalled() + + const modalProps = openModalMock.mock.calls[0][0] + expect(modalProps.content.type).toBe(WebhookCrudModalFeature) + expect(modalProps.content.props.webhook).toEqual(mockWebhooks[0]) + }) + + it('should toggle a webhook', async () => { + const { userEvent } = renderWithProviders() + + const rows = screen.getAllByTestId('webhook-row') + const toggle = within(rows[0]).getByTestId('input-toggle-button') + + await userEvent.click(toggle) + + expect(editWebhookMock).toHaveBeenCalledWith({ + organizationId: '1', + webhookId: mockWebhooks[0].id, + webhookRequest: { + ...mockWebhooks[0], + enabled: true, + }, + }) + expect(closeModalMock).toHaveBeenCalled() + }) + + it('should display empty placeholder', () => { + useWebhooksMockSpy.mockReturnValue({ + data: [], + isLoading: false, + }) + + renderWithProviders() + + screen.getByTestId('empty-webhook') + }) + + it('should call delete action from confirmation modal', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getAllByTestId('delete-webhook')[0]) + + expect(openModalConfirmationMock).toHaveBeenCalled() + + const modalProps = openModalConfirmationMock.mock.calls[0][0] + modalProps.action() + + expect(deleteWebhookMock).toHaveBeenCalledWith({ + organizationId: '1', + webhookId: mockWebhooks[0].id, + }) + }) +}) diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx new file mode 100644 index 00000000000..e7b7d7faed1 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx @@ -0,0 +1,198 @@ +import { useParams } from '@tanstack/react-router' +import { OrganizationWebhookKindEnum, type OrganizationWebhookResponse } from 'qovery-typescript-axios' +import { SettingsHeading } from '@qovery/shared/console-shared' +import { IconEnum } from '@qovery/shared/enums' +import { useModal, useModalConfirmation } from '@qovery/shared/ui' +import { BlockContent, Button, Icon, InputToggle, LoaderSpinner, Section, Tooltip, Truncate } from '@qovery/shared/ui' +import { dateUTCString, timeAgo } from '@qovery/shared/util-dates' +import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { upperCaseFirstLetter } from '@qovery/shared/util-js' +import { useDeleteWebhook } from '../hooks/use-delete-webhook/use-delete-webhook' +import { useEditWebhook } from '../hooks/use-edit-webhook/use-edit-webhook' +import { useWebhooks } from '../hooks/use-webhooks/use-webhooks' +import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' + +interface PageOrganizationWebhooksProps { + webhookLoading: boolean + webhooks?: OrganizationWebhookResponse[] + openAddNew: () => void + openEdit: (webhook: OrganizationWebhookResponse) => void + onToggle: (id: string, enabled: boolean) => void + onDelete: (webhook: OrganizationWebhookResponse) => void +} + +function PageOrganizationWebhooks(props: PageOrganizationWebhooksProps) { + return ( +
+
+
+ + +
+
+ + {props.webhookLoading ? ( +
+ +
+ ) : props.webhooks && props.webhooks?.length > 0 ? ( +
    + {props.webhooks?.map((webhook) => ( +
  • +
    +

    + + {webhook.description && ( + +

    + +
    + + )} +

    +
    + + {' '} + {upperCaseFirstLetter(webhook.kind)} + + {webhook.updated_at && ( + + Last updated {timeAgo(new Date(webhook.updated_at))} ago + + )} +
    +
    +
    + props.onToggle(webhook.id, e)} + /> + + +
    +
  • + ))} +
+ ) : ( +
+ +

+ No webhook found.
Please add one. +

+
+ )} +
+
+
+
+ ) +} + +export function SettingsWebhook() { + useDocumentTitle('Webhooks - Organization settings') + const { organizationId = '' } = useParams({ strict: false }) + const fetchWebhooks = useWebhooks({ organizationId }) + const { mutateAsync: deleteWebhook } = useDeleteWebhook() + const { openModal, closeModal } = useModal() + const { openModalConfirmation } = useModalConfirmation() + const { mutateAsync: editWebhook } = useEditWebhook() + + const openAddNew = () => { + openModal({ + content: , + }) + } + + const openEdit = (webhook: OrganizationWebhookResponse) => { + openModal({ + content: , + }) + } + + const onDelete = (webhook: OrganizationWebhookResponse) => { + openModalConfirmation({ + title: 'Delete webhook', + confirmationMethod: 'action', + name: webhook.target_url || '', + action: () => { + try { + deleteWebhook({ organizationId, webhookId: webhook.id }) + } catch (error) { + console.error(error) + } + }, + }) + } + + const toggleWebhook = (webhookId: string, enabled: boolean) => { + // this cast as Required is there to fix an incoherency in the api-doc. If request have all the field required + // then the Response must also have all the fields defined + const webhook = fetchWebhooks.data?.find( + (webhook) => webhook.id === webhookId + ) as Required + + if (webhook !== undefined) { + try { + editWebhook({ + organizationId, + webhookId, + webhookRequest: { + ...webhook, + enabled: enabled, + }, + }) + closeModal() + } catch (error) { + console.error(error) + } + } + } + + return ( + + ) +} diff --git a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx similarity index 88% rename from libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx rename to libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx index 0883efe6328..e75f72da2a1 100644 --- a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx @@ -1,7 +1,8 @@ import selectEvent from 'react-select-event' -import * as organizationDomain from '@qovery/domains/organizations/feature' import { webhookFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import * as useCreateWebhookHook from '../../hooks/use-create-webhook/use-create-webhook' +import * as useEditWebhookHook from '../../hooks/use-edit-webhook/use-edit-webhook' import WebhookCrudModalFeature, { type WebhookCrudModalFeatureProps } from './webhook-crud-modal-feature' const mockWebhook = webhookFactoryMock(1)[0] @@ -12,16 +13,23 @@ const props: WebhookCrudModalFeatureProps = { webhook: undefined, } -const useEditWebhooksMockSpy = jest.spyOn(organizationDomain, 'useEditWebhook') as jest.Mock -const useCreateWebhookMockSpy = jest.spyOn(organizationDomain, 'useCreateWebhook') as jest.Mock +const useEditWebhookMockSpy = jest.spyOn(useEditWebhookHook, 'useEditWebhook') as jest.Mock +const useCreateWebhookMockSpy = jest.spyOn(useCreateWebhookHook, 'useCreateWebhook') as jest.Mock describe('WebhookCrudModalFeature', () => { + let createWebhookMock: jest.Mock + let editWebhookMock: jest.Mock + beforeEach(() => { - useEditWebhooksMockSpy.mockReturnValue({ - mutateAsync: jest.fn(), + createWebhookMock = jest.fn().mockResolvedValue(undefined) + editWebhookMock = jest.fn().mockResolvedValue(undefined) + useEditWebhookMockSpy.mockReturnValue({ + mutateAsync: editWebhookMock, + isLoading: false, }) useCreateWebhookMockSpy.mockReturnValue({ - mutateAsync: jest.fn(), + mutateAsync: createWebhookMock, + isLoading: false, }) }) @@ -88,7 +96,7 @@ describe('WebhookCrudModalFeature', () => { await userEvent.click(button) - expect(useCreateWebhookMockSpy().mutateAsync).toHaveBeenCalledWith({ + expect(createWebhookMock).toHaveBeenCalledWith({ organizationId: '000-000-000', webhookRequest: { target_url: 'https://test.com', @@ -132,7 +140,7 @@ describe('WebhookCrudModalFeature', () => { await userEvent.click(button) - expect(useEditWebhooksMockSpy().mutateAsync).toHaveBeenCalledWith({ + expect(editWebhookMock).toHaveBeenCalledWith({ organizationId: '000-000-000', webhookId: mockWebhook.id, webhookRequest: { @@ -169,7 +177,7 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useCreateWebhookMockSpy().mutateAsync).toHaveBeenCalledWith({ + expect(createWebhookMock).toHaveBeenCalledWith({ organizationId: '000-000-000', webhookRequest: expect.objectContaining({ target_url: 'https://test.com', @@ -198,7 +206,7 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useCreateWebhookMockSpy().mutateAsync).toHaveBeenCalledWith({ + expect(createWebhookMock).toHaveBeenCalledWith({ organizationId: '000-000-000', webhookRequest: expect.objectContaining({ target_url: 'https://test.com', @@ -218,7 +226,7 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useEditWebhooksMockSpy().mutateAsync).toHaveBeenCalledWith({ + expect(editWebhookMock).toHaveBeenCalledWith({ organizationId: '000-000-000', webhookId: mockWebhook.id, webhookRequest: expect.objectContaining({ @@ -278,7 +286,7 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useEditWebhooksMockSpy().mutateAsync).not.toHaveBeenCalled() + expect(editWebhookMock).not.toHaveBeenCalled() }) it('should allow submit when editing webhook with existing secret and secret provided', async () => { @@ -294,11 +302,11 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useEditWebhooksMockSpy().mutateAsync).toHaveBeenCalledWith({ - organizationId: '000-000-000', - webhookId: mockWebhookWithSecret.id, - webhookRequest: expect.objectContaining({ - target_secret: 'new-secret-value', + expect(editWebhookMock).toHaveBeenCalledWith({ + organizationId: '000-000-000', + webhookId: mockWebhookWithSecret.id, + webhookRequest: expect.objectContaining({ + target_secret: 'new-secret-value', }), }) }) @@ -323,11 +331,11 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(useEditWebhooksMockSpy().mutateAsync).toHaveBeenCalledWith({ - organizationId: '000-000-000', - webhookId: mockWebhook.id, - webhookRequest: expect.objectContaining({ - target_secret: undefined, + expect(editWebhookMock).toHaveBeenCalledWith({ + organizationId: '000-000-000', + webhookId: mockWebhook.id, + webhookRequest: expect.objectContaining({ + target_secret: undefined, }), }) }) diff --git a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx similarity index 70% rename from libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx rename to libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx index b82d8299fb8..fc704235a41 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx @@ -3,13 +3,18 @@ import { type OrganizationWebhookCreateRequest, OrganizationWebhookEventEnum, OrganizationWebhookKindEnum, + type OrganizationWebhookResponse, } from 'qovery-typescript-axios' import { type FormEventHandler } from 'react' +import { FormProvider, useForm } from 'react-hook-form' import { Controller, useFormContext } from 'react-hook-form' import { IconEnum } from '@qovery/shared/enums' +import { useModal } from '@qovery/shared/ui' import { Icon, InputSelect, InputTags, InputText, InputTextArea, ModalCrud } from '@qovery/shared/ui' +import { useCreateWebhook } from '../../hooks/use-create-webhook/use-create-webhook' +import { useEditWebhook } from '../../hooks/use-edit-webhook/use-edit-webhook' -export interface WebhookCrudModalProps { +interface WebhookCrudModalProps { closeModal: () => void onSubmit: FormEventHandler isEdition?: boolean @@ -18,7 +23,7 @@ export interface WebhookCrudModalProps { isEditDirty?: boolean } -export function WebhookCrudModal(props: WebhookCrudModalProps) { +function WebhookCrudModal(props: WebhookCrudModalProps) { const { closeModal, onSubmit, isEdition, isLoading, hasExistingSecret, isEditDirty } = props const { control } = useFormContext() @@ -30,7 +35,7 @@ export function WebhookCrudModal(props: WebhookCrudModalProps) { submitLabel={isEdition ? 'Update' : 'Create'} loading={isLoading} > -
General
+
General
)} -
Event & filters
+
Event & filters
)} /> -

+

Webhook will be triggered only for projects whose names match or, if you're using a wildcard, start with one of the values from your list.
@@ -213,8 +218,8 @@ export function WebhookCrudModal(props: WebhookCrudModalProps) { {isEditDirty && hasExistingSecret && ( <> -


- Confirm your secret +
+ Confirm your secret void + webhook?: OrganizationWebhookResponse +} + +export function WebhookCrudModalFeature({ organizationId, closeModal, webhook }: WebhookCrudModalFeatureProps) { + const { enableAlertClickOutside } = useModal() + const { mutateAsync: createWebhook, isLoading: isLoadingCreateWebhook } = useCreateWebhook() + const { mutateAsync: editWebhook, isLoading: isLoadingEditWebhook } = useEditWebhook() + + const isEdit = Boolean(webhook) + const hasExistingSecret = Boolean(webhook?.target_secret_set) + + const methods = useForm({ + mode: 'all', + defaultValues: { + kind: webhook?.kind ?? undefined, + environment_types_filter: webhook?.environment_types_filter ?? [], + project_names_filter: webhook?.project_names_filter ?? [], + events: webhook?.events ?? [], + description: webhook?.description ?? '', + target_url: webhook?.target_url ?? '', + target_secret: '', + }, + }) + + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + + const isEditDirty = isEdit && methods.formState.isDirty + + const onSubmit = methods.handleSubmit(async (data) => { + const trimmedData = { + ...data, + target_url: data.target_url.trim(), + target_secret: data.target_secret || undefined, + } + try { + if (webhook) { + await editWebhook({ + organizationId, + webhookId: webhook.id, + webhookRequest: { ...webhook, ...trimmedData }, + }) + closeModal() + } else { + await createWebhook({ + organizationId, + webhookRequest: trimmedData, + }) + closeModal() + } + } catch (error) { + console.error(error) + } + }) + + return ( + + + + ) +} + +export default WebhookCrudModalFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.spec.tsx deleted file mode 100644 index dc453ad3bd6..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.spec.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { webhookFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import PageOrganizationWebhooksFeature from './page-organization-webhooks-feature' - -const useWebhooksMockSpy = jest.spyOn(organizationsDomain, 'useWebhooks') as jest.Mock -const useEditWebhooksMockSpy = jest.spyOn(organizationsDomain, 'useEditWebhook') as jest.Mock - -const mockWebhooks = webhookFactoryMock(1) - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ organizationId: '1' }), -})) - -describe('PageOrganizationWebhooksFeature', () => { - beforeEach(() => { - useWebhooksMockSpy.mockReturnValue({ - data: mockWebhooks, - }) - useEditWebhooksMockSpy.mockReturnValue({ - mutateAsync: jest.fn(), - }) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should render 1 row', () => { - renderWithProviders() - const rows = screen.getAllByTestId('webhook-row') - expect(rows).toHaveLength(1) - }) - - it('should render empty placeholder', () => { - useWebhooksMockSpy.mockReturnValue({ - data: [], - }) - renderWithProviders() - screen.getByTestId('empty-webhook') - }) - - it('should render pass the toggle to true', async () => { - const { userEvent } = renderWithProviders() - - const input = screen.getByTestId('input-toggle-button') - await userEvent.click(input) - - expect(useEditWebhooksMockSpy().mutateAsync).toHaveBeenCalledWith({ - organizationId: '1', - webhookId: mockWebhooks[0].id, - webhookRequest: { - ...mockWebhooks[0], - enabled: true, - }, - }) - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.tsx deleted file mode 100644 index 249080e4ff8..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/page-organization-webhooks-feature.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { type OrganizationWebhookResponse } from 'qovery-typescript-axios' -import { useParams } from 'react-router-dom' -import { useDeleteWebhook, useEditWebhook, useWebhooks } from '@qovery/domains/organizations/feature' -import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import { useDocumentTitle } from '@qovery/shared/util-hooks' -import PageOrganizationWebhooks from '../../ui/page-organization-webhooks/page-organization-webhooks' -import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' - -export function PageOrganizationWebhooksFeature() { - useDocumentTitle('Webhooks - Organization settings') - const { organizationId = '' } = useParams() - const fetchWebhooks = useWebhooks({ organizationId }) - const { mutateAsync: deleteWebhook } = useDeleteWebhook() - const { openModal, closeModal } = useModal() - const { openModalConfirmation } = useModalConfirmation() - const { mutateAsync: editWebhook } = useEditWebhook() - - const openAddNew = () => { - openModal({ - content: , - }) - } - - const openEdit = (webhook: OrganizationWebhookResponse) => { - openModal({ - content: , - }) - } - - const onDelete = (webhook: OrganizationWebhookResponse) => { - openModalConfirmation({ - title: 'Delete webhook', - confirmationMethod: 'action', - name: webhook.target_url || '', - action: () => { - try { - deleteWebhook({ organizationId, webhookId: webhook.id }) - } catch (error) { - console.error(error) - } - }, - }) - } - - const toggleWebhook = (webhookId: string, enabled: boolean) => { - // this cast as Required is there to fix an incoherency in the api-doc. If request have all the field required - // then the Response must also have all the fields defined - const webhook = fetchWebhooks.data?.find( - (webhook) => webhook.id === webhookId - ) as Required - - if (webhook !== undefined) { - try { - editWebhook({ - organizationId, - webhookId, - webhookRequest: { - ...webhook, - enabled: enabled, - }, - }) - closeModal() - } catch (error) { - console.error(error) - } - } - } - - return ( - - ) -} - -export default PageOrganizationWebhooksFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx deleted file mode 100644 index 453f4f8038d..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-webhooks-feature/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { type OrganizationWebhookCreateRequest, type OrganizationWebhookResponse } from 'qovery-typescript-axios' -import { FormProvider, useForm } from 'react-hook-form' -import { useCreateWebhook, useEditWebhook } from '@qovery/domains/organizations/feature' -import { useModal } from '@qovery/shared/ui' -import WebhookCrudModal from '../../../ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal' - -export interface WebhookCrudModalFeatureProps { - organizationId: string - closeModal: () => void - webhook?: OrganizationWebhookResponse -} - -export function WebhookCrudModalFeature({ organizationId, closeModal, webhook }: WebhookCrudModalFeatureProps) { - const { enableAlertClickOutside } = useModal() - const { mutateAsync: createWebhook, isLoading: isLoadingCreateWebhook } = useCreateWebhook() - const { mutateAsync: editWebhook, isLoading: isLoadingEditWebhook } = useEditWebhook() - - const isEdit = Boolean(webhook) - const hasExistingSecret = Boolean(webhook?.target_secret_set) - - const methods = useForm({ - mode: 'all', - defaultValues: { - kind: webhook?.kind ?? undefined, - environment_types_filter: webhook?.environment_types_filter ?? [], - project_names_filter: webhook?.project_names_filter ?? [], - events: webhook?.events ?? [], - description: webhook?.description ?? '', - target_url: webhook?.target_url ?? '', - target_secret: '', - }, - }) - - methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) - - const isEditDirty = isEdit && methods.formState.isDirty - - const onSubmit = methods.handleSubmit(async (data) => { - const trimmedData = { - ...data, - target_url: data.target_url.trim(), - target_secret: data.target_secret || undefined, - } - try { - if (webhook) { - await editWebhook({ - organizationId, - webhookId: webhook.id, - webhookRequest: { ...webhook, ...trimmedData }, - }) - closeModal() - } else { - await createWebhook({ - organizationId, - webhookRequest: trimmedData, - }) - closeModal() - } - } catch (error) { - console.error(error) - } - }) - - return ( - - - - ) -} - -export default WebhookCrudModalFeature diff --git a/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.spec.tsx deleted file mode 100644 index f497259ab56..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { webhookFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' -import PageOrganizationWebhooks, { type PageOrganizationWebhooksProps } from './page-organization-webhooks' - -const mockWebhooks = webhookFactoryMock(3) -const props: PageOrganizationWebhooksProps = { - openEdit: jest.fn(), - onToggle: jest.fn(), - webhooks: mockWebhooks, - webhookLoading: false, - openAddNew: jest.fn(), - onDelete: jest.fn(), -} - -describe('PageOrganizationWebhooks', () => { - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should open create modal on click on add new', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getByTestId('add-new') - - await userEvent.click(button) - - expect(props.openAddNew).toHaveBeenCalled() - }) - - it('should open edit modal on click on cog', async () => { - const { userEvent } = renderWithProviders() - const button = screen.getAllByTestId('edit-webhook')[0] - - await userEvent.click(button) - - expect(props.openEdit).toHaveBeenCalledWith(mockWebhooks[0]) - }) - - it('should display three rows with good values inside', async () => { - renderWithProviders() - const rows = screen.getAllByTestId('webhook-row') - expect(rows).toHaveLength(3) - - if (mockWebhooks[0].target_url) within(rows[0]).getByText(mockWebhooks[0].target_url) - within(rows[0]).getByTestId('edit-webhook') - within(rows[0]).getByTestId('input-toggle') - within(rows[0]).getByTestId('delete-webhook') - }) - - it('should call onToggle', async () => { - const { userEvent } = renderWithProviders() - const toggles = screen.getAllByTestId('input-toggle') - - const input = within(toggles[0]).getByLabelText('toggle-btn') - await userEvent.click(input) - - expect(props.onToggle).toHaveBeenCalledWith(mockWebhooks[0].id, !mockWebhooks[0].enabled) - }) - - it('should display empty placeholder', async () => { - renderWithProviders() - - screen.getByTestId('empty-webhook') - }) - - it('should call onDelete', async () => { - const { userEvent } = renderWithProviders() - const rows = screen.getAllByTestId('webhook-row') - - const toggle = within(rows[0]).getByTestId('delete-webhook') - await userEvent.click(toggle) - - expect(props.onDelete).toHaveBeenCalledWith(mockWebhooks[0]) - }) -}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.tsx b/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.tsx deleted file mode 100644 index 2bfcf733609..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-webhooks/page-organization-webhooks.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { OrganizationWebhookKindEnum, type OrganizationWebhookResponse } from 'qovery-typescript-axios' -import { NeedHelp } from '@qovery/shared/assistant/feature' -import { IconEnum } from '@qovery/shared/enums' -import { - BlockContent, - Button, - Heading, - Icon, - InputToggle, - LoaderSpinner, - Section, - Tooltip, - Truncate, -} from '@qovery/shared/ui' -import { dateUTCString, timeAgo } from '@qovery/shared/util-dates' -import { upperCaseFirstLetter } from '@qovery/shared/util-js' - -export interface PageOrganizationWebhooksProps { - webhookLoading: boolean - webhooks?: OrganizationWebhookResponse[] - openAddNew: () => void - openEdit: (webhook: OrganizationWebhookResponse) => void - onToggle: (id: string, enabled: boolean) => void - onDelete: (webhook: OrganizationWebhookResponse) => void -} - -export function PageOrganizationWebhooks(props: PageOrganizationWebhooksProps) { - return ( -
-
-
-
- Webhook -

- Qovery allows you to create webhooks at organization-level so that, when an event happens on an - environment within your organization, you can get notified on external applications (for instance, Slack). -

- -
- -
- - {props.webhookLoading ? ( -
- -
- ) : props.webhooks && props.webhooks?.length > 0 ? ( -
    - {props.webhooks?.map((webhook) => ( -
  • -
    -

    - - {webhook.description && ( - -

    - -
    - - )} -

    -
    - - {' '} - {upperCaseFirstLetter(webhook.kind)} - - {webhook.updated_at && ( - - Last updated {timeAgo(new Date(webhook.updated_at))} ago - - )} -
    -
    -
    - props.onToggle(webhook.id, e)} - /> - - -
    -
  • - ))} -
- ) : ( -
- -

- No webhook found.
Please add one. -

-
- )} -
-
-
- ) -} - -export default PageOrganizationWebhooks diff --git a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.spec.tsx deleted file mode 100644 index 8726be9b86e..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.spec.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' -import { - EnvironmentModeEnum, - type OrganizationWebhookCreateRequest, - OrganizationWebhookEventEnum, - OrganizationWebhookKindEnum, -} from 'qovery-typescript-axios' -import { fireEvent, renderWithProviders, screen } from '@qovery/shared/util-tests' -import WebhookCrudModal, { type WebhookCrudModalProps } from './webhook-crud-modal' - -const props: WebhookCrudModalProps = { - onSubmit: jest.fn((e) => e.preventDefault()), - isEdition: false, - closeModal: jest.fn(), -} - -describe('WebhookCrudModal', () => { - const defaultValues: OrganizationWebhookCreateRequest = { - target_url: 'https://test.com', - kind: OrganizationWebhookKindEnum.STANDARD, - description: 'description', - target_secret: 'secret', - events: [OrganizationWebhookEventEnum.STARTED], - project_names_filter: ['test'], - environment_types_filter: [EnvironmentModeEnum.PRODUCTION], - } - it('should render successfully', () => { - const { baseElement } = renderWithProviders( - wrapWithReactHookForm(, { - defaultValues, - }) - ) - expect(baseElement).toBeTruthy() - }) - - it('should display create', () => { - renderWithProviders( - wrapWithReactHookForm(, { - defaultValues, - }) - ) - screen.getByText('Create') - }) - - it('should display update', () => { - renderWithProviders( - wrapWithReactHookForm(, { - defaultValues, - }) - ) - screen.getByText('Update') - }) - - it('should call onSubmit', async () => { - const spy = jest.fn().mockImplementation((e) => e.preventDefault()) - props.onSubmit = spy - const { userEvent } = renderWithProviders( - wrapWithReactHookForm(, { - defaultValues, - }) - ) - const url = screen.getByLabelText('URL') - fireEvent.change(url, { target: { value: 'https://test.com' } }) - - const button = screen.getByText('Create') - - await userEvent.click(button) - - expect(props.onSubmit).toHaveBeenCalled() - }) -}) From c864ea2473278e0ccfe7530fcc0ae71c3015b679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Mon, 16 Feb 2026 18:26:25 +0100 Subject: [PATCH 2/4] fix(tests): format check --- .../settings-webhook.spec.tsx | 4 ++-- .../webhook-crud-modal-feature.spec.tsx | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx index 37d9ad75f43..cda5f81d290 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx @@ -1,11 +1,11 @@ import { webhookFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' import * as sharedUi from '@qovery/shared/ui' +import { renderWithProviders, screen, within } from '@qovery/shared/util-tests' import * as useDeleteWebhookHook from '../hooks/use-delete-webhook/use-delete-webhook' import * as useEditWebhookHook from '../hooks/use-edit-webhook/use-edit-webhook' import * as useWebhooksHook from '../hooks/use-webhooks/use-webhooks' -import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' import { SettingsWebhook } from './settings-webhook' +import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx index e75f72da2a1..9e8a7d22b64 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx @@ -286,7 +286,7 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(editWebhookMock).not.toHaveBeenCalled() + expect(editWebhookMock).not.toHaveBeenCalled() }) it('should allow submit when editing webhook with existing secret and secret provided', async () => { @@ -302,11 +302,11 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(editWebhookMock).toHaveBeenCalledWith({ - organizationId: '000-000-000', - webhookId: mockWebhookWithSecret.id, - webhookRequest: expect.objectContaining({ - target_secret: 'new-secret-value', + expect(editWebhookMock).toHaveBeenCalledWith({ + organizationId: '000-000-000', + webhookId: mockWebhookWithSecret.id, + webhookRequest: expect.objectContaining({ + target_secret: 'new-secret-value', }), }) }) @@ -331,11 +331,11 @@ describe('WebhookCrudModalFeature', () => { const button = screen.getByTestId('submit-button') await userEvent.click(button) - expect(editWebhookMock).toHaveBeenCalledWith({ - organizationId: '000-000-000', - webhookId: mockWebhook.id, - webhookRequest: expect.objectContaining({ - target_secret: undefined, + expect(editWebhookMock).toHaveBeenCalledWith({ + organizationId: '000-000-000', + webhookId: mockWebhook.id, + webhookRequest: expect.objectContaining({ + target_secret: undefined, }), }) }) From e0aab6757589e1b803546422f02765f1bc4c9980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 18 Feb 2026 11:50:31 +0100 Subject: [PATCH 3/4] feat(webhooks): add suspense option to useWebhooks and update related tests --- .../lib/hooks/use-webhooks/use-webhooks.ts | 4 +- .../settings-webhook.spec.tsx | 5 +- .../lib/settings-webhook/settings-webhook.tsx | 260 +++++++++--------- 3 files changed, 138 insertions(+), 131 deletions(-) diff --git a/libs/domains/organizations/feature/src/lib/hooks/use-webhooks/use-webhooks.ts b/libs/domains/organizations/feature/src/lib/hooks/use-webhooks/use-webhooks.ts index 39b4b7f8902..02cb14c2914 100644 --- a/libs/domains/organizations/feature/src/lib/hooks/use-webhooks/use-webhooks.ts +++ b/libs/domains/organizations/feature/src/lib/hooks/use-webhooks/use-webhooks.ts @@ -3,11 +3,13 @@ import { queries } from '@qovery/state/util-queries' export interface UseWebhooksProps { organizationId: string + suspense?: boolean } -export function useWebhooks({ organizationId }: UseWebhooksProps) { +export function useWebhooks({ organizationId, suspense = false }: UseWebhooksProps) { return useQuery({ ...queries.organizations.webhooks({ organizationId }), + suspense, }) } diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx index cda5f81d290..795011d7f4b 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx @@ -62,6 +62,8 @@ describe('SettingsWebhook', () => { it('should render webhooks with actions', () => { renderWithProviders() + expect(useWebhooksMockSpy).toHaveBeenCalledWith({ organizationId: '1', suspense: true }) + const rows = screen.getAllByTestId('webhook-row') expect(rows).toHaveLength(3) @@ -118,11 +120,12 @@ describe('SettingsWebhook', () => { it('should display empty placeholder', () => { useWebhooksMockSpy.mockReturnValue({ data: [], - isLoading: false, }) renderWithProviders() + expect(useWebhooksMockSpy).toHaveBeenCalledWith({ organizationId: '1', suspense: true }) + screen.getByTestId('empty-webhook') }) diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx index e7b7d7faed1..7f62018d868 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx @@ -1,9 +1,10 @@ import { useParams } from '@tanstack/react-router' import { OrganizationWebhookKindEnum, type OrganizationWebhookResponse } from 'qovery-typescript-axios' +import { Suspense } from 'react' import { SettingsHeading } from '@qovery/shared/console-shared' import { IconEnum } from '@qovery/shared/enums' import { useModal, useModalConfirmation } from '@qovery/shared/ui' -import { BlockContent, Button, Icon, InputToggle, LoaderSpinner, Section, Tooltip, Truncate } from '@qovery/shared/ui' +import { BlockContent, Button, Icon, InputToggle, Section, Skeleton, Tooltip, Truncate } from '@qovery/shared/ui' import { dateUTCString, timeAgo } from '@qovery/shared/util-dates' import { useDocumentTitle } from '@qovery/shared/util-hooks' import { upperCaseFirstLetter } from '@qovery/shared/util-js' @@ -12,133 +13,31 @@ import { useEditWebhook } from '../hooks/use-edit-webhook/use-edit-webhook' import { useWebhooks } from '../hooks/use-webhooks/use-webhooks' import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' -interface PageOrganizationWebhooksProps { - webhookLoading: boolean - webhooks?: OrganizationWebhookResponse[] - openAddNew: () => void - openEdit: (webhook: OrganizationWebhookResponse) => void - onToggle: (id: string, enabled: boolean) => void - onDelete: (webhook: OrganizationWebhookResponse) => void -} - -function PageOrganizationWebhooks(props: PageOrganizationWebhooksProps) { - return ( -
-
-
- - +const WebhookSkeleton = () => ( + + {[0, 1, 2].map((index) => ( +
+
+ +
-
- - {props.webhookLoading ? ( -
- -
- ) : props.webhooks && props.webhooks?.length > 0 ? ( -
    - {props.webhooks?.map((webhook) => ( -
  • -
    -

    - - {webhook.description && ( - -

    - -
    - - )} -

    -
    - - {' '} - {upperCaseFirstLetter(webhook.kind)} - - {webhook.updated_at && ( - - Last updated {timeAgo(new Date(webhook.updated_at))} ago - - )} -
    -
    -
    - props.onToggle(webhook.id, e)} - /> - - -
    -
  • - ))} -
- ) : ( -
- -

- No webhook found.
Please add one. -

-
- )} -
+
+ + +
-
-
- ) -} +
+ ))} + +) -export function SettingsWebhook() { - useDocumentTitle('Webhooks - Organization settings') - const { organizationId = '' } = useParams({ strict: false }) - const fetchWebhooks = useWebhooks({ organizationId }) +const WebhookList = ({ organizationId }: { organizationId: string }) => { + const fetchWebhooks = useWebhooks({ organizationId, suspense: true }) const { mutateAsync: deleteWebhook } = useDeleteWebhook() const { openModal, closeModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const { mutateAsync: editWebhook } = useEditWebhook() - - const openAddNew = () => { - openModal({ - content: , - }) - } + const webhooks = fetchWebhooks.data ?? [] const openEdit = (webhook: OrganizationWebhookResponse) => { openModal({ @@ -186,13 +85,116 @@ export function SettingsWebhook() { } return ( - + + {webhooks.length > 0 ? ( +
    + {webhooks.map((webhook) => ( +
  • +
    +

    + + {webhook.description && ( + +

    + +
    + + )} +

    +
    + + {' '} + {upperCaseFirstLetter(webhook.kind)} + + {webhook.updated_at && ( + + Last updated {timeAgo(new Date(webhook.updated_at))} ago + + )} +
    +
    +
    + toggleWebhook(webhook.id, e)} + /> + + +
    +
  • + ))} +
+ ) : ( +
+ +

+ No webhook found.
Please add one. +

+
+ )} +
+ ) +} + +export function SettingsWebhook() { + useDocumentTitle('Webhooks - Organization settings') + const { organizationId = '' } = useParams({ strict: false }) + const { openModal, closeModal } = useModal() + + const openAddNew = () => { + openModal({ + content: , + }) + } + + return ( +
+
+
+ + +
+
+ }> + + +
+
+
) } From d1e69bcfd85f2e9d04b82f122ffcc9e664147c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Wed, 18 Feb 2026 14:41:41 +0100 Subject: [PATCH 4/4] refactor(webhooks): replace WebhookCrudModalFeature with WebhookCrudModal and update related tests --- .../settings-webhook.spec.tsx | 6 +- .../lib/settings-webhook/settings-webhook.tsx | 6 +- .../webhook-crud-modal-feature.tsx | 318 ------------------ .../webhook-crud-modal.spec.tsx} | 36 +- .../webhook-crud-modal/webhook-crud-modal.tsx | 292 ++++++++++++++++ 5 files changed, 316 insertions(+), 342 deletions(-) delete mode 100644 libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx rename libs/domains/organizations/feature/src/lib/settings-webhook/{webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx => webhook-crud-modal/webhook-crud-modal.spec.tsx} (89%) create mode 100644 libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.tsx diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx index 795011d7f4b..16d4c221da3 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx @@ -5,7 +5,7 @@ import * as useDeleteWebhookHook from '../hooks/use-delete-webhook/use-delete-we import * as useEditWebhookHook from '../hooks/use-edit-webhook/use-edit-webhook' import * as useWebhooksHook from '../hooks/use-webhooks/use-webhooks' import { SettingsWebhook } from './settings-webhook' -import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' +import WebhookCrudModal from './webhook-crud-modal/webhook-crud-modal' jest.mock('@tanstack/react-router', () => ({ ...jest.requireActual('@tanstack/react-router'), @@ -81,7 +81,7 @@ describe('SettingsWebhook', () => { expect(openModalMock).toHaveBeenCalled() const modalProps = openModalMock.mock.calls[0][0] - expect(modalProps.content.type).toBe(WebhookCrudModalFeature) + expect(modalProps.content.type).toBe(WebhookCrudModal) expect(modalProps.content.props.organizationId).toBe('1') expect(modalProps.content.props.webhook).toBeUndefined() }) @@ -94,7 +94,7 @@ describe('SettingsWebhook', () => { expect(openModalMock).toHaveBeenCalled() const modalProps = openModalMock.mock.calls[0][0] - expect(modalProps.content.type).toBe(WebhookCrudModalFeature) + expect(modalProps.content.type).toBe(WebhookCrudModal) expect(modalProps.content.props.webhook).toEqual(mockWebhooks[0]) }) diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx index 7f62018d868..43c127fccc9 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx @@ -11,7 +11,7 @@ import { upperCaseFirstLetter } from '@qovery/shared/util-js' import { useDeleteWebhook } from '../hooks/use-delete-webhook/use-delete-webhook' import { useEditWebhook } from '../hooks/use-edit-webhook/use-edit-webhook' import { useWebhooks } from '../hooks/use-webhooks/use-webhooks' -import WebhookCrudModalFeature from './webhook-crud-modal-feature/webhook-crud-modal-feature' +import WebhookCrudModal from './webhook-crud-modal/webhook-crud-modal' const WebhookSkeleton = () => ( @@ -41,7 +41,7 @@ const WebhookList = ({ organizationId }: { organizationId: string }) => { const openEdit = (webhook: OrganizationWebhookResponse) => { openModal({ - content: , + content: , }) } @@ -171,7 +171,7 @@ export function SettingsWebhook() { const openAddNew = () => { openModal({ - content: , + content: , }) } diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx deleted file mode 100644 index fc704235a41..00000000000 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { - EnvironmentModeEnum, - type OrganizationWebhookCreateRequest, - OrganizationWebhookEventEnum, - OrganizationWebhookKindEnum, - type OrganizationWebhookResponse, -} from 'qovery-typescript-axios' -import { type FormEventHandler } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { Controller, useFormContext } from 'react-hook-form' -import { IconEnum } from '@qovery/shared/enums' -import { useModal } from '@qovery/shared/ui' -import { Icon, InputSelect, InputTags, InputText, InputTextArea, ModalCrud } from '@qovery/shared/ui' -import { useCreateWebhook } from '../../hooks/use-create-webhook/use-create-webhook' -import { useEditWebhook } from '../../hooks/use-edit-webhook/use-edit-webhook' - -interface WebhookCrudModalProps { - closeModal: () => void - onSubmit: FormEventHandler - isEdition?: boolean - isLoading?: boolean - hasExistingSecret?: boolean - isEditDirty?: boolean -} - -function WebhookCrudModal(props: WebhookCrudModalProps) { - const { closeModal, onSubmit, isEdition, isLoading, hasExistingSecret, isEditDirty } = props - const { control } = useFormContext() - - return ( - -
General
- - ( - - )} - /> - - ( - , - }, - { - label: 'Standard', - value: OrganizationWebhookKindEnum.STANDARD, - icon: , - }, - ]} - onChange={field.onChange} - value={field.value} - label="Kind" - error={error?.message} - /> - )} - /> - - ( - - )} - /> - - {!isEdition && ( - ( - - )} - /> - )} - -
Event & filters
- -
- ( - - )} - /> -
- -
- ( - - )} - /> -

- Webhook will be triggered only for projects whose names match or, if you're using a wildcard, start with one - of the values from your list. -
- Press Enter to add a new value. -

-
- -
- ( - - )} - /> -
- - {isEditDirty && hasExistingSecret && ( - <> -
- Confirm your secret - ( - - )} - /> - - )} -
- ) -} - -export interface WebhookCrudModalFeatureProps { - organizationId: string - closeModal: () => void - webhook?: OrganizationWebhookResponse -} - -export function WebhookCrudModalFeature({ organizationId, closeModal, webhook }: WebhookCrudModalFeatureProps) { - const { enableAlertClickOutside } = useModal() - const { mutateAsync: createWebhook, isLoading: isLoadingCreateWebhook } = useCreateWebhook() - const { mutateAsync: editWebhook, isLoading: isLoadingEditWebhook } = useEditWebhook() - - const isEdit = Boolean(webhook) - const hasExistingSecret = Boolean(webhook?.target_secret_set) - - const methods = useForm({ - mode: 'all', - defaultValues: { - kind: webhook?.kind ?? undefined, - environment_types_filter: webhook?.environment_types_filter ?? [], - project_names_filter: webhook?.project_names_filter ?? [], - events: webhook?.events ?? [], - description: webhook?.description ?? '', - target_url: webhook?.target_url ?? '', - target_secret: '', - }, - }) - - methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) - - const isEditDirty = isEdit && methods.formState.isDirty - - const onSubmit = methods.handleSubmit(async (data) => { - const trimmedData = { - ...data, - target_url: data.target_url.trim(), - target_secret: data.target_secret || undefined, - } - try { - if (webhook) { - await editWebhook({ - organizationId, - webhookId: webhook.id, - webhookRequest: { ...webhook, ...trimmedData }, - }) - closeModal() - } else { - await createWebhook({ - organizationId, - webhookRequest: trimmedData, - }) - closeModal() - } - } catch (error) { - console.error(error) - } - }) - - return ( - - - - ) -} - -export default WebhookCrudModalFeature diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.spec.tsx similarity index 89% rename from libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx rename to libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.spec.tsx index 9e8a7d22b64..49f5566b550 100644 --- a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal-feature/webhook-crud-modal-feature.spec.tsx +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.spec.tsx @@ -3,7 +3,7 @@ import { webhookFactoryMock } from '@qovery/shared/factories' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import * as useCreateWebhookHook from '../../hooks/use-create-webhook/use-create-webhook' import * as useEditWebhookHook from '../../hooks/use-edit-webhook/use-edit-webhook' -import WebhookCrudModalFeature, { type WebhookCrudModalFeatureProps } from './webhook-crud-modal-feature' +import WebhookCrudModal, { type WebhookCrudModalFeatureProps } from './webhook-crud-modal' const mockWebhook = webhookFactoryMock(1)[0] const mockWebhookWithSecret = { ...mockWebhook, target_secret_set: true } @@ -16,7 +16,7 @@ const props: WebhookCrudModalFeatureProps = { const useEditWebhookMockSpy = jest.spyOn(useEditWebhookHook, 'useEditWebhook') as jest.Mock const useCreateWebhookMockSpy = jest.spyOn(useCreateWebhookHook, 'useCreateWebhook') as jest.Mock -describe('WebhookCrudModalFeature', () => { +describe('WebhookCrudModal', () => { let createWebhookMock: jest.Mock let editWebhookMock: jest.Mock @@ -34,12 +34,12 @@ describe('WebhookCrudModalFeature', () => { }) it('should render successfully', () => { - const { baseElement } = renderWithProviders() + const { baseElement } = renderWithProviders() expect(baseElement).toBeTruthy() }) it('should render all the inputs when creating', () => { - renderWithProviders() + renderWithProviders() screen.getByLabelText('URL') screen.getByLabelText('Kind') @@ -51,7 +51,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should not show secret field when editing (before form is dirty)', () => { - renderWithProviders() + renderWithProviders() screen.getByLabelText('URL') screen.getByLabelText('Kind') @@ -61,7 +61,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should mutate useCreateWebhook', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const url = screen.getByLabelText('URL') const kind = screen.getByLabelText('Kind') @@ -111,7 +111,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should mutate useEditWebhook (webhook without existing secret)', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const url = screen.getByLabelText('URL') const kind = screen.getByLabelText('Kind') const description = screen.getByLabelText('Description') @@ -157,7 +157,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should trim URL with trailing whitespace on create', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const url = screen.getByLabelText('URL') const kind = screen.getByLabelText('Kind') @@ -186,7 +186,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should trim URL with leading whitespace on create', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const url = screen.getByLabelText('URL') const kind = screen.getByLabelText('Kind') @@ -215,7 +215,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should trim URL with whitespace on edit', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const url = screen.getByLabelText('URL') @@ -237,20 +237,20 @@ describe('WebhookCrudModalFeature', () => { describe('secret field handling', () => { it('should show secret field as optional when creating new webhook', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByLabelText('Secret')).toBeInTheDocument() }) it('should not show secret field when editing webhook (before form is dirty)', () => { - renderWithProviders() + renderWithProviders() expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() expect(screen.queryByText('Confirm your secret')).not.toBeInTheDocument() }) it('should show "Confirm your secret" section when editing webhook with existing secret and form is dirty', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() expect(screen.queryByText('Confirm your secret')).not.toBeInTheDocument() @@ -263,7 +263,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should not show secret field when editing webhook without existing secret (even when dirty)', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.clear(description) @@ -274,7 +274,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should require secret when editing webhook with existing secret', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.clear(description) @@ -290,7 +290,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should allow submit when editing webhook with existing secret and secret provided', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.clear(description) @@ -312,7 +312,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should display password type input for secret field when shown', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.type(description, 'x') @@ -322,7 +322,7 @@ describe('WebhookCrudModalFeature', () => { }) it('should allow editing webhook without secret and not providing a secret', async () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.clear(description) diff --git a/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.tsx b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.tsx new file mode 100644 index 00000000000..5e060f55c45 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/webhook-crud-modal/webhook-crud-modal.tsx @@ -0,0 +1,292 @@ +import { + EnvironmentModeEnum, + type OrganizationWebhookCreateRequest, + OrganizationWebhookEventEnum, + OrganizationWebhookKindEnum, + type OrganizationWebhookResponse, +} from 'qovery-typescript-axios' +import { type FormEventHandler } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { Controller } from 'react-hook-form' +import { IconEnum } from '@qovery/shared/enums' +import { useModal } from '@qovery/shared/ui' +import { Icon, InputSelect, InputTags, InputText, InputTextArea, ModalCrud } from '@qovery/shared/ui' +import { useCreateWebhook } from '../../hooks/use-create-webhook/use-create-webhook' +import { useEditWebhook } from '../../hooks/use-edit-webhook/use-edit-webhook' + +export interface WebhookCrudModalFeatureProps { + organizationId: string + closeModal: () => void + webhook?: OrganizationWebhookResponse +} + +export function WebhookCrudModal({ organizationId, closeModal, webhook }: WebhookCrudModalFeatureProps) { + const { enableAlertClickOutside } = useModal() + const { mutateAsync: createWebhook, isLoading: isLoadingCreateWebhook } = useCreateWebhook() + const { mutateAsync: editWebhook, isLoading: isLoadingEditWebhook } = useEditWebhook() + const isEdit = Boolean(webhook) + const hasExistingSecret = Boolean(webhook?.target_secret_set) + + const methods = useForm({ + mode: 'all', + defaultValues: { + kind: webhook?.kind ?? undefined, + environment_types_filter: webhook?.environment_types_filter ?? [], + project_names_filter: webhook?.project_names_filter ?? [], + events: webhook?.events ?? [], + description: webhook?.description ?? '', + target_url: webhook?.target_url ?? '', + target_secret: '', + }, + }) + + methods.watch(() => enableAlertClickOutside(methods.formState.isDirty)) + + const isEditDirty = isEdit && methods.formState.isDirty + + const onSubmit = methods.handleSubmit(async (data) => { + const trimmedData = { + ...data, + target_url: data.target_url.trim(), + target_secret: data.target_secret || undefined, + } + try { + if (webhook) { + await editWebhook({ + organizationId, + webhookId: webhook.id, + webhookRequest: { ...webhook, ...trimmedData }, + }) + closeModal() + } else { + await createWebhook({ + organizationId, + webhookRequest: trimmedData, + }) + closeModal() + } + } catch (error) { + console.error(error) + } + }) + + return ( + + +
General
+ + ( + + )} + /> + + ( + , + }, + { + label: 'Standard', + value: OrganizationWebhookKindEnum.STANDARD, + icon: , + }, + ]} + onChange={field.onChange} + value={field.value} + label="Kind" + error={error?.message} + /> + )} + /> + + ( + + )} + /> + + {!isEdit && ( + ( + + )} + /> + )} + +
Event & filters
+ +
+ ( + + )} + /> +
+ +
+ ( + + )} + /> +

+ Webhook will be triggered only for projects whose names match or, if you're using a wildcard, start with one + of the values from your list. +
+ Press Enter to add a new value. +

+
+ +
+ ( + + )} + /> +
+ + {isEditDirty && hasExistingSecret && ( + <> +
+ Confirm your secret + ( + + )} + /> + + )} +
+
+ ) +} + +export default WebhookCrudModal