Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
48eefa9
feat: add webhook support for card events
nickmeinhold Jan 28, 2026
540d3bc
fix: address code review feedback
nickmeinhold Jan 28, 2026
c15020a
chore(db): add workspace_webhooks table schema
nickmeinhold Jan 29, 2026
4c9811a
feat(db): add webhook repository with CRUD operations
nickmeinhold Jan 29, 2026
d10f133
feat(api): add workspace webhook sending functions
nickmeinhold Jan 29, 2026
a6fbf50
feat(api): add webhook CRUD API router
nickmeinhold Jan 29, 2026
c7aa804
refactor(api): use sendWebhooksForWorkspace in card router
nickmeinhold Jan 29, 2026
d94828b
feat(web): add webhook management UI components
nickmeinhold Jan 29, 2026
0a39220
feat(web): add webhooks settings page and navigation
nickmeinhold Jan 29, 2026
412f0ea
refactor(api): remove legacy env var webhook support
nickmeinhold Jan 29, 2026
10d4b3c
test(api): add webhook utility and router tests
nickmeinhold Jan 29, 2026
63312f6
test(api): add webhook integration tests with PGlite
nickmeinhold Jan 29, 2026
c3f7ff5
chore: update pnpm-lock.yaml for vitest
nickmeinhold Jan 29, 2026
392ce5d
fix: add non-null assertion for TypeScript build
nickmeinhold Jan 29, 2026
6187966
chore(i18n): extract and compile webhook translations
nickmeinhold Jan 29, 2026
2d347ea
fix: regenerate i18n locale files from clean base
nickmeinhold Feb 8, 2026
51400c7
feat: include board and list names in webhook payloads
nickmeinhold Feb 8, 2026
4fe7786
fix: generate presigned avatar URLs for card members on board view
nickmeinhold Feb 12, 2026
1be3ee3
Merge branch 'feature/webhooks' into deploy/combined
nickmeinhold Feb 13, 2026
3e3b147
feat: add Cmd+Enter to submit comments and Esc to close card view
nickmeinhold Feb 14, 2026
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
14 changes: 14 additions & 0 deletions apps/web/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export default function Editor({
content,
onChange,
onBlur,
onModEnter,
readOnly = false,
workspaceMembers,
enableYouTubeEmbed = true,
Expand All @@ -449,6 +450,7 @@ export default function Editor({
content: string | null;
onChange?: (value: string) => void;
onBlur?: () => void;
onModEnter?: () => void;
readOnly?: boolean;
workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean;
Expand Down Expand Up @@ -569,6 +571,18 @@ export default function Editor({
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
handleKeyDown: (_view, event) => {
if (
onModEnter &&
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
onModEnter();
return true;
}
return false;
},
},
editable: !readOnly,
injectCSS: false,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
Expand Down Expand Up @@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: true,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export const env = createEnv({
S3_FORCE_PATH_STYLE: z.string().optional(),
EMAIL_FROM: z.string().optional(),
REDIS_URL: z.string().url().optional().or(z.literal("")),
// Webhook configuration
WEBHOOK_URL: z.string().url().optional(),
WEBHOOK_SECRET: z.string().optional(),
},

/**
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/pages/settings/webhooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NextPageWithLayout } from "~/pages/_app";
import { getDashboardLayout } from "~/components/Dashboard";
import { SettingsLayout } from "~/components/SettingsLayout";
import WebhookSettings from "~/views/settings/WebhookSettings";

const WebhookSettingsPage: NextPageWithLayout = () => {
return (
<SettingsLayout currentTab="webhooks">
<WebhookSettings />
</SettingsLayout>
);
};

WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);

export default WebhookSettingsPage;
1 change: 1 addition & 0 deletions apps/web/src/views/card/components/NewCommentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const NewCommentForm = ({
<Editor
content={watch("comment")}
onChange={(value) => setValue("comment", value)}
onModEnter={handleSubmit(onSubmit)}
workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/views/card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
Expand All @@ -18,6 +18,10 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
import { usePermissions } from "~/hooks/usePermissions";
import {
useKeyboardShortcut,
type KeyboardShortcut,
} from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
Expand Down Expand Up @@ -201,6 +205,25 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const workspaceMembers = board?.workspace.members;
const boardId = board?.publicId;

const navigateToBoard = useCallback(() => {
if (!isOpen && boardId) {
const boardPath = isTemplate ? "templates" : "boards";
void router.push(`/${boardPath}/${boardId}`);
}
}, [isOpen, isTemplate, boardId, router]);

const escShortcut = useMemo(
(): KeyboardShortcut => ({
type: "PRESS",
stroke: { key: "Escape" },
action: navigateToBoard,
description: t`Close card`,
group: "NAVIGATION",
}),
[navigateToBoard],
);
useKeyboardShortcut(escShortcut);

const editorWorkspaceMembers =
workspaceMembers
?.filter((member) => member.email)
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/views/settings/WebhookSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { t } from "@lingui/core/macro";

import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
import { NewWebhookModal } from "./components/NewWebhookModal";
import WebhookList from "./components/WebhookList";

export default function WebhookSettings() {
const { modalContentType, openModal, isOpen } = useModal();
const { workspace } = useWorkspace();

if (!workspace) {
return null;
}

return (
<>
<PageHead title={t`Settings | Webhooks`} />

<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{t`Webhooks`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
</p>

<div className="mb-4 flex items-center justify-between">
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
{t`Add webhook`}
</Button>
</div>

<WebhookList workspacePublicId={workspace.publicId} />
</div>

{/* Webhook-specific modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} />
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
>
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
>
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
</Modal>

{/* Global modals */}
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
>
<FeedbackModal />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { t } from "@lingui/core/macro";
import { HiXMark } from "react-icons/hi2";

import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";

interface DeleteWebhookConfirmationProps {
workspacePublicId: string;
}

export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();

const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
message: error.message || t`Failed to delete webhook`,
type: "error",
});
},
});

const handleDelete = () => {
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
});
};

return (
<div>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
<button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>

<p className="text-sm text-neutral-500 dark:text-dark-900">
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
</p>
</div>

<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button
variant="danger"
onClick={handleDelete}
isLoading={deleteWebhookMutation.isPending}
>
{t`Delete`}
</Button>
</div>
</div>
);
}
Loading