From 8398cd6f91ecb7e2f53f455f4806fb63064aa97c Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 30 May 2026 11:23:05 +0800 Subject: [PATCH] feat: implement TagSelector component for improved tag management across dashboard features - Added TagSelector component to streamline tag selection in various components. - Refactored existing tag handling in DashboardConversationsPage, ConversationTagPicker, EditDialog, and TicketsPage to utilize the new TagSelector. - Removed redundant tag handling functions and optimized state management for tags. - Updated tests to ensure proper functionality of the new tag handling logic. --- .../dashboard/conversation-monitor/page.tsx | 38 +- .../_components/conversation-tag-picker.tsx | 184 +++------ web/app/dashboard/tags/_components/edit.tsx | 65 +--- .../dashboard/tickets/_components/edit.tsx | 113 +----- web/app/dashboard/tickets/page.tsx | 34 +- .../dashboard/list/dashboard-list-page.tsx | 24 +- web/components/tag-selector.tsx | 356 ++++++++++++++++++ web/lib/tag-tree.test.mjs | 104 +++++ web/lib/tag-tree.ts | 89 +++++ 9 files changed, 653 insertions(+), 354 deletions(-) create mode 100644 web/components/tag-selector.tsx create mode 100644 web/lib/tag-tree.test.mjs create mode 100644 web/lib/tag-tree.ts diff --git a/web/app/dashboard/conversation-monitor/page.tsx b/web/app/dashboard/conversation-monitor/page.tsx index 8d6df4d..d31e1e9 100644 --- a/web/app/dashboard/conversation-monitor/page.tsx +++ b/web/app/dashboard/conversation-monitor/page.tsx @@ -23,6 +23,7 @@ import { OptionCombobox, type ComboboxOption, } from "@/components/option-combobox" +import { TagSelector } from "@/components/tag-selector" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" @@ -112,24 +113,6 @@ function getStatusOptions( ] } -function buildTagOptions( - nodes: TagTree[], - parentPath = "" -): ComboboxOption[] { - const result: ComboboxOption[] = [] - nodes.forEach((item) => { - const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name - result.push({ - value: String(item.id), - label: currentPath, - }) - if (item.children.length > 0) { - result.push(...buildTagOptions(item.children, currentPath)) - } - }) - return result -} - export default function DashboardConversationsPage() { const t = useI18n() const statusOptions = useMemo(() => getStatusOptions(t), [t]) @@ -145,9 +128,7 @@ export default function DashboardConversationsPage() { const [agentTeamFilter, setAgentTeamFilter] = useState("0") const [page, setPage] = useState(1) const [limit, setLimit] = useState(20) - const [tagOptions, setTagOptions] = useState([ - { value: "0", label: t("conversationMonitor.allTags") }, - ]) + const [tags, setTags] = useState([]) const [assigneeOptions, setAssigneeOptions] = useState([ { value: "0", label: t("conversationMonitor.allAssignees") }, ]) @@ -213,10 +194,7 @@ export default function DashboardConversationsPage() { fetchAgentTeamsAll(), ]) if (!cancelled) { - setTagOptions([ - { value: "0", label: t("conversationMonitor.allTags") }, - ...buildTagOptions(tagData), - ]) + setTags(Array.isArray(tagData) ? tagData : []) setAssigneeOptions([ { value: "0", label: t("conversationMonitor.allAssignees") }, ...assigneeData.map((item: AdminAgentProfile) => ({ @@ -675,13 +653,15 @@ export default function DashboardConversationsPage() { />
- setTagFilterInput(String(value))} + tags={tags} placeholder={t("conversationMonitor.selectTag")} searchPlaceholder={t("conversationMonitor.searchTagPath")} emptyText={t("conversationMonitor.emptyTags")} - onChange={setTagFilterInput} + rootOption={{ value: 0, label: t("conversationMonitor.allTags") }} />
diff --git a/web/app/dashboard/conversations/_components/conversation-tag-picker.tsx b/web/app/dashboard/conversations/_components/conversation-tag-picker.tsx index 32da2ff..94cd71d 100644 --- a/web/app/dashboard/conversations/_components/conversation-tag-picker.tsx +++ b/web/app/dashboard/conversations/_components/conversation-tag-picker.tsx @@ -1,24 +1,9 @@ "use client" -import { CheckIcon, Loader2Icon, TagIcon } from "lucide-react" import { useMemo, useState } from "react" import { toast } from "sonner" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" +import { TagBadges, TagSelector } from "@/components/tag-selector" import { addConversationTag, removeConversationTag, @@ -27,39 +12,6 @@ import { } from "@/lib/api/agent" import { type TagTree } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" -import { cn } from "@/lib/utils" - -type TagNode = TagTree & { - depth: number -} - -function flattenTagTree(nodes: TagTree[], depth = 0): TagNode[] { - const result: TagNode[] = [] - nodes.forEach((item) => { - result.push({ ...item, depth }) - if (item.children.length > 0) { - result.push(...flattenTagTree(item.children, depth + 1)) - } - }) - return result -} - -function buildTagPathMap( - nodes: TagTree[], - parentPath = "" -): Map { - const result = new Map() - nodes.forEach((item) => { - const currentPath = parentPath ? `${parentPath} / ${item.name}` : item.name - result.set(item.id, currentPath) - if (item.children.length > 0) { - buildTagPathMap(item.children, currentPath).forEach((value, key) => { - result.set(key, value) - }) - } - }) - return result -} type ConversationTagPickerProps = { conversation: AgentConversation @@ -77,34 +29,45 @@ export function ConversationTagPicker({ const t = useI18n() const [pendingTagId, setPendingTagId] = useState(null) - const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags]) - const selectedTagIds = useMemo( - () => new Set((conversation.tags ?? []).map((item) => item.id)), + const selectedValues = useMemo( + () => (conversation.tags ?? []).map((item) => item.id), [conversation.tags] ) + const selectedTagIds = useMemo( + () => new Set(selectedValues), + [selectedValues] + ) - async function handleToggle(tag: TagNode) { + async function handleChange(nextTagIds: number[]) { if (pendingTagId !== null) { return } - const exists = selectedTagIds.has(tag.id) + const tagId = + nextTagIds.find((id) => !selectedTagIds.has(id)) ?? + selectedValues.find((id) => !nextTagIds.includes(id)) + + if (!tagId) { + return + } + + const exists = selectedTagIds.has(tagId) const currentTags = conversation.tags ?? [] const nextTags = exists - ? currentTags.filter((item) => item.id !== tag.id) - : [...currentTags, { id: tag.id, name: tag.name }] + ? currentTags.filter((item) => item.id !== tagId) + : [...currentTags, { id: tagId, name: "" }] - setPendingTagId(tag.id) + setPendingTagId(tagId) try { if (exists) { await removeConversationTag({ conversationId: conversation.id, - tagId: tag.id, + tagId, }) } else { await addConversationTag({ conversationId: conversation.id, - tagId: tag.id, + tagId, }) } onTagsChange(nextTags) @@ -117,70 +80,25 @@ export function ConversationTagPicker({ } return ( - - - } - > - - {t("conversation.edit")} - - event.stopPropagation()} - > - - - - {loading ? {t("conversation.loadingTags")} : null} - {!loading && flattenedTags.length === 0 ? ( - {t("conversation.emptyTags")} - ) : null} - {!loading ? ( - - {flattenedTags.map((tag) => { - const checked = selectedTagIds.has(tag.id) - const pending = pendingTagId === tag.id - return ( - void handleToggle(tag)} - > - {pending ? ( - - ) : ( - - )} - - {tag.name} - - - ) - })} - - ) : null} - - - - + void handleChange(value)} + tags={availableTags} + loading={loading} + pendingTagId={pendingTagId} + placeholder={t("conversation.edit")} + triggerText={t("conversation.edit")} + searchPlaceholder={t("conversation.searchTags")} + loadingText={t("conversation.loadingTags")} + emptyText={t("conversation.emptyTags")} + align="end" + showSelectedBadges={false} + triggerVariant="ghost" + triggerSize="sm" + triggerClassName="h-7 w-auto shrink-0 justify-start gap-1 px-2 text-xs" + contentClassName="w-72" + /> ) } @@ -197,21 +115,11 @@ export function ConversationTagBadges({ return null } - const tagPathMap = buildTagPathMap(availableTags) - return ( -
- {tags.map((tag) => ( - - - {tagPathMap.get(tag.id) ?? tag.name} - - - ))} -
+ tag.id)} + tags={availableTags} + fallbackTags={tags} + /> ) } diff --git a/web/app/dashboard/tags/_components/edit.tsx b/web/app/dashboard/tags/_components/edit.tsx index d26d22c..f072f54 100644 --- a/web/app/dashboard/tags/_components/edit.tsx +++ b/web/app/dashboard/tags/_components/edit.tsx @@ -5,8 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Controller, Resolver, useForm } from "react-hook-form"; import { z } from "zod/v4"; -import { OptionCombobox } from "@/components/option-combobox"; import { ProjectDialog } from "@/components/project-dialog"; +import { TagSelector } from "@/components/tag-selector"; import { Button } from "@/components/ui/button"; import { Field, @@ -66,39 +66,6 @@ function buildPayload(form: EditForm): CreateTagPayload { }; } -type TagTreeNode = TagTree & { - children: TagTreeNode[]; - depth: number; -}; - -function withDepth( - nodes: TagTree[] | null | undefined, - depth = 0, -): TagTreeNode[] { - const safeNodes = Array.isArray(nodes) ? nodes : []; - - return safeNodes.map((node) => ({ - ...node, - depth, - children: withDepth(node.children, depth + 1), - })); -} - -function flattenTreeForSelect( - nodes: TagTreeNode[], - excludeId?: number, -): { id: number; name: string; depth: number }[] { - const result: { id: number; name: string; depth: number }[] = []; - function traverse(node: TagTreeNode) { - if (node.id !== excludeId) { - result.push({ id: node.id, name: node.name, depth: node.depth }); - node.children.forEach(traverse); - } - } - nodes.forEach(traverse); - return result; -} - export function EditDialog({ open, saving, @@ -140,9 +107,7 @@ function TagFormDialogBody({ const t = useI18n(); const formId = "tag-edit-form"; const [loading, setLoading] = useState(false); - const [parentTags, setParentTags] = useState< - { id: number; name: string; depth: number }[] - >([]); + const [parentTags, setParentTags] = useState([]); const tagFormSchema = useMemo( () => @@ -157,17 +122,6 @@ function TagFormDialogBody({ () => zodResolver(tagFormSchema as never) as Resolver, [tagFormSchema], ); - const parentOptions = useMemo( - () => [ - { value: "0", label: t("tag.rootParent") }, - ...parentTags.map((tag) => ({ - value: String(tag.id), - label: `${" ".repeat(tag.depth)}${tag.name}`, - })), - ], - [parentTags, t], - ); - const form = useForm({ resolver: editFormResolver, defaultValues: emptyForm, @@ -184,9 +138,7 @@ function TagFormDialogBody({ async function loadParentTags() { try { const data = await fetchTagsAll(); - const tree = withDepth(data); - const flatList = flattenTreeForSelect(tree, itemId ?? undefined); - setParentTags(flatList); + setParentTags(Array.isArray(data) ? data : []); } catch (error) { console.error("Failed to load parent tags:", error); } @@ -258,14 +210,17 @@ function TagFormDialogBody({ control={control} name="parentId" render={({ field }) => ( - field.onChange(String(value))} + tags={parentTags} placeholder={t("tag.rootParent")} searchPlaceholder={t("tag.searchParent")} emptyText={t("tag.emptyParent")} disabled={saving} - onChange={field.onChange} + rootOption={{ value: 0, label: t("tag.rootParent") }} + excludeIds={itemId ? [itemId] : undefined} /> )} /> diff --git a/web/app/dashboard/tickets/_components/edit.tsx b/web/app/dashboard/tickets/_components/edit.tsx index 65809e5..fb98efc 100644 --- a/web/app/dashboard/tickets/_components/edit.tsx +++ b/web/app/dashboard/tickets/_components/edit.tsx @@ -1,6 +1,5 @@ "use client" -import { CheckIcon, TagIcon } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { zodResolver } from "@hookform/resolvers/zod" import type { Resolver } from "react-hook-form" @@ -11,16 +10,8 @@ import { ContentEditor } from "@/components/content-editor" import { OptionCombobox } from "@/components/option-combobox" import { ProjectDialog } from "@/components/project-dialog" import { isRichTextEmpty } from "@/components/safe-rich-html" -import { Badge } from "@/components/ui/badge" +import { TagSelector } from "@/components/tag-selector" import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command" import { Field, FieldContent, @@ -29,7 +20,6 @@ import { FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { fetchAgentProfilesAll, fetchTagsAll, @@ -113,97 +103,6 @@ function buildPayload(form: EditForm): CreateTicketPayload { } } -type FlatTagNode = TagTree & { - depth: number - path: string -} - -function flattenTagTree(nodes: TagTree[], depth = 0, parentPath = ""): FlatTagNode[] { - const result: FlatTagNode[] = [] - nodes.forEach((item) => { - const path = parentPath ? `${parentPath} / ${item.name}` : item.name - result.push({ ...item, depth, path }) - if (item.children.length > 0) { - result.push(...flattenTagTree(item.children, depth + 1, path)) - } - }) - return result -} - -type TicketTagSelectorProps = { - value?: number[] - onChange: (value: number[]) => void - availableTags: TagTree[] - t: TFunction -} - -function TicketTagSelector({ value, onChange, availableTags, t }: TicketTagSelectorProps) { - const selectedValues = useMemo(() => value ?? [], [value]) - const flatTags = useMemo(() => flattenTagTree(availableTags), [availableTags]) - const selectedTagIDs = useMemo(() => new Set(selectedValues), [selectedValues]) - const selectedTags = useMemo( - () => flatTags.filter((tag) => selectedTagIDs.has(tag.id)), - [flatTags, selectedTagIDs], - ) - - function handleToggle(tagID: number) { - if (selectedTagIDs.has(tagID)) { - onChange(selectedValues.filter((item) => item !== tagID)) - return - } - onChange(selectedValues.concat(tagID)) - } - - return ( -
- - - } - > - - {selectedTags.length > 0 ? t("ticket.selectedTags", { count: selectedTags.length }) : t("ticket.selectTags")} - - - - - - {t("ticket.emptyTags")} - - {flatTags.map((tag) => { - const checked = selectedTagIDs.has(tag.id) - return ( - handleToggle(tag.id)} - > - - - {tag.name} - - - ) - })} - - - - - - {selectedTags.length > 0 ? ( -
- {selectedTags.map((tag) => ( - - {tag.path} - - ))} -
- ) : null} -
- ) -} - export function EditDialog({ open, saving, @@ -421,11 +320,15 @@ function TicketEditDialogBody({ control={control} name="tagIds" render={({ field }) => ( - t("ticket.selectedTags", { count })} + searchPlaceholder={t("ticket.searchTags")} + emptyText={t("ticket.emptyTags")} /> )} /> diff --git a/web/app/dashboard/tickets/page.tsx b/web/app/dashboard/tickets/page.tsx index 462c25c..2b1ed4e 100644 --- a/web/app/dashboard/tickets/page.tsx +++ b/web/app/dashboard/tickets/page.tsx @@ -60,10 +60,6 @@ function getAssigneeAllOption(t: TFunction): ComboboxOption { return { value: "0", label: t("ticket.allAssignees") } } -function getTagAllOption(t: TFunction): ComboboxOption { - return { value: "0", label: t("ticket.allTags") } -} - function getStaleHourOptions(t: TFunction): ComboboxOption[] { return [ { value: "24", label: t("ticket.hours", { hours: 24 }) }, @@ -72,21 +68,6 @@ function getStaleHourOptions(t: TFunction): ComboboxOption[] { ] } -function buildTagOptions(nodes: TagTree[], parentPath = ""): ComboboxOption[] { - const result: ComboboxOption[] = [] - nodes.forEach((item) => { - const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name - result.push({ - value: String(item.id), - label: currentPath, - }) - if (item.children.length > 0) { - result.push(...buildTagOptions(item.children, currentPath)) - } - }) - return result -} - function sourceLabel(source: string, t: TFunction) { switch (source) { case "manual": @@ -117,7 +98,7 @@ export default function TicketsPage() { const searchParams = useSearchParams() const [summary, setSummary] = useState(emptySummary) const [assigneeOptions, setAssigneeOptions] = useState([]) - const [tagOptions, setTagOptions] = useState([]) + const [tags, setTags] = useState([]) const [selectedTicketId, setSelectedTicketId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) @@ -125,7 +106,6 @@ export default function TicketsPage() { const listReloadRef = useRef<() => Promise>(async () => undefined) const assigneeAllOption = useMemo(() => getAssigneeAllOption(t), [t]) - const tagAllOption = useMemo(() => getTagAllOption(t), [t]) const staleHourOptions = useMemo(() => getStaleHourOptions(t), [t]) const quickViews = useMemo( @@ -201,11 +181,13 @@ export default function TicketsPage() { { name: "tagId", label: t("ticket.allTags"), - type: "select", + type: "tag", defaultValue: "0", allValue: "0", valueType: "number", - options: tagOptions, + tags, + searchPlaceholder: t("ticket.searchTags"), + emptyText: t("ticket.emptyTags"), className: "w-full sm:w-44", }, { @@ -217,7 +199,7 @@ export default function TicketsPage() { className: "w-full sm:w-40", }, ], - [assigneeOptions, quickViews, staleHourOptions, tagOptions, t], + [assigneeOptions, quickViews, staleHourOptions, tags, t], ) const fetchList = useCallback( @@ -365,7 +347,7 @@ export default function TicketsPage() { t("ticket.agentFallback", { id: agent.userId }), })), ]) - setTagOptions([tagAllOption, ...buildTagOptions(Array.isArray(tags) ? tags : [])]) + setTags(Array.isArray(tags) ? tags : []) }) .catch((error) => { toast.error(error instanceof Error ? error.message : t("ticket.loadFiltersFailed")) @@ -373,7 +355,7 @@ export default function TicketsPage() { return () => { active = false } - }, [assigneeAllOption, tagAllOption, t]) + }, [assigneeAllOption, t]) async function handleCreateTicket(payload: CreateTicketPayload) { setSavingCreate(true) diff --git a/web/components/dashboard/list/dashboard-list-page.tsx b/web/components/dashboard/list/dashboard-list-page.tsx index 8e79762..549168c 100644 --- a/web/components/dashboard/list/dashboard-list-page.tsx +++ b/web/components/dashboard/list/dashboard-list-page.tsx @@ -11,8 +11,10 @@ import { } from "@/components/dashboard-page" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" +import { TagSelector } from "@/components/tag-selector" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import type { TagTree } from "@/lib/api/admin" import { Table, TableBody, @@ -34,10 +36,11 @@ export type DashboardListFilter = DashboardCrudQueryFilter & { label: string placeholder?: string defaultValue: string | number - type?: "text" | "select" | "segment" + type?: "text" | "select" | "segment" | "tag" className?: string inputClassName?: string options?: ReadonlyArray<{ value: string; label: string }> + tags?: TagTree[] searchPlaceholder?: string emptyText?: string icon?: ReactNode @@ -172,6 +175,25 @@ export function DashboardListPage({
) } + if (filter.type === "tag") { + return ( +
+ list.setDraftFilter(filter.name, nextValue)} + tags={filter.tags ?? []} + placeholder={filter.placeholder ?? filter.label} + searchPlaceholder={filter.searchPlaceholder} + emptyText={filter.emptyText} + rootOption={{ + value: Number(filter.allValue ?? filter.defaultValue), + label: filter.placeholder ?? filter.label, + }} + /> +
+ ) + } return (
diff --git a/web/components/tag-selector.tsx b/web/components/tag-selector.tsx new file mode 100644 index 0000000..ead0e21 --- /dev/null +++ b/web/components/tag-selector.tsx @@ -0,0 +1,356 @@ +"use client" + +import { + CheckIcon, + ChevronRightIcon, + ChevronsUpDownIcon, + Loader2Icon, + TagIcon, +} from "lucide-react" +import { useMemo, useState, type ComponentProps } from "react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { useI18n } from "@/i18n/provider" +import type { TagTree } from "@/lib/api/admin" +import { + buildTagPathMap, + flattenTagTree, + flattenVisibleTagTree, + type FlatTagNode, +} from "@/lib/tag-tree" +import { cn } from "@/lib/utils" + +type CommonTagSelectorProps = { + tags: TagTree[] + placeholder: string + searchPlaceholder?: string + emptyText?: string + loadingText?: string + disabled?: boolean + loading?: boolean + excludeIds?: number[] + align?: "start" | "center" | "end" + className?: string + triggerClassName?: string + triggerVariant?: ComponentProps["variant"] + triggerSize?: ComponentProps["size"] + contentClassName?: string + showSelectedBadges?: boolean + selectedCountText?: (count: number) => string + triggerText?: string + pendingTagId?: number | null +} + +type MultipleTagSelectorProps = CommonTagSelectorProps & { + mode: "multiple" + value?: number[] + onChange: (value: number[]) => void +} + +type SingleTagSelectorProps = CommonTagSelectorProps & { + mode: "single" + value?: number | null + onChange: (value: number) => void + rootOption?: { + value: number + label: string + } +} + +export type TagSelectorProps = MultipleTagSelectorProps | SingleTagSelectorProps + +function isSelected(props: TagSelectorProps, tagId: number) { + if (props.mode === "single") { + return props.value === tagId + } + return new Set(props.value ?? []).has(tagId) +} + +function getSelectedTags(flatTags: FlatTagNode[], value?: number[]) { + const selectedIds = new Set(value ?? []) + return flatTags.filter((tag) => selectedIds.has(tag.id)) +} + +export function TagSelector(props: TagSelectorProps) { + const t = useI18n() + const [open, setOpen] = useState(false) + const { + tags, + placeholder, + searchPlaceholder = t("common.searchKeyword"), + emptyText = t("common.emptyOptions"), + loadingText = t("common.loading"), + disabled = false, + loading = false, + excludeIds, + align = "start", + className, + triggerClassName, + triggerVariant = "outline", + triggerSize, + contentClassName, + showSelectedBadges = props.mode === "multiple", + selectedCountText, + triggerText, + pendingTagId = null, + } = props + const [query, setQuery] = useState("") + const [collapsedIds, setCollapsedIds] = useState>(new Set()) + + const flatTags = useMemo( + () => flattenTagTree(tags, { excludeIds }), + [excludeIds, tags] + ) + const visibleFlatTags = useMemo( + () => flattenVisibleTagTree(tags, { excludeIds, collapsedIds: [...collapsedIds] }), + [collapsedIds, excludeIds, tags] + ) + + const selectedTags = useMemo( + () => (props.mode === "multiple" ? getSelectedTags(flatTags, props.value) : []), + [flatTags, props] + ) + + const rootTag = useMemo(() => { + if (props.mode !== "single" || !props.rootOption) { + return null + } + return { + id: props.rootOption.value, + parentId: 0, + name: props.rootOption.label, + remark: "", + sortNo: 0, + status: 0, + createdAt: "", + updatedAt: "", + children: [], + depth: 0, + path: props.rootOption.label, + searchableText: `${props.rootOption.label} ${props.rootOption.value}`, + } + }, [props]) + + const allSelectableTags = useMemo( + () => (props.mode === "single" && rootTag ? [rootTag, ...flatTags] : flatTags), + [flatTags, props.mode, rootTag] + ) + const normalizedQuery = query.trim().toLowerCase() + const visibleSelectableTags = useMemo(() => { + const visibleTags = props.mode === "single" && rootTag + ? [rootTag, ...visibleFlatTags] + : visibleFlatTags + + if (!normalizedQuery) { + return visibleTags + } + + return allSelectableTags.filter((tag) => + tag.searchableText.toLowerCase().includes(normalizedQuery) + ) + }, [allSelectableTags, normalizedQuery, props.mode, rootTag, visibleFlatTags]) + const singleSelected = props.mode === "single" + ? allSelectableTags.find((tag) => tag.id === props.value) + : null + const triggerLabel = + triggerText ?? + (props.mode === "multiple" + ? selectedTags.length > 0 + ? selectedCountText?.(selectedTags.length) ?? `${placeholder} (${selectedTags.length})` + : placeholder + : singleSelected?.path ?? placeholder) + + function handleSelect(tagId: number) { + if (props.mode === "single") { + props.onChange(tagId) + setOpen(false) + return + } + + const selectedIds = new Set(props.value ?? []) + if (selectedIds.has(tagId)) { + props.onChange((props.value ?? []).filter((item) => item !== tagId)) + return + } + props.onChange([...(props.value ?? []), tagId]) + } + + function toggleCollapsed(tagId: number) { + setCollapsedIds((current) => { + const next = new Set(current) + if (next.has(tagId)) { + next.delete(tagId) + } else { + next.add(tagId) + } + return next + }) + } + + return ( +
+ + + } + > + + + {triggerLabel} + + + + + + + + {loading ? {loadingText} : null} + {!loading && visibleSelectableTags.length === 0 ? ( + {emptyText} + ) : null} + {!loading ? ( + + {visibleSelectableTags.map((tag) => { + const checked = isSelected(props, tag.id) + const pending = pendingTagId === tag.id + const hasChildren = tag.children.length > 0 + const collapsed = collapsedIds.has(tag.id) + + return ( + handleSelect(tag.id)} + > +
+ {hasChildren && !normalizedQuery ? ( + + ) : ( + + )} + {pending ? ( + + ) : props.mode === "multiple" ? ( +
+
+ ) + })} +
+ ) : null} +
+
+
+
+ + {showSelectedBadges && props.mode === "multiple" && selectedTags.length > 0 ? ( + + ) : null} +
+ ) +} + +type TagBadgesProps = { + ids?: number[] + tags: TagTree[] + fallbackTags?: Array<{ id: number; name: string }> + className?: string +} + +export function TagBadges({ + ids, + tags, + fallbackTags = [], + className, +}: TagBadgesProps) { + const tagPathMap = buildTagPathMap(tags) + const fallbackMap = new Map(fallbackTags.map((tag) => [tag.id, tag.name])) + const safeIds = ids ?? [] + + if (safeIds.length === 0) { + return null + } + + return ( +
+ {safeIds.map((id) => ( + + {tagPathMap.get(id) ?? fallbackMap.get(id) ?? `#${id}`} + + ))} +
+ ) +} diff --git a/web/lib/tag-tree.test.mjs b/web/lib/tag-tree.test.mjs new file mode 100644 index 0000000..f82036e --- /dev/null +++ b/web/lib/tag-tree.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import test from "node:test" +import ts from "typescript" +import vm from "node:vm" + +async function loadModule() { + const source = await readFile(new URL("./tag-tree.ts", import.meta.url), "utf8") + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + }, + fileName: "tag-tree.ts", + }) + const sandbox = { + exports: {}, + module: { exports: {} }, + } + sandbox.exports = sandbox.module.exports + vm.runInNewContext(compiled.outputText, sandbox) + return sandbox.module.exports +} + +const tags = [ + { + id: 1, + parentId: 0, + name: "产品", + remark: "", + sortNo: 1, + status: 0, + createdAt: "", + updatedAt: "", + children: [ + { + id: 2, + parentId: 1, + name: "退款", + remark: "售后", + sortNo: 1, + status: 0, + createdAt: "", + updatedAt: "", + children: [], + }, + ], + }, + { + id: 3, + parentId: 0, + name: "技术", + remark: "", + sortNo: 2, + status: 0, + createdAt: "", + updatedAt: "", + children: [], + }, +] + +test("flattens tag tree with depth and full path", async () => { + const { flattenTagTree } = await loadModule() + + assert.equal( + JSON.stringify(flattenTagTree(tags).map((item) => ({ + id: item.id, + depth: item.depth, + path: item.path, + searchableText: item.searchableText, + }))), + JSON.stringify([ + { id: 1, depth: 0, path: "产品", searchableText: "产品 1 " }, + { id: 2, depth: 1, path: "产品 / 退款", searchableText: "产品 / 退款 2 售后" }, + { id: 3, depth: 0, path: "技术", searchableText: "技术 3 " }, + ]) + ) +}) + +test("excludes a tag and its descendants for parent selection", async () => { + const { flattenTagTree } = await loadModule() + + assert.equal( + JSON.stringify(flattenTagTree(tags, { excludeIds: [1] }).map((item) => item.id)), + JSON.stringify([3]) + ) +}) + +test("builds full-path map for selected tag badges", async () => { + const { buildTagPathMap } = await loadModule() + + assert.equal(buildTagPathMap(tags).get(2), "产品 / 退款") +}) + +test("flattens only visible branches when a parent is collapsed", async () => { + const { flattenVisibleTagTree } = await loadModule() + + assert.equal( + JSON.stringify( + flattenVisibleTagTree(tags, { collapsedIds: [1] }).map((item) => item.id) + ), + JSON.stringify([1, 3]) + ) +}) diff --git a/web/lib/tag-tree.ts b/web/lib/tag-tree.ts new file mode 100644 index 0000000..ab51607 --- /dev/null +++ b/web/lib/tag-tree.ts @@ -0,0 +1,89 @@ +import type { TagTree } from "@/lib/api/admin" + +export type FlatTagNode = TagTree & { + depth: number + path: string + searchableText: string +} + +type FlattenTagTreeOptions = { + excludeIds?: number[] +} + +type FlattenVisibleTagTreeOptions = FlattenTagTreeOptions & { + collapsedIds?: number[] +} + +export function flattenTagTree( + nodes: TagTree[] | null | undefined, + options: FlattenTagTreeOptions = {} +): FlatTagNode[] { + const excluded = new Set(options.excludeIds ?? []) + const result: FlatTagNode[] = [] + + function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) { + const safeItems = Array.isArray(items) ? items : [] + + safeItems.forEach((item) => { + if (excluded.has(item.id)) { + return + } + + const path = parentPath ? `${parentPath} / ${item.name}` : item.name + result.push({ + ...item, + depth, + path, + searchableText: `${path} ${item.id} ${item.remark ?? ""}`, + }) + walk(item.children, depth + 1, path) + }) + } + + walk(nodes, 0, "") + return result +} + +export function buildTagPathMap( + nodes: TagTree[] | null | undefined +): Map { + const result = new Map() + flattenTagTree(nodes).forEach((item) => { + result.set(item.id, item.path) + }) + return result +} + +export function flattenVisibleTagTree( + nodes: TagTree[] | null | undefined, + options: FlattenVisibleTagTreeOptions = {} +): FlatTagNode[] { + const excluded = new Set(options.excludeIds ?? []) + const collapsed = new Set(options.collapsedIds ?? []) + const result: FlatTagNode[] = [] + + function walk(items: TagTree[] | null | undefined, depth: number, parentPath: string) { + const safeItems = Array.isArray(items) ? items : [] + + safeItems.forEach((item) => { + if (excluded.has(item.id)) { + return + } + + const path = parentPath ? `${parentPath} / ${item.name}` : item.name + result.push({ + ...item, + depth, + path, + searchableText: `${path} ${item.id} ${item.remark ?? ""}`, + }) + + if (!collapsed.has(item.id)) { + walk(item.children, depth + 1, path) + } + }) + } + + walk(nodes, 0, "") + return result +}