Compare commits

...

37 Commits

Author SHA1 Message Date
Benjamin Lu
b06ad201d8 Merge branch 'feat/vue-widget-toggle-switch' into bl-vue-widget 2025-07-02 02:24:04 -04:00
Benjamin Lu
4808018b10 remove barrel export 2025-07-02 01:41:46 -04:00
Benjamin Lu
3ad9c1bd7c "improve" typings 2025-07-01 15:13:41 -04:00
Benjamin Lu
9056a2e89c add AllowedToggleSwitchProps 2025-07-01 14:09:07 -04:00
Benjamin Lu
79b5c75b85 add interface for ToggleSwitchProps 2025-07-01 14:06:56 -04:00
Benjamin Lu
36955407e7 [feat] Add WidgetToggleSwitch Vue component
- New WidgetToggleSwitch component for boolean values
- Added export to vueWidgets index
2025-07-01 13:49:11 -04:00
Benjamin Lu
951b04144a add outline 2025-06-30 20:41:17 -04:00
Benjamin Lu
e2814ec761 add index file for exports 2025-06-30 20:28:49 -04:00
Benjamin Lu
8142d6fcbd basic styling 2025-06-30 19:40:44 -04:00
Benjamin Lu
e25a6e5068 Adjust widget test panel layout from 3 to 2 columns
Changed the grid layout of the widget test panel from 3 columns (xl:grid-cols-3) to 2 columns maximum for better widget visibility and usability
2025-06-30 15:43:34 -04:00
Benjamin Lu
1bd85099ec Add temporary widget testing panel 2025-06-30 14:19:39 -04:00
bymyself
56f59103a5 [feat] Add Vue action widgets
- WidgetButton: Action button with Button component and callback handling
- WidgetFileUpload: File upload interface with FileUpload component
2025-06-24 12:33:42 -07:00
bymyself
8129ba2132 [feat] Add Vue visual widgets
- WidgetColorPicker: Color selection with ColorPicker component
- WidgetImage: Single image display with Image component
- WidgetImageCompare: Before/after comparison with ImageCompare component
- WidgetGalleria: Image gallery/carousel with Galleria component
- WidgetChart: Data visualization with Chart component
2025-06-24 12:33:27 -07:00
bymyself
346cac0889 [feat] Add Vue selection widgets
- WidgetSelect: Dropdown selection with Select component
- WidgetMultiSelect: Multiple selection with MultiSelect component
- WidgetSelectButton: Button group selection with SelectButton component
- WidgetTreeSelect: Hierarchical selection with TreeSelect component
2025-06-24 12:33:11 -07:00
bymyself
7573bca6a2 [feat] Add Vue input widgets
- WidgetInputText: Single-line text input with InputText component
- WidgetTextarea: Multi-line text input with Textarea component
- WidgetSlider: Numeric range input with Slider component
- WidgetToggleSwitch: Boolean toggle with ToggleSwitch component
2025-06-24 12:32:54 -07:00
bymyself
c25206ad3b [feat] Add Vue widget registry system
- Complete widget type enum with all 15 widget types
- Component mapping registry for dynamic widget rendering
- Helper function for type-safe widget component resolution
2025-06-24 12:32:41 -07:00
bymyself
36e4e79994 [feat] Add core Vue widget infrastructure
- SimplifiedWidget interface for Vue-based node widgets
- widgetPropFilter utility with component-specific exclusion lists
- Removes DOM manipulation and positioning concerns
- Provides clean API for value binding and prop filtering
2025-06-24 12:32:27 -07:00
Jin Yi
47145ce4b8 [Manager] Modal UI Adjustment (Align with Design) (#4222) 2025-06-23 21:30:56 -07:00
Christian Byrne
6cf77a9814 [Manager] Fix bug: installed packs metadata not re-fetched after installations (#4254) 2025-06-23 04:37:50 -07:00
Christian Byrne
886e4908d4 [Manager] Fix flush timing issue when switching tabs (#4253) 2025-06-23 03:49:47 -07:00
Christian Byrne
24cbc41832 [Manager] Fix bug: opening modal when last focused tab was 'Installed' always shows empty list (#4252) 2025-06-23 02:41:15 -07:00
Christian Byrne
a80a939324 Fix: virtual grid scrolling bug when container is rendered with emtpy items (switching tabs) (#4251) 2025-06-23 00:13:46 -07:00
Christian Byrne
8e2d7cabba Fix bug: drag-and-drop, copy-paste, and upload don't work in nodes that specify upload folder that isn't 'input' (#4186) 2025-06-22 20:18:36 -07:00
Christian Byrne
e8dd26ff59 [Manager] Fix: When using registry search provider, results not properly paginated' (#4249) 2025-06-22 20:05:37 -07:00
Christian Byrne
3a1bd1829a [feat] Add auto-refresh on task completion for RemoteWidget nodes (#4191)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-22 17:30:24 -07:00
ComfyUI Wiki
2f9dcd1669 Fix: fix typo in Lite Graph settings (#4245) 2025-06-22 08:32:09 +00:00
filtered
e23547dd5a [TS] Remove expect-error (type fix) (#4235) 2025-06-21 20:52:35 -07:00
Comfy Org PR Bot
f0f40bc39b 1.23.1 (#4234)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-21 18:37:51 +00:00
Christian Byrne
4b32786ef5 [Manager] Update Algolia mappings (#4230)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-21 11:09:14 -07:00
Comfy Org PR Bot
9942b17388 [chore] Update Comfy Registry API types from comfy-api@4286a10 (#4229)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:47:04 -07:00
Christian Byrne
b99214bf5e [feat] Show version-specific missing core nodes in workflow warnings (#4227)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-20 15:33:47 -07:00
Christian Byrne
2ef760c599 [Manager] Keep progress dialog on top using priority system (#4225) 2025-06-20 15:22:42 -07:00
Christian Byrne
429ab6c365 [Manager] Fix "total nodes" count when selecting multiple packs (#4228) 2025-06-20 15:20:26 -07:00
ComfyUI Wiki
b7693ae9f5 Fix typo in 3D settings (#4224) 2025-06-20 13:26:40 -07:00
filtered
ebedf1074d [CI] Fix intermittent actions/cache errors (#4220) 2025-06-18 03:55:05 -07:00
filtered
0832347f47 [CI] Fix intermittent failure when using actions/cache (#4219) 2025-06-18 01:24:42 -07:00
filtered
c745af0f25 [Test] Fix vitest scope overlaps playwright tests (#4218) 2025-06-18 01:08:30 -07:00
62 changed files with 2850 additions and 91 deletions

View File

@@ -46,8 +46,8 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Cache setup
uses: actions/cache@v3
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
path: |
ComfyUI
@@ -62,9 +62,13 @@ jobs:
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache@v3
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
with:
fail-on-cache-miss: true
path: |
ComfyUI
ComfyUI_frontend

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.0",
"version": "1.23.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.0",
"version": "1.23.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.0",
"version": "1.23.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,15 +1,12 @@
<template>
<hr
<div
:class="{
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
'content-divider': true,
'content-divider--horizontal': orientation === 'horizontal',
'content-divider--vertical': orientation === 'vertical'
}"
:style="{
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
}"
/>
</template>
@@ -29,3 +26,25 @@ const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>
<style scoped>
.content-divider {
display: inline-block;
margin: 0;
padding: 0;
border: none;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.content-divider--horizontal {
width: 100%;
height: v-bind('width + "px"');
}
.content-divider--vertical {
height: 100%;
width: v-bind('width + "px"');
}
</style>

View File

@@ -92,12 +92,21 @@ whenever(
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {
itemHeight.value = firstItem.clientHeight
}
if (itemWidth.value !== firstItem.clientWidth) {
itemWidth.value = firstItem.clientWidth
}
}
}
const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls
})

View File

@@ -50,4 +50,17 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -5,6 +5,7 @@
title="Missing Node Types"
message="When loading the graph, the following node types were not found"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
:options="uniqueNodes"
option-label="label"
@@ -47,6 +48,7 @@ import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
@@ -61,7 +63,8 @@ const props = defineProps<{
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available

View File

@@ -0,0 +1,95 @@
<template>
<Message
v-if="hasMissingCoreNodes"
severity="info"
icon="pi pi-info-circle"
class="my-2 mx-2"
:pt="{
root: { class: 'flex-col' },
text: { class: 'flex-1' }
}"
>
<div class="flex flex-col gap-2">
<div>
{{
currentComfyUIVersion
? $t('loadWorkflowWarning.outdatedVersion', {
version: currentComfyUIVersion
})
: $t('loadWorkflowWarning.outdatedVersionGeneric')
}}
</div>
<div
v-for="[version, nodes] in sortedMissingCoreNodes"
:key="version"
class="ml-4"
>
<div
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
>
{{
$t('loadWorkflowWarning.coreNodesFromVersion', {
version: version || 'unknown'
})
}}
</div>
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
{{ getUniqueNodeNames(nodes).join(', ') }}
</div>
</div>
</div>
</Message>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import Message from 'primevue/message'
import { computed, ref } from 'vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
}>()
const systemStatsStore = useSystemStatsStore()
const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
})
const currentComfyUIVersion = ref<string | null>(null)
whenever(
hasMissingCoreNodes,
async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
currentComfyUIVersion.value =
systemStatsStore.systemStats?.system?.comfyui_version ?? null
},
{
immediate: true
}
)
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
})
})
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
return nodes
.reduce<string[]>((acc, node) => {
if (node.type && !acc.includes(node.type)) {
acc.push(node.type)
}
return acc
}, [])
.sort()
}
</script>

View File

@@ -88,6 +88,7 @@ const { defaultPanel } = defineProps<{
| 'server-config'
| 'user'
| 'credits'
| 'widget-test'
}>()
const {

View File

@@ -1,14 +1,16 @@
<template>
<div
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
class="h-full flex flex-col mx-auto overflow-hidden"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
text
severity="secondary"
filled
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
@@ -18,14 +20,12 @@
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto pr-80"
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
:class="{
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
'transition-all duration-300': isSmallScreen
}"
>
<div class="px-6 pt-6 flex flex-col h-full">
<div class="px-6 flex flex-col h-full">
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
@@ -59,7 +59,7 @@
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="3"
:buffer-rows="4"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
@@ -77,9 +77,9 @@
</div>
</div>
</div>
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<div class="w-full flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
@@ -219,10 +219,6 @@ const {
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
whenever(selectedTab, () => {
pageNumber.value = 0
})
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
@@ -468,9 +464,10 @@ let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch(searchQuery, () => {
watch([searchQuery, selectedTab], () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
gridContainer.scrollTop = 0
}
})

View File

@@ -1,14 +1,9 @@
<template>
<div class="w-full">
<div class="px-6 py-4">
<div class="flex items-center">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -1,8 +1,8 @@
<template>
<aside
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
>
<ScrollPanel class="w-80 mt-7">
<ScrollPanel class="flex-1">
<Listbox
v-model="selectedTab"
:options="tabs"
@@ -10,20 +10,20 @@
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
<span class="text-lg">{{ slotProps.option.label }}</span>
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" />
<ContentDivider orientation="vertical" :width="0.3" />
</aside>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
@@ -42,7 +42,7 @@
</div>
</template>
<template v-else>
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>

View File

@@ -51,7 +51,11 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!pack.latest_version?.version) return []
const nodeDefs = await getNodeDefs.call({
packId: pack.id,
version: pack.latest_version?.version
version: pack.latest_version?.version,
// Fetch all nodes.
// TODO: Render all nodes previews and handle pagination.
// For determining length, use the `totalNumberOfPages` field of response
limit: 8192
})
return nodeDefs?.comfy_nodes ?? []
}

View File

@@ -0,0 +1,405 @@
<template>
<PanelTemplate value="Widget Test" class="widget-test-container">
<h2 class="text-2xl font-bold mb-4">{{ $t('g.widgetTesting') }}</h2>
<div class="mb-4 flex gap-4">
<label class="flex items-center gap-2">
<input v-model="readonly" type="checkbox" />
<span>{{ $t('g.readonlyMode') }}</span>
</label>
</div>
<ScrollPanel class="h-[60vh]">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 pr-4">
<!-- Text Input Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Text Input (STRING)</h3>
<WidgetInputText
v-model="widgets.string.value"
:widget="widgets.string"
:readonly="readonly"
@update:model-value="onValueChange('string', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.string.value }}
</div>
</div>
<!-- Textarea Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Textarea</h3>
<WidgetTextarea
v-model="widgets.textarea.value"
:widget="widgets.textarea"
:readonly="readonly"
@update:model-value="onValueChange('textarea', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.textarea.value }}
</div>
</div>
<!-- Number Slider Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Number Slider (INT)</h3>
<WidgetSlider
v-model="widgets.int.value"
:widget="widgets.int"
:readonly="readonly"
@update:model-value="onValueChange('int', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.int.value }}
</div>
</div>
<!-- Float Slider Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Float Slider</h3>
<WidgetSlider
v-model="widgets.float.value"
:widget="widgets.float"
:readonly="readonly"
@update:model-value="onValueChange('float', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.float.value }}
</div>
</div>
<!-- Toggle Switch Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Toggle Switch (BOOLEAN)</h3>
<WidgetToggleSwitch
v-model="widgets.boolean.value"
:widget="widgets.boolean"
:readonly="readonly"
@update:model-value="onValueChange('boolean', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.boolean.value }}
</div>
</div>
<!-- Select Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Select (COMBO)</h3>
<WidgetSelect
v-model="widgets.combo.value"
:widget="widgets.combo"
:readonly="readonly"
@update:model-value="onValueChange('combo', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.combo.value }}
</div>
</div>
<!-- Multi Select Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Multi Select</h3>
<WidgetMultiSelect
v-model="widgets.multiselect.value"
:widget="widgets.multiselect"
:readonly="readonly"
@update:model-value="onValueChange('multiselect', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ JSON.stringify(widgets.multiselect.value) }}
</div>
</div>
<!-- Select Button Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Select Button</h3>
<WidgetSelectButton
v-model="widgets.selectbutton.value"
:widget="widgets.selectbutton"
:readonly="readonly"
@update:model-value="onValueChange('selectbutton', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.selectbutton.value }}
</div>
</div>
<!-- Color Picker Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Color Picker</h3>
<WidgetColorPicker
v-model="widgets.color.value"
:widget="widgets.color"
:readonly="readonly"
@update:model-value="onValueChange('color', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ widgets.color.value }}
</div>
</div>
<!-- Button Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Button</h3>
<WidgetButton
:widget="widgets.button"
:readonly="readonly"
@click="onButtonClick"
/>
<div class="mt-2 text-sm text-gray-600">
Clicks: {{ buttonClicks }}
</div>
</div>
<!-- File Upload Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">File Upload</h3>
<WidgetFileUpload
v-model="widgets.fileupload.value"
:widget="widgets.fileupload"
:readonly="readonly"
@update:model-value="onValueChange('fileupload', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Files: {{ widgets.fileupload.value?.length || 0 }}
</div>
</div>
<!-- Tree Select Widget -->
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-2">Tree Select</h3>
<WidgetTreeSelect
v-model="widgets.treeselect.value"
:widget="widgets.treeselect"
:readonly="readonly"
@update:model-value="onValueChange('treeselect', $event)"
/>
<div class="mt-2 text-sm text-gray-600">
Value: {{ JSON.stringify(widgets.treeselect.value) }}
</div>
</div>
</div>
<!-- Callback Log -->
<div class="mt-6 border rounded-lg p-4">
<h3 class="font-semibold mb-2">Callback Log</h3>
<div class="font-mono text-sm max-h-40 overflow-auto">
<div v-for="(log, index) in callbackLog" :key="index" class="py-1">
{{ log }}
</div>
<div v-if="callbackLog.length === 0" class="text-gray-500">
{{ $t('g.noCallbacksTriggered') }}
</div>
</div>
</div>
</ScrollPanel>
</PanelTemplate>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { reactive, ref } from 'vue'
// Import all widget components from index
import {
WidgetButton,
WidgetColorPicker,
WidgetFileUpload,
WidgetInputText,
WidgetMultiSelect,
WidgetSelect,
WidgetSelectButton,
WidgetSlider,
WidgetTextarea,
WidgetToggleSwitch,
WidgetTreeSelect
} from '@/components/graph/vueWidgets'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import PanelTemplate from './PanelTemplate.vue'
// Test state
const readonly = ref(false)
const buttonClicks = ref(0)
const callbackLog = ref<string[]>([])
// Mock widget data
const widgets = reactive({
string: {
name: 'Text Input',
type: 'STRING',
value: 'Hello World',
options: {
placeholder: 'Enter text...'
}
} as SimplifiedWidget<string>,
textarea: {
name: 'Multi-line Text',
type: 'TEXTAREA',
value: 'Line 1\nLine 2\nLine 3',
options: {
rows: 4,
placeholder: 'Enter multiple lines...'
}
} as SimplifiedWidget<string>,
int: {
name: 'Integer Value',
type: 'INT',
value: 50,
options: {
min: 0,
max: 100,
step: 1
}
} as SimplifiedWidget<number>,
float: {
name: 'Float Value',
type: 'FLOAT',
value: 0.5,
options: {
min: 0,
max: 1,
step: 0.01
}
} as SimplifiedWidget<number>,
boolean: {
name: 'Enable Feature',
type: 'BOOLEAN',
value: true,
options: {}
} as SimplifiedWidget<boolean>,
combo: {
name: 'Select Option',
type: 'COMBO',
value: 'option2',
options: {
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' }
],
placeholder: 'Choose an option...'
}
} as SimplifiedWidget<string>,
multiselect: {
name: 'Select Multiple',
type: 'MULTISELECT',
value: ['option1', 'option3'],
options: {
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Option 4', value: 'option4' }
],
placeholder: 'Choose multiple...'
}
} as SimplifiedWidget<string[]>,
selectbutton: {
name: 'Choose Size',
type: 'SELECTBUTTON',
value: 'medium',
options: {
options: [
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' }
]
}
} as SimplifiedWidget<string>,
color: {
name: 'Pick Color',
type: 'COLOR',
value: '#3b82f6',
options: {
format: 'hex'
}
} as SimplifiedWidget<string>,
button: {
name: 'Click Me',
type: 'BUTTON',
value: undefined as any,
options: {
label: 'Execute Action',
icon: 'pi pi-play'
}
} as SimplifiedWidget<void>,
fileupload: {
name: 'Upload Files',
type: 'FILEUPLOAD',
value: [],
options: {
accept: 'image/*',
multiple: true
}
} as SimplifiedWidget<File[] | null>,
treeselect: {
name: 'Select Categories',
type: 'TREESELECT',
value: null,
options: {
options: [
{
key: '0',
label: 'Documents',
children: [
{ key: '0-0', label: 'Work' },
{ key: '0-1', label: 'Personal' }
]
},
{
key: '1',
label: 'Images',
children: [
{ key: '1-0', label: 'Photos' },
{ key: '1-1', label: 'Screenshots' }
]
}
],
placeholder: 'Select category...'
}
} as SimplifiedWidget<any>
})
// Add callbacks to widgets
Object.entries(widgets).forEach(([key, widget]) => {
if (key !== 'button') {
widget.callback = (value: any) => {
logCallback(`${key}: ${JSON.stringify(value)}`)
}
}
})
function onValueChange(widgetType: string, value: any) {
console.log(`Widget ${widgetType} changed:`, value)
}
function onButtonClick() {
buttonClicks.value++
logCallback(`Button clicked ${buttonClicks.value} times`)
}
function logCallback(message: string) {
const timestamp = new Date().toLocaleTimeString()
callbackLog.value.unshift(`[${timestamp}] ${message}`)
if (callbackLog.value.length > 20) {
callbackLog.value = callbackLog.value.slice(0, 20)
}
}
</script>
<style scoped>
.widget-test-container {
padding: 0;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button v-bind="filteredProps" :disabled="readonly" @click="handleClick" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<div class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded">
<!-- Simple chart placeholder - can be enhanced with Chart.js when available -->
<div
v-if="!value || !Array.isArray(value.data)"
class="text-center text-gray-500 dark-theme:text-gray-400"
>
No chart data available
</div>
<div v-else class="space-y-2">
<div v-if="value.title" class="text-center font-semibold">
{{ value.title }}
</div>
<div class="space-y-1">
<div
v-for="(item, index) in value.data"
:key="index"
class="flex justify-between items-center"
>
<span class="text-sm">{{ item.label || `Item ${index + 1}` }}</span>
<div class="flex items-center gap-2">
<div
class="h-3 bg-blue-500 rounded"
:style="{
width: `${Math.max((item.value / maxValue) * 100, 5)}px`
}"
></div>
<span class="text-sm font-mono">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ChartData {
title?: string
data: Array<{
label: string
value: number
}>
}
const value = defineModel<ChartData>({ required: true })
defineProps<{
widget: SimplifiedWidget<ChartData>
readonly?: boolean
}>()
const maxValue = computed(() => {
if (!value.value?.data?.length) return 1
return Math.max(...value.value.data.map((item) => item.value))
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ColorPicker v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<FileUpload
v-bind="filteredProps"
:disabled="readonly"
@upload="handleUpload"
@select="handleSelect"
@remove="handleRemove"
@clear="handleClear"
@error="handleError"
/>
</div>
</template>
<script setup lang="ts">
import FileUpload from 'primevue/fileupload'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// FileUpload doesn't have a traditional v-model, it handles files through events
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
const handleUpload = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleSelect = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleRemove = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleClear = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback([])
}
}
const handleError = (event: any) => {
// Could be extended to handle error reporting
console.warn('File upload error:', event)
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Galleria
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:disabled="readonly"
:show-thumbnails="showThumbnails"
:show-indicators="showIndicators"
:show-nav-buttons="showNavButtons"
class="max-w-full"
>
<template #item="{ item }">
<img
:src="item.itemImageSrc || item.src || item"
:alt="item.alt || 'Gallery image'"
class="w-full h-auto max-h-64 object-contain"
/>
</template>
<template #thumbnail="{ item }">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-16 h-16 object-cover"
/>
</template>
</Galleria>
</div>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value.map((item, index) => {
if (typeof item === 'string') {
return {
itemImageSrc: item,
thumbnailImageSrc: item,
alt: `Image ${index + 1}`
}
}
return item
})
})
const showThumbnails = computed(() => {
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
)
})
const showIndicators = computed(() => {
return (
props.widget.options?.showIndicators !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
galleryImages.value.length > 1
)
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Image v-bind="filteredProps" :src="widget.value" />
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Image widgets typically don't have v-model, they display a source URL/path
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<div
class="image-compare-container relative overflow-hidden rounded border border-gray-300 dark-theme:border-gray-600"
>
<div
v-if="!beforeImage || !afterImage"
class="p-4 text-center text-gray-500 dark-theme:text-gray-400"
>
Before and after images required
</div>
<div v-else class="relative">
<!-- After image (base layer) -->
<Image
v-bind="filteredProps"
:src="afterImage"
class="w-full h-auto"
:alt="afterAlt"
/>
<!-- Before image (overlay layer) -->
<div
class="absolute top-0 left-0 h-full overflow-hidden transition-all duration-300 ease-in-out"
:style="{ width: `${sliderPosition}%` }"
>
<Image
v-bind="filteredProps"
:src="beforeImage"
class="w-full h-auto"
:alt="beforeAlt"
/>
</div>
<!-- Slider handle -->
<div
class="absolute top-0 h-full w-0.5 bg-white shadow-lg cursor-col-resize z-10 transition-all duration-100"
:style="{ left: `${sliderPosition}%` }"
@mousedown="startDrag"
@touchstart="startDrag"
>
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 bg-white rounded-full shadow-md flex items-center justify-center"
>
<div class="w-4 h-4 flex items-center justify-center">
<div class="w-0.5 h-3 bg-gray-600 mr-0.5"></div>
<div class="w-0.5 h-3 bg-gray-600"></div>
</div>
</div>
</div>
<!-- Labels -->
<div
v-if="showLabels"
class="absolute top-2 left-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
>
{{ beforeLabel }}
</div>
<div
v-if="showLabels"
class="absolute top-2 right-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
>
{{ afterLabel }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
beforeLabel?: string
afterLabel?: string
showLabels?: boolean
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue>
readonly?: boolean
}>()
const sliderPosition = ref(50) // Default to 50% (middle)
const isDragging = ref(false)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.after
})
const beforeAlt = computed(
() => props.widget.value?.beforeAlt || 'Before image'
)
const afterAlt = computed(() => props.widget.value?.afterAlt || 'After image')
const beforeLabel = computed(() => props.widget.value?.beforeLabel || 'Before')
const afterLabel = computed(() => props.widget.value?.afterLabel || 'After')
const showLabels = computed(() => props.widget.value?.showLabels !== false)
onMounted(() => {
if (props.widget.value?.initialPosition !== undefined) {
sliderPosition.value = Math.max(
0,
Math.min(100, props.widget.value.initialPosition)
)
}
})
const startDrag = (event: MouseEvent | TouchEvent) => {
if (props.readonly) return
isDragging.value = true
event.preventDefault()
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
updateSliderPosition(e)
}
const handleMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('touchmove', handleMouseMove)
document.removeEventListener('touchend', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('touchmove', handleMouseMove)
document.addEventListener('touchend', handleMouseUp)
}
const updateSliderPosition = (event: MouseEvent | TouchEvent) => {
const container = (event.target as HTMLElement).closest(
'.image-compare-container'
)
if (!container) return
const rect = container.getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const x = clientX - rect.left
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
sliderPosition.value = percentage
}
onUnmounted(() => {
isDragging.value = false
})
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<InputText v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<MultiSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any[]>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any[]>
readonly?: boolean
}>()
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Select v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<SelectButton v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Slider v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<number>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Textarea v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ToggleSwitch
v-model="value"
v-bind="filteredProps"
:disabled="readonly"
:pt="{
root: {
class:
'hover:outline hover:outline-1 hover:outline-[#5B5E7D] rounded-full'
},
slider: ({ props }) => ({
style: {
backgroundColor: props.modelValue ? '#0b8ce9' : '#0e0e12'
}
}),
handle: ({ props }) => ({
style: {
backgroundColor: props.modelValue ? '#ffffff' : '#5b5e7d'
}
})
}"
/>
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { WidgetToggleSwitchProps } from '@/types/widgetPropTypes'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<boolean>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<boolean, WidgetToggleSwitchProps>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<TreeSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,27 @@
/**
* Central export file for all Vue widget components and utilities
*/
// Widget Components
export { default as WidgetButton } from './WidgetButton.vue'
export { default as WidgetChart } from './WidgetChart.vue'
export { default as WidgetColorPicker } from './WidgetColorPicker.vue'
export { default as WidgetFileUpload } from './WidgetFileUpload.vue'
export { default as WidgetGalleria } from './WidgetGalleria.vue'
export { default as WidgetImage } from './WidgetImage.vue'
export { default as WidgetImageCompare } from './WidgetImageCompare.vue'
export { default as WidgetInputText } from './WidgetInputText.vue'
export { default as WidgetMultiSelect } from './WidgetMultiSelect.vue'
export { default as WidgetSelect } from './WidgetSelect.vue'
export { default as WidgetSelectButton } from './WidgetSelectButton.vue'
export { default as WidgetSlider } from './WidgetSlider.vue'
export { default as WidgetTextarea } from './WidgetTextarea.vue'
export { default as WidgetToggleSwitch } from './WidgetToggleSwitch.vue'
export { default as WidgetTreeSelect } from './WidgetTreeSelect.vue'
// Registry and Utilities
export {
WidgetType,
widgetTypeToComponent,
getWidgetComponent
} from './widgetRegistry'

View File

@@ -0,0 +1,80 @@
/**
* Widget type registry and component mapping for Vue-based widgets
*/
import type { Component } from 'vue'
// Component imports
import WidgetButton from './WidgetButton.vue'
import WidgetChart from './WidgetChart.vue'
import WidgetColorPicker from './WidgetColorPicker.vue'
import WidgetFileUpload from './WidgetFileUpload.vue'
import WidgetGalleria from './WidgetGalleria.vue'
import WidgetImage from './WidgetImage.vue'
import WidgetImageCompare from './WidgetImageCompare.vue'
import WidgetInputText from './WidgetInputText.vue'
import WidgetMultiSelect from './WidgetMultiSelect.vue'
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectButton from './WidgetSelectButton.vue'
import WidgetSlider from './WidgetSlider.vue'
import WidgetTextarea from './WidgetTextarea.vue'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
import WidgetTreeSelect from './WidgetTreeSelect.vue'
/**
* Enum of all available widget types
*/
export enum WidgetType {
BUTTON = 'BUTTON',
STRING = 'STRING',
INT = 'INT',
FLOAT = 'FLOAT',
NUMBER = 'NUMBER',
BOOLEAN = 'BOOLEAN',
COMBO = 'COMBO',
COLOR = 'COLOR',
MULTISELECT = 'MULTISELECT',
SELECTBUTTON = 'SELECTBUTTON',
SLIDER = 'SLIDER',
TEXTAREA = 'TEXTAREA',
TOGGLESWITCH = 'TOGGLESWITCH',
CHART = 'CHART',
IMAGE = 'IMAGE',
IMAGECOMPARE = 'IMAGECOMPARE',
GALLERIA = 'GALLERIA',
FILEUPLOAD = 'FILEUPLOAD',
TREESELECT = 'TREESELECT'
}
/**
* Maps widget types to their corresponding Vue components
* Components will be added as they are implemented
*/
export const widgetTypeToComponent: Record<string, Component> = {
// Components will be uncommented as they are implemented
[WidgetType.BUTTON]: WidgetButton,
[WidgetType.STRING]: WidgetInputText,
[WidgetType.INT]: WidgetSlider,
[WidgetType.FLOAT]: WidgetSlider,
[WidgetType.NUMBER]: WidgetSlider, // For compatibility
[WidgetType.BOOLEAN]: WidgetToggleSwitch,
[WidgetType.COMBO]: WidgetSelect,
[WidgetType.COLOR]: WidgetColorPicker,
[WidgetType.MULTISELECT]: WidgetMultiSelect,
[WidgetType.SELECTBUTTON]: WidgetSelectButton,
[WidgetType.SLIDER]: WidgetSlider,
[WidgetType.TEXTAREA]: WidgetTextarea,
[WidgetType.TOGGLESWITCH]: WidgetToggleSwitch,
[WidgetType.CHART]: WidgetChart,
[WidgetType.IMAGE]: WidgetImage,
[WidgetType.IMAGECOMPARE]: WidgetImageCompare,
[WidgetType.GALLERIA]: WidgetGalleria,
[WidgetType.FILEUPLOAD]: WidgetFileUpload,
[WidgetType.TREESELECT]: WidgetTreeSelect
}
/**
* Helper function to get widget component by type
*/
export function getWidgetComponent(type: string): Component | undefined {
return widgetTypeToComponent[type]
}

View File

@@ -3,15 +3,29 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
interface ImageUploadFormFields {
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
type: ResultItemType
}
const uploadFile = async (
file: File,
isPasted: boolean,
formFields: Partial<ImageUploadFormFields> = {}
) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -36,6 +50,11 @@ interface ImageUploadOptions {
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
*/
accept?: string
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
folder?: ResultItemType
}
/**
@@ -53,7 +72,9 @@ export const useNodeImageUpload = (
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
const path = await uploadFile(file, isPastedFile(file), {
type: options.folder
})
if (!path) return
return path
} catch (error) {

View File

@@ -1,3 +1,4 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted } from 'vue'
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
@@ -18,6 +19,16 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
await comfyManagerStore.refreshInstalledList()
await startFetch()
}
// When installedPackIds changes, we need to update the nodePacks
whenever(installedPackIds, async () => {
await startFetch()
})
onUnmounted(() => {
cleanup()
})
@@ -27,7 +38,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
isLoading,
isReady,
installedPacks: nodePacks,
startFetchInstalled: startFetch,
startFetchInstalled,
filterInstalledPack
}
}

View File

@@ -1,7 +1,12 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
import { groupBy } from 'lodash'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
/**
@@ -10,6 +15,7 @@ import type { components } from '@/types/comfyRegistryTypes'
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
@@ -24,6 +30,36 @@ export const useMissingNodes = () => {
return filterMissingPacks(workflowPacks.value)
})
/**
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
* @param packId - The id of the pack to check
* @returns True if the pack is the comfy-core pack, false otherwise
*/
const isCorePack = (packId: NodeProperty) => {
return packId === 'comfy-core'
}
/**
* Check if a node is a missing core node
* A missing core node is a node that is in the workflow and originates from
* the comfy-core pack (pre-installed) but not registered in the node def
* store (the node def was not found on the server)
* @param node - The node to check
* @returns True if the node is a missing core node, false otherwise
*/
const isMissingCoreNode = (node: LGraphNode) => {
const packId = node.properties?.cnr_id
if (packId === undefined || !isCorePack(packId)) return false
const nodeName = node.type
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
return !isRegisteredNodeDef
}
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
@@ -33,6 +69,7 @@ export const useMissingNodes = () => {
return {
missingNodePacks,
missingCoreNodes,
isLoading,
error
}

View File

@@ -28,6 +28,7 @@ export function useSettingUI(
| 'server-config'
| 'user'
| 'credits'
| 'widget-test'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
@@ -127,6 +128,17 @@ export function useSettingUI(
)
}
const widgetTestPanel: SettingPanelItem = {
node: {
key: 'widget-test',
label: 'Widget Test',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/WidgetTestPanel.vue')
)
}
const panels = computed<SettingPanelItem[]>(() =>
[
aboutPanel,
@@ -134,6 +146,7 @@ export function useSettingUI(
userPanel,
keybindingPanel,
extensionPanel,
widgetTestPanel,
...(isElectron() ? [serverConfigPanel] : [])
].filter((panel) => panel.component)
)
@@ -182,6 +195,7 @@ export function useSettingUI(
children: [
keybindingPanel.node,
extensionPanel.node,
widgetTestPanel.node,
aboutPanel.node,
...(isElectron() ? [serverConfigPanel.node] : [])
].map(translateCategory)

View File

@@ -5,7 +5,7 @@ import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { ResultItem } from '@/schemas/apiSchema'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -42,6 +42,7 @@ export const useImageUploadWidget = () => {
const inputOptions = inputData[1]
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
@@ -75,6 +76,7 @@ export const useImageUploadWidget = () => {
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet

View File

@@ -2,7 +2,9 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -220,6 +222,46 @@ export function useRemoteWidget<
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,

View File

@@ -72,6 +72,9 @@
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found",
"noTasksFoundMessage": "There are no tasks in the queue.",
"widgetTesting": "Widget Testing",
"readonlyMode": "Readonly Mode",
"noCallbacksTriggered": "No callbacks triggered yet...",
"newFolder": "New Folder",
"enableAll": "Enable All",
"disableAll": "Disable All",
@@ -916,6 +919,7 @@
"Light": "Light",
"User": "User",
"Credits": "Credits",
"Widget Test": "Widget Test",
"API Nodes": "API Nodes"
},
"serverConfigItems": {
@@ -1195,6 +1199,11 @@
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Subir imagen de fondo",
"uploadTexture": "Subir textura"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
},
"maintenance": {
"None": "Ninguno",
"OK": "OK",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Télécharger l'image de fond",
"uploadTexture": "Télécharger Texture"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
},
"maintenance": {
"None": "Aucun",
"OK": "OK",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "背景画像をアップロード",
"uploadTexture": "テクスチャをアップロード"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
"outdatedVersion": "一部のードはより新しいバージョンのComfyUIが必要です現在のバージョン{version})。すべてのノードを使用するにはアップデートしてください。",
"outdatedVersionGeneric": "一部のードはより新しいバージョンのComfyUIが必要です。すべてのードを使用するにはアップデートしてください。"
},
"maintenance": {
"None": "なし",
"OK": "OK",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "배경 이미지 업로드",
"uploadTexture": "텍스처 업로드"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
},
"maintenance": {
"None": "없음",
"OK": "확인",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "Загрузить фоновое изображение",
"uploadTexture": "Загрузить текстуру"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
},
"maintenance": {
"None": "Нет",
"OK": "OK",

View File

@@ -551,6 +551,11 @@
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
"outdatedVersion": "某些节点需要更高版本的 ComfyUI当前版本{version})。请更新以使用所有节点。",
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
},
"maintenance": {
"None": "无",
"OK": "确定",
@@ -1038,9 +1043,9 @@
"Extension": "扩展",
"General": "常规",
"Graph": "画面",
"Group": "组节点",
"Group": "组",
"Keybinding": "快捷键",
"Light": "浅色",
"Light": "光照",
"Link": "连线",
"LinkRelease": "释放链接",
"LiteGraph": "画面",

View File

@@ -137,8 +137,17 @@ export const useDialogService = () => {
dialogComponentProps: {
closable: true,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
pcCloseButton: {
root: {
class:
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
}
},
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
content: {
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
},
root: { class: 'manager-dialog' }
}
},
props
@@ -154,6 +163,7 @@ export const useDialogService = () => {
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,

View File

@@ -42,7 +42,13 @@ const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
'latest_version_status',
'comfy_node_extract_status',
'id',
'icon_url'
'icon_url',
'github_stars',
'supported_os',
'supported_comfyui_version',
'supported_comfyui_frontend_version',
'supported_accelerators',
'banner_url'
]
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
@@ -74,7 +80,9 @@ const toRegistryPublisher = (
* Convert from node pack in Algolia format to Comfy Registry format
*/
const toRegistryPack = memoize(
(algoliaNode: AlgoliaNodePack): RegistryNodePack => {
(
algoliaNode: AlgoliaNodePack
): RegistryNodePack & { comfy_nodes: string[] } => {
return {
id: algoliaNode.id ?? algoliaNode.objectID,
name: algoliaNode.name,
@@ -86,9 +94,18 @@ const toRegistryPack = memoize(
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error comfy_nodes also not in node info
comfy_nodes: algoliaNode.comfy_nodes,
create_time: algoliaNode.create_time
created_at: algoliaNode.create_time,
category: algoliaNode.category,
author: algoliaNode.author,
tags: algoliaNode.tags,
github_stars: algoliaNode.github_stars,
supported_os: algoliaNode.supported_os,
supported_comfyui_version: algoliaNode.supported_comfyui_version,
supported_comfyui_frontend_version:
algoliaNode.supported_comfyui_frontend_version,
supported_accelerators: algoliaNode.supported_accelerators,
banner_url: algoliaNode.banner_url,
comfy_nodes: algoliaNode.comfy_nodes
}
},
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
@@ -187,9 +204,7 @@ export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
case SortableAlgoliaField.Downloads:
return pack.downloads ?? 0
case SortableAlgoliaField.Created: {
// TODO: add create time to backend return type
// @ts-expect-error create_time is not in the RegistryNodePack type
const createTime = pack.create_time
const createTime = pack.created_at
return createTime ? new Date(createTime).getTime() : 0
}
case SortableAlgoliaField.Updated:

View File

@@ -32,7 +32,7 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
search: isNodeSearch ? undefined : query,
comfy_node_search: isNodeSearch ? query : undefined,
limit: pageSize,
offset: pageNumber * pageSize
page: pageNumber + 1 // Registry API uses 1-based pagination
}
const searchResult = await registryStore.search.call(searchParams)

View File

@@ -213,6 +213,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
refreshInstalledList,
// Pack actions
installPack,

View File

@@ -40,6 +40,7 @@ interface DialogInstance {
contentProps: Record<string, any>
footerComponent?: Component
dialogComponentProps: DialogComponentProps
priority: number
}
export interface ShowDialogOptions {
@@ -50,6 +51,12 @@ export interface ShowDialogOptions {
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
* A dialog will never be shown above a dialog with a higher priority.
* @default 1
*/
priority?: number
}
export const useDialogStore = defineStore('dialog', () => {
@@ -57,13 +64,29 @@ export const useDialogStore = defineStore('dialog', () => {
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
/**
* Inserts a dialog into the stack at the correct position based on priority.
* Higher priority dialogs are placed before lower priority ones.
*/
function insertDialogByPriority(dialog: DialogInstance) {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
dialog
)
}
function riseDialog(options: { key: string }) {
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index !== -1) {
const dialogs = dialogStack.value.splice(index, 1)
dialogStack.value.push(...dialogs)
const [dialog] = dialogStack.value.splice(index, 1)
insertDialogByPriority(dialog)
}
}
@@ -85,12 +108,13 @@ export const useDialogStore = defineStore('dialog', () => {
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
if (dialogStack.value.length >= 10) {
dialogStack.value.shift()
}
const dialog = {
const dialog: DialogInstance = {
key: options.key,
visible: true,
title: options.title,
@@ -102,6 +126,7 @@ export const useDialogStore = defineStore('dialog', () => {
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
priority: options.priority ?? 1,
dialogComponentProps: {
maximizable: false,
modal: true,
@@ -110,6 +135,7 @@ export const useDialogStore = defineStore('dialog', () => {
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
// @ts-expect-error TODO: fix this
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
@@ -128,7 +154,8 @@ export const useDialogStore = defineStore('dialog', () => {
})
}
}
dialogStack.value.push(dialog)
insertDialogByPriority(dialog)
return dialog
}

View File

@@ -59,6 +59,15 @@ export interface AlgoliaNodePack {
'comfy_node_extract_status'
>
icon_url: RegistryNodePack['icon']
category: RegistryNodePack['category']
author: RegistryNodePack['author']
tags: RegistryNodePack['tags']
github_stars: RegistryNodePack['github_stars']
supported_os: RegistryNodePack['supported_os']
supported_comfyui_version: RegistryNodePack['supported_comfyui_version']
supported_comfyui_frontend_version: RegistryNodePack['supported_comfyui_frontend_version']
supported_accelerators: RegistryNodePack['supported_accelerators']
banner_url: RegistryNodePack['banner_url']
}
/**

View File

@@ -896,6 +896,23 @@ export interface paths {
patch?: never
trace?: never
}
'/nodes/update-github-stars': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Update GitHub stars for nodes */
post: operations['updateGithubStars']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/nodes': {
parameters: {
query?: never
@@ -1078,6 +1095,30 @@ export interface paths {
patch?: never
trace?: never
}
'/releases': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Get release notes
* @description Fetch release notes from Strapi with caching
*/
get: operations['getReleaseNotes']
put?: never
/**
* Process Github release webhook
* @description Webhook endpoint to process Github release events and generate release notes
*/
post: operations['processReleaseWebhook']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/security-scan': {
parameters: {
query?: never
@@ -3207,6 +3248,13 @@ export interface components {
preempted_comfy_node_names?: string[]
/** @description URL to the node's banner. */
banner_url?: string
/** @description Number of stars on the GitHub repository. */
github_stars?: number
/**
* Format: date-time
* @description The date and time when the node was created
*/
created_at?: string
}
NodeVersion: {
id?: string
@@ -3345,6 +3393,8 @@ export interface components {
stripe_id?: string
/** @description The Metronome customer ID */
metronome_id?: string
/** @description Whether the user has funds */
has_fund?: boolean
}
AuditLog: {
/** @description the type of the event */
@@ -8692,6 +8742,71 @@ export interface components {
MoonvalleyUploadResponse: {
access_url?: string
}
GithubReleaseWebhook: {
/** @description The action performed on the release */
action: string
release: {
/** @description The tag name of the release */
tag_name: string
/** @description The name of the release */
name: string
/** @description The release notes/body */
body: string
/** @description Whether the release is a draft */
draft: boolean
/** @description Whether the release is a prerelease */
prerelease: boolean
/**
* Format: date-time
* @description When the release was created
*/
created_at?: string
/**
* Format: date-time
* @description When the release was published
*/
published_at?: string
/** @description URL to the tarball */
tarball_url?: string
/** @description URL to the zipball */
zipball_url?: string
/** @description The branch or commit the release was created from */
target_commitish: string
}
repository: {
/** @description The name of the repository */
name: string
/** @description The full name of the repository (owner/repo) */
full_name: string
/** @description The HTML URL of the repository */
html_url: string
/** @description The clone URL of the repository */
clone_url: string
}
}
ReleaseNote: {
/** @description Unique identifier for the release note */
id?: number
/**
* @description The project this release note belongs to
* @enum {string}
*/
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
/** @description The version of the release */
version: string
/**
* @description The attention level for this release
* @enum {string}
*/
attention: 'low' | 'medium' | 'high'
/** @description The content of the release note in markdown format */
content: string
/**
* Format: date-time
* @description When the release note was published
*/
published_at?: string
}
}
responses: never
parameters: {
@@ -10795,8 +10910,6 @@ export interface operations {
query?: {
/** @description Maximum number of nodes to send to algolia at a time */
max_batch?: number
/** @description Minimum interval from the last time the nodes were indexed to algolia */
min_age?: string
}
header?: never
path?: never
@@ -10831,6 +10944,52 @@ export interface operations {
}
}
}
updateGithubStars: {
parameters: {
query?: {
/** @description Maximum number of nodes to update in one batch */
max_batch?: number
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Update GithubStars request triggered successfully */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Bad request. */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
listAllNodes: {
parameters: {
query?: {
@@ -11418,6 +11577,89 @@ export interface operations {
}
}
}
getReleaseNotes: {
parameters: {
query: {
/** @description The project to get release notes for */
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
/** @description The current version to filter release notes */
current_version?: string
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Release notes retrieved successfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ReleaseNote'][]
}
}
/** @description Bad request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
processReleaseWebhook: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['GithubReleaseWebhook']
}
}
responses: {
/** @description Webhook processed successfully */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Bad request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
securityScan: {
parameters: {
query?: {

View File

@@ -0,0 +1,27 @@
/**
* Simplified widget interface for Vue-based node rendering
* Removes all DOM manipulation and positioning concerns
*/
export interface SimplifiedWidget<T = any, O = Record<string, any>> {
/** Display name of the widget */
name: string
/** Widget type identifier (e.g., 'STRING', 'INT', 'COMBO') */
type: string
/** Current value of the widget */
value: T
/** Widget options including filtered PrimeVue props */
options?: O
/** Callback fired when value changes */
callback?: (value: T) => void
/** Optional serialization method for custom value handling */
serializeValue?: () => any
/** Optional method to compute widget size requirements */
computeSize?: () => { minHeight: number; maxHeight?: number }
}

View File

@@ -0,0 +1,30 @@
/**
* Type definitions for widget component props
*
* These interfaces define the subset of PrimeVue component props that are exposed
* for the node-based widget system. They exclude props that allow custom styling,
* colors, arbitrary CSS, or could create chaotic interfaces.
*
* Based on the design authority at:
* https://www.figma.com/design/CmhEJxo4oZSuYpqG1Yc39w/Nodes-V3?node-id=441-7806&m=dev
*/
import type { ToggleSwitchProps as PrimeVueToggleSwitchProps } from 'primevue/toggleswitch'
/**
* Widget ToggleSwitch Component
* Excludes: style, class, inputClass, inputStyle, dt, pt, ptOptions, unstyled
*
* These props are excluded from widget.options to prevent external styling overrides.
* The widget component itself can still use these props internally for consistent styling.
*/
export type WidgetToggleSwitchProps = Omit<
PrimeVueToggleSwitchProps,
| 'style'
| 'class'
| 'inputClass'
| 'inputStyle'
| 'dt'
| 'pt'
| 'ptOptions'
| 'unstyled'
>

View File

@@ -0,0 +1,76 @@
/**
* Widget prop filtering utilities
* Filters out style-related and customization props from PrimeVue components
* to maintain consistent widget appearance across the application
*/
// Props to exclude based on the widget interface specifications
export const STANDARD_EXCLUDED_PROPS = [
'style',
'class',
'dt',
'pt',
'ptOptions',
'unstyled'
] as const
export const INPUT_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
export const PANEL_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'panelClass',
'panelStyle',
'overlayClass'
] as const
export const IMAGE_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'imageClass',
'imageStyle'
] as const
export const GALLERIA_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'thumbnailsPosition',
'verticalThumbnailViewPortHeight',
'indicatorsPosition',
'maskClass',
'containerStyle',
'containerClass',
'galleriaClass'
] as const
export const BADGE_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'badgeClass'
] as const
export const LABEL_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'labelStyle'
] as const
/**
* Filters widget props by excluding specified properties
* @param props - The props object to filter
* @param excludeList - List of property names to exclude
* @returns Filtered props object
*/
export function filterWidgetProps<T extends Record<string, any>>(
props: T | undefined,
excludeList: readonly string[]
): Partial<T> {
if (!props) return {}
const filtered: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (!excludeList.includes(key)) {
filtered[key] = value
}
}
return filtered as Partial<T>
}

View File

@@ -0,0 +1,205 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock the stores
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
const createMockNode = (type: string, version?: string): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: 'comfy-core', ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
fetchSystemStats: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock store state
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.fetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
})
const mountComponent = (props = {}) => {
return mount(MissingCoreNodesMessage, {
global: {
plugins: [PrimeVue],
components: { Message },
mocks: {
$t: (key: string, params?: { version?: string }) => {
const translations: Record<string, string> = {
'loadWorkflowWarning.outdatedVersion': `Some nodes require a newer version of ComfyUI (current: ${params?.version}). Please update to use all nodes.`,
'loadWorkflowWarning.outdatedVersionGeneric':
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.',
'loadWorkflowWarning.coreNodesFromVersion': `Requires ComfyUI ${params?.version}:`
}
return translations[key] || key
}
}
},
props: {
missingCoreNodes: {},
...props
}
})
}
it('does not render when there are no missing core nodes', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Message).exists()).toBe(false)
})
it('renders message when there are missing core nodes', async () => {
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.findComponent(Message).exists()).toBe(true)
})
it('fetches and displays current ComfyUI version', async () => {
// Start with no systemStats to trigger fetch
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
// Simulate the fetch setting the systemStats
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
return Promise.resolve()
})
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for all async operations
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
)
})
it('displays generic message when version is unavailable', async () => {
// Mock fetchSystemStats to resolve without setting systemStats
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
// Wait for the async operations to complete
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI. Please update to use all nodes.'
)
})
it('groups nodes by version and displays them', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('NodeA', '1.2.0'),
createMockNode('NodeB', '1.2.0')
],
'1.3.0': [createMockNode('NodeC', '1.3.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
expect(text).toContain('Requires ComfyUI 1.3.0:')
expect(text).toContain('NodeC')
expect(text).toContain('Requires ComfyUI 1.2.0:')
expect(text).toContain('NodeA, NodeB')
})
it('sorts versions in descending order', async () => {
const missingCoreNodes = {
'1.1.0': [createMockNode('Node1', '1.1.0')],
'1.3.0': [createMockNode('Node3', '1.3.0')],
'1.2.0': [createMockNode('Node2', '1.2.0')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
const version13Index = text.indexOf('1.3.0')
const version12Index = text.indexOf('1.2.0')
const version11Index = text.indexOf('1.1.0')
expect(version13Index).toBeLessThan(version12Index)
expect(version12Index).toBeLessThan(version11Index)
})
it('removes duplicate node names within the same version', async () => {
const missingCoreNodes = {
'1.2.0': [
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('DuplicateNode', '1.2.0'),
createMockNode('UniqueNode', '1.2.0')
]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
const text = wrapper.text()
// Should only appear once in the sorted list
expect(text).toContain('DuplicateNode, UniqueNode')
// Count occurrences of 'DuplicateNode' - should be only 1
const matches = text.match(/DuplicateNode/g) || []
expect(matches.length).toBe(1)
})
it('handles nodes with missing version info', async () => {
const missingCoreNodes = {
'': [createMockNode('NoVersionNode')]
}
const wrapper = mountComponent({ missingCoreNodes })
await nextTick()
expect(wrapper.text()).toContain('Requires ComfyUI unknown:')
expect(wrapper.text()).toContain('NoVersionNode')
})
})

View File

@@ -1,13 +1,16 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
const actual = await vi.importActual<typeof import('vue')>('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
@@ -23,8 +26,21 @@ vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {
nodes: []
}
}
}))
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
describe('useMissingNodes', () => {
const mockWorkflowPacks = [
@@ -54,9 +70,11 @@ describe('useMissingNodes', () => {
// Default setup: pack-3 is installed, others are not
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
// We only need isPackInstalled method for these tests.
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled
} as any)
})
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
@@ -65,7 +83,18 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
})
// Reset node def store mock
// @ts-expect-error - Mocking partial NodeDefStore for testing.
// We only need nodeDefsByName for these tests.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
// Reset app.graph.nodes
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = []
})
describe('core filtering logic', () => {
@@ -77,7 +106,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
})
const { missingNodePacks } = useMissingNodes()
@@ -98,7 +127,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
})
// Mock all packs as installed
mockIsPackInstalled.mockReturnValue(true)
@@ -116,7 +145,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
})
// Mock no packs as installed
mockIsPackInstalled.mockReturnValue(false)
@@ -149,7 +178,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
})
useMissingNodes()
@@ -164,7 +193,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
})
useMissingNodes()
@@ -181,7 +210,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
})
const { isLoading } = useMissingNodes()
@@ -197,7 +226,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
})
const { error } = useMissingNodes()
@@ -215,7 +244,7 @@ describe('useMissingNodes', () => {
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
})
const { missingNodePacks } = useMissingNodes()
@@ -223,11 +252,134 @@ describe('useMissingNodes', () => {
expect(missingNodePacks.value).toEqual([])
// Update workflow packs
workflowPacksRef.value = mockWorkflowPacks as any
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
workflowPacksRef.value = mockWorkflowPacks
await nextTick()
// Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
})
})
describe('missing core nodes detection', () => {
const createMockNode = (
type: string,
packId?: string,
version?: string
): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
it('identifies missing core nodes not in nodeDefStore', () => {
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
const registeredNode = createMockNode(
'RegisteredNode',
'comfy-core',
'1.0.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode1, coreNode2, registeredNode]
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode: { name: 'RegisteredNode' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
})
it('groups missing core nodes by version', () => {
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [node120, node130, nodeNoVer]
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
expect(missingCoreNodes.value['']).toHaveLength(1)
})
it('ignores non-core nodes', () => {
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
const noPackNode = createMockNode('NoPackNode')
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [coreNode, customNode, noPackNode]
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
})
it('returns empty object when no core nodes are missing', () => {
const registeredNode1 = createMockNode(
'RegisteredNode1',
'comfy-core',
'1.0.0'
)
const registeredNode2 = createMockNode(
'RegisteredNode2',
'comfy-core',
'1.1.0'
)
// @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing.
app.graph.nodes = [registeredNode1, registeredNode2]
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode1: { name: 'RegisteredNode1' },
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
RegisteredNode2: { name: 'RegisteredNode2' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
})
})

View File

@@ -26,6 +26,22 @@ vi.mock('@/stores/settingStore', () => ({
})
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, ...callbacks) => {
return function (this: any, ...args: any[]) {
original?.apply(this, args)
callbacks.forEach((cb: any) => cb.apply(this, args))
}
})
}))
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...'
@@ -40,7 +56,9 @@ function createMockConfig(overrides = {}): RemoteWidgetConfig {
const createMockOptions = (inputOverrides = {}) => ({
remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE,
node: {} as any,
node: {
addWidget: vi.fn()
} as any,
widget: {} as any
})
@@ -499,4 +517,168 @@ describe('useRemoteWidget', () => {
expect(data2).toEqual(DEFAULT_VALUE)
})
})
describe('auto-refresh on task completion', () => {
it('should add auto-refresh toggle widget', () => {
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Should add auto-refresh toggle widget
expect(mockNode.addWidget).toHaveBeenCalledWith(
'toggle',
'Auto-refresh after generation',
false,
expect.any(Function),
{
serialize: false
}
)
})
it('should register event listener when enabled', async () => {
const { api } = await import('@/scripts/api')
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Event listener should be registered immediately
expect(api.addEventListener).toHaveBeenCalledWith(
'execution_success',
expect.any(Function)
)
})
it('should refresh widget when workflow completes successfully', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Get the toggle callback and enable auto-refresh
const toggleCallback = mockNode.addWidget.mock.calls.find(
(call) => call[0] === 'toggle'
)?.[3]
toggleCallback?.(true)
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).toHaveBeenCalled()
})
it('should not refresh when toggle is disabled', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: []
}
const mockWidget = {} as any
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget
})
// Spy on the refresh function that was added by useRemoteWidget
const refreshSpy = vi.spyOn(mockWidget, 'refresh')
// Toggle is disabled by default
// Simulate workflow completion
executionSuccessHandler?.()
expect(refreshSpy).not.toHaveBeenCalled()
})
it('should cleanup event listener on node removal', async () => {
const { api } = await import('@/scripts/api')
let executionSuccessHandler: (() => void) | undefined
// Capture the event handler
vi.mocked(api.addEventListener).mockImplementation((event, handler) => {
if (event === 'execution_success') {
executionSuccessHandler = handler as () => void
}
})
const mockNode = {
addWidget: vi.fn(),
widgets: [],
onRemoved: undefined as any
}
const mockWidget = {
refresh: vi.fn()
}
useRemoteWidget({
remoteConfig: createMockConfig(),
defaultValue: DEFAULT_VALUE,
node: mockNode as any,
widget: mockWidget as any
})
// Simulate node removal
mockNode.onRemoved?.()
expect(api.removeEventListener).toHaveBeenCalledWith(
'execution_success',
executionSuccessHandler
)
})
})
})

View File

@@ -108,8 +108,17 @@ describe('useAlgoliaSearchProvider', () => {
id: 'publisher-1',
name: 'publisher-1'
},
create_time: '2024-01-01T00:00:00Z',
comfy_nodes: ['LoadImage', 'SaveImage']
created_at: '2024-01-01T00:00:00Z',
comfy_nodes: ['LoadImage', 'SaveImage'],
category: undefined,
author: undefined,
tags: undefined,
github_stars: undefined,
supported_os: undefined,
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_accelerators: undefined,
banner_url: undefined
})
})
@@ -253,7 +262,7 @@ describe('useAlgoliaSearchProvider', () => {
version: '1.0.0',
createdAt: '2024-01-15T10:00:00Z'
},
create_time: '2024-01-01T10:00:00Z'
created_at: '2024-01-01T10:00:00Z'
}
it('should return correct values for each sort field', () => {

View File

@@ -45,7 +45,7 @@ describe('useComfyRegistrySearchProvider', () => {
search: 'test',
comfy_node_search: undefined,
limit: 10,
offset: 0
page: 1
})
expect(result.nodePacks).toEqual(mockResults.nodes)
expect(result.querySuggestions).toEqual([])
@@ -68,7 +68,7 @@ describe('useComfyRegistrySearchProvider', () => {
search: undefined,
comfy_node_search: 'LoadImage',
limit: 20,
offset: 20
page: 2
})
expect(result.nodePacks).toEqual(mockResults.nodes)
})

View File

@@ -0,0 +1,175 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const MockComponent = defineComponent({
name: 'MockComponent',
template: '<div>Mock</div>'
})
describe('dialogStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('priority system', () => {
it('should create dialogs in correct priority order', () => {
const store = useDialogStore()
// Create dialogs with different priorities
store.showDialog({
key: 'low-priority',
component: MockComponent,
priority: 0
})
store.showDialog({
key: 'high-priority',
component: MockComponent,
priority: 10
})
store.showDialog({
key: 'medium-priority',
component: MockComponent,
priority: 5
})
store.showDialog({
key: 'no-priority',
component: MockComponent
})
// Check order: high (2) -> medium (1) -> low (0)
expect(store.dialogStack.map((d) => d.key)).toEqual([
'high-priority',
'medium-priority',
'no-priority',
'low-priority'
])
})
it('should maintain priority order when rising dialogs', () => {
const store = useDialogStore()
// Create dialogs with different priorities
store.showDialog({
key: 'priority-2',
component: MockComponent,
priority: 2
})
store.showDialog({
key: 'priority-1',
component: MockComponent,
priority: 1
})
store.showDialog({
key: 'priority-0',
component: MockComponent,
priority: 0
})
// Try to rise the lowest priority dialog
store.riseDialog({ key: 'priority-0' })
// Should still be at the bottom because of its priority
expect(store.dialogStack.map((d) => d.key)).toEqual([
'priority-2',
'priority-1',
'priority-0'
])
// Rise the medium priority dialog
store.riseDialog({ key: 'priority-1' })
// Should be above priority-0 but below priority-2
expect(store.dialogStack.map((d) => d.key)).toEqual([
'priority-2',
'priority-1',
'priority-0'
])
})
it('should keep high priority dialogs on top when creating new lower priority dialogs', () => {
const store = useDialogStore()
// Create a high priority dialog (like manager progress)
store.showDialog({
key: 'manager-progress',
component: MockComponent,
priority: 10
})
store.showDialog({
key: 'dialog-2',
component: MockComponent,
priority: 0
})
store.showDialog({
key: 'dialog-3',
component: MockComponent
// Default priority is 1
})
// Manager progress should still be on top
expect(store.dialogStack[0].key).toBe('manager-progress')
// Check full order
expect(store.dialogStack.map((d) => d.key)).toEqual([
'manager-progress', // priority 2
'dialog-3', // priority 1 (default)
'dialog-2' // priority 0
])
})
})
describe('basic dialog operations', () => {
it('should show and close dialogs', () => {
const store = useDialogStore()
store.showDialog({
key: 'test-dialog',
component: MockComponent
})
expect(store.dialogStack).toHaveLength(1)
expect(store.isDialogOpen('test-dialog')).toBe(true)
store.closeDialog({ key: 'test-dialog' })
expect(store.dialogStack).toHaveLength(0)
expect(store.isDialogOpen('test-dialog')).toBe(false)
})
it('should reuse existing dialog when showing with same key', () => {
const store = useDialogStore()
store.showDialog({
key: 'reusable-dialog',
component: MockComponent,
title: 'Original Title'
})
// First call should create the dialog
expect(store.dialogStack).toHaveLength(1)
expect(store.dialogStack[0].title).toBe('Original Title')
// Second call with same key should reuse the dialog
store.showDialog({
key: 'reusable-dialog',
component: MockComponent,
title: 'New Title' // This should be ignored
})
// Should still have only one dialog with original title
expect(store.dialogStack).toHaveLength(1)
expect(store.dialogStack[0].key).toBe('reusable-dialog')
expect(store.dialogStack[0].title).toBe('Original Title')
})
})
})

View File

@@ -7,7 +7,10 @@ export default defineConfig({
globals: true,
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
include: [
'tests-ui/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
],
coverage: {
reporter: ['text', 'json', 'html']
}