Compare commits

...

9 Commits

Author SHA1 Message Date
Dante
5b9a530b23 Merge branch 'main' into fix/fe-226-all-tab-chronological-sort 2026-05-08 08:06:14 +09:00
dante01yoon
213c705772 test: align FE-226 chip names with rendered i18n labels
The new e2e spec asserted button names "Inputs"/"Outputs", but the
filter chips render "Imported"/"Generated" from
sideToolbar.labels.imported / sideToolbar.labels.generated. The
test failed in CI because those buttons did not exist.
2026-05-06 08:11:11 +09:00
dante01yoon
d927ef30c9 Merge remote-tracking branch 'origin/main' into fix/fe-226-all-tab-chronological-sort
# Conflicts:
#	src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue
#	src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
#	src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
2026-05-05 23:01:43 +09:00
dante01yoon
33b37951b4 fix: FE-226 label select-dropdown trigger with widget name
The FormDropdownInput trigger button had no accessible name tied to
the widget — the widget's text (e.g. "image") was rendered as plain
text in a sibling div, which is invisible to assistive tech and broke
the shard-8 regression test that relied on getByLabel('image').
Thread widget.name through FormDropdown → FormDropdownInput as an
aria-label on the button.
2026-04-21 12:27:04 +09:00
dante01yoon
1ebc51d52e fix: FE-226 thread input-side created_at into All-tab sort 2026-04-21 11:11:11 +09:00
dante01yoon
9c9e87fd1b test: FE-226 add failing test for fresh-input vs older-output All-tab order 2026-04-21 11:08:38 +09:00
dante01yoon
0d6b633a23 test: FE-226 add @regression e2e for input-image dropdown filter chips 2026-04-21 10:53:05 +09:00
dante01yoon
69daace950 fix: FE-226 sort input-image dropdown All tab by created_at DESC 2026-04-21 10:46:51 +09:00
dante01yoon
9e06d08ae9 test: FE-226 add failing test for input-image dropdown All tab sort 2026-04-21 10:37:50 +09:00
7 changed files with 204 additions and 14 deletions

View File

@@ -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/
)
})
}
)

View File

@@ -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

View File

@@ -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"
/>

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 = [

View File

@@ -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[]>(() => {