mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-01 19:20:10 +00:00
refactor: address review feedback
- Consolidate layout config into single computed with Record type - Use reactive props destructuring per AGENTS.md - Change h-[640px] back to max-h-[640px] for proper sizing - Replace hardcoded zinc color with semantic text-muted-foreground - Add defineSlots to VirtualGrid for type safety - Add unit tests for VirtualGrid and FormDropdownMenu Amp-Thread-ID: https://ampcode.com/threads/T-019c0ca8-be8d-770e-ab31-349937cd2acf Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
121
src/components/common/VirtualGrid.test.ts
Normal file
121
src/components/common/VirtualGrid.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import VirtualGrid from './VirtualGrid.vue'
|
||||
|
||||
type TestItem = { key: string; name: string }
|
||||
|
||||
function createItems(count: number): TestItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
key: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('VirtualGrid', () => {
|
||||
const defaultGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '1rem'
|
||||
}
|
||||
|
||||
it('renders items within the visible range', async () => {
|
||||
const items = createItems(100)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 100,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 4,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeLessThan(items.length)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('provides correct index in slot props', async () => {
|
||||
const items = createItems(20)
|
||||
const receivedIndices: number[] = []
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: ({ index }: { index: number }) => {
|
||||
receivedIndices.push(index)
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (receivedIndices.length > 0) {
|
||||
expect(receivedIndices[0]).toBe(0)
|
||||
for (let i = 1; i < receivedIndices.length; i++) {
|
||||
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
const items = createItems(10)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
maxColumns: 2
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const gridElement = wrapper.find('[style*="grid"]')
|
||||
expect(gridElement.exists()).toBe(true)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders empty when no items provided', async () => {
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items: [],
|
||||
gridStyle: defaultGridStyle
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,10 @@ const emit = defineEmits<{
|
||||
'approach-end': []
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
item: (props: { item: T & { key: string }; index: number }) => unknown
|
||||
}>()
|
||||
|
||||
const itemHeight = ref(defaultItemHeight)
|
||||
const itemWidth = ref(defaultItemWidth)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import type { DropdownItem, LayoutMode } from './types'
|
||||
|
||||
function createItem(id: string, name: string): DropdownItem {
|
||||
return {
|
||||
id,
|
||||
mediaSrc: '',
|
||||
name,
|
||||
label: name,
|
||||
metadata: ''
|
||||
}
|
||||
}
|
||||
|
||||
describe('FormDropdownMenu', () => {
|
||||
const defaultProps = {
|
||||
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
|
||||
isSelected: () => false,
|
||||
filterOptions: [],
|
||||
sortOptions: []
|
||||
}
|
||||
|
||||
it('renders empty state when no items', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
|
||||
expect(emptyIcon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders VirtualGrid when items exist', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms items to include key property for VirtualGrid', async () => {
|
||||
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
const virtualItems = virtualGrid.props('items')
|
||||
|
||||
expect(virtualItems).toHaveLength(2)
|
||||
expect(virtualItems[0]).toHaveProperty('key', '1')
|
||||
expect(virtualItems[1]).toHaveProperty('key', '2')
|
||||
})
|
||||
|
||||
it('passes correct layout config for grid mode', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'grid' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(4)
|
||||
expect(virtualGrid.props('defaultItemHeight')).toBe(120)
|
||||
})
|
||||
|
||||
it('passes correct layout config for list mode', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'list' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(1)
|
||||
expect(virtualGrid.props('defaultItemHeight')).toBe(64)
|
||||
})
|
||||
|
||||
it('passes correct layout config for list-small mode', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'list-small' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(1)
|
||||
expect(virtualGrid.props('defaultItemHeight')).toBe(40)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,8 @@ interface Props {
|
||||
updateKey?: MaybeRefOrGetter<unknown>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { items, isSelected, filterOptions, sortOptions, searcher, updateKey } =
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: DropdownItem, index: number): void
|
||||
}>()
|
||||
@@ -38,61 +39,40 @@ const layoutMode = defineModel<LayoutMode>('layoutMode')
|
||||
const sortSelected = defineModel<OptionId>('sortSelected')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
|
||||
// VirtualGrid configuration based on layout mode
|
||||
const maxColumns = computed(() => {
|
||||
switch (layoutMode.value) {
|
||||
case 'grid':
|
||||
return 4
|
||||
case 'list':
|
||||
case 'list-small':
|
||||
return 1
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
})
|
||||
// VirtualGrid layout configuration
|
||||
type LayoutConfig = {
|
||||
maxColumns: number
|
||||
itemHeight: number
|
||||
itemWidth: number
|
||||
gap: string
|
||||
}
|
||||
|
||||
const defaultItemHeight = computed(() => {
|
||||
switch (layoutMode.value) {
|
||||
case 'grid':
|
||||
return 120
|
||||
case 'list':
|
||||
return 64
|
||||
case 'list-small':
|
||||
return 40
|
||||
default:
|
||||
return 120
|
||||
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
|
||||
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
|
||||
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
|
||||
'list-small': {
|
||||
maxColumns: 1,
|
||||
itemHeight: 40,
|
||||
itemWidth: 380,
|
||||
gap: '0.25rem'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const defaultItemWidth = computed(() => {
|
||||
switch (layoutMode.value) {
|
||||
case 'grid':
|
||||
return 89
|
||||
case 'list':
|
||||
case 'list-small':
|
||||
return 380
|
||||
default:
|
||||
return 89
|
||||
}
|
||||
})
|
||||
const layoutConfig = computed<LayoutConfig>(
|
||||
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
|
||||
)
|
||||
|
||||
const gridStyle = computed<CSSProperties>(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns:
|
||||
layoutMode.value === 'grid' ? 'repeat(4, 1fr)' : 'repeat(1, 1fr)',
|
||||
gap:
|
||||
layoutMode.value === 'grid'
|
||||
? '1rem 0.5rem'
|
||||
: layoutMode.value === 'list'
|
||||
? '0.5rem'
|
||||
: '0.25rem',
|
||||
gridTemplateColumns: `repeat(${layoutConfig.value.maxColumns}, 1fr)`,
|
||||
gap: layoutConfig.value.gap,
|
||||
padding: '1rem',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
type VirtualDropdownItem = DropdownItem & { key: string }
|
||||
const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
props.items.map((item) => ({
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
key: String(item.id)
|
||||
}))
|
||||
@@ -101,7 +81,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
>
|
||||
<!-- Filter -->
|
||||
<FormDropdownMenuFilter
|
||||
@@ -128,7 +108,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
<!-- Virtualized Grid -->
|
||||
@@ -137,9 +117,9 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:key="layoutMode"
|
||||
:items="virtualItems"
|
||||
:grid-style="gridStyle"
|
||||
:max-columns="maxColumns"
|
||||
:default-item-height="defaultItemHeight"
|
||||
:default-item-width="defaultItemWidth"
|
||||
:max-columns="layoutConfig.maxColumns"
|
||||
:default-item-height="layoutConfig.itemHeight"
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="h-full"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user