fix: pages feature now works properly
This commit is contained in:
@@ -747,7 +747,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
// Search the stemmed content, filtered by project_id for project isolation
|
// Search the stemmed content, filtered by project_id for project isolation
|
||||||
const result = await client.execute({
|
const result = await client.execute({
|
||||||
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 50`,
|
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500`,
|
||||||
args: [this.currentProjectId, stemmedQuery],
|
args: [this.currentProjectId, stemmedQuery],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -483,12 +483,14 @@ type PostsListMode = 'posts' | 'pages';
|
|||||||
|
|
||||||
interface PostsListProps {
|
interface PostsListProps {
|
||||||
mode: PostsListMode;
|
mode: PostsListMode;
|
||||||
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostsList: React.FC<PostsListProps> = ({ mode }) => {
|
const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||||
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
|
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
|
||||||
const isPagesMode = mode === 'pages';
|
const isPagesMode = mode === 'pages';
|
||||||
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
|
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
|
||||||
|
const [pageBasePosts, setPageBasePosts] = useState<PostData[] | null>(null);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -534,6 +536,35 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
|
|||||||
loadFilters();
|
loadFilters();
|
||||||
}, [posts]);
|
}, [posts]);
|
||||||
|
|
||||||
|
// In pages mode, load the full pages subset from backend filtering,
|
||||||
|
// independent of currently paged post list in the store.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPagesMode || !isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const loadPagesBase = async () => {
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.filter({ categories: [PAGE_CATEGORY] });
|
||||||
|
if (!isCancelled && results) {
|
||||||
|
setPageBasePosts(results as PostData[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Failed to load pages subset:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPagesBase();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [isPagesMode, isActive, posts]);
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
@@ -725,15 +756,17 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
|
|||||||
|
|
||||||
// Determine which posts to display
|
// Determine which posts to display
|
||||||
// Filters only apply to published/archived posts — drafts are always shown unfiltered
|
// Filters only apply to published/archived posts — drafts are always shown unfiltered
|
||||||
const filteredDisplayPosts = searchResults ?? filteredPosts ?? null;
|
const filteredDisplayPosts = searchResults ?? filteredPosts ?? (isPagesMode ? pageBasePosts : null);
|
||||||
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 baseDisplayPosts = isPagesMode ? (pageBasePosts ?? postSubset) : postSubset;
|
||||||
|
|
||||||
// Memoized grouping that freshens cached filter results with current store data
|
// Memoized grouping that freshens cached filter results with current store data
|
||||||
// This ensures status changes are reflected even when filters are active
|
// This ensures status changes are reflected even when filters are active
|
||||||
const groupedPosts = useMemo(
|
const groupedPosts = useMemo(
|
||||||
() => groupPostsByStatus(postSubset, filteredDisplayPosts),
|
() => groupPostsByStatus(baseDisplayPosts, filteredDisplayPosts),
|
||||||
[postSubset, filteredDisplayPosts]
|
[baseDisplayPosts, filteredDisplayPosts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
@@ -896,7 +929,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{postSubset.length === 0 && !isFiltered && (
|
{baseDisplayPosts.length === 0 && !isFiltered && (
|
||||||
<div className="sidebar-empty">
|
<div className="sidebar-empty">
|
||||||
<p>{isPagesMode ? 'No pages yet' : 'No posts yet'}</p>
|
<p>{isPagesMode ? 'No pages yet' : 'No posts yet'}</p>
|
||||||
<button onClick={handleCreatePost}>Create your first post</button>
|
<button onClick={handleCreatePost}>Create your first post</button>
|
||||||
@@ -1582,11 +1615,25 @@ export const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
<div style={{ display: activeView === 'posts' ? 'block' : 'none' }}>
|
<div
|
||||||
<PostsList mode="posts" />
|
style={{
|
||||||
|
display: activeView === 'posts' ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PostsList mode="posts" isActive={activeView === 'posts'} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: activeView === 'pages' ? 'block' : 'none' }}>
|
<div
|
||||||
<PostsList mode="pages" />
|
style={{
|
||||||
|
display: activeView === 'pages' ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PostsList mode="pages" isActive={activeView === 'pages'} />
|
||||||
</div>
|
</div>
|
||||||
{activeView === 'media' && <MediaList />}
|
{activeView === 'media' && <MediaList />}
|
||||||
{activeView === 'settings' && <SettingsNav />}
|
{activeView === 'settings' && <SettingsNav />}
|
||||||
|
|||||||
@@ -2175,6 +2175,19 @@ Published content`);
|
|||||||
const result = await postEngine.searchPosts('nonexistent');
|
const result = await postEngine.searchPosts('nonexistent');
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should cap search results at 500', async () => {
|
||||||
|
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
|
||||||
|
|
||||||
|
await postEngine.searchPosts('term');
|
||||||
|
|
||||||
|
expect(mockLocalClient.execute).toHaveBeenCalled();
|
||||||
|
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
|
||||||
|
expect(call?.sql).toBeDefined();
|
||||||
|
const sql = call?.sql?.toLowerCase() ?? '';
|
||||||
|
expect(sql).toContain('limit 500');
|
||||||
|
expect(sql).not.toMatch(/\blimit\s+50\b/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTagsWithCounts', () => {
|
describe('getTagsWithCounts', () => {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ describe('Pages shortcut UI', () => {
|
|||||||
|
|
||||||
it('shows only page-category posts when pages view is active', async () => {
|
it('shows only page-category posts when pages view is active', async () => {
|
||||||
useAppStore.setState({ activeView: 'pages', sidebarVisible: true });
|
useAppStore.setState({ activeView: 'pages', sidebarVisible: true });
|
||||||
|
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
|
||||||
|
createMockPost({ id: 'post-page', title: 'About Page', categories: ['page'] }),
|
||||||
|
]);
|
||||||
|
|
||||||
render(<Sidebar />);
|
render(<Sidebar />);
|
||||||
|
|
||||||
@@ -87,4 +90,70 @@ describe('Pages shortcut UI', () => {
|
|||||||
expect(within(pagesPanel as HTMLElement).getByText('About Page')).toBeInTheDocument();
|
expect(within(pagesPanel as HTMLElement).getByText('About Page')).toBeInTheDocument();
|
||||||
expect(within(pagesPanel as HTMLElement).queryByText('Regular Article')).not.toBeInTheDocument();
|
expect(within(pagesPanel as HTMLElement).queryByText('Regular Article')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads pages subset from full table and does not require load-more pagination', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
activeView: 'pages',
|
||||||
|
sidebarVisible: true,
|
||||||
|
posts: [
|
||||||
|
createMockPost({
|
||||||
|
id: 'post-article-only',
|
||||||
|
title: 'Loaded Article',
|
||||||
|
categories: ['article'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
hasMorePosts: true,
|
||||||
|
totalPosts: 1200,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
|
||||||
|
createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const pagesHeader = await screen.findByText('PAGES');
|
||||||
|
const pagesPanel = pagesHeader.closest('.sidebar-content') as HTMLElement;
|
||||||
|
|
||||||
|
expect(within(pagesPanel).getByText('Remote Page')).toBeInTheDocument();
|
||||||
|
expect(within(pagesPanel).queryByText('Loaded Article')).not.toBeInTheDocument();
|
||||||
|
expect(within(pagesPanel).queryByText(/Load more/i)).not.toBeInTheDocument();
|
||||||
|
expect(window.electronAPI.posts.filter).toHaveBeenCalledWith({ categories: ['page'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prefetch pages subset while posts view is active', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
activeView: 'posts',
|
||||||
|
sidebarVisible: true,
|
||||||
|
posts: [
|
||||||
|
createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] }),
|
||||||
|
],
|
||||||
|
hasMorePosts: true,
|
||||||
|
totalPosts: 1200,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
|
||||||
|
createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('POSTS')).toBeInTheDocument();
|
||||||
|
expect(window.electronAPI.posts.filter).not.toHaveBeenCalledWith({ categories: ['page'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a flex-height wrapper for active posts/pages sidebar view', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
activeView: 'posts',
|
||||||
|
sidebarVisible: true,
|
||||||
|
posts: [createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<Sidebar />);
|
||||||
|
expect(await screen.findByText('POSTS')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const wrappers = container.querySelectorAll('.sidebar > div');
|
||||||
|
expect(wrappers.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect((wrappers[0] as HTMLElement).style.display).toBe('flex');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user