Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <div>Hello "/_authenticated/organization/$organizationId/settings/webhook"!</div>
return <SettingsWebhook />
}
1 change: 1 addition & 0 deletions libs/domains/organizations/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<SettingsWebhook />)
expect(baseElement).toBeTruthy()
})

it('should render webhooks with actions', () => {
renderWithProviders(<SettingsWebhook />)

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(<SettingsWebhook />)

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(<SettingsWebhook />)

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(<SettingsWebhook />)

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(<SettingsWebhook />)

expect(useWebhooksMockSpy).toHaveBeenCalledWith({ organizationId: '1', suspense: true })

screen.getByTestId('empty-webhook')
})

it('should call delete action from confirmation modal', async () => {
const { userEvent } = renderWithProviders(<SettingsWebhook />)

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,
})
})
})
Original file line number Diff line number Diff line change
@@ -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 = () => (
<BlockContent title="Webhook" classNameContent="p-0">
{[0, 1, 2].map((index) => (
<div key={index} className="flex items-center justify-between border-b border-neutral px-5 py-4 last:border-0">
<div className="flex flex-col gap-1">
<Skeleton width={320} height={16} show={true} />
<Skeleton width={220} height={16} show={true} />
</div>
<div className="flex items-center gap-2">
<Skeleton width={32} height={18} show={true} />
<Skeleton width={36} height={36} show={true} />
<Skeleton width={36} height={36} show={true} />
</div>
</div>
))}
</BlockContent>
)

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: <WebhookCrudModal organizationId={organizationId} webhook={webhook} closeModal={closeModal} />,
})
}

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<OrganizationWebhookResponse>

if (webhook !== undefined) {
try {
editWebhook({
organizationId,
webhookId,
webhookRequest: {
...webhook,
enabled: enabled,
},
})
closeModal()
} catch (error) {
console.error(error)
}
}
}

return (
<BlockContent title="Webhook" classNameContent="p-0">
{webhooks.length > 0 ? (
<ul className="flex flex-col">
{webhooks.map((webhook) => (
<li
key={webhook.id}
data-testid="webhook-row"
className="flex items-center justify-between border-b border-neutral px-5 py-4 last:border-0"
>
<div className="flex flex-col">
<p className="mb-1 flex text-xs font-medium text-neutral">
<Truncate truncateLimit={58} text={webhook.target_url || ''} />
{webhook.description && (
<Tooltip content={webhook.description}>
<div className="ml-1 cursor-pointer">
<Icon iconName="circle-info" iconStyle="regular" />
</div>
</Tooltip>
)}
</p>
<div className="flex gap-3 text-xs text-neutral-subtle">
<span className="flex gap-2">
<Icon
name={webhook.kind === OrganizationWebhookKindEnum.STANDARD ? IconEnum.QOVERY : IconEnum.SLACK}
className="h-4 w-4"
/>{' '}
{upperCaseFirstLetter(webhook.kind)}
</span>
{webhook.updated_at && (
<span title={dateUTCString(webhook.updated_at)}>
Last updated {timeAgo(new Date(webhook.updated_at))} ago
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<InputToggle
title={`${webhook.enabled ? 'Enabled' : 'Disabled'}`}
className={`${webhook.enabled ? 'mr-5' : 'mr-4'}`}
value={webhook.enabled}
small
onChange={(e) => toggleWebhook(webhook.id, e)}
/>
<Button
data-testid="edit-webhook"
variant="outline"
iconOnly
color="neutral"
size="md"
onClick={() => openEdit(webhook)}
>
<Icon iconName="gear" iconStyle="regular" />
</Button>
<Button
data-testid="delete-webhook"
variant="outline"
iconOnly
color="neutral"
size="md"
onClick={() => onDelete(webhook)}
>
<Icon iconName="trash-can" iconStyle="regular" />
</Button>
</div>
</li>
))}
</ul>
) : (
<div className="px-5 py-4 text-center">
<Icon iconName="wave-pulse" className="text-neutral-subtle" />
<p className="mt-1 text-xs font-medium text-neutral-subtle" data-testid="empty-webhook">
No webhook found. <br /> Please add one.
</p>
</div>
)}
</BlockContent>
)
}

export function SettingsWebhook() {
useDocumentTitle('Webhooks - Organization settings')
const { organizationId = '' } = useParams({ strict: false })
const { openModal, closeModal } = useModal()

const openAddNew = () => {
openModal({
content: <WebhookCrudModal organizationId={organizationId} closeModal={closeModal} />,
})
}

return (
<div className="w-full">
<Section className="p-8">
<div className="relative">
<SettingsHeading
title="Webhook"
description="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)."
/>
<Button size="md" className="absolute right-0 top-0 shrink-0 gap-2" onClick={openAddNew}>
<Icon iconName="circle-plus" iconStyle="regular" />
Add new
</Button>
</div>
<div className="max-w-content-with-navigation-left">
<Suspense fallback={<WebhookSkeleton />}>
<WebhookList organizationId={organizationId} />
</Suspense>
</div>
</Section>
</div>
)
}
Loading