From f4ca919dd7da0b72ac05fc078d4dd347c8d9fb19 Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:33:46 +0200 Subject: [PATCH 1/2] Add tag table filtering and search Add URL-persisted filters for tag IDs, tag types, and command aliases, with searchable counts and empty states. Expand tag type handling to distinguish knowledge base, embed, text, and combined content while preserving sortable columns. --- frontend/src/pages/manage/tags/_index.tsx | 346 ++++++++++++++++------ 1 file changed, 254 insertions(+), 92 deletions(-) diff --git a/frontend/src/pages/manage/tags/_index.tsx b/frontend/src/pages/manage/tags/_index.tsx index 362aa4d1..b1432792 100644 --- a/frontend/src/pages/manage/tags/_index.tsx +++ b/frontend/src/pages/manage/tags/_index.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type FC } from "reac import { useQueryClient } from "@tanstack/react-query"; import { apiClient, SKIP_ERROR_TOAST } from "@/lib/api"; import { guildKeys, useGuildPremium, useGuildTags } from "@/hooks/queries/useGuild"; -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import { getGuildById } from "@/stores/auth"; import { toast } from "sonner"; import { MainLayout } from "@/pages/layout/Main"; @@ -21,12 +21,17 @@ import { faShareNodes, faTag, faTrash, + faXmark, } from "@fortawesome/free-solid-svg-icons"; import ActionDropdown from "@/components/ActionDropdown"; +import Checkbox from "@/components/Checkbox"; +import ColumnFilter from "@/components/ColumnFilter"; import EmptyState from "@/components/EmptyState"; import Table from "@/components/Table"; -import SortableHeaderCell from "@/components/SortableHeaderCell"; +import TextInput from "@/components/TextInput"; +import { SortTrigger, ariaSortFor } from "@/components/SortableHeaderCell"; import { useTableSort } from "@/hooks/useTableSort"; +import { matchesSearch } from "@/lib/search"; import type { SortColumn } from "@/lib/table-sort"; import TableSkeleton from "@/components/skeletons/TableSkeleton"; import GallerySubmitModal from "@/components/modals/GallerySubmitModal"; @@ -68,9 +73,29 @@ function resyncSummary(status: TagAliasResyncStatus): string { return `Command aliases resynced: ${parts.join(", ")}.${cause} Discord may take a few minutes to show the changes.`; } +const TAG_TYPES = ["text", "embed", "embed_text", "kb"] as const; +type TagType = (typeof TAG_TYPES)[number]; + +const TAG_TYPE_LABELS: Record = { + text: "Text", + embed: "Embed", + embed_text: "Embed + Text", + kb: "Knowledge Base", +}; + +function isTagType(value: string): value is TagType { + return (TAG_TYPES as readonly string[]).includes(value); +} + +function tagType(tag: Tag): TagType { + if (tag.kb_article_id != null) return "kb"; + if (!tag.use_embed) return "text"; + return tag.content ? "embed_text" : "embed"; +} + const TAG_SORT_COLUMNS: Record<"id" | "type", SortColumn> = { id: { value: (t) => t.id, defaultDir: "asc" }, - type: { value: (t) => (t.use_embed ? "Embed" : "Text"), defaultDir: "asc" }, + type: { value: (t) => TAG_TYPE_LABELS[tagType(t)], defaultDir: "asc" }, }; const TagsPage: FC = () => { @@ -298,12 +323,61 @@ const TagsPage: FC = () => { setDeleteModal(null); }; + const [searchParams, setSearchParams] = useSearchParams(); + const [idFilter, setIdFilter] = useState(() => searchParams.get("id") ?? ""); + const [typeFilter, setTypeFilter] = useState( + () => searchParams.get("type")?.split(",").filter(isTagType) ?? [], + ); + const [aliasOnly, setAliasOnly] = useState(() => searchParams.get("alias") === "1"); + const idQuery = idFilter.trim(); + + useEffect(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (idQuery) next.set("id", idQuery); + else next.delete("id"); + if (typeFilter.length > 0) next.set("type", typeFilter.join(",")); + else next.delete("type"); + if (aliasOnly) next.set("alias", "1"); + else next.delete("alias"); + return next; + }, + { replace: true }, + ); + }, [idQuery, typeFilter, aliasOnly, setSearchParams]); + const tagList = useMemo(() => Object.values(tags), [tags]); - const sort = useTableSort(tagList, TAG_SORT_COLUMNS, { + const filteredTags = useMemo( + () => + tagList.filter( + (t) => + matchesSearch(idQuery, t.id) && + (typeFilter.length === 0 || typeFilter.includes(tagType(t))) && + (!aliasOnly || t.use_guild_command), + ), + [tagList, idQuery, typeFilter, aliasOnly], + ); + const sort = useTableSort(filteredTags, TAG_SORT_COLUMNS, { initialSort: { key: "id", dir: "asc" }, persistKey: "guild-tags", }); + const hasActiveFilters = !!idQuery || typeFilter.length > 0 || aliasOnly; + const hasAliases = tagList.some((t) => t.use_guild_command); + + const clearFilters = () => { + setIdFilter(""); + setTypeFilter([]); + setAliasOnly(false); + }; + + const toggleTypeFilter = (type: TagType) => { + setTypeFilter((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type], + ); + }; + if (loading) { return ( { existingLabel="tags" />
-
- - {cooldownLeft > 0 - ? "Discord is rate limiting this server's commands. Run this again once it clears and it picks up where it stopped." - : RESYNC_TOOLTIP} - {!canResync && " Requires Premium."} - - } - placement="bottom" - className="flex" - > +
+
+

+ {filteredTags.length} + {hasActiveFilters ? ` of ${tagList.length}` : ""} tag + {tagList.length !== 1 ? "s" : ""} +

+ {hasActiveFilters && ( + + )} +
+
+ + {cooldownLeft > 0 + ? "Discord is rate limiting this server's commands. Run this again once it clears and it picks up where it stopped." + : RESYNC_TOOLTIP} + {!canResync && " Requires Premium."} + + } + placement="bottom" + className="flex" + > + + - - +
@@ -398,64 +487,137 @@ const TagsPage: FC = () => { - - + + + } + > + + {(hasAliases || aliasOnly) && ( +
+ +
+ )} +
+
+ + 0} + labelSlot={ + + } + > +
+ {TAG_TYPES.map((type) => ( + toggleTypeFilter(type)} + label={TAG_TYPE_LABELS[type]} + className="text-sm text-gray-200 hover:text-white" + /> + ))} +
+ {typeFilter.length > 0 && ( + + )} +
+
Actions
- {sort.sortedRows.map((tag) => ( - - - {tag.id} - - - {tag.use_embed ? "Embed" : "Text"} - {tag.content && tag.use_embed ? " + Text" : ""} - - - { - setEditingTag(tag); - setEditorOpen(true); - }, - }, - { - label: "Clone", - icon: faCopy, - onClick: () => { - setEditingTag(tag); - setCloningTag(true); - setEditorOpen(true); - }, - }, - ...(canPublishToGallery - ? [ - { - label: "Publish to Gallery", - icon: faShareNodes, - onClick: () => setGallerySubmitTag(tag), - }, - ] - : []), - { - label: "Remove", - icon: faTrash, - variant: "danger", - disabled: isLocked, - onClick: () => setDeleteModal({ isOpen: true, tagId: tag.id }), - }, - ]} + {sort.sortedRows.length === 0 ? ( + + + - ))} + ) : ( + sort.sortedRows.map((tag) => ( + + + {tag.id} + + + {TAG_TYPE_LABELS[tagType(tag)]} + + + { + setEditingTag(tag); + setEditorOpen(true); + }, + }, + { + label: "Clone", + icon: faCopy, + onClick: () => { + setEditingTag(tag); + setCloningTag(true); + setEditorOpen(true); + }, + }, + ...(canPublishToGallery + ? [ + { + label: "Publish to Gallery", + icon: faShareNodes, + onClick: () => setGallerySubmitTag(tag), + }, + ] + : []), + { + label: "Remove", + icon: faTrash, + variant: "danger", + disabled: isLocked, + onClick: () => setDeleteModal({ isOpen: true, tagId: tag.id }), + }, + ]} + /> + + + )) + )}
)} From 24bff06ac41a454f2dd16057cb2f7366148188bf Mon Sep 17 00:00:00 2001 From: biast12 <53872542+biast12@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:37:56 +0200 Subject: [PATCH 2/2] Trim verbose comments on tags page Remove redundant implementation comments and shorten the remaining EmptyState explanation without changing runtime behavior. --- frontend/src/pages/manage/tags/_index.tsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/frontend/src/pages/manage/tags/_index.tsx b/frontend/src/pages/manage/tags/_index.tsx index b1432792..6ca331ae 100644 --- a/frontend/src/pages/manage/tags/_index.tsx +++ b/frontend/src/pages/manage/tags/_index.tsx @@ -122,20 +122,12 @@ const TagsPage: FC = () => { const [forcedLock, setForcedLock] = useState(false); const isLocked = forcedLock || polledLock === true; - // This page is a long-lived list rather than a form the user navigates away - // from after one submit, so a forced lock from a 503 must release once the - // poll confirms the flag is back on, otherwise the page stays locked forever - // after a single incident even though the flag was re-enabled. useEffect(() => { if (polledLock === false) { setForcedLock(false); } }, [polledLock]); - // Announce the lock lifting mid-session (e.g. a flag re-enabled while this page - // is open). The banner's own aria-live region only reliably announces the - // unlocked-to-locked transition (see FeatureLockBanner), so the reverse gets a - // toast instead. Guarded so it never fires on mount, only on a genuine flip. const previousLockRef = useRef(isLocked); useEffect(() => { if (previousLockRef.current && !isLocked) { @@ -162,7 +154,6 @@ const TagsPage: FC = () => { const isResyncRunning = resyncStatus?.status === "running"; const isResyncing = isResyncRunning || isStartingResync; - // Ticked locally so the button re-enables without polling const [cooldownLeft, setCooldownLeft] = useState(0); const cooldownUntil = resyncStatus?.cooldown_until; @@ -195,7 +186,6 @@ const TagsPage: FC = () => { } }, [guildId]); - // Re-attach to a job left running by an earlier visit useEffect(() => { if (!canResync) return; @@ -218,7 +208,6 @@ const TagsPage: FC = () => { return () => clearInterval(id); }, [isResyncRunning, pollResync]); - // Reusing the id replaces the toast in place useEffect(() => { const toastId = resyncToastRef.current; if (!resyncStatus || toastId === null) return; @@ -246,7 +235,6 @@ const TagsPage: FC = () => { } }, [resyncStatus]); - // Never leave a spinner behind on a page the user has left. useEffect( () => () => { if (resyncToastRef.current !== null) { @@ -291,7 +279,6 @@ const TagsPage: FC = () => { const handleSave = async (tag: Tag, originalId?: string) => { try { - // If the ID was renamed, delete the old one first if (originalId && originalId !== tag.id) { await apiClient.tags.delete(guildId, originalId, SKIP_ERROR_TOAST); } @@ -474,9 +461,7 @@ const TagsPage: FC = () => { icon={faTag} title="No tags yet" description="Tags are reusable responses your staff can send in tickets." - // EmptyState has no lock affordance of its own, so when locked the - // action is omitted entirely rather than left clickable: the banner - // above is the only explanation available at this point. + // EmptyState can't show a disabled action, so it's omitted while locked action={ isLocked ? undefined