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.
This commit is contained in:
mlogclub
2026-05-30 11:23:05 +08:00
parent d2da9de641
commit 8398cd6f91
9 changed files with 653 additions and 354 deletions
@@ -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<TItem>({
</div>
)
}
if (filter.type === "tag") {
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-48"}>
<TagSelector
mode="single"
value={Number(value ?? filter.defaultValue)}
onChange={(nextValue) => 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,
}}
/>
</div>
)
}
return (
<div key={filter.name} className={filter.className ?? "w-full sm:w-64"}>
+356
View File
@@ -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<typeof Button>["variant"]
triggerSize?: ComponentProps<typeof Button>["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<Set<number>>(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<FlatTagNode | null>(() => {
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 (
<div className={cn("space-y-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant={triggerVariant}
size={triggerSize}
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", triggerClassName)}
/>
}
>
<span className="flex min-w-0 items-center gap-2">
<TagIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
</PopoverTrigger>
<PopoverContent
align={align}
className={cn("w-(--radix-popover-trigger-width) min-w-72 p-0", contentClassName)}
>
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={searchPlaceholder}
/>
<CommandList>
{loading ? <CommandEmpty>{loadingText}</CommandEmpty> : null}
{!loading && visibleSelectableTags.length === 0 ? (
<CommandEmpty>{emptyText}</CommandEmpty>
) : null}
{!loading ? (
<CommandGroup>
{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 (
<CommandItem
key={tag.id}
value={tag.searchableText}
disabled={disabled || pendingTagId !== null}
onSelect={() => handleSelect(tag.id)}
>
<div
className="flex min-w-0 flex-1 items-center gap-1.5"
style={{ paddingLeft: `${tag.depth * 14}px` }}
title={tag.path}
>
{hasChildren && !normalizedQuery ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-5 shrink-0"
aria-label={collapsed ? "展开标签" : "折叠标签"}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleCollapsed(tag.id)
}}
>
<ChevronRightIcon
className={cn(
"size-3.5 transition-transform",
!collapsed && "rotate-90"
)}
/>
</Button>
) : (
<span className="size-5 shrink-0" />
)}
{pending ? (
<Loader2Icon className="size-4 shrink-0 animate-spin" />
) : props.mode === "multiple" ? (
<Checkbox
checked={checked}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
) : (
<CheckIcon
className={cn(
"size-4 shrink-0",
checked ? "opacity-100" : "opacity-0"
)}
/>
)}
<span className="min-w-0 flex-1 truncate">{tag.name}</span>
</div>
</CommandItem>
)
})}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showSelectedBadges && props.mode === "multiple" && selectedTags.length > 0 ? (
<TagBadges ids={props.value ?? []} tags={tags} />
) : null}
</div>
)
}
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 (
<div className={cn("flex flex-wrap items-center gap-1.5", className)}>
{safeIds.map((id) => (
<Badge
key={id}
variant="outline"
className="max-w-full px-2 text-[12px] font-normal"
>
<span className="break-all">{tagPathMap.get(id) ?? fallbackMap.get(id) ?? `#${id}`}</span>
</Badge>
))}
</div>
)
}