fix: menu editor crashed category meta data
This commit is contained in:
@@ -172,7 +172,11 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
||||
: rawType === 'home'
|
||||
? 'home'
|
||||
: 'page';
|
||||
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
|
||||
const textTitle = normalizeNonEmptyString(node['@_text']);
|
||||
const explicitTitle = normalizeNonEmptyString(node['@_title']);
|
||||
const title = kind === 'category-archive'
|
||||
? explicitTitle || textTitle || 'Untitled'
|
||||
: textTitle || explicitTitle || 'Untitled';
|
||||
|
||||
return {
|
||||
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
|
||||
|
||||
@@ -2,11 +2,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import '../TagInput/TagInput.css';
|
||||
import './CategoryInput.css';
|
||||
|
||||
export interface CategoryOption {
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CategoryInputProps {
|
||||
categories: string[];
|
||||
onSelectCategory: (categoryName: string) => void;
|
||||
categories: CategoryOption[];
|
||||
onSelectCategory: (category: CategoryOption) => void;
|
||||
placeholder?: string;
|
||||
createCategoryArchiveLabel: string;
|
||||
allowCreate?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
inlinePlain?: boolean;
|
||||
@@ -17,6 +23,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
onSelectCategory,
|
||||
placeholder = '',
|
||||
createCategoryArchiveLabel,
|
||||
allowCreate = true,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
inlinePlain = false,
|
||||
@@ -34,7 +41,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
|
||||
const query = inputValue.toLowerCase().trim();
|
||||
return categories
|
||||
.filter((categoryName) => categoryName.toLowerCase().includes(query))
|
||||
.filter((category) => category.title.toLowerCase().includes(query) || category.name.toLowerCase().includes(query))
|
||||
.slice(0, 8);
|
||||
}, [categories, inputValue]);
|
||||
|
||||
@@ -62,23 +69,33 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoFocus, disabled]);
|
||||
|
||||
const createArchive = (label: string): void => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
const selectCategory = (category: CategoryOption): void => {
|
||||
onSelectCategory(category);
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const createArchiveFromInput = (label: string): void => {
|
||||
const trimmedName = label.trim();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCategory(trimmed);
|
||||
onSelectCategory({
|
||||
name: trimmedName,
|
||||
title: trimmedName,
|
||||
});
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const exactMatchExists = inputValue.trim()
|
||||
? suggestions.some((item) => item.toLowerCase() === inputValue.trim().toLowerCase())
|
||||
? suggestions.some((item) => item.title.toLowerCase() === inputValue.trim().toLowerCase() || item.name.toLowerCase() === inputValue.trim().toLowerCase())
|
||||
: false;
|
||||
|
||||
const showCreateOption = inputValue.trim() && !exactMatchExists;
|
||||
const showCreateOption = allowCreate && Boolean(inputValue.trim()) && !exactMatchExists;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
@@ -97,15 +114,18 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
createArchive(suggestions[selectedIndex]);
|
||||
selectCategory(suggestions[selectedIndex]);
|
||||
} else if (selectedIndex === suggestions.length && showCreateOption) {
|
||||
createArchive(inputValue);
|
||||
createArchiveFromInput(inputValue);
|
||||
} else {
|
||||
const exactMatch = categories.find((categoryName) => categoryName.toLowerCase() === inputValue.trim().toLowerCase());
|
||||
const exactMatch = categories.find((category) => {
|
||||
const query = inputValue.trim().toLowerCase();
|
||||
return category.name.toLowerCase() === query || category.title.toLowerCase() === query;
|
||||
});
|
||||
if (exactMatch) {
|
||||
createArchive(exactMatch);
|
||||
} else if (inputValue.trim()) {
|
||||
createArchive(inputValue);
|
||||
selectCategory(exactMatch);
|
||||
} else if (allowCreate && inputValue.trim()) {
|
||||
createArchiveFromInput(inputValue);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -144,14 +164,14 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
|
||||
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
||||
<div className="tag-suggestions">
|
||||
{suggestions.map((categoryName, index) => (
|
||||
{suggestions.map((category, index) => (
|
||||
<button
|
||||
key={categoryName}
|
||||
key={category.name}
|
||||
type="button"
|
||||
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
|
||||
onClick={() => createArchive(categoryName)}
|
||||
onClick={() => selectCategory(category)}
|
||||
>
|
||||
<span className="tag-suggestion-name">{categoryName}</span>
|
||||
<span className="tag-suggestion-name">{category.title}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -159,7 +179,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||
onClick={() => createArchive(inputValue)}
|
||||
onClick={() => createArchiveFromInput(inputValue)}
|
||||
>
|
||||
<span className="tag-suggestion-icon">+</span>
|
||||
<span>{createCategoryArchiveLabel}</span>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { CategoryInput } from './CategoryInput';
|
||||
export type { CategoryOption } from './CategoryInput';
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
.menu-editor-view {
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-editor-header {
|
||||
@@ -33,6 +36,7 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-editor-tree-wrap {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from '../../i18n';
|
||||
import { showToast } from '../Toast';
|
||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||
import { PageInput } from '../PageInput';
|
||||
import { CategoryInput } from '../CategoryInput';
|
||||
import { CategoryInput, type CategoryOption } from '../CategoryInput';
|
||||
import { createAutoExpandController } from './menuAutoExpand';
|
||||
import { resolveInsertTarget } from './menuInsertTarget';
|
||||
import { isPickerCloseKey } from './menuPagePicker';
|
||||
@@ -191,7 +191,7 @@ export const MenuEditorView: React.FC = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
||||
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
|
||||
@@ -354,8 +354,16 @@ export const MenuEditorView: React.FC = () => {
|
||||
|
||||
setIsLoadingCategories(true);
|
||||
try {
|
||||
const nextCategories = await window.electronAPI.meta.getCategories();
|
||||
setCategories(nextCategories);
|
||||
const [nextCategories, projectMetadata] = await Promise.all([
|
||||
window.electronAPI.meta.getCategories(),
|
||||
window.electronAPI.meta.getProjectMetadata().catch(() => null),
|
||||
]);
|
||||
|
||||
const categoryMetadata = projectMetadata?.categoryMetadata ?? {};
|
||||
setCategories(nextCategories.map((categoryName) => ({
|
||||
name: categoryName,
|
||||
title: categoryMetadata[categoryName]?.title?.trim() || categoryName,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
showToast.error(tr('menuEditor.categoryPicker.loadError'));
|
||||
@@ -414,33 +422,60 @@ export const MenuEditorView: React.FC = () => {
|
||||
setEditingEntryType(null);
|
||||
};
|
||||
|
||||
const setDraftAsCategoryArchive = (categoryName: string): void => {
|
||||
const setDraftAsCategoryArchive = (category: CategoryOption): void => {
|
||||
if (!editingEntryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = categoryName.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const draftEntryId = editingEntryId;
|
||||
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== editingEntryId) {
|
||||
return item;
|
||||
void (async () => {
|
||||
const trimmedName = category.name.trim();
|
||||
const trimmedTitle = category.title.trim();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: trimmed,
|
||||
kind: 'category-archive',
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
categoryName: trimmed,
|
||||
};
|
||||
}));
|
||||
let nextCategoryName = trimmedName;
|
||||
const exists = categories.some((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
if (!exists) {
|
||||
try {
|
||||
const updatedCategories = await window.electronAPI.meta.addCategory(trimmedName);
|
||||
const matched = updatedCategories.find((item) => item.toLowerCase() === trimmedName.toLowerCase());
|
||||
nextCategoryName = matched || trimmedName;
|
||||
setCategories((previous) => {
|
||||
if (previous.some((item) => item.name.toLowerCase() === nextCategoryName.toLowerCase())) {
|
||||
return previous;
|
||||
}
|
||||
return [...previous, {
|
||||
name: nextCategoryName,
|
||||
title: trimmedTitle || nextCategoryName,
|
||||
}];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create category from menu editor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setItems((previous) => mapItems(previous, (item) => {
|
||||
if (item.id !== draftEntryId) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: trimmedTitle || nextCategoryName,
|
||||
kind: 'category-archive',
|
||||
pageId: undefined,
|
||||
pageSlug: undefined,
|
||||
categoryName: nextCategoryName,
|
||||
};
|
||||
}));
|
||||
|
||||
setEditingEntryId(null);
|
||||
setEditingEntryType(null);
|
||||
})();
|
||||
};
|
||||
|
||||
const startCreateEntry = async (): Promise<void> => {
|
||||
|
||||
@@ -88,6 +88,24 @@ describe('MenuEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves custom title for category archive entries when OPML includes both text and title', async () => {
|
||||
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
|
||||
mockFiles.set(
|
||||
menuPath,
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n <head><title>Blog Menu</title></head>\n <body>\n <outline id="menu-home" text="Home" type="home" pageSlug="home"/>\n <outline id="cat-news" text="news" title="Editorial Highlights" type="category-archive" categoryName="news"/>\n </body>\n</opml>`,
|
||||
);
|
||||
|
||||
const result = await menuEngine.getMenu();
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[1]).toMatchObject({
|
||||
id: 'cat-news',
|
||||
kind: 'category-archive',
|
||||
categoryName: 'news',
|
||||
title: 'Editorial Highlights',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes menu state as OPML and can read it back', async () => {
|
||||
const saved = await menuEngine.saveMenu({
|
||||
items: [
|
||||
@@ -130,4 +148,27 @@ describe('MenuEngine', () => {
|
||||
|
||||
expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists category archives as menu references without category metadata fields', async () => {
|
||||
await menuEngine.saveMenu({
|
||||
items: [
|
||||
{
|
||||
id: 'cat-news',
|
||||
title: 'Newsroom',
|
||||
kind: 'category-archive',
|
||||
categoryName: 'news',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
|
||||
const xml = mockFiles.get(menuPath);
|
||||
|
||||
expect(xml).toBeDefined();
|
||||
expect(xml).toContain('categoryName="news"');
|
||||
expect(xml).toContain('text="Newsroom"');
|
||||
expect(xml).not.toContain('renderInLists');
|
||||
expect(xml).not.toContain('showTitle');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,13 @@ describe('MenuEditorView entry editor', () => {
|
||||
meta: {
|
||||
...(window as any).electronAPI?.meta,
|
||||
getCategories: vi.fn().mockResolvedValue(['news', 'tech']),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({
|
||||
name: 'Project 1',
|
||||
categoryMetadata: {
|
||||
news: { title: 'Newsroom', renderInLists: true, showTitle: true },
|
||||
tech: { title: 'Technology', renderInLists: true, showTitle: true },
|
||||
},
|
||||
}),
|
||||
},
|
||||
posts: {
|
||||
...(window as any).electronAPI?.posts,
|
||||
@@ -228,4 +235,44 @@ describe('MenuEditorView entry editor', () => {
|
||||
expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses category titles for suggestions and outline while saving category slug internally', async () => {
|
||||
render(<MenuEditorView />);
|
||||
|
||||
const categoryButton = await screen.findByRole('button', { name: /add category archive/i });
|
||||
fireEvent.click(categoryButton);
|
||||
|
||||
const input = await screen.findByPlaceholderText(/type a category name/i);
|
||||
fireEvent.input(input, { target: { value: 'room' } });
|
||||
|
||||
const suggestion = await screen.findByRole('button', { name: /newsroom/i });
|
||||
fireEvent.click(suggestion);
|
||||
|
||||
expect(screen.getByText('Newsroom')).toBeInTheDocument();
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /^save menu$/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const saveMock = (window as any).electronAPI.menu.save;
|
||||
expect(saveMock).toHaveBeenCalled();
|
||||
const payload = saveMock.mock.calls[0][0];
|
||||
|
||||
const categoryItem = payload.items.find((item: any) => item.kind === 'category-archive');
|
||||
expect(categoryItem).toBeDefined();
|
||||
expect(categoryItem.title).toBe('Newsroom');
|
||||
expect(categoryItem.categoryName).toBe('news');
|
||||
});
|
||||
|
||||
it('allows creating a new category archive from free text', async () => {
|
||||
const { container } = render(<MenuEditorView />);
|
||||
|
||||
const categoryButton = await screen.findByRole('button', { name: /add category archive/i });
|
||||
fireEvent.click(categoryButton);
|
||||
|
||||
const input = await screen.findByPlaceholderText(/type a category name/i);
|
||||
fireEvent.input(input, { target: { value: 'not-existing-category' } });
|
||||
|
||||
const createSuggestion = container.querySelector('.tag-suggestion.create-new');
|
||||
expect(createSuggestion).not.toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user