From 321b232f17b929ae1b5bfac95ba67b09f7f92f53 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 19 Apr 2026 19:27:32 -0400 Subject: [PATCH] Move "Webhook" settings to table (#12119) * Move 'Webhook' settings to table * Add tests --- app/models/WebhookSubscription.ts | 24 ++- app/scenes/Settings/components/Status.tsx | 18 ++- plugins/webhooks/client/Settings.tsx | 137 +++++++++++++---- plugins/webhooks/client/actions.tsx | 84 +++++++++++ .../WebhookSubscriptionListItem.tsx | 86 ----------- .../components/WebhookSubscriptionMenu.tsx | 24 +++ .../components/WebhookSubscriptionsTable.tsx | 138 ++++++++++++++++++ .../useWebhookSubscriptionMenuActions.ts | 26 ++++ plugins/webhooks/server/api/schema.ts | 30 ++++ .../server/api/webhookSubscriptions.test.ts | 72 +++++++++ .../server/api/webhookSubscriptions.ts | 61 ++++++-- .../server/presenters/webhookSubscription.ts | 3 + server/routes/api/apiKeys/apiKeys.test.ts | 57 ++++++++ server/routes/api/apiKeys/apiKeys.ts | 33 ++++- server/routes/api/apiKeys/schema.ts | 2 + shared/i18n/locales/en_US/translation.json | 10 +- shared/styles/theme.ts | 2 +- 17 files changed, 654 insertions(+), 153 deletions(-) create mode 100644 plugins/webhooks/client/actions.tsx delete mode 100644 plugins/webhooks/client/components/WebhookSubscriptionListItem.tsx create mode 100644 plugins/webhooks/client/components/WebhookSubscriptionMenu.tsx create mode 100644 plugins/webhooks/client/components/WebhookSubscriptionsTable.tsx create mode 100644 plugins/webhooks/client/hooks/useWebhookSubscriptionMenuActions.ts diff --git a/app/models/WebhookSubscription.ts b/app/models/WebhookSubscription.ts index d2e1d64a87..14b47d912e 100644 --- a/app/models/WebhookSubscription.ts +++ b/app/models/WebhookSubscription.ts @@ -1,8 +1,11 @@ -import { observable } from "mobx"; +import { computed, observable } from "mobx"; import Model from "./base/Model"; import Field from "./decorators/Field"; +import Relation from "./decorators/Relation"; +import type { Searchable } from "./interfaces/Searchable"; +import User from "./User"; -class WebhookSubscription extends Model { +class WebhookSubscription extends Model implements Searchable { static modelName = "WebhookSubscription"; @Field @@ -24,6 +27,23 @@ class WebhookSubscription extends Model { @Field @observable events: string[]; + + /** The user who created this webhook subscription. */ + @Relation(() => User) + createdBy?: User; + + /** The user ID that created this webhook subscription. */ + createdById: string; + + @computed + get searchContent(): string[] { + return [this.name, this.url, ...(this.events ?? [])].filter(Boolean); + } + + @computed + get searchSuppressed(): boolean { + return false; + } } export default WebhookSubscription; diff --git a/app/scenes/Settings/components/Status.tsx b/app/scenes/Settings/components/Status.tsx index 05c76fcad0..d7aa06db9f 100644 --- a/app/scenes/Settings/components/Status.tsx +++ b/app/scenes/Settings/components/Status.tsx @@ -1,12 +1,14 @@ import Text from "@shared/components/Text"; import { s } from "@shared/styles"; -import styled from "styled-components"; +import styled, { css } from "styled-components"; + +type StatusColor = "accent" | "warning" | "danger" | "textTertiary" | "success"; export const Status = styled(Text).attrs({ type: "secondary", size: "small", as: "span", -})` +})<{ $color?: StatusColor }>` display: inline-flex; align-items: center; @@ -16,11 +18,13 @@ export const Status = styled(Text).attrs({ width: 17px; height: 17px; - background: radial-gradient( - circle at center, - ${s("accent")} 0 33%, - transparent 33% - ); + ${(props) => css` + background: radial-gradient( + circle at center, + ${s(props.$color ?? "accent")} 0 33%, + transparent 33% + ); + `} border-radius: 50%; } `; diff --git a/plugins/webhooks/client/Settings.tsx b/plugins/webhooks/client/Settings.tsx index d16490b263..b940f7132c 100644 --- a/plugins/webhooks/client/Settings.tsx +++ b/plugins/webhooks/client/Settings.tsx @@ -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) => { + 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 ( - - - - - - } - /> - ); -}; - -const StyledBadge = styled(Badge)` - position: absolute; -`; - -export default WebhookSubscriptionListItem; diff --git a/plugins/webhooks/client/components/WebhookSubscriptionMenu.tsx b/plugins/webhooks/client/components/WebhookSubscriptionMenu.tsx new file mode 100644 index 0000000000..547f1f45e9 --- /dev/null +++ b/plugins/webhooks/client/components/WebhookSubscriptionMenu.tsx @@ -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 ( + + + + ); +} + +export default observer(WebhookSubscriptionMenu); diff --git a/plugins/webhooks/client/components/WebhookSubscriptionsTable.tsx b/plugins/webhooks/client/components/WebhookSubscriptionsTable.tsx new file mode 100644 index 0000000000..a5b33e1c60 --- /dev/null +++ b/plugins/webhooks/client/components/WebhookSubscriptionsTable.tsx @@ -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, "columns" | "rowHeight">; + +const WebhookRowContextMenu = observer(function WebhookRowContextMenu({ + webhook, + menuLabel, + children, +}: { + webhook: WebhookSubscription; + menuLabel: string; + children: React.ReactNode; +}) { + const action = useWebhookSubscriptionMenuActions(webhook); + return ( + + {children} + + ); +}); + +export const WebhookSubscriptionsTable = observer( + function WebhookSubscriptionsTable(props: Props) { + const { t } = useTranslation(); + + const applyContextMenu = useCallback( + (webhook: WebhookSubscription, rowElement: React.ReactNode) => ( + + {rowElement} + + ), + [t] + ); + + const columns = useMemo[]>( + () => [ + { + type: "data", + id: "name", + header: t("Name"), + accessor: (webhook) => webhook.name, + component: (webhook) => ( + + + + + {webhook.name} + + ), + width: "2fr", + }, + { + type: "data", + id: "url", + header: t("URL"), + accessor: (webhook) => webhook.url, + component: (webhook) => ( + + {webhook.url} + + ), + width: "3fr", + }, + { + type: "data", + id: "createdById", + header: t("Created by"), + accessor: (webhook) => webhook.createdBy?.name, + component: (webhook) => + webhook.createdBy ? ( + + + {webhook.createdBy.name} + + ) : null, + width: "2fr", + }, + { + type: "data", + id: "updatedAt", + header: t("Last updated"), + accessor: (webhook) => webhook.updatedAt, + component: (webhook) => + webhook.updatedAt ? ( +