mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
9 Commits
v1.45.2
...
fix/fe-226
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b9a530b23 | ||
|
|
213c705772 | ||
|
|
d927ef30c9 | ||
|
|
33b37951b4 | ||
|
|
1ebc51d52e | ||
|
|
9c9e87fd1b | ||
|
|
0d6b633a23 | ||
|
|
69daace950 | ||
|
|
9e06d08ae9 |
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression guard for the filter-chip surface affected by FE-226.
|
||||
// The unit test at src/renderer/extensions/vueNodes/widgets/composables/
|
||||
// useWidgetSelectItems.test.ts asserts the chronological sort order —
|
||||
// this spec only ensures the "All / Imported / Generated" chips still
|
||||
// render and the default filter is "All". A deeper e2e that drives cloud
|
||||
// asset timestamps requires the @cloud build and is deferred.
|
||||
test.describe(
|
||||
'FE-226 input-image dropdown filter chips',
|
||||
{ tag: ['@vue-nodes', '@regression'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('renders All, Imported, and Generated chips with All selected by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageNode = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const imageWidget = loadImageNode
|
||||
.locator('.lg-node-widget')
|
||||
.filter({ has: comfyPage.page.getByLabel('image', { exact: true }) })
|
||||
await imageWidget.click()
|
||||
|
||||
const allChip = comfyPage.page.getByRole('button', {
|
||||
name: 'All',
|
||||
exact: true
|
||||
})
|
||||
const importedChip = comfyPage.page.getByRole('button', {
|
||||
name: 'Imported',
|
||||
exact: true
|
||||
})
|
||||
const generatedChip = comfyPage.page.getByRole('button', {
|
||||
name: 'Generated',
|
||||
exact: true
|
||||
})
|
||||
|
||||
await expect(allChip).toBeVisible()
|
||||
await expect(importedChip).toBeVisible()
|
||||
await expect(generatedChip).toBeVisible()
|
||||
|
||||
await expect(allChip).toHaveClass(
|
||||
/bg-interface-menu-component-surface-selected/
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -48,6 +48,7 @@ const modelValue = defineModel<string | undefined>({
|
||||
const { t } = useI18n()
|
||||
|
||||
const outputMediaAssets = useMediaAssets('output')
|
||||
const inputMediaAssets = useMediaAssets('input')
|
||||
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
@@ -84,6 +85,7 @@ const {
|
||||
modelValue,
|
||||
assetKind: () => props.assetKind,
|
||||
outputMediaAssets,
|
||||
inputMediaAssets,
|
||||
assetData,
|
||||
isAssetMode: () => props.isAssetMode
|
||||
})
|
||||
@@ -146,9 +148,13 @@ const acceptTypes = computed(() => {
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
|
||||
function handleIsOpenUpdate(isOpen: boolean) {
|
||||
if (isOpen && !outputMediaAssets.loading.value) {
|
||||
if (!isOpen) return
|
||||
if (!outputMediaAssets.loading.value) {
|
||||
void outputMediaAssets.refresh()
|
||||
}
|
||||
if (!inputMediaAssets.loading.value) {
|
||||
void inputMediaAssets.refresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -166,6 +172,7 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
:multiple="false"
|
||||
:uploadable
|
||||
:accept="acceptTypes"
|
||||
:aria-label="widget.name"
|
||||
:filter-options
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
|
||||
@@ -31,6 +31,7 @@ interface Props {
|
||||
uploadable?: boolean
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
ariaLabel?: string
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
showOwnershipFilter?: boolean
|
||||
@@ -57,6 +58,7 @@ const {
|
||||
uploadable = false,
|
||||
disabled = false,
|
||||
accept,
|
||||
ariaLabel,
|
||||
filterOptions = [],
|
||||
sortOptions = getDefaultSortOptions(),
|
||||
showOwnershipFilter,
|
||||
@@ -202,6 +204,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
:uploadable
|
||||
:disabled
|
||||
:accept
|
||||
:aria-label="ariaLabel"
|
||||
@select-click="toggleDropdown"
|
||||
@file-change="handleFileChange"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,8 @@ const {
|
||||
maxSelectable,
|
||||
uploadable,
|
||||
disabled,
|
||||
accept
|
||||
accept,
|
||||
ariaLabel
|
||||
} = defineProps<FormDropdownInputProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -51,6 +52,7 @@ const theButtonStyle = computed(() =>
|
||||
"
|
||||
>
|
||||
<button
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn(
|
||||
theButtonStyle,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface FormDropdownItem {
|
||||
is_immutable?: boolean
|
||||
/** Base models this item is compatible with - used for base model filtering */
|
||||
base_models?: string[]
|
||||
/** ISO timestamp used by the "All" filter to sort items chronologically */
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
export interface SortOption<TId extends string = string> {
|
||||
@@ -39,6 +41,7 @@ export interface FormDropdownInputProps {
|
||||
uploadable: boolean
|
||||
disabled: boolean
|
||||
accept?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export interface FormDropdownMenuItemProps {
|
||||
|
||||
@@ -683,6 +683,95 @@ describe('useWidgetSelectItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('FE-226: All tab chronological sort', () => {
|
||||
it('FE-226 follow-up: a fresh input (with created_at) sorts above older outputs when inputMediaAssets is provided', async () => {
|
||||
const freshInput = '2026-04-20T12:00:00Z'
|
||||
const olderOutput = '2026-04-19T12:00:00Z'
|
||||
const oldestOutput = '2026-04-18T12:00:00Z'
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'out-older',
|
||||
name: 'output_older.png',
|
||||
preview_url: '',
|
||||
tags: ['output'],
|
||||
created_at: olderOutput
|
||||
},
|
||||
{
|
||||
id: 'out-oldest',
|
||||
name: 'output_oldest.png',
|
||||
preview_url: '',
|
||||
tags: ['output'],
|
||||
created_at: oldestOutput
|
||||
}
|
||||
]
|
||||
|
||||
const inputAssets = createMockMediaAssets()
|
||||
inputAssets.media.value = [
|
||||
{
|
||||
id: 'in-fresh',
|
||||
name: 'input_fresh.png',
|
||||
preview_url: '',
|
||||
tags: ['input'],
|
||||
created_at: freshInput
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['input_fresh.png'],
|
||||
inputMediaAssets: inputAssets,
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'all'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((item) => item.name)).toEqual([
|
||||
'input_fresh.png',
|
||||
'output_older.png [output]',
|
||||
'output_oldest.png [output]'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts All tab by created_at DESC, interleaving outputs with legacy-combo inputs (which land last)', async () => {
|
||||
const newer = '2026-04-20T12:00:00Z'
|
||||
const older = '2026-04-19T12:00:00Z'
|
||||
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'out-older',
|
||||
name: 'older_output.png',
|
||||
preview_url: '',
|
||||
tags: ['output'],
|
||||
created_at: older
|
||||
},
|
||||
{
|
||||
id: 'out-newer',
|
||||
name: 'newer_output.png',
|
||||
preview_url: '',
|
||||
tags: ['output'],
|
||||
created_at: newer
|
||||
}
|
||||
]
|
||||
|
||||
const { dropdownItems, filterSelected } = useWidgetSelectItems(
|
||||
createDefaultOptions({
|
||||
values: () => ['legacy_input.png'],
|
||||
modelValue: ref(undefined)
|
||||
})
|
||||
)
|
||||
filterSelected.value = 'all'
|
||||
await nextTick()
|
||||
|
||||
expect(dropdownItems.value.map((item) => item.name)).toEqual([
|
||||
'newer_output.png [output]',
|
||||
'older_output.png [output]',
|
||||
'legacy_input.png'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('output asset subfolder', () => {
|
||||
it('prefixes the subfolder onto the annotated path so the load URL targets the right folder', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
|
||||
@@ -65,6 +65,7 @@ interface UseWidgetSelectItemsOptions {
|
||||
modelValue: Ref<string | undefined>
|
||||
assetKind: MaybeRefOrGetter<AssetKind | undefined>
|
||||
outputMediaAssets: ReturnType<typeof useMediaAssets>
|
||||
inputMediaAssets?: ReturnType<typeof useMediaAssets>
|
||||
assetData: ReturnType<typeof useAssetWidgetData> | null
|
||||
isAssetMode: MaybeRefOrGetter<boolean | undefined>
|
||||
}
|
||||
@@ -147,18 +148,34 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const inputAssetsByName = computed<Map<string, AssetItem>>(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
const assets = options.inputMediaAssets?.media.value
|
||||
if (!assets) return map
|
||||
for (const asset of assets) {
|
||||
map.set(asset.name, asset)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const inputItems = computed<FormDropdownItem[]>(() => {
|
||||
const values = toValue(options.values) || []
|
||||
if (!Array.isArray(values)) return []
|
||||
|
||||
const labelFn = toValue(options.getOptionLabel)
|
||||
const kind = toValue(options.assetKind)
|
||||
return values.map((value, index) => ({
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(String(value), 'input', kind),
|
||||
name: String(value),
|
||||
label: getDisplayLabel(String(value), labelFn)
|
||||
}))
|
||||
const lookup = inputAssetsByName.value
|
||||
return values.map((value, index) => {
|
||||
const name = String(value)
|
||||
const matched = lookup.get(name)
|
||||
return {
|
||||
id: `input-${index}`,
|
||||
preview_url: getMediaUrl(name, 'input', kind),
|
||||
name,
|
||||
label: getDisplayLabel(name, labelFn),
|
||||
created_at: matched?.created_at
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const outputItems = computed<FormDropdownItem[]>(() => {
|
||||
@@ -196,7 +213,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
? ''
|
||||
: asset.preview_url || getMediaUrl(asset.name, 'output', kind),
|
||||
name: annotatedPath,
|
||||
label: getDisplayLabel(displayLabel, labelFn)
|
||||
label: getDisplayLabel(displayLabel, labelFn),
|
||||
created_at: asset.created_at
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,11 +290,26 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
if (toValue(options.isAssetMode) && assetData) {
|
||||
return filteredAssetItems.value
|
||||
}
|
||||
return [
|
||||
...(missingValueItem.value ? [missingValueItem.value] : []),
|
||||
...inputItems.value,
|
||||
...outputItems.value
|
||||
]
|
||||
const missing = missingValueItem.value ? [missingValueItem.value] : []
|
||||
const combined = [...inputItems.value, ...outputItems.value]
|
||||
const sorted = combined
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aTime = a.item.created_at
|
||||
? new Date(a.item.created_at).getTime()
|
||||
: NaN
|
||||
const bTime = b.item.created_at
|
||||
? new Date(b.item.created_at).getTime()
|
||||
: NaN
|
||||
const aHas = Number.isFinite(aTime)
|
||||
const bHas = Number.isFinite(bTime)
|
||||
if (aHas && bHas) return bTime - aTime
|
||||
if (aHas) return -1
|
||||
if (bHas) return 1
|
||||
return a.index - b.index
|
||||
})
|
||||
.map(({ item }) => item)
|
||||
return [...missing, ...sorted]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<FormDropdownItem[]>(() => {
|
||||
|
||||
Reference in New Issue
Block a user