fix: category titles in the menu outline

This commit is contained in:
2026-02-22 07:47:36 +01:00
parent 0d86fe1c9d
commit c58df4b107
2 changed files with 86 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Tree } from 'react-arborist';
import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
@@ -192,6 +192,7 @@ export const MenuEditorView: React.FC = () => {
const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [categoryTitlesByName, setCategoryTitlesByName] = useState<Record<string, string>>({});
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
@@ -202,12 +203,33 @@ export const MenuEditorView: React.FC = () => {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
const recalculateTreeHeight = useCallback((): void => {
const wrap = treeWrapRef.current;
const toolbar = toolbarRef.current;
if (!wrap) {
return;
}
const wrapHeight = wrap.clientHeight;
const toolbarHeight = toolbar?.offsetHeight ?? 0;
const next = Math.max(120, wrapHeight - toolbarHeight - 8);
setTreeHeight(next);
}, []);
useEffect(() => {
const load = async () => {
setIsLoading(true);
try {
const menu = await window.electronAPI.menu.get();
const [menu, projectMetadata] = await Promise.all([
window.electronAPI.menu.get(),
window.electronAPI.meta.getProjectMetadata().catch(() => null),
]);
const metadataEntries = Object.entries(projectMetadata?.categoryMetadata ?? {});
setCategoryTitlesByName(Object.fromEntries(
metadataEntries.map(([name, metadata]) => [name, metadata.title?.trim() || name]),
));
setItems(menu.items);
setSelectedId(menu.items[0]?.id ?? null);
} catch (error) {
@@ -280,34 +302,21 @@ export const MenuEditorView: React.FC = () => {
}, [editingEntryId, editingEntryType, isLoadingPages, isLoadingCategories]);
useEffect(() => {
const updateTreeHeight = (): void => {
const wrap = treeWrapRef.current;
const toolbar = toolbarRef.current;
if (!wrap) {
return;
}
const wrapHeight = wrap.clientHeight;
const toolbarHeight = toolbar?.offsetHeight ?? 0;
const next = Math.max(120, wrapHeight - toolbarHeight - 8);
setTreeHeight(next);
};
updateTreeHeight();
recalculateTreeHeight();
if (typeof ResizeObserver === 'undefined') {
if (typeof window.addEventListener !== 'function') {
return;
}
window.addEventListener('resize', updateTreeHeight);
window.addEventListener('resize', recalculateTreeHeight);
return () => {
window.removeEventListener('resize', updateTreeHeight);
window.removeEventListener('resize', recalculateTreeHeight);
};
}
const observer = new ResizeObserver(() => {
updateTreeHeight();
recalculateTreeHeight();
});
if (treeWrapRef.current) {
@@ -320,7 +329,21 @@ export const MenuEditorView: React.FC = () => {
return () => {
observer.disconnect();
};
}, [editingEntryId]);
}, [editingEntryId, isLoading, recalculateTreeHeight]);
useEffect(() => {
if (isLoading) {
return;
}
const timer = setTimeout(() => {
recalculateTreeHeight();
}, 0);
return () => {
clearTimeout(timer);
};
}, [isLoading, recalculateTreeHeight]);
const selectedPath = useMemo(() => {
if (!selectedId) {
@@ -360,6 +383,9 @@ export const MenuEditorView: React.FC = () => {
]);
const categoryMetadata = projectMetadata?.categoryMetadata ?? {};
setCategoryTitlesByName(Object.fromEntries(
Object.entries(categoryMetadata).map(([name, metadata]) => [name, metadata.title?.trim() || name]),
));
setCategories(nextCategories.map((categoryName) => ({
name: categoryName,
title: categoryMetadata[categoryName]?.title?.trim() || categoryName,
@@ -818,7 +844,9 @@ export const MenuEditorView: React.FC = () => {
inlinePlain
/>
)
) : node.data.title}
) : node.data.kind === 'category-archive' && node.data.categoryName
? (categoryTitlesByName[node.data.categoryName] || node.data.title)
: node.data.title}
</span>
</>
</div>

View File

@@ -216,6 +216,33 @@ describe('MenuEditorView entry editor', () => {
expect(await screen.findByPlaceholderText(/type a category name/i)).toBeInTheDocument();
});
it('shows category metadata title in outline rows for category archives', async () => {
(window as any).electronAPI.menu.get = vi.fn().mockResolvedValue({
items: [
{
id: 'menu-home',
title: 'Home',
kind: 'home',
pageSlug: 'home',
children: [],
},
{
id: 'cat-news',
title: 'news',
kind: 'category-archive',
categoryName: 'news',
children: [],
},
],
});
render(<MenuEditorView />);
await screen.findByText('Home');
expect(screen.getByText('Newsroom')).toBeInTheDocument();
expect(screen.queryByText(/^news$/i)).not.toBeInTheDocument();
});
it('disables delete action when Home entry is selected', async () => {
render(<MenuEditorView />);
@@ -235,6 +262,16 @@ describe('MenuEditorView entry editor', () => {
expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument();
});
it('recalculates tree viewport height after loading so outline uses available space', async () => {
const { container } = render(<MenuEditorView />);
await screen.findByText('Home');
await testUtils.wait(0);
const tree = container.querySelector('[role="tree"]') as HTMLElement | null;
expect(tree).not.toBeNull();
expect(tree?.style.height).not.toBe('460px');
});
it('uses category titles for suggestions and outline while saving category slug internally', async () => {
render(<MenuEditorView />);