fix: better handling of deletes and links

This commit is contained in:
2026-02-13 10:19:43 +01:00
parent b61dfd7b61
commit f904f42f88
13 changed files with 728 additions and 45 deletions

1
.gitignore vendored
View File

@@ -109,6 +109,7 @@ temp/
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
tmpclaude-*
# User data (created at runtime) # User data (created at runtime)
userData/ userData/

View File

@@ -25,7 +25,7 @@ metadata can always be reconstructed from posts. Do the same with images, keepin
user local path, in that case storing the image file sand for each image file a properties sidecar file that user local path, in that case storing the image file sand for each image file a properties sidecar file that
uses the same header structure as for posts. uses the same header structure as for posts.
The application must be offline-first, everything must work in airplane mode (except exporting of course). The application must be offline-first, everything must work in airplane mode (except exporting / AI of course).
It must be fully self-contained during editing and previewing and managing content. Every internal structure It must be fully self-contained during editing and previewing and managing content. Every internal structure
must have reflections in the filesystem, so available tags, available categories, all those things must be must have reflections in the filesystem, so available tags, available categories, all those things must be
automatically reflected to the filesystem in a per-project way. Use a meta/ folder under the project folder automatically reflected to the filesystem in a per-project way. Use a meta/ folder under the project folder

View File

@@ -529,6 +529,10 @@ export class MediaEngine extends EventEmitter {
// Delete thumbnails // Delete thumbnails
await this.deleteThumbnails(id); await this.deleteThumbnails(id);
// Delete post-media links (cascade cleanup)
const { postMedia } = await import('../database/schema');
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
await db.delete(media).where(eq(media.id, id)); await db.delete(media).where(eq(media.id, id));
this.emit('mediaDeleted', id); this.emit('mediaDeleted', id);

View File

@@ -469,6 +469,25 @@ export class PostEngine extends EventEmitter {
await db.delete(postLinks).where(eq(postLinks.sourcePostId, id)); await db.delete(postLinks).where(eq(postLinks.sourcePostId, id));
await db.delete(postLinks).where(eq(postLinks.targetPostId, id)); await db.delete(postLinks).where(eq(postLinks.targetPostId, id));
// Delete post-media links and update media sidecars
const { postMedia } = await import('../database/schema');
const { getMediaEngine } = await import('./MediaEngine');
const linkedMediaResult = await db.select().from(postMedia).where(eq(postMedia.postId, id));
const linkedMedia = Array.isArray(linkedMediaResult) ? linkedMediaResult : [];
// Remove this post from each linked media's sidecar
const mediaEngine = getMediaEngine();
for (const link of linkedMedia) {
const media = await mediaEngine.getMedia(link.mediaId);
if (media && media.linkedPostIds) {
const updatedLinkedPostIds = media.linkedPostIds.filter(pid => pid !== id);
await mediaEngine.updateMedia(link.mediaId, { linkedPostIds: updatedLinkedPostIds });
}
}
// Delete post-media junction entries
await db.delete(postMedia).where(eq(postMedia.postId, id));
// Delete from database // Delete from database
await db.delete(posts).where(eq(posts.id, id)); await db.delete(posts).where(eq(posts.id, id));

View File

@@ -0,0 +1,172 @@
.confirm-delete-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.confirm-delete-modal {
background: var(--color-bg-secondary, #1e1e1e);
border: 1px solid var(--color-border, #3c3c3c);
border-radius: 8px;
min-width: 450px;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.confirm-delete-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border, #3c3c3c);
}
.confirm-delete-modal-header h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text, #fff);
}
.confirm-delete-modal-close {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
}
.confirm-delete-modal-close:hover {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text, #fff);
}
.confirm-delete-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.confirm-delete-message {
font-size: 14px;
line-height: 1.5;
color: var(--color-text, #ccc);
margin-bottom: 16px;
}
.confirm-delete-message strong {
color: var(--color-text, #fff);
font-weight: 600;
}
.confirm-delete-warning {
display: flex;
gap: 12px;
background: rgba(255, 165, 0, 0.1);
border: 1px solid rgba(255, 165, 0, 0.3);
border-radius: 6px;
padding: 12px;
margin-top: 16px;
}
.warning-icon {
font-size: 20px;
flex-shrink: 0;
}
.warning-content {
flex: 1;
font-size: 13px;
color: var(--color-text, #ccc);
}
.warning-content strong {
color: #ffa500;
font-weight: 600;
}
.reference-list {
margin: 12px 0;
padding-left: 0;
list-style: none;
max-height: 200px;
overflow-y: auto;
}
.reference-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--color-bg-primary, #0d0d0d);
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
.reference-type {
font-size: 16px;
flex-shrink: 0;
}
.reference-title {
color: var(--color-text, #ccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.warning-note {
margin: 8px 0 0 0;
font-size: 12px;
color: var(--color-text-muted, #888);
}
.confirm-delete-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--color-border, #3c3c3c);
}
.confirm-delete-modal-footer button {
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.button-cancel {
background: var(--color-bg-tertiary, #2a2a2a);
color: var(--color-text, #ccc);
border: 1px solid var(--color-border, #3c3c3c);
}
.button-cancel:hover {
background: var(--color-bg-hover, #333);
color: var(--color-text, #fff);
}
.button-delete {
background: var(--color-error, #f14c4c);
color: #fff;
}
.button-delete:hover {
background: #d43f3f;
}

View File

@@ -0,0 +1,86 @@
import React, { useCallback } from 'react';
import './ConfirmDeleteModal.css';
export interface DeleteReference {
id: string;
title: string;
type: 'post' | 'media' | 'link';
}
export interface ConfirmDeleteDetails {
itemType: 'post' | 'media';
itemTitle: string;
references: DeleteReference[];
onConfirm: () => void | Promise<void>;
}
interface ConfirmDeleteModalProps {
details: ConfirmDeleteDetails | null;
onClose: () => void;
}
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
if (!details) return null;
const handleConfirm = useCallback(async () => {
await details.onConfirm();
onClose();
}, [details, onClose]);
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
const hasReferences = details.references.length > 0;
return (
<div className="confirm-delete-modal-backdrop" onClick={handleBackdropClick}>
<div className="confirm-delete-modal">
<div className="confirm-delete-modal-header">
<h2>Confirm Deletion</h2>
<button className="confirm-delete-modal-close" onClick={onClose} title="Close">
</button>
</div>
<div className="confirm-delete-modal-body">
<div className="confirm-delete-message">
Are you sure you want to delete {details.itemType === 'post' ? 'the post' : 'the media file'}{' '}
<strong>{details.itemTitle}</strong>?
</div>
{hasReferences && (
<div className="confirm-delete-warning">
<div className="warning-icon"></div>
<div className="warning-content">
<strong>Warning:</strong> This {details.itemType} is referenced by the following items:
<ul className="reference-list">
{details.references.map((ref) => (
<li key={ref.id}>
<span className="reference-type">
{ref.type === 'post' ? '📄' : ref.type === 'media' ? '🖼️' : '🔗'}
</span>
<span className="reference-title">{ref.title}</span>
</li>
))}
</ul>
<p className="warning-note">
Deleting this {details.itemType} will remove all these references.
</p>
</div>
</div>
)}
</div>
<div className="confirm-delete-modal-footer">
<button className="button-cancel" onClick={onClose}>
Cancel
</button>
<button className="button-delete" onClick={handleConfirm}>
Delete {details.itemType === 'post' ? 'Post' : 'Media'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { ConfirmDeleteModal } from './ConfirmDeleteModal';
export type { DeleteReference, ConfirmDeleteDetails } from './ConfirmDeleteModal';

View File

@@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks'; import { PostLinks } from '../PostLinks';
import { LinkedMediaPanel } from '../LinkedMediaPanel'; import { LinkedMediaPanel } from '../LinkedMediaPanel';
import { ErrorModal } from '../ErrorModal'; import { ErrorModal } from '../ErrorModal';
import { ConfirmDeleteModal } from '../ConfirmDeleteModal';
import { SettingsView } from '../SettingsView'; import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView'; import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput'; import { TagInput } from '../TagInput';
@@ -248,6 +249,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
preferredEditorMode, preferredEditorMode,
setPreferredEditorMode, setPreferredEditorMode,
showErrorModal, showErrorModal,
showConfirmDeleteModal,
media, media,
} = useAppStore(); } = useAppStore();
@@ -510,7 +512,43 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) { try {
// Fetch references to this post
const [linkedBy, linkedMedia] = await Promise.all([
window.electronAPI?.posts.getLinkedBy(post.id),
window.electronAPI?.postMedia.getForPost(post.id),
]);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that link to this post
if (linkedBy && linkedBy.length > 0) {
linkedBy.forEach((p: { id: string; title: string }) => {
references.push({ id: p.id, title: p.title, type: 'link' });
});
}
// Add linked media
if (linkedMedia && linkedMedia.length > 0) {
linkedMedia.forEach((m: { mediaId: string }) => {
const mediaItem = media.find(item => item.id === m.mediaId);
if (mediaItem) {
references.push({
id: mediaItem.id,
title: mediaItem.originalName,
type: 'media',
});
}
});
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'post',
itemTitle: post.title || 'Untitled',
references,
onConfirm: async () => {
try { try {
await window.electronAPI?.posts.delete(post.id); await window.electronAPI?.posts.delete(post.id);
// Clear pending ref to prevent auto-save on unmount from resurrecting the post // Clear pending ref to prevent auto-save on unmount from resurrecting the post
@@ -527,6 +565,16 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
stack: err.stack, stack: err.stack,
}); });
} }
},
});
} catch (error) {
console.error('Failed to fetch post references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch post references',
stack: err.stack,
});
} }
}; };
@@ -879,7 +927,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
}; };
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, posts, updateMedia, showErrorModal, openTab } = useAppStore(); const { media, posts, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
const item = media.find(m => m.id === mediaId); const item = media.find(m => m.id === mediaId);
const [alt, setAlt] = useState(item?.alt || ''); const [alt, setAlt] = useState(item?.alt || '');
@@ -984,7 +1032,33 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (confirm('Are you sure you want to delete this media file?')) { try {
// Fetch posts that link to this media
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
// Build references array
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
// Add posts that use this media
if (linkedPostsList && linkedPostsList.length > 0) {
linkedPostsList.forEach((link: { postId: string }) => {
const post = posts.find(p => p.id === link.postId);
if (post) {
references.push({
id: post.id,
title: post.title || 'Untitled',
type: 'post',
});
}
});
}
// Show confirmation modal
showConfirmDeleteModal({
itemType: 'media',
itemTitle: item.originalName,
references,
onConfirm: async () => {
try { try {
await window.electronAPI?.media.delete(item.id); await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id); useAppStore.getState().removeMedia(item.id);
@@ -998,6 +1072,16 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
stack: err.stack, stack: err.stack,
}); });
} }
},
});
} catch (error) {
console.error('Failed to fetch media references:', error);
const err = error as Error;
showErrorModal({
title: 'Error',
message: err.message || 'Failed to fetch media references',
stack: err.stack,
});
} }
}; };
@@ -1385,6 +1469,8 @@ export const Editor: React.FC = () => {
media, media,
errorModal, errorModal,
hideErrorModal, hideErrorModal,
confirmDeleteModal,
hideConfirmDeleteModal,
isLoading, isLoading,
setSelectedPost, setSelectedPost,
setSelectedMedia, setSelectedMedia,
@@ -1450,12 +1536,18 @@ export const Editor: React.FC = () => {
<ErrorModal error={errorModal} onClose={hideErrorModal} /> <ErrorModal error={errorModal} onClose={hideErrorModal} />
); );
// Show confirm delete modal if present
const renderConfirmDeleteModal = () => (
<ConfirmDeleteModal details={confirmDeleteModal} onClose={hideConfirmDeleteModal} />
);
// Show settings if settings tab is active or settings view with no tab // Show settings if settings tab is active or settings view with no tab
if (showSettings) { if (showSettings) {
return ( return (
<div className="editor"> <div className="editor">
<SettingsView /> <SettingsView />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1466,6 +1558,7 @@ export const Editor: React.FC = () => {
<div className="editor"> <div className="editor">
<TagsView /> <TagsView />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1476,6 +1569,7 @@ export const Editor: React.FC = () => {
<div className="editor"> <div className="editor">
<ChatPanel key={activeTabId} conversationId={activeTabId} /> <ChatPanel key={activeTabId} conversationId={activeTabId} />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1488,6 +1582,7 @@ export const Editor: React.FC = () => {
<div className="editor"> <div className="editor">
<PostEditor key={post.id} post={post} /> <PostEditor key={post.id} post={post} />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1501,6 +1596,7 @@ export const Editor: React.FC = () => {
</div> </div>
</div> </div>
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1511,6 +1607,7 @@ export const Editor: React.FC = () => {
<div className="editor"> <div className="editor">
<MediaEditor key={activeTabId} mediaId={activeTabId} /> <MediaEditor key={activeTabId} mediaId={activeTabId} />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
} }
@@ -1520,6 +1617,7 @@ export const Editor: React.FC = () => {
<div className="editor"> <div className="editor">
<Dashboard /> <Dashboard />
{renderErrorModal()} {renderErrorModal()}
{renderConfirmDeleteModal()}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store'; import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron'; import type { ChatConversation } from '../../types/electron';
import './Sidebar.css'; import './Sidebar.css';

View File

@@ -17,4 +17,5 @@ export { TagInput } from './TagInput';
export { PostLinks } from './PostLinks'; export { PostLinks } from './PostLinks';
export { LinkedMediaPanel } from './LinkedMediaPanel'; export { LinkedMediaPanel } from './LinkedMediaPanel';
export { ErrorModal, type ErrorDetails } from './ErrorModal'; export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ConfirmDeleteModal, type ConfirmDeleteDetails, type DeleteReference } from './ConfirmDeleteModal';
export { ChatPanel } from './ChatPanel'; export { ChatPanel } from './ChatPanel';

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { DeleteReference, ConfirmDeleteDetails } from '../components/ConfirmDeleteModal';
// Storage key for persisted state // Storage key for persisted state
const STORAGE_KEY = 'bds-app-state'; const STORAGE_KEY = 'bds-app-state';
@@ -76,6 +77,9 @@ export interface ErrorDetails {
stack?: string; stack?: string;
} }
// Re-export types from ConfirmDeleteModal for convenience
export type { DeleteReference, ConfirmDeleteDetails };
export type EditorMode = 'wysiwyg' | 'markdown' | 'preview'; export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
// App State Store // App State Store
@@ -111,6 +115,9 @@ interface AppState {
// Error modal // Error modal
errorModal: ErrorDetails | null; errorModal: ErrorDetails | null;
// Confirm delete modal
confirmDeleteModal: ConfirmDeleteDetails | null;
// Sync // Sync
syncStatus: 'idle' | 'syncing' | 'error'; syncStatus: 'idle' | 'syncing' | 'error';
syncConfigured: boolean; syncConfigured: boolean;
@@ -159,6 +166,10 @@ interface AppState {
showErrorModal: (error: ErrorDetails) => void; showErrorModal: (error: ErrorDetails) => void;
hideErrorModal: () => void; hideErrorModal: () => void;
// Confirm delete modal actions
showConfirmDeleteModal: (details: ConfirmDeleteDetails) => void;
hideConfirmDeleteModal: () => void;
setMedia: (media: MediaData[]) => void; setMedia: (media: MediaData[]) => void;
addMedia: (media: MediaData) => void; addMedia: (media: MediaData) => void;
updateMedia: (id: string, media: Partial<MediaData>) => void; updateMedia: (id: string, media: Partial<MediaData>) => void;
@@ -209,6 +220,9 @@ export const useAppStore = create<AppState>()(
// Error modal // Error modal
errorModal: null, errorModal: null,
// Confirm delete modal
confirmDeleteModal: null,
// Initial Sync State // Initial Sync State
syncStatus: 'idle', syncStatus: 'idle',
syncConfigured: false, syncConfigured: false,
@@ -357,6 +371,10 @@ export const useAppStore = create<AppState>()(
showErrorModal: (error) => set({ errorModal: error }), showErrorModal: (error) => set({ errorModal: error }),
hideErrorModal: () => set({ errorModal: null }), hideErrorModal: () => set({ errorModal: null }),
// Confirm delete modal actions
showConfirmDeleteModal: (details) => set({ confirmDeleteModal: details }),
hideConfirmDeleteModal: () => set({ confirmDeleteModal: null }),
// Media Actions // Media Actions
setMedia: (media) => set({ media }), setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })), addMedia: (media) => set((state) => ({ media: [...state.media, media] })),

View File

@@ -66,6 +66,19 @@ export interface MediaData {
tags: string[]; tags: string[];
} }
export interface MediaFilter {
tags?: string[];
year?: number;
month?: number;
}
export interface MediaSearchResult {
id: string;
originalName: string;
mimeType: string;
createdAt: string;
}
export interface TaskProgress { export interface TaskProgress {
taskId: string; taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
@@ -291,6 +304,11 @@ export interface ElectronAPI {
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>; getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => Promise<string | null>;
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>; regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>; regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
filter: (filter: MediaFilter) => Promise<MediaData[]>;
search: (query: string) => Promise<MediaSearchResult[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getTags: () => Promise<string[]>;
getTagsWithCounts: () => Promise<TagCount[]>;
}; };
postMedia: { postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>; link: (postId: string, mediaId: string) => Promise<MediaLinkData>;

View File

@@ -0,0 +1,264 @@
/**
* ConfirmDeleteModal Component Tests
*
* Tests the REAL ConfirmDeleteModal component with mocked callbacks.
* Following TDD best practices: verify component behavior.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmDeleteModal, type ConfirmDeleteDetails } from '../../../src/renderer/components/ConfirmDeleteModal';
describe('ConfirmDeleteModal', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should not render when details is null', () => {
const { container } = render(
<ConfirmDeleteModal details={null} onClose={mockOnClose} />
);
expect(container.firstChild).toBeNull();
});
it('should render modal when details is provided', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText('Confirm Deletion')).toBeInTheDocument();
expect(screen.getByText(/Test Post/)).toBeInTheDocument();
});
it('should display post deletion message for post type', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'My Blog Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText(/delete the post/)).toBeInTheDocument();
expect(screen.getByText('My Blog Post')).toBeInTheDocument();
});
it('should display media deletion message for media type', () => {
const details: ConfirmDeleteDetails = {
itemType: 'media',
itemTitle: 'image.jpg',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText(/delete the media file/)).toBeInTheDocument();
expect(screen.getByText('image.jpg')).toBeInTheDocument();
});
});
describe('References Warning', () => {
it('should not show warning when no references exist', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.queryByText(/Warning/)).not.toBeInTheDocument();
});
it('should show warning when references exist', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [
{ id: '1', title: 'Linked Post', type: 'link' },
],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText(/Warning/)).toBeInTheDocument();
expect(screen.getByText('Linked Post')).toBeInTheDocument();
});
it('should list all reference types correctly', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [
{ id: '1', title: 'Some Post', type: 'post' },
{ id: '2', title: 'Some Image', type: 'media' },
{ id: '3', title: 'Some Link', type: 'link' },
],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText('Some Post')).toBeInTheDocument();
expect(screen.getByText('Some Image')).toBeInTheDocument();
expect(screen.getByText('Some Link')).toBeInTheDocument();
});
it('should show correct warning note', () => {
const details: ConfirmDeleteDetails = {
itemType: 'media',
itemTitle: 'photo.jpg',
references: [
{ id: '1', title: 'Blog Post', type: 'post' },
],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText(/Deleting this media will remove all these references/)).toBeInTheDocument();
});
});
describe('User Actions', () => {
it('should call onClose when Cancel button is clicked', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when close button is clicked', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
fireEvent.click(screen.getByTitle('Close'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should call onClose when backdrop is clicked', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
const backdrop = document.querySelector('.confirm-delete-modal-backdrop');
fireEvent.click(backdrop!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should not call onClose when modal content is clicked', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
const modal = document.querySelector('.confirm-delete-modal');
fireEvent.click(modal!);
expect(mockOnClose).not.toHaveBeenCalled();
});
it('should call onConfirm and onClose when Delete button is clicked', async () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
fireEvent.click(screen.getByText('Delete Post'));
// Wait for the async handler to complete
await vi.waitFor(() => {
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should show correct delete button text for post', () => {
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText('Delete Post')).toBeInTheDocument();
});
it('should show correct delete button text for media', () => {
const details: ConfirmDeleteDetails = {
itemType: 'media',
itemTitle: 'image.jpg',
references: [],
onConfirm: mockOnConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
expect(screen.getByText('Delete Media')).toBeInTheDocument();
});
it('should handle async onConfirm correctly', async () => {
const asyncConfirm = vi.fn().mockResolvedValue(undefined);
const details: ConfirmDeleteDetails = {
itemType: 'post',
itemTitle: 'Test Post',
references: [],
onConfirm: asyncConfirm,
};
render(<ConfirmDeleteModal details={details} onClose={mockOnClose} />);
fireEvent.click(screen.getByText('Delete Post'));
// Wait for the async handler to complete
await vi.waitFor(() => {
expect(asyncConfirm).toHaveBeenCalledTimes(1);
});
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
});