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 08d63d01e2f..116706b3c94 100644 --- a/libs/domains/organizations/feature/src/index.ts +++ b/libs/domains/organizations/feature/src/index.ts @@ -92,5 +92,6 @@ export * from './lib/settings-general/settings-general' export * from './lib/settings-labels-annotations/settings-labels-annotations' export * from './lib/settings-helm-repositories/settings-helm-repositories' export * from './lib/settings-container-registries/settings-container-registries' +export * from './lib/settings-webhook/settings-webhook' export * from './lib/settings-api-token/settings-api-token' export * from './lib/settings-danger-zone/settings-danger-zone' 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 new file mode 100644 index 00000000000..16d4c221da3 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.spec.tsx @@ -0,0 +1,147 @@ +import { webhookFactoryMock } from '@qovery/shared/factories' +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 { SettingsWebhook } from './settings-webhook' +import WebhookCrudModal from './webhook-crud-modal/webhook-crud-modal' + +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() + + expect(useWebhooksMockSpy).toHaveBeenCalledWith({ organizationId: '1', suspense: true }) + + 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(WebhookCrudModal) + 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(WebhookCrudModal) + 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: [], + }) + + renderWithProviders() + + expect(useWebhooksMockSpy).toHaveBeenCalledWith({ organizationId: '1', suspense: true }) + + 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..43c127fccc9 --- /dev/null +++ b/libs/domains/organizations/feature/src/lib/settings-webhook/settings-webhook.tsx @@ -0,0 +1,200 @@ +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, 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' +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 WebhookCrudModal from './webhook-crud-modal/webhook-crud-modal' + +const WebhookSkeleton = () => ( + + {[0, 1, 2].map((index) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+) + +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 webhooks = fetchWebhooks.data ?? [] + + 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 ( + + {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 ( +
+
+
+ + +
+
+ }> + + +
+
+
+ ) +} 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/webhook-crud-modal.spec.tsx similarity index 81% 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/webhook-crud-modal.spec.tsx index 0883efe6328..49f5566b550 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/webhook-crud-modal.spec.tsx @@ -1,8 +1,9 @@ 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 WebhookCrudModalFeature, { type WebhookCrudModalFeatureProps } from './webhook-crud-modal-feature' +import * as useCreateWebhookHook from '../../hooks/use-create-webhook/use-create-webhook' +import * as useEditWebhookHook from '../../hooks/use-edit-webhook/use-edit-webhook' +import WebhookCrudModal, { type WebhookCrudModalFeatureProps } from './webhook-crud-modal' const mockWebhook = webhookFactoryMock(1)[0] const mockWebhookWithSecret = { ...mockWebhook, target_secret_set: true } @@ -12,26 +13,33 @@ 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('WebhookCrudModal', () => { + let createWebhookMock: jest.Mock + let editWebhookMock: jest.Mock -describe('WebhookCrudModalFeature', () => { 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, }) }) 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') @@ -43,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') @@ -53,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') @@ -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', @@ -103,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') @@ -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: { @@ -149,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') @@ -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', @@ -178,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') @@ -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', @@ -207,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') @@ -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({ @@ -229,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() @@ -255,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) @@ -266,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) @@ -278,11 +286,11 @@ 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 () => { - const { userEvent } = renderWithProviders() + const { userEvent } = renderWithProviders() const description = screen.getByLabelText('Description') await userEvent.clear(description) @@ -294,7 +302,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: mockWebhookWithSecret.id, webhookRequest: expect.objectContaining({ @@ -304,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') @@ -314,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) @@ -323,7 +331,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({ 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 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() - }) -}) diff --git a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx b/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx deleted file mode 100644 index b82d8299fb8..00000000000 --- a/libs/pages/settings/src/lib/ui/page-organization-webhooks/webhook-crud-modal/webhook-crud-modal.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { - EnvironmentModeEnum, - type OrganizationWebhookCreateRequest, - OrganizationWebhookEventEnum, - OrganizationWebhookKindEnum, -} from 'qovery-typescript-axios' -import { type FormEventHandler } from 'react' -import { Controller, useFormContext } from 'react-hook-form' -import { IconEnum } from '@qovery/shared/enums' -import { Icon, InputSelect, InputTags, InputText, InputTextArea, ModalCrud } from '@qovery/shared/ui' - -export interface WebhookCrudModalProps { - closeModal: () => void - onSubmit: FormEventHandler - isEdition?: boolean - isLoading?: boolean - hasExistingSecret?: boolean - isEditDirty?: boolean -} - -export 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 default WebhookCrudModal