From c429fb608732253ab3ccb5e91be1b2d142326928 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 14 Feb 2026 21:51:04 +0100 Subject: [PATCH] fix: publish/draft status. handlign better --- src/renderer/components/Editor/Editor.tsx | 2 + src/renderer/components/Sidebar/Sidebar.tsx | 84 +++++++- src/renderer/utils/index.ts | 1 + src/renderer/utils/postGrouping.ts | 39 ++++ tests/renderer/utils/postGrouping.test.ts | 223 ++++++++++++++++++++ 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 src/renderer/utils/postGrouping.ts create mode 100644 tests/renderer/utils/postGrouping.test.ts diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 24c8c22..fe8bed8 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -906,6 +906,8 @@ const PostEditor: React.FC = ({ postId }) => { setContent(reverted.content); setTags(reverted.tags); setCategory(reverted.categories[0] || 'article'); + // Update local post state so UI reflects the published status + setPost(reverted as PostData); updatePost(postId, reverted as Partial); markClean(postId); showToast.success('Reverted to last published version'); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 8549614..7d62489 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -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 { showToast } from '../Toast'; +import { groupPostsByStatus } from '../../utils'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import './Sidebar.css'; @@ -524,6 +525,76 @@ const PostsList: React.FC = () => { applyFilters(); }, [selectedTags, selectedCategories]); + // Track previous post statuses to detect changes + const prevPostStatusMapRef = useRef>(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 () => { // Create a real post immediately in the database with default empty content try { @@ -566,11 +637,12 @@ const PostsList: React.FC = () => { const isFiltered = filteredDisplayPosts !== null; const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0; - const groupedPosts = { - draft: posts.filter(p => p.status === 'draft'), - published: (filteredDisplayPosts ?? posts).filter(p => p.status === 'published'), - archived: (filteredDisplayPosts ?? posts).filter(p => p.status === 'archived'), - }; + // Memoized grouping that freshens cached filter results with current store data + // This ensures status changes are reflected even when filters are active + const groupedPosts = useMemo( + () => groupPostsByStatus(posts, filteredDisplayPosts), + [posts, filteredDisplayPosts] + ); const clearAllFilters = () => { setSearchQuery(''); diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 745599a..09131ce 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -1,2 +1,3 @@ export { AutoSaveManager, type AutoSaveConfig } from './autoSave'; export { unescapeMacroSyntax } from './markdownEscape'; +export { groupPostsByStatus, type GroupedPosts, type PostStatus } from './postGrouping'; diff --git a/src/renderer/utils/postGrouping.ts b/src/renderer/utils/postGrouping.ts new file mode 100644 index 0000000..d7c76b5 --- /dev/null +++ b/src/renderer/utils/postGrouping.ts @@ -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'), + }; +} diff --git a/tests/renderer/utils/postGrouping.test.ts b/tests/renderer/utils/postGrouping.test.ts new file mode 100644 index 0000000..05441f1 --- /dev/null +++ b/tests/renderer/utils/postGrouping.test.ts @@ -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 { + 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); + }); + }); +});