fix: publish/draft status. handlign better
This commit is contained in:
@@ -906,6 +906,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
setContent(reverted.content);
|
setContent(reverted.content);
|
||||||
setTags(reverted.tags);
|
setTags(reverted.tags);
|
||||||
setCategory(reverted.categories[0] || 'article');
|
setCategory(reverted.categories[0] || 'article');
|
||||||
|
// Update local post state so UI reflects the published status
|
||||||
|
setPost(reverted as PostData);
|
||||||
updatePost(postId, reverted as Partial<PostData>);
|
updatePost(postId, reverted as Partial<PostData>);
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
showToast.success('Reverted to last published version');
|
showToast.success('Reverted to last published version');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useAppStore, PostData, MediaData } from '../../store';
|
import { useAppStore, PostData, MediaData } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
|
import { groupPostsByStatus } from '../../utils';
|
||||||
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
|
||||||
@@ -524,6 +525,76 @@ const PostsList: React.FC = () => {
|
|||||||
applyFilters();
|
applyFilters();
|
||||||
}, [selectedTags, selectedCategories]);
|
}, [selectedTags, selectedCategories]);
|
||||||
|
|
||||||
|
// Track previous post statuses to detect changes
|
||||||
|
const prevPostStatusMapRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// Re-run search/filter when a post's status changes (e.g., draft becomes published or vice versa)
|
||||||
|
// This ensures the sidebar lists are always up-to-date without stale cached data
|
||||||
|
useEffect(() => {
|
||||||
|
const currentStatusMap = new Map(posts.map(p => [p.id, p.status]));
|
||||||
|
const prevStatusMap = prevPostStatusMapRef.current;
|
||||||
|
|
||||||
|
// Check if any post's status changed
|
||||||
|
let statusChanged = false;
|
||||||
|
for (const [id, status] of currentStatusMap) {
|
||||||
|
const prevStatus = prevStatusMap.get(id);
|
||||||
|
if (prevStatus !== undefined && prevStatus !== status) {
|
||||||
|
statusChanged = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the ref for next comparison
|
||||||
|
prevPostStatusMapRef.current = currentStatusMap;
|
||||||
|
|
||||||
|
// If a status changed and we have active filters, re-run them to get fresh data
|
||||||
|
if (statusChanged) {
|
||||||
|
if (searchQuery) {
|
||||||
|
// Re-run search inline to avoid dependency on handleSearch
|
||||||
|
const refreshSearch = async () => {
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.search(searchQuery);
|
||||||
|
if (results) {
|
||||||
|
const fullPosts: PostData[] = [];
|
||||||
|
for (const result of results as { id: string }[]) {
|
||||||
|
const post = await window.electronAPI?.posts.get(result.id);
|
||||||
|
if (post) {
|
||||||
|
fullPosts.push(post as PostData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSearchResults(fullPosts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search refresh failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refreshSearch();
|
||||||
|
} else if (selectedYear || selectedTags.length > 0 || selectedCategories.length > 0) {
|
||||||
|
// Re-run filter
|
||||||
|
const refetchFilters = async () => {
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.filter({
|
||||||
|
year: selectedYear,
|
||||||
|
month: selectedMonth,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||||
|
});
|
||||||
|
if (results) {
|
||||||
|
setFilteredPosts(results as PostData[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Filter refresh failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refetchFilters();
|
||||||
|
} else {
|
||||||
|
// No active filters, just clear any stale cached results
|
||||||
|
setSearchResults(null);
|
||||||
|
setFilteredPosts(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories]);
|
||||||
|
|
||||||
const handleCreatePost = async () => {
|
const handleCreatePost = async () => {
|
||||||
// Create a real post immediately in the database with default empty content
|
// Create a real post immediately in the database with default empty content
|
||||||
try {
|
try {
|
||||||
@@ -566,11 +637,12 @@ const PostsList: React.FC = () => {
|
|||||||
const isFiltered = filteredDisplayPosts !== null;
|
const isFiltered = filteredDisplayPosts !== null;
|
||||||
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
|
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
|
||||||
|
|
||||||
const groupedPosts = {
|
// Memoized grouping that freshens cached filter results with current store data
|
||||||
draft: posts.filter(p => p.status === 'draft'),
|
// This ensures status changes are reflected even when filters are active
|
||||||
published: (filteredDisplayPosts ?? posts).filter(p => p.status === 'published'),
|
const groupedPosts = useMemo(
|
||||||
archived: (filteredDisplayPosts ?? posts).filter(p => p.status === 'archived'),
|
() => groupPostsByStatus(posts, filteredDisplayPosts),
|
||||||
};
|
[posts, filteredDisplayPosts]
|
||||||
|
);
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
||||||
export { unescapeMacroSyntax } from './markdownEscape';
|
export { unescapeMacroSyntax } from './markdownEscape';
|
||||||
|
export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping';
|
||||||
|
|||||||
39
src/renderer/utils/postGrouping.ts
Normal file
39
src/renderer/utils/postGrouping.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PostData } from '../store';
|
||||||
|
|
||||||
|
export type PostStatus = 'draft' | 'published' | 'archived';
|
||||||
|
|
||||||
|
export interface GroupedPosts {
|
||||||
|
draft: PostData[];
|
||||||
|
published: PostData[];
|
||||||
|
archived: PostData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups posts by status, freshening cached filter results with current store data.
|
||||||
|
*
|
||||||
|
* This ensures that when a post's status changes in the store, the change is
|
||||||
|
* reflected in all sections even when filters are active and have cached stale data.
|
||||||
|
*
|
||||||
|
* @param storePosts - Fresh posts from the Zustand store
|
||||||
|
* @param filteredPosts - Cached posts from search/filter results (may be stale), or null if no filter active
|
||||||
|
* @returns Posts grouped by status with freshened data
|
||||||
|
*/
|
||||||
|
export function groupPostsByStatus(
|
||||||
|
storePosts: PostData[],
|
||||||
|
filteredPosts: PostData[] | null
|
||||||
|
): GroupedPosts {
|
||||||
|
// Create a map for O(1) lookup of fresh post data from the store
|
||||||
|
const postsById = new Map(storePosts.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
// Freshen filtered posts by using current store data when available
|
||||||
|
const freshenedFilteredPosts = filteredPosts?.map(cachedPost => {
|
||||||
|
const freshPost = postsById.get(cachedPost.id);
|
||||||
|
return freshPost ?? cachedPost;
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft: storePosts.filter(p => p.status === 'draft'),
|
||||||
|
published: (freshenedFilteredPosts ?? storePosts).filter(p => p.status === 'published'),
|
||||||
|
archived: (freshenedFilteredPosts ?? storePosts).filter(p => p.status === 'archived'),
|
||||||
|
};
|
||||||
|
}
|
||||||
223
tests/renderer/utils/postGrouping.test.ts
Normal file
223
tests/renderer/utils/postGrouping.test.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Tests for postGrouping utility
|
||||||
|
*
|
||||||
|
* Tests the groupPostsByStatus function that ensures posts are correctly
|
||||||
|
* grouped by status, with stale cached filter results freshened from store data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { groupPostsByStatus } from '../../../src/renderer/utils/postGrouping';
|
||||||
|
import type { PostData } from '../../../src/renderer/store/appStore';
|
||||||
|
|
||||||
|
// Factory function for creating test posts
|
||||||
|
function createTestPost(overrides: Partial<PostData>): PostData {
|
||||||
|
return {
|
||||||
|
id: 'test-id',
|
||||||
|
title: 'Test Post',
|
||||||
|
slug: 'test-post',
|
||||||
|
excerpt: 'Test excerpt',
|
||||||
|
content: 'Test content',
|
||||||
|
status: 'draft',
|
||||||
|
author: 'Test Author',
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
tags: [],
|
||||||
|
categories: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupPostsByStatus', () => {
|
||||||
|
let draftPost: PostData;
|
||||||
|
let publishedPost: PostData;
|
||||||
|
let archivedPost: PostData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
draftPost = createTestPost({ id: 'draft-1', status: 'draft', title: 'Draft Post' });
|
||||||
|
publishedPost = createTestPost({ id: 'published-1', status: 'published', title: 'Published Post', publishedAt: '2024-01-01T00:00:00Z' });
|
||||||
|
archivedPost = createTestPost({ id: 'archived-1', status: 'archived', title: 'Archived Post' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without filters (filteredPosts is null)', () => {
|
||||||
|
it('should group all posts by their status', () => {
|
||||||
|
const storePosts = [draftPost, publishedPost, archivedPost];
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, null);
|
||||||
|
|
||||||
|
expect(result.draft).toHaveLength(1);
|
||||||
|
expect(result.draft[0].id).toBe('draft-1');
|
||||||
|
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
expect(result.published[0].id).toBe('published-1');
|
||||||
|
|
||||||
|
expect(result.archived).toHaveLength(1);
|
||||||
|
expect(result.archived[0].id).toBe('archived-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty arrays when no posts exist', () => {
|
||||||
|
const result = groupPostsByStatus([], null);
|
||||||
|
|
||||||
|
expect(result.draft).toHaveLength(0);
|
||||||
|
expect(result.published).toHaveLength(0);
|
||||||
|
expect(result.archived).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple posts with the same status', () => {
|
||||||
|
const draft2 = createTestPost({ id: 'draft-2', status: 'draft' });
|
||||||
|
const published2 = createTestPost({ id: 'published-2', status: 'published' });
|
||||||
|
const storePosts = [draftPost, draft2, publishedPost, published2];
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, null);
|
||||||
|
|
||||||
|
expect(result.draft).toHaveLength(2);
|
||||||
|
expect(result.published).toHaveLength(2);
|
||||||
|
expect(result.archived).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with active filters (filteredPosts is not null)', () => {
|
||||||
|
it('should use filtered posts for published/archived, but always use store for drafts', () => {
|
||||||
|
const storePosts = [draftPost, publishedPost, archivedPost];
|
||||||
|
// Filter only returns the published post
|
||||||
|
const filteredPosts = [publishedPost];
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, filteredPosts);
|
||||||
|
|
||||||
|
// Drafts always come from store
|
||||||
|
expect(result.draft).toHaveLength(1);
|
||||||
|
expect(result.draft[0].id).toBe('draft-1');
|
||||||
|
|
||||||
|
// Published/archived come from filtered list
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
expect(result.published[0].id).toBe('published-1');
|
||||||
|
expect(result.archived).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freshening stale cached data (the bug fix)', () => {
|
||||||
|
it('should NOT show post in published when its status changed to draft in store', () => {
|
||||||
|
// Initial state: post is published
|
||||||
|
const originalPublishedPost = createTestPost({
|
||||||
|
id: 'post-1',
|
||||||
|
status: 'published',
|
||||||
|
title: 'My Post'
|
||||||
|
});
|
||||||
|
|
||||||
|
// User edits the post, changing status to draft in the store
|
||||||
|
const updatedPost = createTestPost({
|
||||||
|
id: 'post-1',
|
||||||
|
status: 'draft', // Changed!
|
||||||
|
title: 'My Post'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store has the updated version
|
||||||
|
const storePosts = [updatedPost];
|
||||||
|
|
||||||
|
// Filter cache still has the old version with 'published' status
|
||||||
|
const staleFilteredPosts = [originalPublishedPost];
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, staleFilteredPosts);
|
||||||
|
|
||||||
|
// Post should appear in drafts (from fresh store data)
|
||||||
|
expect(result.draft).toHaveLength(1);
|
||||||
|
expect(result.draft[0].id).toBe('post-1');
|
||||||
|
expect(result.draft[0].status).toBe('draft');
|
||||||
|
|
||||||
|
// Post should NOT appear in published (stale data should be freshened)
|
||||||
|
expect(result.published).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should freshen all properties from store, not just status', () => {
|
||||||
|
// Store has updated post
|
||||||
|
const storePost = createTestPost({
|
||||||
|
id: 'post-1',
|
||||||
|
status: 'published',
|
||||||
|
title: 'Updated Title',
|
||||||
|
content: 'Updated content'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache has stale data
|
||||||
|
const cachedPost = createTestPost({
|
||||||
|
id: 'post-1',
|
||||||
|
status: 'published',
|
||||||
|
title: 'Old Title',
|
||||||
|
content: 'Old content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = groupPostsByStatus([storePost], [cachedPost]);
|
||||||
|
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
expect(result.published[0].title).toBe('Updated Title');
|
||||||
|
expect(result.published[0].content).toBe('Updated content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep cached post if not found in store (e.g., paginated out)', () => {
|
||||||
|
// Store doesn't have the post (paginated out)
|
||||||
|
const storePosts: PostData[] = [];
|
||||||
|
|
||||||
|
// Cache still has the post from a search result
|
||||||
|
const cachedPost = createTestPost({
|
||||||
|
id: 'post-1',
|
||||||
|
status: 'published',
|
||||||
|
title: 'Paginated Post'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, [cachedPost]);
|
||||||
|
|
||||||
|
// Should keep the cached version since it's not in store
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
expect(result.published[0].title).toBe('Paginated Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mix of fresh and stale posts in filter results', () => {
|
||||||
|
// Store has two posts
|
||||||
|
const freshPost1 = createTestPost({ id: 'post-1', status: 'published', title: 'Fresh 1' });
|
||||||
|
const freshPost2 = createTestPost({ id: 'post-2', status: 'draft', title: 'Fresh 2' }); // Changed to draft!
|
||||||
|
|
||||||
|
// Cache has stale versions
|
||||||
|
const stalePost1 = createTestPost({ id: 'post-1', status: 'published', title: 'Stale 1' });
|
||||||
|
const stalePost2 = createTestPost({ id: 'post-2', status: 'published', title: 'Stale 2' }); // Was published
|
||||||
|
const cachedOnlyPost = createTestPost({ id: 'post-3', status: 'archived', title: 'Cached Only' });
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(
|
||||||
|
[freshPost1, freshPost2],
|
||||||
|
[stalePost1, stalePost2, cachedOnlyPost]
|
||||||
|
);
|
||||||
|
|
||||||
|
// post-1: freshened from store, still published
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
expect(result.published[0].id).toBe('post-1');
|
||||||
|
expect(result.published[0].title).toBe('Fresh 1'); // Freshened!
|
||||||
|
|
||||||
|
// post-2: freshened from store, now draft - appears in drafts
|
||||||
|
expect(result.draft).toHaveLength(1);
|
||||||
|
expect(result.draft[0].id).toBe('post-2');
|
||||||
|
expect(result.draft[0].status).toBe('draft');
|
||||||
|
|
||||||
|
// post-3: not in store, kept from cache
|
||||||
|
expect(result.archived).toHaveLength(1);
|
||||||
|
expect(result.archived[0].id).toBe('post-3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty filter results', () => {
|
||||||
|
const storePosts = [draftPost, publishedPost];
|
||||||
|
|
||||||
|
const result = groupPostsByStatus(storePosts, []);
|
||||||
|
|
||||||
|
// Empty filter returns no published/archived
|
||||||
|
expect(result.draft).toHaveLength(1); // Drafts always from store
|
||||||
|
expect(result.published).toHaveLength(0);
|
||||||
|
expect(result.archived).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create duplicates when same post is in store and filter', () => {
|
||||||
|
const post = createTestPost({ id: 'post-1', status: 'published' });
|
||||||
|
|
||||||
|
const result = groupPostsByStatus([post], [post]);
|
||||||
|
|
||||||
|
expect(result.published).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user