feat: first cut at menu editor

This commit is contained in:
2026-02-21 19:51:34 +01:00
parent f371dbd2b2
commit 76c3a8368e
37 changed files with 2148 additions and 4 deletions

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAutoExpandController } from '../../../src/renderer/components/MenuEditorView/menuAutoExpand';
describe('createAutoExpandController', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('runs callback after configured delay', () => {
const controller = createAutoExpandController(300);
const callback = vi.fn();
controller.schedule('node-a', callback);
vi.advanceTimersByTime(299);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(callback).toHaveBeenCalledTimes(1);
});
it('cancels scheduled callback for a node', () => {
const controller = createAutoExpandController(300);
const callback = vi.fn();
controller.schedule('node-a', callback);
controller.cancel('node-a');
vi.runAllTimers();
expect(callback).not.toHaveBeenCalled();
});
it('replaces existing schedule for same node id', () => {
const controller = createAutoExpandController(300);
const first = vi.fn();
const second = vi.fn();
controller.schedule('node-a', first);
controller.schedule('node-a', second);
vi.runAllTimers();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it('cancels all pending callbacks', () => {
const controller = createAutoExpandController(300);
const first = vi.fn();
const second = vi.fn();
controller.schedule('node-a', first);
controller.schedule('node-b', second);
controller.cancelAll();
vi.runAllTimers();
expect(first).not.toHaveBeenCalled();
expect(second).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { PostData } from '../../../src/main/shared/electronApi';
import {
createMenuPageItemFromPost,
filterPagePosts,
getNextPickerIndex,
isPickerCloseKey,
isPickerFocusShortcut,
} from '../../../src/renderer/components/MenuEditorView/menuPagePicker';
function createPost(overrides: Partial<PostData>): PostData {
return {
id: 'post-1',
projectId: 'project-1',
title: 'Sample Page',
slug: 'sample-page',
content: '',
status: 'draft',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: ['page'],
...overrides,
};
}
describe('menuPagePicker', () => {
it('filters to page-category posts only', () => {
const posts = [
createPost({ id: 'page-1', categories: ['page'] }),
createPost({ id: 'article-1', categories: ['article'] }),
];
const result = filterPagePosts(posts, '');
expect(result.map((post) => post.id)).toEqual(['page-1']);
});
it('filters by title and slug using case-insensitive query', () => {
const posts = [
createPost({ id: 'alpha', title: 'About Us', slug: 'about-us' }),
createPost({ id: 'beta', title: 'Imprint', slug: 'impressum' }),
];
expect(filterPagePosts(posts, 'about').map((post) => post.id)).toEqual(['alpha']);
expect(filterPagePosts(posts, 'IMPRESS').map((post) => post.id)).toEqual(['beta']);
});
it('creates a menu page node with linked page metadata', () => {
const post = createPost({
id: 'page-3',
title: 'Contact',
slug: 'contact',
categories: ['page'],
});
const item = createMenuPageItemFromPost(post);
expect(item.kind).toBe('page');
expect(item.title).toBe('Contact');
expect(item.pageId).toBe('page-3');
expect(item.pageSlug).toBe('contact');
expect(item.children).toEqual([]);
expect(item.id.length).toBeGreaterThan(0);
});
it('moves active index with arrow navigation and wraps around', () => {
expect(getNextPickerIndex(-1, 'ArrowDown', 3)).toBe(0);
expect(getNextPickerIndex(0, 'ArrowDown', 3)).toBe(1);
expect(getNextPickerIndex(2, 'ArrowDown', 3)).toBe(0);
expect(getNextPickerIndex(-1, 'ArrowUp', 3)).toBe(2);
expect(getNextPickerIndex(2, 'ArrowUp', 3)).toBe(1);
expect(getNextPickerIndex(0, 'ArrowUp', 3)).toBe(2);
});
it('returns -1 when there are no picker items', () => {
expect(getNextPickerIndex(-1, 'ArrowDown', 0)).toBe(-1);
expect(getNextPickerIndex(1, 'ArrowUp', 0)).toBe(-1);
});
it('detects escape as picker close key', () => {
expect(isPickerCloseKey('Escape')).toBe(true);
expect(isPickerCloseKey('Enter')).toBe(false);
});
it('detects cmd/ctrl+k as picker focus shortcut', () => {
expect(isPickerFocusShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true);
expect(isPickerFocusShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true);
expect(isPickerFocusShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false);
expect(isPickerFocusShortcut({ key: 'p', metaKey: true, ctrlKey: false })).toBe(false);
});
});

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { applyTreeMove, type MenuTreeItem } from '../../../src/renderer/components/MenuEditorView/menuTreeMove';
function createTree(): MenuTreeItem[] {
return [
{
id: 'home',
title: 'Home',
kind: 'page',
children: [],
},
{
id: 'docs',
title: 'Docs',
kind: 'submenu',
children: [
{
id: 'about',
title: 'About',
kind: 'page',
children: [],
},
],
},
{
id: 'blog',
title: 'Blog',
kind: 'submenu',
children: [
{
id: 'post-1',
title: 'Post 1',
kind: 'page',
children: [],
},
{
id: 'post-2',
title: 'Post 2',
kind: 'page',
children: [],
},
],
},
];
}
describe('applyTreeMove', () => {
it('moves a page into a submenu', () => {
const moved = applyTreeMove(createTree(), {
dragIds: ['home'],
parentId: 'docs',
index: 1,
});
const docs = moved.find((item) => item.id === 'docs');
expect(docs?.children.map((child) => child.id)).toEqual(['about', 'home']);
expect(moved.map((item) => item.id)).toEqual(['docs', 'blog']);
});
it('moves a whole subtree without losing children', () => {
const moved = applyTreeMove(createTree(), {
dragIds: ['blog'],
parentId: null,
index: 0,
});
expect(moved[0].id).toBe('blog');
expect(moved[0].children.map((child) => child.id)).toEqual(['post-1', 'post-2']);
});
it('reorders siblings within same parent', () => {
const moved = applyTreeMove(createTree(), {
dragIds: ['post-2'],
parentId: 'blog',
index: 0,
});
const blog = moved.find((item) => item.id === 'blog');
expect(blog?.children.map((child) => child.id)).toEqual(['post-2', 'post-1']);
});
});

View File

@@ -63,4 +63,15 @@ describe('Help menu documentation entry', () => {
it('maps Edit Preferences to a renderer menu event', () => {
expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences');
});
it('includes Edit Menu action in Blog menu', () => {
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
expect(blogGroup).toBeDefined();
expect(blogGroup?.items.some((item) => item.action === 'editMenu')).toBe(true);
});
it('maps Edit Menu to a renderer menu event', () => {
expect(APP_MENU_ACTION_EVENT_MAP.editMenu).toBe('menu:editMenu');
});
});

View File

@@ -15,6 +15,7 @@ describe('editorRouting', () => {
tags: 'tags',
chat: 'chat',
import: 'import',
'menu-editor': 'menu-editor',
'metadata-diff': 'metadata-diff',
'git-diff': 'git-diff',
documentation: 'documentation',

View File

@@ -20,6 +20,7 @@ describe('tabPolicy', () => {
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
@@ -33,8 +34,9 @@ describe('tabPolicy', () => {
let captured: { type: string; id: string; isTransient: boolean } | null = null;
openSingletonToolTab(openTab, 'site-validation');
openSingletonToolTab(openTab, 'menu-editor');
expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
expect(captured).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
});
it('provides canonical entity tab spec for preview and pin intents', () => {