From d2e65415a8b5b1c27c6282b17bca56b881037e57 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 2 Jun 2026 15:49:40 +0800 Subject: [PATCH] refactor: enhance FAQList component with improved filtering and action handling --- .../knowledge/_components/faq-list.tsx | 468 +++++++++++------- 1 file changed, 287 insertions(+), 181 deletions(-) diff --git a/web/app/dashboard/knowledge/_components/faq-list.tsx b/web/app/dashboard/knowledge/_components/faq-list.tsx index 26f6345..d237e18 100644 --- a/web/app/dashboard/knowledge/_components/faq-list.tsx +++ b/web/app/dashboard/knowledge/_components/faq-list.tsx @@ -1,16 +1,37 @@ "use client"; -import { WrenchIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { + MoreHorizontalIcon, + PencilIcon, + SearchIcon, + Trash2Icon, + WrenchIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react"; import { toast } from "sonner"; import { - DashboardCrudPage, - type DashboardCrudActionState, - type DashboardCrudColumn, - type DashboardCrudFilter, -} from "@/components/dashboard/crud"; + useDashboardPagedList, + type DashboardPagedListFilter, +} from "@/components/dashboard/list"; +import { ListPagination } from "@/components/list-pagination"; +import { OptionCombobox } from "@/components/option-combobox"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, @@ -38,8 +59,11 @@ type FAQListProps = { onActionStateChange?: (state: FAQListActionState) => void; }; -export type FAQListActionState = DashboardCrudActionState & { +export type FAQListActionState = { + onRefresh: () => void; + onCreate: () => void; onImport: () => void; + loading: boolean; importing: boolean; }; @@ -48,31 +72,16 @@ type TFunction = (key: string, values?: Record) => stri function getIndexStatusOptions(t: TFunction) { return [ { value: "all", label: t("knowledge.allIndexStatus") }, - { - value: KnowledgeDocumentIndexStatus.Pending, - label: t("knowledge.indexPending"), - }, - { - value: KnowledgeDocumentIndexStatus.Indexed, - label: t("knowledge.indexIndexed"), - }, - { - value: KnowledgeDocumentIndexStatus.Failed, - label: t("knowledge.indexFailed"), - }, + { value: KnowledgeDocumentIndexStatus.Pending, label: t("knowledge.indexPending") }, + { value: KnowledgeDocumentIndexStatus.Indexed, label: t("knowledge.indexIndexed") }, + { value: KnowledgeDocumentIndexStatus.Failed, label: t("knowledge.indexFailed") }, ]; } function getIndexStatusLabel(status: string, t: TFunction) { - if (status === KnowledgeDocumentIndexStatus.Pending) { - return t("knowledge.indexPending"); - } - if (status === KnowledgeDocumentIndexStatus.Indexed) { - return t("knowledge.indexIndexed"); - } - if (status === KnowledgeDocumentIndexStatus.Failed) { - return t("knowledge.indexFailed"); - } + if (status === KnowledgeDocumentIndexStatus.Pending) return t("knowledge.indexPending"); + if (status === KnowledgeDocumentIndexStatus.Indexed) return t("knowledge.indexIndexed"); + if (status === KnowledgeDocumentIndexStatus.Failed) return t("knowledge.indexFailed"); return status || "-"; } @@ -120,87 +129,151 @@ export function FAQList({ onActionStateChange, }: FAQListProps) { const t = useI18n(); + const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); - const [crudActionState, setCrudActionState] = - useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [actionLoadingMap, setActionLoadingMap] = useState>({}); const indexStatusOptions = useMemo(() => getIndexStatusOptions(t), [t]); useEffect(() => { setSelectedDirectoryId(null); }, [knowledgeBaseId]); + const filters = useMemo(() => [ + { + name: "question", + defaultValue: "", + trim: true, + }, + { + name: "indexStatus", + defaultValue: "all", + allValue: "all", + }, + ], []); + + const fetchList = useCallback(async (query: Record) => { + return fetchKnowledgeFAQs({ + knowledgeBaseId: knowledgeBaseId ?? 0, + directoryId: selectedDirectoryId === null ? undefined : selectedDirectoryId, + question: typeof query.question === "string" ? query.question : undefined, + indexStatus: typeof query.indexStatus === "string" ? query.indexStatus : undefined, + page: typeof query.page === "number" ? query.page : Number(query.page ?? 1), + limit: typeof query.limit === "number" ? query.limit : Number(query.limit ?? 20), + }); + }, [knowledgeBaseId, selectedDirectoryId]); + + const { + draftFilters, + setDraftFilter, + applyFilter, + applyFilters, + loading, + result: faqs, + loadData, + handlePageChange, + handleLimitChange, + } = useDashboardPagedList({ + filters, + fetchList, + enabled: Boolean(knowledgeBaseId), + reloadKey: `${knowledgeBaseId ?? 0}-${selectedDirectoryId ?? "all"}`, + loadFailed: t("knowledge.loadFAQFailed"), + }); + + const openCreateDialog = useCallback(() => { + setEditingItem(null); + setDialogOpen(true); + }, []); + useEffect(() => { - if (!crudActionState) { - return; - } onActionStateChange?.({ - ...crudActionState, + onRefresh: () => void loadData(), + onCreate: openCreateDialog, onImport: () => setImportDialogOpen(true), + loading, importing, }); - }, [crudActionState, importing, onActionStateChange]); + }, [loadData, loading, importing, onActionStateChange, openCreateDialog]); - const filters = useMemo( - () => [ - { - name: "question", - label: t("knowledge.searchFAQ"), - placeholder: t("knowledge.searchFAQ"), - defaultValue: "", - trim: true, - className: "max-w-md flex-1", - }, - { - name: "indexStatus", - label: t("knowledge.allIndexStatus"), - type: "select", - defaultValue: "all", - allValue: "all", - options: indexStatusOptions, - className: "w-full sm:w-48", - }, - ], - [indexStatusOptions, t], - ); + function handleIndexStatusFilterChange(value: string | null) { + applyFilter("indexStatus", value ?? "all"); + } - const columns = useMemo[]>( - () => [ - { - key: "question", - label: t("knowledge.question"), - className: "max-w-sm", - render: (item) => ( - <> -
{item.question}
-
- {item.answer} -
- - ), - }, - { - key: "indexStatus", - label: t("knowledge.indexStatus"), - render: (item) => renderIndexStatusBadge(item, t), - }, - { - key: "similarQuestions", - label: t("knowledge.similarQuestions"), - render: (item) => - Array.isArray(item.similarQuestions) - ? item.similarQuestions.length - : 0, - }, - { - key: "updatedAt", - label: t("knowledge.updatedAt"), - render: (item) => formatDateTime(item.updatedAt), - }, - ], - [t], - ); + function handleFilterKeyDown(event: KeyboardEvent) { + if (event.key !== "Enter") { + return; + } + event.preventDefault(); + applyFilters(); + } + + function openEditDialog(item: KnowledgeFAQ) { + setEditingItem(item); + setDialogOpen(true); + } + + function handleDialogOpenChange(open: boolean) { + if (saving) { + return; + } + if (!open) { + setEditingItem(null); + } + setDialogOpen(open); + } + + async function handleSubmit(payload: CreateKnowledgeFAQPayload) { + if (saving) { + return; + } + setSaving(true); + try { + if (editingItem) { + await updateKnowledgeFAQ({ id: editingItem.id, ...payload }); + toast.success(t("knowledge.faqSaved")); + } else { + await createKnowledgeFAQ(payload); + toast.success(t("knowledge.faqSaved")); + } + setDialogOpen(false); + setEditingItem(null); + await loadData(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.faqSaveFailed")); + } finally { + setSaving(false); + } + } + + async function handleDelete(item: KnowledgeFAQ) { + setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: true } })); + try { + await deleteKnowledgeFAQ(item.id); + toast.success(t("knowledge.faqDeleted")); + await loadData(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.faqDeleteFailed")); + } finally { + setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], delete: false } })); + } + } + + async function handleBuildIndex(item: KnowledgeFAQ) { + setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } })); + try { + await buildKnowledgeFAQIndex(item.id); + toast.success(t("knowledge.faqIndexRebuilt")); + await loadData(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("knowledge.faqIndexRebuildFailed")); + } finally { + setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: false } })); + } + } if (!knowledgeBaseId) { return ( @@ -217,97 +290,130 @@ export function FAQList({ knowledgeBaseId={knowledgeBaseId} selectedDirectoryId={selectedDirectoryId} onSelectDirectory={setSelectedDirectoryId} - onChanged={() => crudActionState?.onRefresh()} + onChanged={() => void loadData()} /> -
- - key={`${knowledgeBaseId}-${selectedDirectoryId ?? "all"}`} - layout="fragment" - showToolbarActions={false} - filters={filters} - columns={columns} - fetchList={(query) => - fetchKnowledgeFAQs({ - knowledgeBaseId, - directoryId: - selectedDirectoryId === null ? undefined : selectedDirectoryId, - question: - typeof query.question === "string" ? query.question : undefined, - indexStatus: - typeof query.indexStatus === "string" - ? query.indexStatus - : undefined, - page: Number(query.page), - limit: Number(query.limit), - }) - } - getItemId={(item) => item.id} - createItem={createKnowledgeFAQ} - updateItem={(item, payload) => - updateKnowledgeFAQ({ id: item.id, ...payload }) - } - deleteItem={(item) => deleteKnowledgeFAQ(item.id)} - rowActions={[ - { - key: "rebuild-index", - icon: , - label: t("knowledge.rebuildIndex"), - run: async ({ item, reload }) => { - try { - await buildKnowledgeFAQIndex(item.id); - toast.success(t("knowledge.faqIndexRebuilt")); - await reload(); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("knowledge.faqIndexRebuildFailed"), - ); - } - }, - }, - ]} - renderEditDialog={({ - open, - saving, - itemId, - onOpenChange, - onSubmit, - }) => ( - +
+
+
+ + setDraftFilter("question", event.target.value)} + onKeyDown={handleFilterKeyDown} + placeholder={t("knowledge.searchFAQ")} + className="h-8 pl-8 text-xs" + /> +
+
+ +
+
+
+
+ +
+ {faqs.results.map((item) => ( + + +
+
+
+
{item.question}
+ {renderIndexStatusBadge(item, t)} +
+
+ {item.answer} +
+
+
+ {formatDateTime(item.updatedAt)} +
+ + } + aria-label={t("knowledge.moreActions", { name: item.question })} + > + + + + openEditDialog(item)}> + + {t("knowledge.edit")} + + void handleBuildIndex(item)}> + + {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")} + + void handleDelete(item)} + className="text-destructive focus:text-destructive" + > + + {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")} + + + +
+
+ + openEditDialog(item)}> + + {t("knowledge.edit")} + + void handleBuildIndex(item)} disabled={actionLoadingMap[item.id]?.rebuildIndex}> + + {actionLoadingMap[item.id]?.rebuildIndex ? t("knowledge.running") : t("knowledge.rebuildIndex")} + + void handleDelete(item)} + variant="destructive" + disabled={actionLoadingMap[item.id]?.delete} + > + + {actionLoadingMap[item.id]?.delete ? t("knowledge.deleting") : t("knowledge.delete")} + + +
+ ))} + {!loading && faqs.results.length === 0 ? ( +
+ {t("knowledge.emptyFAQ")} +
+ ) : null} +
+
+
+
+ - )} - onActionStateChange={setCrudActionState} - labels={{ - refresh: t("knowledge.refreshFAQ"), - create: t("knowledge.newFAQ"), - query: t("knowledge.query"), - loading: t("knowledge.loadingFAQ"), - empty: t("knowledge.emptyFAQ"), - actions: t("knowledge.actions"), - edit: t("knowledge.edit"), - delete: t("knowledge.delete"), - processing: t("knowledge.deleting"), - moreActions: (item) => - t("knowledge.moreActions", { name: item.question }), - loadFailed: t("knowledge.loadFAQFailed"), - saveFailed: t("knowledge.faqSaveFailed"), - deleteFailed: t("knowledge.faqDeleteFailed"), - created: () => t("knowledge.faqSaved"), - updated: () => t("knowledge.faqSaved"), - deleted: () => t("knowledge.faqDeleted"), - }} - /> +
+ + { - crudActionState?.onRefresh(); + await loadData(); }} />