From 0860dbe557287015f4afeb972abc1685f7d8f8f9 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 21 Feb 2026 20:49:10 +0100 Subject: [PATCH] feat: rudimentary menu working now --- .../MenuEditorView/MenuEditorView.css | 78 +- .../MenuEditorView/MenuEditorView.tsx | 725 ++++++++++-------- .../MenuEditorView/menuInsertTarget.ts | 70 ++ src/renderer/i18n/locales/de.json | 3 + src/renderer/i18n/locales/en.json | 3 + src/renderer/i18n/locales/es.json | 3 + src/renderer/i18n/locales/fr.json | 3 + src/renderer/i18n/locales/it.json | 3 + .../components/MenuEditorView.styles.test.ts | 22 + .../components/MenuEditorView.test.tsx | 121 +++ .../components/menuInsertTarget.test.ts | 45 ++ 11 files changed, 724 insertions(+), 352 deletions(-) create mode 100644 src/renderer/components/MenuEditorView/menuInsertTarget.ts create mode 100644 tests/renderer/components/MenuEditorView.styles.test.ts create mode 100644 tests/renderer/components/MenuEditorView.test.tsx create mode 100644 tests/renderer/components/menuInsertTarget.test.ts diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.css b/src/renderer/components/MenuEditorView/MenuEditorView.css index 3da75c3..6c16b8b 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.css +++ b/src/renderer/components/MenuEditorView/MenuEditorView.css @@ -29,15 +29,12 @@ } .menu-editor-main { - display: grid; - grid-template-columns: minmax(480px, 1fr) minmax(280px, 340px); - gap: 0.75rem; + display: block; min-height: 0; flex: 1; } -.menu-editor-tree-wrap, -.menu-editor-details { +.menu-editor-tree-wrap { border: 1px solid var(--vscode-panel-border); border-radius: 6px; background: var(--vscode-editor-background); @@ -68,6 +65,26 @@ padding: 0; } +.menu-editor-tool-wrap { + display: inline-flex; +} + +.menu-editor-toolbar-tooltip { + margin-left: auto; + max-width: 320px; + min-width: 120px; + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 4px; + background: var(--vscode-editorHoverWidget-background); + color: var(--vscode-editorHoverWidget-foreground); + font-size: 0.75rem; + line-height: 1.2; + padding: 0.25rem 0.45rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .menu-editor-tool:hover:not(:disabled) { background: var(--vscode-toolbar-hoverBackground); border-color: var(--vscode-panel-border); @@ -92,6 +109,11 @@ color: var(--vscode-list-activeSelectionForeground); } +.menu-editor-row.is-parent-target { + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); + background: var(--vscode-list-hoverBackground); +} + .menu-editor-row-kind { font-size: 0.75rem; opacity: 0.85; @@ -103,20 +125,45 @@ text-overflow: ellipsis; } -.menu-editor-details { - display: flex; - flex-direction: column; - gap: 0.75rem; +.menu-editor-inline-input { + width: 100%; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 0.15rem 0.4rem; + min-height: 1.6rem; } -.menu-editor-details h3 { - margin: 0; -} - -.menu-editor-details label { +.menu-editor-inline-search { + margin-top: 0.5rem; + border-top: 1px solid var(--vscode-panel-border); + padding-top: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; + max-height: 18rem; + overflow: hidden; +} + +.menu-editor-entry-editor { + display: block; +} + +.menu-editor-inline-search-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.menu-editor-inline-search-head strong { + font-size: 0.8rem; +} + +.menu-editor-inline-search-head span { + color: var(--vscode-descriptionForeground); + font-size: 0.75rem; } .menu-editor-picker-backdrop { @@ -154,7 +201,8 @@ display: flex; flex-direction: column; gap: 0.35rem; - overflow: auto; + max-height: 16rem; + overflow-y: auto; } .menu-editor-picker-item { diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.tsx b/src/renderer/components/MenuEditorView/MenuEditorView.tsx index 6ea0fe4..c7321a0 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.tsx +++ b/src/renderer/components/MenuEditorView/MenuEditorView.tsx @@ -2,27 +2,49 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Tree } from 'react-arborist'; import { useI18n } from '../../i18n'; import { showToast } from '../Toast'; -import type { MenuDocument, MenuItemData, MenuItemKind, PostData } from '../../../main/shared/electronApi'; +import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; import { createAutoExpandController } from './menuAutoExpand'; -import { - createMenuPageItemFromPost, - filterPagePosts, - getNextPickerIndex, - isPickerCloseKey, - isPickerFocusShortcut, -} from './menuPagePicker'; +import { resolveInsertTarget } from './menuInsertTarget'; +import { filterPagePosts, isPickerCloseKey, isPickerFocusShortcut } from './menuPagePicker'; import { applyTreeMove } from './menuTreeMove'; import './MenuEditorView.css'; -function createMenuItem(kind: MenuItemKind, title: string): MenuItemData { - return { - id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - title, - kind, - pageId: undefined, - pageSlug: undefined, - children: [], - }; +interface ToolButtonProps { + label: string; + disabled?: boolean; + onClick: () => void; + onShowTooltip: (label: string) => void; + onHideTooltip: () => void; + children: React.ReactNode; +} + +const ToolButton: React.FC = ({ + label, + disabled, + onClick, + onShowTooltip, + onHideTooltip, + children, +}) => ( +
+ +
+); + +function createMenuItemId(): string { + return `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null { @@ -42,28 +64,6 @@ function findPathById(items: MenuItemData[], id: string, path: number[] = []): n return null; } -function updateItemsAtLevel( - items: MenuItemData[], - path: number[], - updater: (level: MenuItemData[]) => MenuItemData[], -): MenuItemData[] { - if (path.length === 0) { - return updater(items); - } - - const [head, ...tail] = path; - return items.map((item, index) => { - if (index !== head) { - return item; - } - - return { - ...item, - children: updateItemsAtLevel(item.children, tail, updater), - }; - }); -} - function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } { if (path.length === 0) { return { next: items, removed: null }; @@ -96,6 +96,28 @@ function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuIt return { next, removed: nested.removed }; } +function updateItemsAtLevel( + items: MenuItemData[], + path: number[], + updater: (level: MenuItemData[]) => MenuItemData[], +): MenuItemData[] { + if (path.length === 0) { + return updater(items); + } + + const [head, ...tail] = path; + return items.map((item, index) => { + if (index !== head) { + return item; + } + + return { + ...item, + children: updateItemsAtLevel(item.children, tail, updater), + }; + }); +} + function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] { if (parentPath.length === 0) { const boundedIndex = Math.max(0, Math.min(index, items.length)); @@ -129,19 +151,32 @@ function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuIte }); } +function createDraftEntry(): MenuItemData { + return { + id: createMenuItemId(), + title: '', + kind: 'submenu', + pageId: undefined, + pageSlug: undefined, + children: [], + }; +} + export const MenuEditorView: React.FC = () => { const { t: tr } = useI18n(); const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const [showPagePicker, setShowPagePicker] = useState(false); - const [pagePickerParentId, setPagePickerParentId] = useState(null); - const [pagePickerLoading, setPagePickerLoading] = useState(false); - const [pagePickerQuery, setPagePickerQuery] = useState(''); - const [pagePickerPosts, setPagePickerPosts] = useState([]); - const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1); - const pagePickerInputRef = useRef(null); + const [isLoadingPages, setIsLoadingPages] = useState(false); + const [pagePosts, setPagePosts] = useState([]); + const [editingEntryId, setEditingEntryId] = useState(null); + const [editingText, setEditingText] = useState(''); + const [selectedPageId, setSelectedPageId] = useState(null); + const [toolbarTooltip, setToolbarTooltip] = useState(''); + const [recentParentInsertId, setRecentParentInsertId] = useState(null); + const entryInputRef = useRef(null); + const recentInsertTimerRef = useRef | null>(null); const autoExpandController = useMemo(() => createAutoExpandController(450), []); useEffect(() => { @@ -165,9 +200,54 @@ export const MenuEditorView: React.FC = () => { useEffect(() => { return () => { autoExpandController.cancelAll(); + if (recentInsertTimerRef.current) { + clearTimeout(recentInsertTimerRef.current); + } }; }, [autoExpandController]); + useEffect(() => { + if (!editingEntryId) { + return; + } + + const onWindowKeyDown = (event: KeyboardEvent): void => { + if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) { + event.preventDefault(); + entryInputRef.current?.focus(); + entryInputRef.current?.select(); + return; + } + + if (isPickerCloseKey(event.key)) { + event.preventDefault(); + setItems((previous) => { + const path = findPathById(previous, editingEntryId); + if (!path) { + return previous; + } + return removeItemByPath(previous, path).next; + }); + setEditingEntryId(null); + setEditingText(''); + setSelectedPageId(null); + } + }; + + window.addEventListener('keydown', onWindowKeyDown); + return () => { + window.removeEventListener('keydown', onWindowKeyDown); + }; + }, [editingEntryId]); + + useEffect(() => { + if (!editingEntryId) { + return; + } + + entryInputRef.current?.focus(); + }, [editingEntryId]); + const selectedPath = useMemo(() => { if (!selectedId) { return null; @@ -175,159 +255,160 @@ export const MenuEditorView: React.FC = () => { return findPathById(items, selectedId); }, [items, selectedId]); - const selectedItem = useMemo(() => { - if (!selectedPath || selectedPath.length === 0) { - return null; - } - - let currentItems = items; - let current: MenuItemData | null = null; - for (const segment of selectedPath) { - current = currentItems[segment] || null; - if (!current) { - return null; - } - currentItems = current.children; - } - - return current; - }, [items, selectedPath]); - const filteredPagePosts = useMemo(() => { - return filterPagePosts(pagePickerPosts, pagePickerQuery); - }, [pagePickerPosts, pagePickerQuery]); + if (!editingEntryId) { + return []; + } + return filterPagePosts(pagePosts, editingText); + }, [editingEntryId, pagePosts, editingText]); - const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => { - if (!selectedId) { + const ensurePagePostsLoaded = async (): Promise => { + if (pagePosts.length > 0) { return; } - setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item))); - }; - - const insertItem = (previous: MenuItemData[], node: MenuItemData, parentId: string | null): MenuItemData[] => { - if (!parentId) { - return [...previous, node]; - } - - return mapItems(previous, (item) => { - if (item.id !== parentId) { - return item; - } - - return { - ...item, - children: [...item.children, node], - }; - }); - }; - - const closePagePicker = (): void => { - setShowPagePicker(false); - setPagePickerParentId(null); - setPagePickerQuery(''); - setPagePickerActiveIndex(-1); - }; - - const openPagePicker = async (parentId: string | null): Promise => { - setShowPagePicker(true); - setPagePickerParentId(parentId); - setPagePickerQuery(''); - setPagePickerActiveIndex(-1); - setPagePickerLoading(true); + setIsLoadingPages(true); try { const posts = await window.electronAPI.posts.filter({ categories: ['page'] }); - setPagePickerPosts(posts); + setPagePosts(posts); } catch (error) { console.error('Failed to load page posts:', error); showToast.error(tr('menuEditor.pagePicker.loadError')); - setPagePickerPosts([]); + setPagePosts([]); } finally { - setPagePickerLoading(false); + setIsLoadingPages(false); } }; - const selectPageForMenu = (post: PostData): void => { - const node = createMenuPageItemFromPost(post); - setItems((previous) => insertItem(previous, node, pagePickerParentId)); - setSelectedId(node.id); - closePagePicker(); + const finalizeEntry = (): void => { + if (!editingEntryId) { + return; + } + + const selectedPage = selectedPageId ? pagePosts.find((post) => post.id === selectedPageId) : null; + const trimmed = editingText.trim(); + + if (selectedPage) { + setItems((previous) => mapItems(previous, (item) => { + if (item.id !== editingEntryId) { + return item; + } + + return { + ...item, + title: selectedPage.title, + kind: 'page', + pageId: selectedPage.id, + pageSlug: selectedPage.slug, + }; + })); + } else if (trimmed) { + setItems((previous) => mapItems(previous, (item) => { + if (item.id !== editingEntryId) { + return item; + } + + return { + ...item, + title: trimmed, + kind: 'submenu', + pageId: undefined, + pageSlug: undefined, + }; + })); + } else { + setItems((previous) => { + const path = findPathById(previous, editingEntryId); + if (!path) { + return previous; + } + return removeItemByPath(previous, path).next; + }); + setSelectedId(null); + } + + setEditingEntryId(null); + setEditingText(''); + setSelectedPageId(null); }; - useEffect(() => { - if (!showPagePicker) { + const finalizeEntryWithPage = (post: PostData): void => { + if (!editingEntryId) { return; } - if (filteredPagePosts.length === 0) { - setPagePickerActiveIndex(-1); - return; - } - - setPagePickerActiveIndex((previous) => { - if (previous < 0) { - return 0; - } - return Math.min(previous, filteredPagePosts.length - 1); - }); - }, [filteredPagePosts, showPagePicker]); - - useEffect(() => { - if (!showPagePicker) { - return; - } - - const onWindowKeyDown = (event: KeyboardEvent): void => { - if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) { - event.preventDefault(); - pagePickerInputRef.current?.focus(); - pagePickerInputRef.current?.select(); - } - }; - - window.addEventListener('keydown', onWindowKeyDown); - return () => { - window.removeEventListener('keydown', onWindowKeyDown); - }; - }, [showPagePicker]); - - const addRootItem = (kind: MenuItemKind): void => { - if (kind === 'page') { - void openPagePicker(null); - return; - } - - const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu'); - const node = createMenuItem(kind, title); - setItems((previous) => [...previous, node]); - setSelectedId(node.id); - }; - - const addChildItem = (kind: MenuItemKind): void => { - if (!selectedId) { - addRootItem(kind); - return; - } - - if (kind === 'page') { - void openPagePicker(selectedId); - return; - } - - const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu'); - const node = createMenuItem(kind, title); - setItems((previous) => mapItems(previous, (item) => { - if (item.id !== selectedId) { + if (item.id !== editingEntryId) { return item; } return { ...item, - children: [...item.children, node], + title: post.title, + kind: 'page', + pageId: post.id, + pageSlug: post.slug, }; })); - setSelectedId(node.id); + + setEditingEntryId(null); + setEditingText(''); + setSelectedPageId(null); + }; + + const startCreateEntry = async (): Promise => { + await ensurePagePostsLoaded(); + + const newEntry = createDraftEntry(); + const target = resolveInsertTarget(items, selectedId); + + if (target.parentPath.length > 0) { + const parentPath = target.parentPath; + let parentNode: MenuItemData | null = null; + let currentLevel = items; + for (const segment of parentPath) { + parentNode = currentLevel[segment] || null; + if (!parentNode) { + break; + } + currentLevel = parentNode.children; + } + + if (parentNode) { + setRecentParentInsertId(parentNode.id); + if (recentInsertTimerRef.current) { + clearTimeout(recentInsertTimerRef.current); + } + recentInsertTimerRef.current = setTimeout(() => { + setRecentParentInsertId(null); + recentInsertTimerRef.current = null; + }, 900); + } + } + + setItems((previous) => { + return insertItemAtPath(previous, target.parentPath, target.index, newEntry); + }); + + setSelectedId(newEntry.id); + setEditingEntryId(newEntry.id); + setEditingText(''); + setSelectedPageId(null); + }; + + const save = async (): Promise => { + setIsSaving(true); + try { + const payload: MenuDocument = { items }; + const saved = await window.electronAPI.menu.save(payload); + setItems(saved.items); + showToast.success(tr('menuEditor.saved')); + } catch (error) { + console.error('Failed to save menu:', error); + showToast.error(tr('menuEditor.saveFailed')); + } finally { + setIsSaving(false); + } }; const moveSelected = (direction: 'up' | 'down'): void => { @@ -402,22 +483,13 @@ export const MenuEditorView: React.FC = () => { const removed = removeItemByPath(previous, selectedPath); return removed.next; }); - setSelectedId(null); - }; - const save = async (): Promise => { - setIsSaving(true); - try { - const payload: MenuDocument = { items }; - const saved = await window.electronAPI.menu.save(payload); - setItems(saved.items); - showToast.success(tr('menuEditor.saved')); - } catch (error) { - console.error('Failed to save menu:', error); - showToast.error(tr('menuEditor.saveFailed')); - } finally { - setIsSaving(false); + if (editingEntryId === selectedId) { + setEditingEntryId(null); + setEditingText(''); + setSelectedPageId(null); } + setSelectedId(null); }; return ( @@ -435,36 +507,71 @@ export const MenuEditorView: React.FC = () => {
- - - - - - - - - - + + moveSelected('up')} + disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0} + onShowTooltip={setToolbarTooltip} + onHideTooltip={() => setToolbarTooltip('')} + > + + + moveSelected('down')} + disabled={!selectedPath} + onShowTooltip={setToolbarTooltip} + onHideTooltip={() => setToolbarTooltip('')} + > + + + setToolbarTooltip('')} + > + + + setToolbarTooltip('')} + > + + + setToolbarTooltip('')} + > + + +
+ {toolbarTooltip || '\u00A0'} +
{items.length === 0 ? ( @@ -472,9 +579,9 @@ export const MenuEditorView: React.FC = () => { ) : ( data={items} - width={720} - height={420} - rowHeight={30} + width="100%" + height={editingEntryId ? 320 : 460} + rowHeight={32} indent={20} openByDefault disableEdit @@ -493,7 +600,7 @@ export const MenuEditorView: React.FC = () => { {({ node, style, tree }) => (
setSelectedId(node.data.id)} onMouseEnter={() => { if (!tree.dragNode || !node.isInternal || node.isOpen) { @@ -509,137 +616,81 @@ export const MenuEditorView: React.FC = () => { autoExpandController.cancel(node.id); }} > - - {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} - - {node.data.title} + <> + + {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} + + {node.data.title} +
)} )} -
-
-

{tr('menuEditor.details')}

- {!selectedItem ? ( -

{tr('menuEditor.selectItem')}

- ) : ( - <> -
- )} - - {showPagePicker && ( -
-
event.stopPropagation()}> -
-

{tr('menuEditor.pagePicker.title')}

- -
- - setPagePickerQuery(event.target.value)} - onKeyDown={(event) => { - if (isPickerCloseKey(event.key)) { - event.preventDefault(); - closePagePicker(); - return; - } - - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - setPagePickerActiveIndex((previous) => getNextPickerIndex(previous, event.key, filteredPagePosts.length)); - return; - } - - if (event.key === 'Enter' && pagePickerActiveIndex >= 0 && pagePickerActiveIndex < filteredPagePosts.length) { - event.preventDefault(); - selectPageForMenu(filteredPagePosts[pagePickerActiveIndex]); - } - }} - placeholder={tr('menuEditor.pagePicker.searchPlaceholder')} - autoFocus - /> - - {pagePickerLoading ? ( -
{tr('menuEditor.pagePicker.loading')}
- ) : filteredPagePosts.length === 0 ? ( -
{tr('menuEditor.pagePicker.empty')}
- ) : ( -
- {filteredPagePosts.map((post) => ( - - ))}
)}
diff --git a/src/renderer/components/MenuEditorView/menuInsertTarget.ts b/src/renderer/components/MenuEditorView/menuInsertTarget.ts new file mode 100644 index 0000000..ceb4b53 --- /dev/null +++ b/src/renderer/components/MenuEditorView/menuInsertTarget.ts @@ -0,0 +1,70 @@ +import type { MenuItemData } from '../../../main/shared/electronApi'; + +function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null { + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const nextPath = [...path, index]; + if (item.id === id) { + return nextPath; + } + + const nested = findPathById(item.children, id, nextPath); + if (nested) { + return nested; + } + } + + return null; +} + +function getNodeByPath(items: MenuItemData[], path: number[]): MenuItemData | null { + let currentItems = items; + let current: MenuItemData | null = null; + + for (const segment of path) { + current = currentItems[segment] || null; + if (!current) { + return null; + } + currentItems = current.children; + } + + return current; +} + +export interface InsertTarget { + parentPath: number[]; + index: number; +} + +export function resolveInsertTarget(items: MenuItemData[], selectedId: string | null): InsertTarget { + if (!selectedId) { + return { + parentPath: [], + index: items.length, + }; + } + + const path = findPathById(items, selectedId); + if (!path || path.length === 0) { + return { + parentPath: [], + index: items.length, + }; + } + + const selectedNode = getNodeByPath(items, path); + if (selectedNode?.kind === 'submenu') { + return { + parentPath: path, + index: 0, + }; + } + + const parentPath = path.slice(0, -1); + const index = path[path.length - 1] + 1; + return { + parentPath, + index, + }; +} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 7e4f94d..c4f2e86 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -50,6 +50,9 @@ "menuEditor.saving": "Speichern...", "menuEditor.saved": "Blog-Menü gespeichert", "menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden", + "menuEditor.addEntry": "Eintrag hinzufügen", + "menuEditor.newEntryPlaceholder": "Seitentitel oder Untermenü-Bezeichnung eingeben", + "menuEditor.createHint": "Unten eine Seite wählen oder Enter drücken, um ein Untermenü zu erstellen", "menuEditor.pagePicker.title": "Seite auswählen", "menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...", "menuEditor.pagePicker.loading": "Seiten werden geladen...", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index c64f142..f5839ef 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -50,6 +50,9 @@ "menuEditor.saving": "Saving...", "menuEditor.saved": "Blog menu saved", "menuEditor.saveFailed": "Failed to save blog menu", + "menuEditor.addEntry": "Add Entry", + "menuEditor.newEntryPlaceholder": "Type a page title or submenu label", + "menuEditor.createHint": "Select a page below or press Enter to create a submenu", "menuEditor.pagePicker.title": "Select Page", "menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...", "menuEditor.pagePicker.loading": "Loading pages...", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index c2edffe..fa98a47 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -50,6 +50,9 @@ "menuEditor.saving": "Guardando...", "menuEditor.saved": "Menú del blog guardado", "menuEditor.saveFailed": "No se pudo guardar el menú del blog", + "menuEditor.addEntry": "Añadir entrada", + "menuEditor.newEntryPlaceholder": "Escribe un título de página o etiqueta de submenú", + "menuEditor.createHint": "Selecciona una página abajo o pulsa Enter para crear un submenú", "menuEditor.pagePicker.title": "Seleccionar página", "menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...", "menuEditor.pagePicker.loading": "Cargando páginas...", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index dbb1d07..4797971 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -50,6 +50,9 @@ "menuEditor.saving": "Enregistrement...", "menuEditor.saved": "Menu du blog enregistré", "menuEditor.saveFailed": "Impossible d’enregistrer le menu du blog", + "menuEditor.addEntry": "Ajouter une entrée", + "menuEditor.newEntryPlaceholder": "Saisissez un titre de page ou un libellé de sous-menu", + "menuEditor.createHint": "Sélectionnez une page ci-dessous ou appuyez sur Entrée pour créer un sous-menu", "menuEditor.pagePicker.title": "Sélectionner une page", "menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...", "menuEditor.pagePicker.loading": "Chargement des pages...", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 8a1e9a0..1d1816b 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -50,6 +50,9 @@ "menuEditor.saving": "Salvataggio...", "menuEditor.saved": "Menu blog salvato", "menuEditor.saveFailed": "Impossibile salvare il menu blog", + "menuEditor.addEntry": "Aggiungi voce", + "menuEditor.newEntryPlaceholder": "Inserisci un titolo pagina o etichetta sottomenu", + "menuEditor.createHint": "Seleziona una pagina qui sotto o premi Invio per creare un sottomenu", "menuEditor.pagePicker.title": "Seleziona pagina", "menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...", "menuEditor.pagePicker.loading": "Caricamento pagine...", diff --git a/tests/renderer/components/MenuEditorView.styles.test.ts b/tests/renderer/components/MenuEditorView.styles.test.ts new file mode 100644 index 0000000..bcff08d --- /dev/null +++ b/tests/renderer/components/MenuEditorView.styles.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +describe('MenuEditorView styles', () => { + const cssPath = path.resolve( + __dirname, + '../../../src/renderer/components/MenuEditorView/MenuEditorView.css' + ); + + it('makes page selector results scrollable with bounded height', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.menu-editor-picker-list\s*\{[^}]*max-height:\s*[^;]+;[^}]*overflow-y:\s*auto;[^}]*\}/s); + }); + + it('bounds the inline selector area so it does not spill beyond the editor viewport', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.menu-editor-inline-search\s*\{[^}]*max-height:\s*[^;]+;[^}]*overflow:\s*hidden;[^}]*\}/s); + }); +}); diff --git a/tests/renderer/components/MenuEditorView.test.tsx b/tests/renderer/components/MenuEditorView.test.tsx new file mode 100644 index 0000000..f9e3b67 --- /dev/null +++ b/tests/renderer/components/MenuEditorView.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { MenuEditorView } from '../../../src/renderer/components/MenuEditorView/MenuEditorView'; + +describe('MenuEditorView entry editor', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (window as any).addEventListener = vi.fn(); + (window as any).removeEventListener = vi.fn(); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + menu: { + get: vi.fn().mockResolvedValue({ + items: [ + { + id: 'root-page', + title: 'Home', + kind: 'page', + children: [], + }, + ], + }), + save: vi.fn().mockResolvedValue({ items: [] }), + }, + posts: { + ...(window as any).electronAPI?.posts, + filter: vi.fn().mockResolvedValue([ + { + id: 'page-about', + projectId: 'project-1', + title: 'About', + slug: 'about', + content: '', + status: 'published', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: ['page'], + }, + ]), + }, + }; + }); + + it('uses a standalone input editor and keeps focus while typing multiple characters', async () => { + const { container } = render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + expect(input.closest('.menu-editor-entry-editor')).not.toBeNull(); + + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.change(input, { target: { value: 'ab' } }); + fireEvent.change(input, { target: { value: 'abc' } }); + + expect((input as HTMLInputElement).value).toBe('abc'); + expect(document.activeElement).toBe(input); + + expect(container.querySelector('.menu-editor-row .menu-editor-inline-input')).toBeNull(); + }); + + it('renders all matching page results without UI capping', async () => { + const pagePosts = Array.from({ length: 12 }).map((_, index) => ({ + id: `page-${index + 1}`, + projectId: 'project-1', + title: `Page ${index + 1}`, + slug: `page-${index + 1}`, + content: '', + status: 'published', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: ['page'], + })); + + (window as any).electronAPI.posts.filter = vi.fn().mockResolvedValue(pagePosts); + + render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + fireEvent.change(input, { target: { value: 'page' } }); + + const options = await screen.findAllByRole('button', { name: /page\s+\d+/i }); + expect(options).toHaveLength(12); + }); + + it('shows standard outliner control buttons in the toolbar', async () => { + render(); + + await screen.findByRole('button', { name: /add entry/i }); + + expect(screen.getByRole('button', { name: /^move up$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^move down$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^indent$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^unindent$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + it('finalizes entry as page on a double-click gesture', async () => { + render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const pageOption = await screen.findByRole('button', { name: /about/i }); + fireEvent.doubleClick(pageOption); + + expect(screen.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument(); + expect(screen.getByText('About')).toBeInTheDocument(); + }); + +}); diff --git a/tests/renderer/components/menuInsertTarget.test.ts b/tests/renderer/components/menuInsertTarget.test.ts new file mode 100644 index 0000000..b9402c3 --- /dev/null +++ b/tests/renderer/components/menuInsertTarget.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import type { MenuItemData } from '../../../src/main/shared/electronApi'; +import { resolveInsertTarget } from '../../../src/renderer/components/MenuEditorView/menuInsertTarget'; + +function createTree(): MenuItemData[] { + return [ + { + id: 'home', + title: 'Home', + kind: 'page', + children: [], + }, + { + id: 'docs', + title: 'Docs', + kind: 'submenu', + children: [ + { + id: 'about', + title: 'About', + kind: 'page', + children: [], + }, + ], + }, + ]; +} + +describe('resolveInsertTarget', () => { + it('inserts on root level when no selection exists', () => { + const result = resolveInsertTarget(createTree(), null); + expect(result).toEqual({ parentPath: [], index: 2 }); + }); + + it('inserts as first child when selected node is submenu', () => { + const result = resolveInsertTarget(createTree(), 'docs'); + expect(result).toEqual({ parentPath: [1], index: 0 }); + }); + + it('inserts as next sibling when selected node is page', () => { + const result = resolveInsertTarget(createTree(), 'home'); + expect(result).toEqual({ parentPath: [], index: 1 }); + }); +});