feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header
This commit is contained in:
1 parent
fc7b2b3a2c
commit
5f27e7ca9c
4 files changed
+394
-87
No files matched your search
@@ -11,6 +11,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
- Aufgaben-Details: Knopf „Beobachten“ / „Nicht mehr beobachten“ (`POST/DELETE /tasks/{id}/watch`).
|
||||
|
||||
### Changed
|
||||
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
|
||||
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,16 +18,26 @@ import {
|
||||
CloudOff,
|
||||
AlertTriangle,
|
||||
GitMerge,
|
||||
Undo2,
|
||||
Redo2,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { Presence } from '../../lib/docSync/transport';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
export interface DocHeaderStatus {
|
||||
showLive: boolean;
|
||||
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
|
||||
otherPeople: Presence[];
|
||||
avatarsById: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
currentUser?: User | null;
|
||||
@@ -45,6 +55,7 @@ export interface DocumentEditorProps {
|
||||
onBackMobile?: () => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
|
||||
/** Opens the dialog to merge this note into another one (own notes only) */
|
||||
onRequestMerge?: (doc: Document) => void;
|
||||
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
||||
@@ -196,6 +207,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onBackMobile,
|
||||
onOpenTask,
|
||||
onSaveStatusChange,
|
||||
onHeaderStatusChange,
|
||||
onRequestMerge,
|
||||
onDocCreated,
|
||||
onDocRemoved,
|
||||
@@ -209,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
|
||||
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
@@ -384,6 +398,48 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
.catch(() => {});
|
||||
}, [otherPeople.length]);
|
||||
|
||||
useEffect(() => {
|
||||
onHeaderStatusChange?.({
|
||||
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
|
||||
channel: engineState?.channel || 'ws',
|
||||
otherPeople,
|
||||
avatarsById,
|
||||
});
|
||||
}, [
|
||||
engineState?.shared,
|
||||
engineState?.sessions,
|
||||
engineState?.channel,
|
||||
otherPeople,
|
||||
avatarsById,
|
||||
onHeaderStatusChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onHeaderStatusChange?.(null);
|
||||
};
|
||||
}, [onHeaderStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActionsMenu) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowActionsMenu(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowActionsMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [showActionsMenu]);
|
||||
|
||||
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
|
||||
@@ -715,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="lg:hidden btn-icon"
|
||||
className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
title="Zurück zur Seitenleiste"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
@@ -813,119 +869,129 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* How changes of others arrive (only relevant for shared notes) */}
|
||||
{engineState && (engineState.shared || engineState.sessions > 1) && (
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title={
|
||||
engineState.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
: engineState.channel === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: engineState.channel === 'longpoll'
|
||||
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
|
||||
}
|
||||
{!inTrash && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={!canEdit || !engineState?.canUndo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Rückgängig (Strg+Z)"
|
||||
aria-label="Rückgängig (Strg+Z)"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
|
||||
}`}
|
||||
/>
|
||||
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
|
||||
</span>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={!canEdit || !engineState?.canRedo}
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Wiederholen (Strg+Y)"
|
||||
aria-label="Wiederholen (Strg+Y)"
|
||||
>
|
||||
<Redo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||
{/* Sharing Section */}
|
||||
{doc && !inTrash && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
const sharesCount = doc.sharedWith?.length || 0;
|
||||
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
||||
const ownerDisplayName = doc.ownerName || 'Eigentümer';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isShared && (
|
||||
isOwner ? (
|
||||
{doc && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="btn-icon"
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
title="Notiz teilen und Berechtigungen"
|
||||
aria-label="Notiz teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{onRequestMerge && doc.isOwner && !inTrash && (
|
||||
)}
|
||||
|
||||
<div className="relative" ref={actionsMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestMerge(doc)}
|
||||
className="btn-icon"
|
||||
title="Mit anderer Notiz zusammenführen…"
|
||||
onClick={() => setShowActionsMenu((prev) => !prev)}
|
||||
aria-expanded={showActionsMenu}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Weitere Aktionen"
|
||||
title="Weitere Aktionen"
|
||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5" />
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
|
||||
>
|
||||
{!inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
void handleTogglePin();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Pin
|
||||
className={`w-3.5 h-3.5 shrink-0 ${
|
||||
doc?.isPinned ? 'text-status-warn' : 'text-accent'
|
||||
}`}
|
||||
/>
|
||||
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inTrash && <button
|
||||
{onRequestMerge && doc?.isOwner && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePin}
|
||||
className={`btn-icon ${
|
||||
doc?.isPinned
|
||||
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
|
||||
: ''
|
||||
}`}
|
||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onRequestMerge(doc);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>}
|
||||
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Mit anderer Notiz zusammenführen…</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
className="btn-icon"
|
||||
title="Als Markdown (.md) exportieren"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
handleExportMarkdown();
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span>Als Markdown (.md) exportieren</span>
|
||||
</button>
|
||||
|
||||
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(docId)}
|
||||
className="btn-icon-danger"
|
||||
title="Notiz löschen"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
onDeleteDoc(docId);
|
||||
}}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
||||
<span>Notiz löschen</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In the trash: read-only with a way back */}
|
||||
|
||||
@@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { TrashView } from './TrashView';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
@@ -386,6 +387,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
|
||||
|
||||
|
||||
// Drag and Drop state
|
||||
@@ -1306,10 +1308,83 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||
{/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
/* Save Status Pill */
|
||||
<>
|
||||
{/* Live connection pill */}
|
||||
{docHeaderStatus?.showLive && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title={
|
||||
docHeaderStatus.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
: docHeaderStatus.channel === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: docHeaderStatus.channel === 'longpoll'
|
||||
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
|
||||
}`}
|
||||
/>
|
||||
{docHeaderStatus.channel === 'longpoll'
|
||||
? 'Live (Long-Polling)'
|
||||
: docHeaderStatus.channel === 'interval'
|
||||
? 'Abgleich alle 3 s'
|
||||
: 'Live'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
{docHeaderStatus && (
|
||||
<PresenceAvatars
|
||||
people={docHeaderStatus.otherPeople}
|
||||
avatars={docHeaderStatus.avatarsById}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share pill */}
|
||||
{selectedDocObj &&
|
||||
!selectedDocObj.isArchived &&
|
||||
(() => {
|
||||
const isOwner =
|
||||
selectedDocObj.isOwner !== false &&
|
||||
(selectedDocObj.userId === currentUser?.id ||
|
||||
!selectedDocObj.userId ||
|
||||
currentUser?.role === 'admin');
|
||||
const sharesCount = selectedDocObj.sharedWith?.length || 0;
|
||||
const isShared =
|
||||
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
|
||||
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
|
||||
if (!isShared) return null;
|
||||
return isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocShareModal(selectedDocObj)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDocShareModal(selectedDocObj)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Save Status Pill */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
@@ -1355,6 +1430,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDocSaveStatus(status);
|
||||
setDocSaveError(err || null);
|
||||
}}
|
||||
onHeaderStatusChange={setDocHeaderStatus}
|
||||
/>
|
||||
) : subView === 'tags' ? (
|
||||
/* =========================================================================
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { DocumentEditor } from '../DocumentEditor';
|
||||
import { api } from '../../../lib/api';
|
||||
import { Document } from '../../../lib/types';
|
||||
import { EngineState } from '../../../lib/docSync/engine';
|
||||
|
||||
let currentListener: ((state: EngineState) => void) | null = null;
|
||||
const mockUndo = vi.fn(() => ({ ok: true }));
|
||||
const mockRedo = vi.fn(() => ({ ok: true }));
|
||||
|
||||
const baseEngineState: EngineState = {
|
||||
ready: true,
|
||||
status: 'saved',
|
||||
role: 'owner',
|
||||
title: 'Test Notiz',
|
||||
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
|
||||
pendingCount: 0,
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [{ userId: 'u2', name: 'Bob' }],
|
||||
lockedByOthers: [],
|
||||
conflicts: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
channel: 'ws',
|
||||
};
|
||||
|
||||
vi.mock('../../../lib/docSync/engine', () => {
|
||||
return {
|
||||
DocSyncEngine: class {
|
||||
subscribe(fn: (state: EngineState) => void) {
|
||||
currentListener = fn;
|
||||
fn(baseEngineState);
|
||||
return () => {
|
||||
currentListener = null;
|
||||
};
|
||||
}
|
||||
start() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
stop() {}
|
||||
getState() {
|
||||
return baseEngineState;
|
||||
}
|
||||
setTaskLinks() {}
|
||||
focusBlock() {}
|
||||
applyFlat() {}
|
||||
setTitle() {}
|
||||
undo() {
|
||||
return mockUndo();
|
||||
}
|
||||
redo() {
|
||||
return mockRedo();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../lib/api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../lib/api')>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getDocument: vi.fn(),
|
||||
getUsersDirectory: vi.fn(() => Promise.resolve([])),
|
||||
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
|
||||
id: 'doc_1',
|
||||
userId: 'u1',
|
||||
title: 'Test Notiz',
|
||||
icon: '📝',
|
||||
folderPath: '',
|
||||
content: '',
|
||||
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isOwner: true,
|
||||
createdAt: '2026-10-30T08:00:00Z',
|
||||
updatedAt: '2026-10-30T08:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DocumentEditor header actions & status callback (Teil D)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const doc = makeDoc();
|
||||
vi.mocked(api.getDocument).mockResolvedValue(doc);
|
||||
});
|
||||
|
||||
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
|
||||
const doc = makeDoc({ isOwner: true });
|
||||
const onHeaderStatusChange = vi.fn();
|
||||
const onRequestMerge = vi.fn();
|
||||
const onDeleteDoc = vi.fn();
|
||||
const onOpenShareModal = vi.fn();
|
||||
|
||||
render(
|
||||
<DocumentEditor
|
||||
docId="doc_1"
|
||||
initialDoc={doc}
|
||||
availableFolders={[]}
|
||||
onDocMetadataChange={vi.fn()}
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onOpenShareModal={onOpenShareModal}
|
||||
onSelectTag={vi.fn()}
|
||||
onRequestMerge={onRequestMerge}
|
||||
onHeaderStatusChange={onHeaderStatusChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onHeaderStatusChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showLive: true,
|
||||
channel: 'ws',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Undo / Redo buttons disabled initially
|
||||
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
|
||||
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
|
||||
expect(undoBtn.disabled).toBe(true);
|
||||
expect(redoBtn.disabled).toBe(true);
|
||||
|
||||
// Enable Undo & Redo via engine state update
|
||||
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
|
||||
await waitFor(() => {
|
||||
expect(undoBtn.disabled).toBe(false);
|
||||
expect(redoBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
fireEvent.click(undoBtn);
|
||||
expect(mockUndo).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(redoBtn);
|
||||
expect(mockRedo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Share button is directly visible
|
||||
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
|
||||
fireEvent.click(shareBtn);
|
||||
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
|
||||
|
||||
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
|
||||
const moreBtn = screen.getByTitle('Weitere Aktionen');
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
expect(screen.getByText('Notiz anheften')).toBeTruthy();
|
||||
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
|
||||
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
|
||||
expect(screen.getByText('Notiz löschen')).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByText('Notiz anheften')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user