feat: add KnowledgeConfirmDialog component and integrate delete confirmation dialog in KnowledgePage
This commit is contained in:
109
src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx
Normal file
109
src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type KnowledgeConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
fileName: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
confirmingLabel: string;
|
||||||
|
closeLabel: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KnowledgeConfirmDialog({
|
||||||
|
open,
|
||||||
|
busy,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cancelLabel,
|
||||||
|
confirmLabel,
|
||||||
|
confirmingLabel,
|
||||||
|
closeLabel,
|
||||||
|
icon,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: KnowledgeConfirmDialogProps) {
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (!nextOpen && !busy) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]" />
|
||||||
|
<Dialog.Content
|
||||||
|
className="fixed left-1/2 top-1/2 z-60 w-[calc(100vw-32px)] max-w-130 -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-[#F4F3EB] p-0 shadow-[0_30px_80px_rgba(15,23,42,0.18)] outline-none dark:bg-[#1f1f22]"
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (busy) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-black/6 px-6 py-5 dark:border-white/6">
|
||||||
|
<div className="flex min-w-0 items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-red-500/15 bg-red-500/10 text-red-600 dark:border-red-400/20 dark:bg-red-500/10 dark:text-red-300">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Dialog.Title
|
||||||
|
className="text-[26px] font-normal leading-none tracking-tight text-[#171717] dark:text-[#f3f4f6]"
|
||||||
|
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description className="mt-3 text-[14px] leading-6 text-[#525866] dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-[#99A0AE] transition-colors hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-[#f3f4f6]"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={closeLabel}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8">
|
||||||
|
<path d="M6 6L18 18M18 6L6 18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 items-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 items-center rounded-full bg-red-600 px-4 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? confirmingLabel : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ export { default as KnowledgeDocsTable } from './KnowledgeDocsTable';
|
|||||||
export type { KnowledgeDocRow } from './KnowledgeDocsTable';
|
export type { KnowledgeDocRow } from './KnowledgeDocsTable';
|
||||||
export { default as KnowledgeEmptyState } from './KnowledgeEmptyState';
|
export { default as KnowledgeEmptyState } from './KnowledgeEmptyState';
|
||||||
export { default as KnowledgeFeedbackBanner } from './KnowledgeFeedbackBanner';
|
export { default as KnowledgeFeedbackBanner } from './KnowledgeFeedbackBanner';
|
||||||
|
export { default as KnowledgeConfirmDialog } from './KnowledgeConfirmDialog';
|
||||||
export { default as KnowledgePageHeader } from './KnowledgePageHeader';
|
export { default as KnowledgePageHeader } from './KnowledgePageHeader';
|
||||||
export { default as KnowledgeToolbar } from './KnowledgeToolbar';
|
export { default as KnowledgeToolbar } from './KnowledgeToolbar';
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const EN_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
storageLabel: 'Storage',
|
storageLabel: 'Storage',
|
||||||
emptyTitle: 'No documents yet',
|
emptyTitle: 'No documents yet',
|
||||||
emptyDescription: 'Upload a file to start managing the local knowledge docs directory.',
|
emptyDescription: 'Upload a file to start managing the local knowledge docs directory.',
|
||||||
deleteConfirm: 'Delete this document?',
|
|
||||||
table: {
|
table: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
size: 'Size',
|
size: 'Size',
|
||||||
@@ -39,6 +38,12 @@ const EN_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
confirm: 'Upload',
|
confirm: 'Upload',
|
||||||
},
|
},
|
||||||
|
deleteDialog: {
|
||||||
|
title: 'Delete document?',
|
||||||
|
description: 'This will permanently remove "{name}" from the local knowledge docs directory.',
|
||||||
|
confirm: 'Delete document',
|
||||||
|
close: 'Close dialog',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
loading: 'Loading documents...',
|
loading: 'Loading documents...',
|
||||||
uploading: 'Uploading...',
|
uploading: 'Uploading...',
|
||||||
@@ -62,7 +67,6 @@ const ZH_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
storageLabel: '占用空间',
|
storageLabel: '占用空间',
|
||||||
emptyTitle: '暂无文档',
|
emptyTitle: '暂无文档',
|
||||||
emptyDescription: '先上传一个文件,开始管理本地知识文档目录。',
|
emptyDescription: '先上传一个文件,开始管理本地知识文档目录。',
|
||||||
deleteConfirm: '确定要删除此文档吗?',
|
|
||||||
table: {
|
table: {
|
||||||
name: '名称',
|
name: '名称',
|
||||||
size: '大小',
|
size: '大小',
|
||||||
@@ -79,6 +83,12 @@ const ZH_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
confirm: '上传',
|
confirm: '上传',
|
||||||
},
|
},
|
||||||
|
deleteDialog: {
|
||||||
|
title: '删除文档?',
|
||||||
|
description: '此操作会将“{name}”从本地知识文档目录中永久移除。',
|
||||||
|
confirm: '删除文档',
|
||||||
|
close: '关闭弹窗',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
loading: '正在加载文档...',
|
loading: '正在加载文档...',
|
||||||
uploading: '上传中...',
|
uploading: '上传中...',
|
||||||
@@ -102,7 +112,6 @@ const JA_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
storageLabel: 'Storage',
|
storageLabel: 'Storage',
|
||||||
emptyTitle: '文書がありません',
|
emptyTitle: '文書がありません',
|
||||||
emptyDescription: 'まずファイルをアップロードして、ローカルの knowledge docs を管理してください。',
|
emptyDescription: 'まずファイルをアップロードして、ローカルの knowledge docs を管理してください。',
|
||||||
deleteConfirm: 'この文書を削除しますか?',
|
|
||||||
table: {
|
table: {
|
||||||
name: '名前',
|
name: '名前',
|
||||||
size: 'サイズ',
|
size: 'サイズ',
|
||||||
@@ -119,6 +128,12 @@ const JA_KNOWLEDGE_MESSAGES: MessageTree = {
|
|||||||
cancel: 'キャンセル',
|
cancel: 'キャンセル',
|
||||||
confirm: 'アップロード',
|
confirm: 'アップロード',
|
||||||
},
|
},
|
||||||
|
deleteDialog: {
|
||||||
|
title: '文書を削除しますか?',
|
||||||
|
description: 'この操作により「{name}」はローカルの knowledge docs ディレクトリから完全に削除されます。',
|
||||||
|
confirm: '文書を削除',
|
||||||
|
close: 'ダイアログを閉じる',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
loading: '文書を読み込み中...',
|
loading: '文書を読み込み中...',
|
||||||
uploading: 'アップロード中...',
|
uploading: 'アップロード中...',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type SVGProps } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type SVGProps } from 'react';
|
||||||
import { knowledgeDocsApi } from '../../lib/knowledge-docs-api';
|
import { knowledgeDocsApi } from '../../lib/knowledge-docs-api';
|
||||||
import { useKnowledgeCopy } from './copy';
|
import { useKnowledgeCopy } from './copy';
|
||||||
|
import KnowledgeConfirmDialog from './components/KnowledgeConfirmDialog';
|
||||||
import KnowledgeDocsTable, { type KnowledgeDocRow } from './components/KnowledgeDocsTable';
|
import KnowledgeDocsTable, { type KnowledgeDocRow } from './components/KnowledgeDocsTable';
|
||||||
import KnowledgeEmptyState from './components/KnowledgeEmptyState';
|
import KnowledgeEmptyState from './components/KnowledgeEmptyState';
|
||||||
import KnowledgeFeedbackBanner from './components/KnowledgeFeedbackBanner';
|
import KnowledgeFeedbackBanner from './components/KnowledgeFeedbackBanner';
|
||||||
@@ -201,6 +202,7 @@ export default function KnowledgePage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [feedback, setFeedback] = useState<KnowledgeFeedback>(null);
|
const [feedback, setFeedback] = useState<KnowledgeFeedback>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDocRow | null>(null);
|
||||||
|
|
||||||
const stats = useMemo(() => ({
|
const stats = useMemo(() => ({
|
||||||
count: docs.length,
|
count: docs.length,
|
||||||
@@ -282,9 +284,18 @@ export default function KnowledgePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(doc: KnowledgeDocRow): Promise<void> {
|
function handleDelete(doc: KnowledgeDocRow): void {
|
||||||
const confirmed = window.confirm(t('knowledge.deleteConfirm'));
|
setDeleteTarget(doc);
|
||||||
if (!confirmed) return;
|
}
|
||||||
|
|
||||||
|
function closeDeleteDialog(): void {
|
||||||
|
if (deletingId) return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(): Promise<void> {
|
||||||
|
const doc = deleteTarget;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
setDeletingId(doc.id);
|
setDeletingId(doc.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -292,10 +303,12 @@ export default function KnowledgePage() {
|
|||||||
try {
|
try {
|
||||||
await knowledgeDocsApi.delete(doc.fileName);
|
await knowledgeDocsApi.delete(doc.fileName);
|
||||||
setDocs((current) => current.filter((item) => item.id !== doc.id));
|
setDocs((current) => current.filter((item) => item.id !== doc.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
pushFeedback(t('knowledge.status.deleteSuccess'), 'success');
|
pushFeedback(t('knowledge.status.deleteSuccess'), 'success');
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
const message = caughtError instanceof Error ? caughtError.message : String(caughtError);
|
||||||
setError(message);
|
setError(message);
|
||||||
|
setDeleteTarget(null);
|
||||||
pushFeedback(t('knowledge.status.failed', { error: message }), 'error');
|
pushFeedback(t('knowledge.status.failed', { error: message }), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
@@ -311,6 +324,27 @@ export default function KnowledgePage() {
|
|||||||
</KnowledgeFeedbackBanner>
|
</KnowledgeFeedbackBanner>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<KnowledgeConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
busy={Boolean(deleteTarget && deletingId === deleteTarget.id)}
|
||||||
|
title={t('knowledge.deleteDialog.title', undefined, 'Delete document?')}
|
||||||
|
description={t(
|
||||||
|
'knowledge.deleteDialog.description',
|
||||||
|
{ name: deleteTarget?.fileName ?? '' },
|
||||||
|
'This will permanently remove "{name}".',
|
||||||
|
)}
|
||||||
|
fileName={deleteTarget?.fileName ?? ''}
|
||||||
|
cancelLabel={t('knowledge.common.cancel')}
|
||||||
|
confirmLabel={t('knowledge.deleteDialog.confirm', undefined, 'Delete document')}
|
||||||
|
confirmingLabel={t('knowledge.status.deleting')}
|
||||||
|
closeLabel={t('knowledge.deleteDialog.close', undefined, 'Close dialog')}
|
||||||
|
icon={<TrashIcon className="h-5 w-5" />}
|
||||||
|
onClose={closeDeleteDialog}
|
||||||
|
onConfirm={() => {
|
||||||
|
void confirmDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex h-full w-full flex-col p-10 pb-0 pt-12">
|
<div className="flex h-full w-full flex-col p-10 pb-0 pt-12">
|
||||||
<KnowledgePageHeader
|
<KnowledgePageHeader
|
||||||
title={t('knowledge.title', undefined, 'Knowledge Docs')}
|
title={t('knowledge.title', undefined, 'Knowledge Docs')}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ vi.mock('../src/pages/Knowledge/copy', () => ({
|
|||||||
'knowledge.storageLabel': 'Storage',
|
'knowledge.storageLabel': 'Storage',
|
||||||
'knowledge.refresh': 'Refresh',
|
'knowledge.refresh': 'Refresh',
|
||||||
'knowledge.upload': 'Upload',
|
'knowledge.upload': 'Upload',
|
||||||
'knowledge.uploadHint': 'Knowledge docs only',
|
|
||||||
'knowledge.status.loading': 'Loading documents...',
|
'knowledge.status.loading': 'Loading documents...',
|
||||||
'knowledge.status.uploading': 'Uploading...',
|
'knowledge.status.uploading': 'Uploading...',
|
||||||
'knowledge.status.deleting': 'Deleting...',
|
'knowledge.status.deleting': 'Deleting...',
|
||||||
@@ -29,7 +28,10 @@ vi.mock('../src/pages/Knowledge/copy', () => ({
|
|||||||
'knowledge.status.deleteSuccess': 'Deleted',
|
'knowledge.status.deleteSuccess': 'Deleted',
|
||||||
'knowledge.emptyTitle': 'No documents yet',
|
'knowledge.emptyTitle': 'No documents yet',
|
||||||
'knowledge.emptyDescription': 'Upload a file',
|
'knowledge.emptyDescription': 'Upload a file',
|
||||||
'knowledge.deleteConfirm': 'Delete this document?',
|
'knowledge.deleteDialog.title': 'Delete document?',
|
||||||
|
'knowledge.deleteDialog.confirm': 'Delete document',
|
||||||
|
'knowledge.deleteDialog.close': 'Close dialog',
|
||||||
|
'knowledge.common.cancel': 'Cancel',
|
||||||
'knowledge.table.name': 'Name',
|
'knowledge.table.name': 'Name',
|
||||||
'knowledge.table.size': 'Size',
|
'knowledge.table.size': 'Size',
|
||||||
'knowledge.table.modifiedAt': 'Modified At',
|
'knowledge.table.modifiedAt': 'Modified At',
|
||||||
@@ -42,6 +44,10 @@ vi.mock('../src/pages/Knowledge/copy', () => ({
|
|||||||
return `Knowledge docs request failed: ${params?.error ?? ''}`;
|
return `Knowledge docs request failed: ${params?.error ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === 'knowledge.deleteDialog.description') {
|
||||||
|
return `This will permanently remove "${params?.name ?? ''}" from the local knowledge docs directory.`;
|
||||||
|
}
|
||||||
|
|
||||||
return dictionary[path] ?? fallback ?? path;
|
return dictionary[path] ?? fallback ?? path;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -53,7 +59,6 @@ describe('KnowledgePage', () => {
|
|||||||
apiMocks.list.mockReset();
|
apiMocks.list.mockReset();
|
||||||
apiMocks.upload.mockReset();
|
apiMocks.upload.mockReset();
|
||||||
apiMocks.delete.mockReset();
|
apiMocks.delete.mockReset();
|
||||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads and renders docs from the knowledge docs api', async () => {
|
it('loads and renders docs from the knowledge docs api', async () => {
|
||||||
@@ -87,6 +92,7 @@ describe('KnowledgePage', () => {
|
|||||||
|
|
||||||
const deleteButton = await screen.findByRole('button', { name: /delete/i });
|
const deleteButton = await screen.findByRole('button', { name: /delete/i });
|
||||||
fireEvent.click(deleteButton);
|
fireEvent.click(deleteButton);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete document' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiMocks.delete).toHaveBeenCalledWith('guide.md');
|
expect(apiMocks.delete).toHaveBeenCalledWith('guide.md');
|
||||||
|
|||||||
Reference in New Issue
Block a user