fix: menu editor crashed category meta data
This commit is contained in:
@@ -172,7 +172,11 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
|||||||
: rawType === 'home'
|
: rawType === 'home'
|
||||||
? 'home'
|
? 'home'
|
||||||
: 'page';
|
: '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 {
|
return {
|
||||||
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
|
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import '../TagInput/TagInput.css';
|
import '../TagInput/TagInput.css';
|
||||||
import './CategoryInput.css';
|
import './CategoryInput.css';
|
||||||
|
|
||||||
|
export interface CategoryOption {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryInputProps {
|
interface CategoryInputProps {
|
||||||
categories: string[];
|
categories: CategoryOption[];
|
||||||
onSelectCategory: (categoryName: string) => void;
|
onSelectCategory: (category: CategoryOption) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
createCategoryArchiveLabel: string;
|
createCategoryArchiveLabel: string;
|
||||||
|
allowCreate?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
inlinePlain?: boolean;
|
inlinePlain?: boolean;
|
||||||
@@ -17,6 +23,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
createCategoryArchiveLabel,
|
createCategoryArchiveLabel,
|
||||||
|
allowCreate = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
inlinePlain = false,
|
inlinePlain = false,
|
||||||
@@ -34,7 +41,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
|
|
||||||
const query = inputValue.toLowerCase().trim();
|
const query = inputValue.toLowerCase().trim();
|
||||||
return categories
|
return categories
|
||||||
.filter((categoryName) => categoryName.toLowerCase().includes(query))
|
.filter((category) => category.title.toLowerCase().includes(query) || category.name.toLowerCase().includes(query))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [categories, inputValue]);
|
}, [categories, inputValue]);
|
||||||
|
|
||||||
@@ -62,23 +69,33 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [autoFocus, disabled]);
|
}, [autoFocus, disabled]);
|
||||||
|
|
||||||
const createArchive = (label: string): void => {
|
const selectCategory = (category: CategoryOption): void => {
|
||||||
const trimmed = label.trim();
|
onSelectCategory(category);
|
||||||
if (!trimmed) {
|
setInputValue('');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createArchiveFromInput = (label: string): void => {
|
||||||
|
const trimmedName = label.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectCategory(trimmed);
|
onSelectCategory({
|
||||||
|
name: trimmedName,
|
||||||
|
title: trimmedName,
|
||||||
|
});
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setSelectedIndex(-1);
|
setSelectedIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const exactMatchExists = inputValue.trim()
|
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;
|
: false;
|
||||||
|
|
||||||
const showCreateOption = inputValue.trim() && !exactMatchExists;
|
const showCreateOption = allowCreate && Boolean(inputValue.trim()) && !exactMatchExists;
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
@@ -97,15 +114,18 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||||
createArchive(suggestions[selectedIndex]);
|
selectCategory(suggestions[selectedIndex]);
|
||||||
} else if (selectedIndex === suggestions.length && showCreateOption) {
|
} else if (selectedIndex === suggestions.length && showCreateOption) {
|
||||||
createArchive(inputValue);
|
createArchiveFromInput(inputValue);
|
||||||
} else {
|
} 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) {
|
if (exactMatch) {
|
||||||
createArchive(exactMatch);
|
selectCategory(exactMatch);
|
||||||
} else if (inputValue.trim()) {
|
} else if (allowCreate && inputValue.trim()) {
|
||||||
createArchive(inputValue);
|
createArchiveFromInput(inputValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -144,14 +164,14 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
|
|
||||||
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
||||||
<div className="tag-suggestions">
|
<div className="tag-suggestions">
|
||||||
{suggestions.map((categoryName, index) => (
|
{suggestions.map((category, index) => (
|
||||||
<button
|
<button
|
||||||
key={categoryName}
|
key={category.name}
|
||||||
type="button"
|
type="button"
|
||||||
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -159,7 +179,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||||
onClick={() => createArchive(inputValue)}
|
onClick={() => createArchiveFromInput(inputValue)}
|
||||||
>
|
>
|
||||||
<span className="tag-suggestion-icon">+</span>
|
<span className="tag-suggestion-icon">+</span>
|
||||||
<span>{createCategoryArchiveLabel}</span>
|
<span>{createCategoryArchiveLabel}</span>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { CategoryInput } from './CategoryInput';
|
export { CategoryInput } from './CategoryInput';
|
||||||
|
export type { CategoryOption } from './CategoryInput';
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
.menu-editor-view {
|
.menu-editor-view {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-editor-header {
|
.menu-editor-header {
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-editor-tree-wrap {
|
.menu-editor-tree-wrap {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from '../../i18n';
|
|||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||||
import { PageInput } from '../PageInput';
|
import { PageInput } from '../PageInput';
|
||||||
import { CategoryInput } from '../CategoryInput';
|
import { CategoryInput, type CategoryOption } from '../CategoryInput';
|
||||||
import { createAutoExpandController } from './menuAutoExpand';
|
import { createAutoExpandController } from './menuAutoExpand';
|
||||||
import { resolveInsertTarget } from './menuInsertTarget';
|
import { resolveInsertTarget } from './menuInsertTarget';
|
||||||
import { isPickerCloseKey } from './menuPagePicker';
|
import { isPickerCloseKey } from './menuPagePicker';
|
||||||
@@ -191,7 +191,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
||||||
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
||||||
const [categories, setCategories] = useState<string[]>([]);
|
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||||
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
|
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
|
||||||
@@ -354,8 +354,16 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
|
|
||||||
setIsLoadingCategories(true);
|
setIsLoadingCategories(true);
|
||||||
try {
|
try {
|
||||||
const nextCategories = await window.electronAPI.meta.getCategories();
|
const [nextCategories, projectMetadata] = await Promise.all([
|
||||||
setCategories(nextCategories);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load categories:', error);
|
console.error('Failed to load categories:', error);
|
||||||
showToast.error(tr('menuEditor.categoryPicker.loadError'));
|
showToast.error(tr('menuEditor.categoryPicker.loadError'));
|
||||||
@@ -414,33 +422,60 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
setEditingEntryType(null);
|
setEditingEntryType(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setDraftAsCategoryArchive = (categoryName: string): void => {
|
const setDraftAsCategoryArchive = (category: CategoryOption): void => {
|
||||||
if (!editingEntryId) {
|
if (!editingEntryId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = categoryName.trim();
|
const draftEntryId = editingEntryId;
|
||||||
if (!trimmed) {
|
|
||||||
|
void (async () => {
|
||||||
|
const trimmedName = category.name.trim();
|
||||||
|
const trimmedTitle = category.title.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nextCategoryName = trimmedName;
|
||||||
|
const exists = categories.some((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
|
||||||
|
|
||||||
|
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) => {
|
setItems((previous) => mapItems(previous, (item) => {
|
||||||
if (item.id !== editingEntryId) {
|
if (item.id !== draftEntryId) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
title: trimmed,
|
title: trimmedTitle || nextCategoryName,
|
||||||
kind: 'category-archive',
|
kind: 'category-archive',
|
||||||
pageId: undefined,
|
pageId: undefined,
|
||||||
pageSlug: undefined,
|
pageSlug: undefined,
|
||||||
categoryName: trimmed,
|
categoryName: nextCategoryName,
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setEditingEntryId(null);
|
setEditingEntryId(null);
|
||||||
setEditingEntryType(null);
|
setEditingEntryType(null);
|
||||||
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startCreateEntry = async (): Promise<void> => {
|
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 () => {
|
it('writes menu state as OPML and can read it back', async () => {
|
||||||
const saved = await menuEngine.saveMenu({
|
const saved = await menuEngine.saveMenu({
|
||||||
items: [
|
items: [
|
||||||
@@ -130,4 +148,27 @@ describe('MenuEngine', () => {
|
|||||||
|
|
||||||
expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true);
|
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: {
|
meta: {
|
||||||
...(window as any).electronAPI?.meta,
|
...(window as any).electronAPI?.meta,
|
||||||
getCategories: vi.fn().mockResolvedValue(['news', 'tech']),
|
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: {
|
posts: {
|
||||||
...(window as any).electronAPI?.posts,
|
...(window as any).electronAPI?.posts,
|
||||||
@@ -228,4 +235,44 @@ describe('MenuEditorView entry editor', () => {
|
|||||||
expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument();
|
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