mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
## Summary - Add `contextMenuConverter.ts` with utilities for converting LiteGraph context menu items to Vue menu format - Improve `contextMenuCompat.ts` with set-based diffing for more reliable legacy extension detection - Extend `MenuOption`/`SubMenuOption` types with `source`, `disabled`, `isColorPicker`, and `category` type fields - Add unit tests for converter functions ## Context This is foundational work for migrating the node context menu from a custom Popover-based component to PrimeVue ContextMenu. The converter provides: - Menu ordering and section grouping (core items first, then extensions) - Deduplication with preference for Vue-native items over LiteGraph items - Extension categorization with labeled section - Support for disabled states and color picker submenus ## Test plan - [x] Unit tests pass for `buildStructuredMenu` (9 tests) - [x] Unit tests pass for `convertContextMenuToOptions` (7 tests) - [x] Typecheck passes - [x] Lint passes - [x] Knip passes (no unused exports) ## Related This is PR 1 of 2 for the node context menu migration. PR 2 will wire up the UI component. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7113-feat-Add-context-menu-converter-infrastructure-2be6d73d3650816ca6c9d2cf50f10159) by [Unito](https://www.unito.io)
191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
|
|
import type { MenuOption } from './useMoreOptionsMenu'
|
|
import {
|
|
buildStructuredMenu,
|
|
convertContextMenuToOptions
|
|
} from './contextMenuConverter'
|
|
|
|
describe('contextMenuConverter', () => {
|
|
describe('buildStructuredMenu', () => {
|
|
it('should order core items before extension items', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Custom Extension Item', source: 'litegraph' },
|
|
{ label: 'Copy', source: 'vue' },
|
|
{ label: 'Rename', source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
// Core items (Rename, Copy) should come before extension items
|
|
const renameIndex = result.findIndex((opt) => opt.label === 'Rename')
|
|
const copyIndex = result.findIndex((opt) => opt.label === 'Copy')
|
|
const extensionIndex = result.findIndex(
|
|
(opt) => opt.label === 'Custom Extension Item'
|
|
)
|
|
|
|
expect(renameIndex).toBeLessThan(extensionIndex)
|
|
expect(copyIndex).toBeLessThan(extensionIndex)
|
|
})
|
|
|
|
it('should add Extensions category label before extension items', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Copy', source: 'vue' },
|
|
{ label: 'My Custom Extension', source: 'litegraph' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
const extensionsLabel = result.find(
|
|
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
|
)
|
|
expect(extensionsLabel).toBeDefined()
|
|
expect(extensionsLabel?.disabled).toBe(true)
|
|
})
|
|
|
|
it('should place Delete at the very end', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Delete', action: () => {}, source: 'vue' },
|
|
{ label: 'Copy', source: 'vue' },
|
|
{ label: 'Rename', source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
const lastNonDivider = [...result]
|
|
.reverse()
|
|
.find((opt) => opt.type !== 'divider')
|
|
expect(lastNonDivider?.label).toBe('Delete')
|
|
})
|
|
|
|
it('should deduplicate items with same label, preferring vue source', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Copy', action: () => {}, source: 'litegraph' },
|
|
{ label: 'Copy', action: () => {}, source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
const copyItems = result.filter((opt) => opt.label === 'Copy')
|
|
expect(copyItems).toHaveLength(1)
|
|
expect(copyItems[0].source).toBe('vue')
|
|
})
|
|
|
|
it('should preserve dividers between sections', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Rename', source: 'vue' },
|
|
{ label: 'Copy', source: 'vue' },
|
|
{ label: 'Pin', source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
const dividers = result.filter((opt) => opt.type === 'divider')
|
|
expect(dividers.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should handle empty input', () => {
|
|
const result = buildStructuredMenu([])
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should handle only dividers', () => {
|
|
const options: MenuOption[] = [{ type: 'divider' }, { type: 'divider' }]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
// Should be empty since dividers are filtered initially
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should recognize Remove as equivalent to Delete', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Remove', action: () => {}, source: 'vue' },
|
|
{ label: 'Copy', source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
// Remove should be placed at the end like Delete
|
|
const lastNonDivider = [...result]
|
|
.reverse()
|
|
.find((opt) => opt.type !== 'divider')
|
|
expect(lastNonDivider?.label).toBe('Remove')
|
|
})
|
|
|
|
it('should group core items in correct section order', () => {
|
|
const options: MenuOption[] = [
|
|
{ label: 'Color', source: 'vue' },
|
|
{ label: 'Node Info', source: 'vue' },
|
|
{ label: 'Pin', source: 'vue' },
|
|
{ label: 'Rename', source: 'vue' }
|
|
]
|
|
|
|
const result = buildStructuredMenu(options)
|
|
|
|
// Get indices of items (excluding dividers and categories)
|
|
const getIndex = (label: string) =>
|
|
result.findIndex((opt) => opt.label === label)
|
|
|
|
// Rename (section 1) should come before Pin (section 2)
|
|
expect(getIndex('Rename')).toBeLessThan(getIndex('Pin'))
|
|
// Pin (section 2) should come before Node Info (section 4)
|
|
expect(getIndex('Pin')).toBeLessThan(getIndex('Node Info'))
|
|
// Node Info (section 4) should come before or with Color (section 4)
|
|
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
|
})
|
|
})
|
|
|
|
describe('convertContextMenuToOptions', () => {
|
|
it('should convert empty array to empty result', () => {
|
|
const result = convertContextMenuToOptions([])
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should convert null items to dividers', () => {
|
|
const result = convertContextMenuToOptions([null], undefined, false)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].type).toBe('divider')
|
|
})
|
|
|
|
it('should skip blacklisted items like Properties', () => {
|
|
const items = [{ content: 'Properties', callback: () => {} }]
|
|
const result = convertContextMenuToOptions(items, undefined, false)
|
|
expect(result.find((opt) => opt.label === 'Properties')).toBeUndefined()
|
|
})
|
|
|
|
it('should convert basic menu items with content', () => {
|
|
const items = [{ content: 'Test Item', callback: () => {} }]
|
|
const result = convertContextMenuToOptions(items, undefined, false)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].label).toBe('Test Item')
|
|
})
|
|
|
|
it('should mark items as litegraph source', () => {
|
|
const items = [{ content: 'Test Item', callback: () => {} }]
|
|
const result = convertContextMenuToOptions(items, undefined, false)
|
|
expect(result[0].source).toBe('litegraph')
|
|
})
|
|
|
|
it('should pass through disabled state', () => {
|
|
const items = [{ content: 'Disabled Item', disabled: true }]
|
|
const result = convertContextMenuToOptions(items, undefined, false)
|
|
expect(result[0].disabled).toBe(true)
|
|
})
|
|
|
|
it('should apply structuring by default', () => {
|
|
const items = [
|
|
{ content: 'Copy', callback: () => {} },
|
|
{ content: 'Custom Extension', callback: () => {} }
|
|
]
|
|
const result = convertContextMenuToOptions(items)
|
|
|
|
// With structuring, there should be Extensions category
|
|
const hasExtensionsCategory = result.some(
|
|
(opt) => opt.label === 'Extensions' && opt.type === 'category'
|
|
)
|
|
expect(hasExtensionsCategory).toBe(true)
|
|
})
|
|
})
|
|
})
|