Move "Webhook" settings to table (#12119)

* Move 'Webhook' settings to table

* Add tests
This commit is contained in:
Tom Moor
2026-04-19 19:27:32 -04:00
committed by GitHub
parent 69e8aac4f1
commit 321b232f17
17 changed files with 654 additions and 153 deletions
+105 -32
View File
@@ -1,30 +1,106 @@
import type { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { PlusIcon, WebhooksIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import Modal from "~/components/Modal";
import PaginatedList from "~/components/PaginatedList";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import WebhookSubscriptionListItem from "./components/WebhookSubscriptionListItem";
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
import { useTableRequest } from "~/hooks/useTableRequest";
import { StickyFilters } from "~/scenes/Settings/components/StickyFilters";
import { createWebhookSubscription } from "./actions";
import { WebhookSubscriptionsTable } from "./components/WebhookSubscriptionsTable";
function Webhooks() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { webhookSubscriptions } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const can = usePolicy(team);
const appName = env.APP_NAME;
const params = useQuery();
const history = useHistory();
const location = useLocation();
const [query, setQuery] = useState(params.get("query") || "");
const reqParams = useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
| "DESC",
}),
[params]
);
const sort: ColumnSort = useMemo(
() => ({
id: reqParams.sort,
desc: reqParams.direction === "DESC",
}),
[reqParams.sort, reqParams.direction]
);
const orderedData = webhookSubscriptions.orderedData;
const filteredWebhooks = useMemo(
() =>
reqParams.query
? webhookSubscriptions.findByQuery(reqParams.query)
: orderedData,
[webhookSubscriptions, orderedData, reqParams.query]
);
const { data, error, loading, next } = useTableRequest({
data: filteredWebhooks,
sort,
reqFn: webhookSubscriptions.fetchPage,
reqParams,
});
const updateParams = useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
},
[]
);
useEffect(() => {
if (error) {
toast.error(t("Could not load webhooks"));
}
}, [t, error]);
useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene
@@ -36,7 +112,7 @@ function Webhooks() {
<Action>
<Button
type="button"
onClick={handleNewModalOpen}
action={createWebhookSubscription}
icon={<PlusIcon />}
>
{`${t("New webhook")}`}
@@ -45,6 +121,7 @@ function Webhooks() {
)}
</>
}
wide
>
<Heading>{t("Webhooks")}</Heading>
<Text as="p" type="secondary">
@@ -54,29 +131,25 @@ function Webhooks() {
in near real-time.
</Trans>
</Text>
<PaginatedList<WebhookSubscription>
fetch={webhookSubscriptions.fetchPage}
items={webhookSubscriptions.enabled}
heading={<h2>{t("Active")}</h2>}
renderItem={(webhook) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
<PaginatedList<WebhookSubscription>
items={webhookSubscriptions.disabled}
heading={<h2>{t("Inactive")}</h2>}
renderItem={(webhook) => (
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
)}
/>
<Modal
title={t("New webhook")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
width="480px"
>
<WebhookSubscriptionNew onSubmit={handleNewModalClose} />
</Modal>
<StickyFilters>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<WebhookSubscriptionsTable
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { EditIcon, PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { createAction } from "~/actions";
import { SettingsSection } from "~/actions/sections";
import WebhookSubscriptionDeleteDialog from "./components/WebhookSubscriptionDeleteDialog";
import WebhookSubscriptionEdit from "./components/WebhookSubscriptionEdit";
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
export const createWebhookSubscription = createAction({
name: ({ t }) => t("New webhook"),
analyticsName: "New webhook",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "")
.createWebhookSubscription,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New webhook"),
content: (
<WebhookSubscriptionNew onSubmit={stores.dialogs.closeAllModals} />
),
});
},
});
export const editWebhookSubscriptionFactory = ({
webhook,
}: {
webhook: WebhookSubscription;
}) =>
createAction({
name: ({ t }) => `${t("Edit")}`,
analyticsName: "Edit webhook",
section: SettingsSection,
icon: <EditIcon />,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Edit webhook"),
content: (
<WebhookSubscriptionEdit
onSubmit={stores.dialogs.closeAllModals}
webhookSubscription={webhook}
/>
),
});
},
});
export const deleteWebhookSubscriptionFactory = ({
webhook,
}: {
webhook: WebhookSubscription;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete webhook",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "delete remove",
dangerous: true,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete webhook"),
content: (
<WebhookSubscriptionDeleteDialog
onSubmit={stores.dialogs.closeAllModals}
webhook={webhook}
/>
),
});
},
});
@@ -1,86 +0,0 @@
import { EditIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import type WebhookSubscription from "~/models/WebhookSubscription";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import useStores from "~/hooks/useStores";
import WebhookSubscriptionRevokeDialog from "./WebhookSubscriptionDeleteDialog";
import WebhookSubscriptionEdit from "./WebhookSubscriptionEdit";
type Props = {
webhook: WebhookSubscription;
};
const WebhookSubscriptionListItem = ({ webhook }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const [editModalOpen, handleEditModalOpen, handleEditModalClose] =
useBoolean();
const showDeletionConfirmation = React.useCallback(() => {
dialogs.openModal({
title: t("Delete webhook"),
content: (
<WebhookSubscriptionRevokeDialog
onSubmit={dialogs.closeAllModals}
webhook={webhook}
/>
),
});
}, [t, dialogs, webhook]);
return (
<ListItem
key={webhook.id}
title={
<>
{webhook.name}
{!webhook.enabled && (
<StyledBadge yellow>{t("Disabled")}</StyledBadge>
)}
</>
}
subtitle={
<>
{t("Subscribed events")}: <code>{webhook.events.join(", ")}</code>
</>
}
actions={
<>
<Button
onClick={showDeletionConfirmation}
icon={<TrashIcon />}
neutral
>
{t("Delete")}
</Button>
<Button icon={<EditIcon />} onClick={handleEditModalOpen} neutral>
{t("Edit")}
</Button>
<Modal
title={t("Edit webhook")}
onRequestClose={handleEditModalClose}
isOpen={editModalOpen}
width="480px"
>
<WebhookSubscriptionEdit
onSubmit={handleEditModalClose}
webhookSubscription={webhook}
/>
</Modal>
</>
}
/>
);
};
const StyledBadge = styled(Badge)`
position: absolute;
`;
export default WebhookSubscriptionListItem;
@@ -0,0 +1,24 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { useWebhookSubscriptionMenuActions } from "../hooks/useWebhookSubscriptionMenuActions";
type Props = {
/** The webhook subscription to associate with the menu */
webhook: WebhookSubscription;
};
function WebhookSubscriptionMenu({ webhook }: Props) {
const { t } = useTranslation();
const rootAction = useWebhookSubscriptionMenuActions(webhook);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Webhook")}>
<OverflowMenuButton />
</DropdownMenu>
);
}
export default observer(WebhookSubscriptionMenu);
@@ -0,0 +1,138 @@
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { HEADER_HEIGHT } from "~/components/Header";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import {
type Props as TableProps,
SortableTable,
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { HStack } from "~/components/primitives/HStack";
import { Status } from "~/scenes/Settings/components/Status";
import { FILTER_HEIGHT } from "~/scenes/Settings/components/StickyFilters";
import { useWebhookSubscriptionMenuActions } from "../hooks/useWebhookSubscriptionMenuActions";
import WebhookSubscriptionMenu from "./WebhookSubscriptionMenu";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<WebhookSubscription>, "columns" | "rowHeight">;
const WebhookRowContextMenu = observer(function WebhookRowContextMenu({
webhook,
menuLabel,
children,
}: {
webhook: WebhookSubscription;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useWebhookSubscriptionMenuActions(webhook);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
});
export const WebhookSubscriptionsTable = observer(
function WebhookSubscriptionsTable(props: Props) {
const { t } = useTranslation();
const applyContextMenu = useCallback(
(webhook: WebhookSubscription, rowElement: React.ReactNode) => (
<WebhookRowContextMenu webhook={webhook} menuLabel={t("Webhook")}>
{rowElement}
</WebhookRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<WebhookSubscription>[]>(
() => [
{
type: "data",
id: "name",
header: t("Name"),
accessor: (webhook) => webhook.name,
component: (webhook) => (
<HStack spacing={4} wrap>
<Tooltip content={webhook.enabled ? t("Active") : t("Disabled")}>
<Status $color={webhook.enabled ? "success" : "textTertiary"} />
</Tooltip>
<Text selectable>{webhook.name}</Text>
</HStack>
),
width: "2fr",
},
{
type: "data",
id: "url",
header: t("URL"),
accessor: (webhook) => webhook.url,
component: (webhook) => (
<UrlText type="tertiary" monospace selectable title={webhook.url}>
{webhook.url}
</UrlText>
),
width: "3fr",
},
{
type: "data",
id: "createdById",
header: t("Created by"),
accessor: (webhook) => webhook.createdBy?.name,
component: (webhook) =>
webhook.createdBy ? (
<HStack>
<Avatar model={webhook.createdBy} size={AvatarSize.Medium} />
<Text selectable>{webhook.createdBy.name}</Text>
</HStack>
) : null,
width: "2fr",
},
{
type: "data",
id: "updatedAt",
header: t("Last updated"),
accessor: (webhook) => webhook.updatedAt,
component: (webhook) =>
webhook.updatedAt ? (
<Time dateTime={webhook.updatedAt} addSuffix shorten />
) : null,
width: "1.5fr",
},
{
type: "action",
id: "action",
component: (webhook) => <WebhookSubscriptionMenu webhook={webhook} />,
width: "50px",
},
],
[t]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
}
);
const UrlText = styled(Text)`
${ellipsis()}
max-width: 100%;
`;
@@ -0,0 +1,26 @@
import { useMemo } from "react";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { useMenuAction } from "~/hooks/useMenuAction";
import {
deleteWebhookSubscriptionFactory,
editWebhookSubscriptionFactory,
} from "../actions";
/**
* Hook that constructs the action menu for webhook subscription operations.
*
* @param webhook - the webhook subscription to build actions for.
* @returns action with children for use in menus.
*/
export function useWebhookSubscriptionMenuActions(
webhook: WebhookSubscription
) {
const actions = useMemo(
() => [
editWebhookSubscriptionFactory({ webhook }),
deleteWebhookSubscriptionFactory({ webhook }),
],
[webhook]
);
return useMenuAction(actions);
}
+30
View File
@@ -1,4 +1,34 @@
import { z } from "zod";
import { WebhookSubscription } from "@server/models";
import { BaseSchema } from "@server/routes/api/schema";
export const WebhookSubscriptionsListSchema = BaseSchema.extend({
body: z.object({
/** Webhook subscriptions sorting direction */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
/** Webhook subscriptions sorting column */
sort: z
.string()
.refine(
(val) => Object.keys(WebhookSubscription.getAttributes()).includes(val),
{
error: "Invalid sort parameter",
}
)
.prefault("createdAt"),
/** Search query to filter webhook subscriptions by name */
query: z.string().optional(),
}),
});
export type WebhookSubscriptionsListReq = z.infer<
typeof WebhookSubscriptionsListSchema
>;
export const WebhookSubscriptionsCreateSchema = z.object({
body: z.object({
@@ -51,6 +51,78 @@ describe("#webhookSubscriptions.list", () => {
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(webhookSubscriptions.length);
});
it("should filter webhook subscriptions by query", async () => {
const user = await buildAdmin();
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Production Webhook",
});
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Staging Webhook",
});
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Development Hook",
});
const res = await server.post("/api/webhookSubscriptions.list", {
body: { token: user.getJwtToken(), query: "webhook" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(2);
expect(
body.data.every((webhook: { name: string }) =>
webhook.name.toLowerCase().includes("webhook")
)
).toBe(true);
});
it("should filter webhook subscriptions by query case-insensitively", async () => {
const user = await buildAdmin();
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Production Webhook",
});
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Staging Webhook",
});
const res = await server.post("/api/webhookSubscriptions.list", {
body: { token: user.getJwtToken(), query: "PRODUCTION" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].name).toEqual("Production Webhook");
});
it("should return empty array when query matches no webhook subscriptions", async () => {
const user = await buildAdmin();
await buildWebhookSubscription({
createdById: user.id,
teamId: user.teamId,
name: "Production Webhook",
});
const res = await server.post("/api/webhookSubscriptions.list", {
body: { token: user.getJwtToken(), query: "nonexistent" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
});
describe("#webhookSubscriptions.create", () => {
@@ -1,13 +1,16 @@
import Router from "koa-router";
import compact from "lodash/compact";
import isEmpty from "lodash/isEmpty";
import { Op, Sequelize, type WhereOptions } from "sequelize";
import { UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { WebhookSubscription } from "@server/models";
import { authorize } from "@server/policies";
import pagination from "@server/routes/api/middlewares/pagination";
import pagination, {
paginateQuery,
} from "@server/routes/api/middlewares/pagination";
import type { APIContext } from "@server/types";
import { AuthenticationType } from "@server/types";
import presentWebhookSubscription from "../presenters/webhookSubscription";
@@ -19,23 +22,57 @@ router.post(
"webhookSubscriptions.list",
auth({ role: UserRole.Admin }),
pagination(),
async (ctx: APIContext) => {
validate(T.WebhookSubscriptionsListSchema),
async (ctx: APIContext<T.WebhookSubscriptionsListReq>) => {
const { sort, direction, query } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "listWebhookSubscription", user.team);
const webhooks = await WebhookSubscription.findAll({
where: {
teamId: user.teamId,
},
order: [["createdAt", "DESC"]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
let where: WhereOptions<WebhookSubscription> = {
teamId: user.teamId,
};
if (query) {
where = {
...where,
[Op.and]: [
Sequelize.literal(
`unaccent(LOWER("webhook_subscription"."name")) like unaccent(LOWER(:query))`
),
],
};
}
const replacements = { query: `%${query}%` };
const { results, pagination } = await paginateQuery(
ctx,
(opts) =>
WebhookSubscription.findAll({
where,
replacements,
include: [
{
association: "createdBy",
required: false,
},
],
order: [[sort, direction]],
offset: opts.offset,
limit: opts.limit,
}),
() =>
WebhookSubscription.count({
where,
// @ts-expect-error Types are incorrect for count
replacements,
}) as unknown as Promise<number>
);
ctx.body = {
pagination: ctx.state.pagination,
data: webhooks.map(presentWebhookSubscription),
pagination,
data: results.map(presentWebhookSubscription),
};
}
);
@@ -1,4 +1,5 @@
import type { WebhookSubscription } from "@server/models";
import presentUser from "@server/presenters/user";
export default function presentWebhookSubscription(
webhook: WebhookSubscription
@@ -10,6 +11,8 @@ export default function presentWebhookSubscription(
secret: webhook.secret,
events: webhook.events,
enabled: webhook.enabled,
createdBy: webhook.createdBy ? presentUser(webhook.createdBy) : undefined,
createdById: webhook.createdById,
createdAt: webhook.createdAt,
updatedAt: webhook.updatedAt,
};