feat(workspace): deleted folders go to the trash as a whole, after a confirmation
A deleted personal folder is kept in trashed_folders with its subfolders; its notes are trashed and tied to it. The trash lists the folder as one entry with its note count; restoring brings back the folder tree (empty subfolders too) and all notes, deleting for good, emptying the trash and the notes trash delay remove it with its notes. Folders in shared folders keep the old way. Deleting a folder now asks first, in a small popover at the click (ConfirmPopover, documented in ui-patterns). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9dcfa6c27e
commit
b26b07cfea
18 files changed
+564
-25
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ConfirmPopoverProps {
|
||||
// Where the click was (clientX/clientY); the popover opens right there, kept inside the window
|
||||
x: number;
|
||||
y: number;
|
||||
message: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MARGIN = 8;
|
||||
|
||||
// Small confirmation right at the mouse (Doku/ui-patterns.md): Enter or the button confirms, Escape, a
|
||||
// click outside or "Abbrechen" closes it.
|
||||
export const ConfirmPopover: React.FC<ConfirmPopoverProps> = ({ x, y, message, confirmLabel, danger, onConfirm, onCancel }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y, ready: false });
|
||||
|
||||
// Below and right of the pointer; flipped above or to the left when it would leave the window
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
let left = x - 12;
|
||||
let top = y + MARGIN;
|
||||
if (left + width > window.innerWidth - MARGIN) left = window.innerWidth - width - MARGIN;
|
||||
if (top + height > window.innerHeight - MARGIN) top = y - height - MARGIN;
|
||||
setPos({ left: Math.max(MARGIN, left), top: Math.max(MARGIN, top), ready: true });
|
||||
confirmRef.current?.focus();
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: Event) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="alertdialog"
|
||||
aria-modal="false"
|
||||
style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}
|
||||
className="fixed z-[70] w-72 max-w-[calc(100vw-1rem)] p-3 bg-panel border border-border rounded-card shadow-panel text-xs text-main space-y-3"
|
||||
>
|
||||
<div className="leading-snug">{message}</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors ${
|
||||
danger
|
||||
? 'bg-status-err/15 text-status-err border-status-err/50 hover:bg-status-err/25'
|
||||
: 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ConfirmPopover } from '../ConfirmPopover';
|
||||
|
||||
describe('ConfirmPopover', () => {
|
||||
it('confirms with its button, which has the focus (Enter)', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="In den Papierkorb" onConfirm={onConfirm} onCancel={vi.fn()} />);
|
||||
const button = screen.getByRole('button', { name: 'In den Papierkorb' });
|
||||
expect(document.activeElement).toBe(button);
|
||||
fireEvent.click(button);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes without confirming on Abbrechen, Escape or a click outside', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="Löschen" onConfirm={onConfirm} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(onCancel).toHaveBeenCalledTimes(3);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,15 @@ import { formatDeletedAt } from '../../lib/due';
|
||||
interface TrashViewProps {
|
||||
items: TrashedDocument[];
|
||||
onOpen: (docId: string) => void;
|
||||
onRestore: (docId: string) => Promise<void>;
|
||||
onPurge: (docId: string) => Promise<void>;
|
||||
onRestore: (item: TrashedDocument) => Promise<void>;
|
||||
onPurge: (item: TrashedDocument) => Promise<void>;
|
||||
onEmpty: () => Promise<void>;
|
||||
onBackMobile: () => void;
|
||||
}
|
||||
|
||||
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
||||
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
||||
// Deleted notes and folders: open a note read-only, restore, or delete for good. A folder is one entry
|
||||
// with its notes and comes back as a whole. Deleting for good always takes a second tap on the same
|
||||
// button ("Wirklich löschen?"), which works the same with mouse and touch.
|
||||
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
@@ -66,8 +67,8 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt
|
||||
waren, sind sie bis dahin ausgeblendet.
|
||||
Gelöschte Notizen und Ordner bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Ein Ordner kommt mit
|
||||
allen Unterordnern und Notizen zurück. Für alle, mit denen etwas geteilt war, ist es bis dahin ausgeblendet.
|
||||
</p>
|
||||
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||
|
||||
@@ -82,6 +83,21 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
||||
key={t.id}
|
||||
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
|
||||
>
|
||||
{t.kind === 'folder' ? (
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<Folder className="w-5 h-5 text-accent shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-main truncate">{t.title}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
|
||||
<span>
|
||||
Ordner · {t.noteCount === 1 ? '1 Notiz' : `${t.noteCount || 0} Notizen`}
|
||||
</span>
|
||||
{t.folderPath && <span className="truncate">in {t.folderPath}</span>}
|
||||
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(t.id)}
|
||||
@@ -102,11 +118,12 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId !== null}
|
||||
onClick={() => run(t.id, () => onRestore(t.id))}
|
||||
onClick={() => run(t.id, () => onRestore(t))}
|
||||
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
@@ -115,7 +132,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId !== null}
|
||||
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))}
|
||||
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t)) : setConfirmId(t.id))}
|
||||
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
|
||||
confirmId === t.id
|
||||
? 'bg-status-err/20 text-status-err border-status-err font-semibold'
|
||||
|
||||
@@ -57,6 +57,7 @@ import { JournalCalendar } from './JournalCalendar';
|
||||
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { ConfirmPopover } from '../../components/ConfirmPopover';
|
||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||
import { LazyMount } from '../../components/LazyMount';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
@@ -97,7 +98,8 @@ interface FolderTreeItemProps {
|
||||
handleCreateDocument: (folderPath?: string) => void;
|
||||
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
|
||||
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
|
||||
handleDeleteFolder: (folderPath: string) => void;
|
||||
// Asks first (popover at the given point), then moves the folder to the trash
|
||||
handleDeleteFolder: (folderPath: string, at: { x: number; y: number }) => void;
|
||||
onSelectDoc: (id: string) => void;
|
||||
// Dropping a note onto another one offers to merge them
|
||||
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
|
||||
@@ -218,7 +220,10 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteFolder(node.fullPath);
|
||||
// Keyboard clicks have no pointer position: open at the button instead
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const byPointer = e.clientX !== 0 || e.clientY !== 0;
|
||||
handleDeleteFolder(node.fullPath, byPointer ? { x: e.clientX, y: e.clientY } : { x: r.left, y: r.bottom });
|
||||
}}
|
||||
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
|
||||
title="Ordner löschen"
|
||||
@@ -736,6 +741,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
reloadTrash();
|
||||
};
|
||||
|
||||
// Trash entries: a note, or a deleted folder that comes back (or goes) with all of its notes
|
||||
const handleRestoreTrashItem = async (item: TrashedDocument) => {
|
||||
if (item.kind !== 'folder') return handleRestoreDocument(item.id);
|
||||
await api.restoreFolder(item.id);
|
||||
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
|
||||
setDocs(freshDocs);
|
||||
setFolders(freshFolders);
|
||||
reloadTrash();
|
||||
onStatsChange();
|
||||
};
|
||||
const handlePurgeTrashItem = async (item: TrashedDocument) => {
|
||||
if (item.kind !== 'folder') return handlePurgeDocument(item.id);
|
||||
await api.purgeFolder(item.id);
|
||||
reloadTrash();
|
||||
};
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
await api.emptyTrash();
|
||||
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
|
||||
@@ -770,6 +791,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Deleting a folder asks first, in a small popover where the click was
|
||||
const [pendingFolderDelete, setPendingFolderDelete] = useState<{ path: string; x: number; y: number } | null>(null);
|
||||
const requestDeleteFolder = (path: string, at: { x: number; y: number }) => setPendingFolderDelete({ path, ...at });
|
||||
const closeFolderDelete = useCallback(() => setPendingFolderDelete(null), []);
|
||||
|
||||
const handleDeleteFolder = async (folderPath: string) => {
|
||||
try {
|
||||
const res = await api.deleteFolder(folderPath);
|
||||
@@ -2031,7 +2057,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
handleCreateDocument={handleCreateDocument}
|
||||
handleOpenNewFolder={handleOpenNewFolder}
|
||||
handleShareFolderClick={handleShareFolderClick}
|
||||
handleDeleteFolder={handleDeleteFolder}
|
||||
handleDeleteFolder={requestDeleteFolder}
|
||||
onSelectDoc={(id) => {
|
||||
setSelectedDocId(id);
|
||||
setTargetBlockId(null);
|
||||
@@ -2190,8 +2216,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<TrashView
|
||||
items={trash}
|
||||
onOpen={handleOpenTrashedDoc}
|
||||
onRestore={handleRestoreDocument}
|
||||
onPurge={handlePurgeDocument}
|
||||
onRestore={handleRestoreTrashItem}
|
||||
onPurge={handlePurgeTrashItem}
|
||||
onEmpty={handleEmptyTrash}
|
||||
onBackMobile={() => {
|
||||
setSubView('editor');
|
||||
@@ -2872,6 +2898,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{pendingFolderDelete &&
|
||||
(() => {
|
||||
const { path } = pendingFolderDelete;
|
||||
const name = path.split('/').pop() || path;
|
||||
const notes = docs.filter((d) => d.folderPath === path || (d.folderPath || '').startsWith(`${path}/`)).length;
|
||||
return (
|
||||
<ConfirmPopover
|
||||
x={pendingFolderDelete.x}
|
||||
y={pendingFolderDelete.y}
|
||||
danger
|
||||
confirmLabel="In den Papierkorb"
|
||||
message={
|
||||
<>
|
||||
Ordner <strong>„{name}“</strong>
|
||||
{notes > 0 ? ` mit ${notes === 1 ? '1 Notiz' : `${notes} Notizen`}` : ''} löschen? Er kommt mit allen
|
||||
Unterordnern in den Papierkorb und lässt sich dort wiederherstellen.
|
||||
</>
|
||||
}
|
||||
onCancel={closeFolderDelete}
|
||||
onConfirm={() => {
|
||||
closeFolderDelete();
|
||||
void handleDeleteFolder(path);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{mergeDialog && (
|
||||
<MergeDialog
|
||||
docs={docs}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('TrashView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Wirklich löschen?'));
|
||||
});
|
||||
expect(onPurge).toHaveBeenCalledWith('doc_1');
|
||||
expect(onPurge).toHaveBeenCalledWith(items[0]);
|
||||
});
|
||||
|
||||
it('restores with one tap and shows the original folder', async () => {
|
||||
@@ -25,6 +25,31 @@ describe('TrashView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Wiederherstellen'));
|
||||
});
|
||||
expect(onRestore).toHaveBeenCalledWith('doc_1');
|
||||
expect(onRestore).toHaveBeenCalledWith(items[0]);
|
||||
});
|
||||
|
||||
it('shows a deleted folder as one entry with its notes and restores it as a whole', async () => {
|
||||
const folder = {
|
||||
id: 'tfld_1',
|
||||
kind: 'folder' as const,
|
||||
title: 'Stadt',
|
||||
icon: '',
|
||||
folderPath: 'Projekte',
|
||||
deletedAt: '2026-10-29T08:00:00Z',
|
||||
isOwner: true,
|
||||
noteCount: 3,
|
||||
};
|
||||
const onOpen = vi.fn();
|
||||
const onRestore = vi.fn(async () => {});
|
||||
render(<TrashView items={[folder]} onOpen={onOpen} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
|
||||
expect(screen.getByText('Stadt')).toBeTruthy();
|
||||
expect(screen.getByText(/Ordner · 3 Notizen/)).toBeTruthy();
|
||||
expect(screen.getByText('in Projekte')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('Stadt'));
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Wiederherstellen'));
|
||||
});
|
||||
expect(onRestore).toHaveBeenCalledWith(folder);
|
||||
});
|
||||
});
|
||||
@@ -832,6 +832,14 @@ export const api = {
|
||||
await request(`/api/v1/documents/${id}/permanent`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async restoreFolder(trashId: string): Promise<void> {
|
||||
await request(`/api/v1/folders/trash/${trashId}/restore`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async purgeFolder(trashId: string): Promise<void> {
|
||||
await request(`/api/v1/folders/trash/${trashId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async emptyTrash(): Promise<{ deleted: number }> {
|
||||
return request<{ deleted: number }>('/api/v1/documents/trash', { method: 'DELETE' });
|
||||
},
|
||||
|
||||
@@ -415,13 +415,17 @@ export interface TrashedTask {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
// A note in the trash, or a deleted folder (kind "folder": id of the trashed folder, title its name,
|
||||
// folderPath where it was, noteCount the notes it holds)
|
||||
export interface TrashedDocument {
|
||||
id: string;
|
||||
kind?: 'note' | 'folder';
|
||||
title: string;
|
||||
icon: string;
|
||||
folderPath: string;
|
||||
deletedAt: string;
|
||||
isOwner: boolean;
|
||||
noteCount?: number;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
|
||||
Reference in new issue
Block a user