abbe7a18fc
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner - P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var - P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup - P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns - P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs) - P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import - P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default - P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed - P2: 28 frontend TODOs (hardcoded constants, deprecated notification API) - P3: dead code, duplicates, deprecated imports, private attr, __import__ inline - P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n) - ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix) - F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String) - Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
// TODO: P3-F29 — Implement DMS trash endpoint
|
|
/**
|
|
* DMS trash page.
|
|
* Shows soft-deleted files with restore button per file.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Link } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { formatDateShort } from '@/utils/date';
|
|
import { Table, TableColumn } from '@/components/ui/Table';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
import { restoreFile, type DmsFile } from '@/api/dms';
|
|
import { Trash2 } from 'lucide-react';
|
|
|
|
export function DmsTrashPage() {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [trashFiles, setTrashFiles] = useState<DmsFile[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
|
|
|
// In a real implementation we would fetch from a trash endpoint
|
|
// For now we use an empty state as the API doesn't have a dedicated trash list
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
// The DMS API doesn't have a GET /trash endpoint in the spec
|
|
// We would need to filter deleted_at !== null from file listing
|
|
// Using empty list for now as trash listing is not in the API spec
|
|
setTrashFiles([]);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
const handleRestore = useCallback(async (fileId: string) => {
|
|
setRestoringId(fileId);
|
|
try {
|
|
await restoreFile(fileId);
|
|
toast.success(t('dms.restored'));
|
|
setTrashFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
toast.error(msg);
|
|
}
|
|
setRestoringId(null);
|
|
}, [toast, t]);
|
|
|
|
const columns: TableColumn<DmsFile>[] = [
|
|
{
|
|
key: 'name',
|
|
header: t('dms.name'),
|
|
sortable: true,
|
|
render: (file) => (
|
|
<span className="font-medium text-secondary-900">{file.name}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'size',
|
|
header: t('dms.fileSize'),
|
|
sortable: true,
|
|
accessor: (file) => file.size_bytes ?? file.size ?? 0,
|
|
render: (file) => {
|
|
const sz = file.size_bytes ?? file.size ?? 0;
|
|
if (sz < 1024) return `${sz} B`;
|
|
if (sz < 1024 * 1024) return `${(sz / 1024).toFixed(1)} KB`;
|
|
return `${(sz / (1024 * 1024)).toFixed(1)} MB`;
|
|
},
|
|
},
|
|
{
|
|
key: 'mime_type',
|
|
header: t('dms.fileType'),
|
|
render: (file) => file.mime_type.split('/')[1]?.toUpperCase() || '—',
|
|
},
|
|
{
|
|
key: 'deleted_at',
|
|
header: t('dms.fileModified'),
|
|
sortable: true,
|
|
accessor: (file) => file.deleted_at || '',
|
|
render: (file) => file.deleted_at
|
|
? (formatDateShort(file.deleted_at) || '—')
|
|
: '—',
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: t('dms.actions'),
|
|
render: (file) => (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => handleRestore(file.id)}
|
|
isLoading={restoringId === file.id}
|
|
>
|
|
{t('dms.restore')}
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="dms-trash-page">
|
|
<Skeleton className="h-8 w-64 mb-6" />
|
|
<Skeleton className="h-64" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="dms-trash-page">
|
|
<EmptyState
|
|
title={t('dms.error')}
|
|
description={error}
|
|
action={<Button onClick={() => window.location.reload()}>{t('dms.cancel')}</Button>}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="dms-trash-page">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/dms" className="text-sm text-primary-600 hover:text-primary-700">
|
|
← {t('dms.title')}
|
|
</Link>
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('dms.trash')}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{trashFiles.length === 0 ? (
|
|
<Card>
|
|
<EmptyState
|
|
title={t('dms.trashEmpty')}
|
|
icon={
|
|
<Trash2 className="w-12 h-12" aria-hidden="true" strokeWidth={1.5} />
|
|
}
|
|
/>
|
|
</Card>
|
|
) : (
|
|
<Card data-testid="trash-file-table">
|
|
<Table
|
|
columns={columns}
|
|
data={trashFiles}
|
|
rowKey={(file) => file.id}
|
|
emptyMessage={t('dms.trashEmpty')}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|