[Manager] Impletent “Install All” button (#4196)

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: comfy-waifu <comfywaifu.ai@gmail.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
This commit is contained in:
Jin Yi
2025-06-18 10:52:24 +09:00
committed by GitHub
parent ec9da0b6c5
commit fa14ec52f4
14 changed files with 346 additions and 32 deletions

View File

@@ -31,6 +31,12 @@
</template> </template>
</ListBox> </ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3"> <div v-if="isManagerInstalled" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
variant="black"
:label="$t('manager.installAllMissingNodes')"
/>
<Button label="Open Manager" size="small" outlined @click="openManager" /> <Button label="Open Manager" size="small" outlined @click="openManager" />
</div> </div>
</template> </template>
@@ -41,6 +47,8 @@ import ListBox from 'primevue/listbox'
import { computed } from 'vue' import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore' import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy' import type { MissingNodeType } from '@/types/comfy'
@@ -52,6 +60,9 @@ const props = defineProps<{
const aboutPanelStore = useAboutPanelStore() const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel // 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 // This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core // TODO: Remove this check when Manager functionality is fully migrated into core

View File

@@ -32,6 +32,7 @@
v-model:sortField="sortField" v-model:sortField="sortField"
:search-results="searchResults" :search-results="searchResults"
:suggestions="suggestions" :suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions" :sort-options="sortOptions"
/> />
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">

View File

@@ -1,16 +1,18 @@
<template> <template>
<Button <Button
outlined outlined
class="m-0 p-0 rounded-lg border-neutral-700" class="!m-0 p-0 rounded-lg"
:class="{ :class="[
'w-full': fullWidth, variant === 'black'
'w-min-content': !fullWidth ? 'bg-neutral-900 text-white border-neutral-900'
}" : 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading" :disabled="loading"
v-bind="$attrs" v-bind="$attrs"
@click="onClick" @click="onClick"
> >
<span class="py-2.5 px-3"> <span class="py-2.5 px-3 whitespace-nowrap">
<template v-if="loading"> <template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }} {{ loadingMessage ?? $t('g.loading') }}
</template> </template>
@@ -27,12 +29,14 @@ import Button from 'primevue/button'
const { const {
label, label,
loadingMessage, loadingMessage,
fullWidth = false fullWidth = false,
variant = 'default'
} = defineProps<{ } = defineProps<{
label: string label: string
loading?: boolean loading?: boolean
loadingMessage?: string loadingMessage?: string
fullWidth?: boolean fullWidth?: boolean
variant?: 'default' | 'black'
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -2,9 +2,11 @@
<PackActionButton <PackActionButton
v-bind="$attrs" v-bind="$attrs"
:label=" :label="
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install') label ??
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
" "
severity="secondary" :severity="variant === 'black' ? undefined : 'secondary'"
:variant="variant"
:loading="isInstalling" :loading="isInstalling"
:loading-message="$t('g.installing')" :loading-message="$t('g.installing')"
@action="installAllPacks" @action="installAllPacks"
@@ -27,8 +29,10 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node'] type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{ const { nodePacks, variant, label } = defineProps<{
nodePacks: NodePack[] nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>() }>()
const isInstalling = inject(IsInstallingKey, ref(false)) const isInstalling = inject(IsInstallingKey, ref(false))

View File

@@ -1,28 +1,37 @@
<template> <template>
<div class="relative w-full p-6"> <div class="relative w-full p-6">
<div class="flex items-center w-full"> <div class="h-12 flex items-center gap-1 justify-between">
<AutoComplete <div class="flex items-center w-5/12">
v-model.lazy="searchQuery" <AutoComplete
:suggestions="suggestions || []" v-model.lazy="searchQuery"
:placeholder="$t('manager.searchPlaceholder')" :suggestions="suggestions || []"
:complete-on-focus="false" :placeholder="$t('manager.searchPlaceholder')"
:delay="8" :complete-on-focus="false"
option-label="query" :delay="8"
class="w-full" option-label="query"
:pt="{ class="w-full"
pcInputText: { :pt="{
root: { pcInputText: {
autofocus: true, root: {
class: 'w-5/12 rounded-2xl' autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
} }
}, }"
loader: { :show-empty-message="false"
style: 'display: none' @complete="stubTrue"
} @option-select="onOptionSelect"
}" />
:show-empty-message="false" </div>
@complete="stubTrue" <PackInstallButton
@option-select="onOptionSelect" v-if="isMissingTab && missingNodePacks.length > 0"
variant="black"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/> />
</div> </div>
<div class="flex mt-3 text-sm"> <div class="flex mt-3 text-sm">
@@ -55,7 +64,9 @@ import AutoComplete, {
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue' import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { import {
type SearchOption, type SearchOption,
SortableAlgoliaField SortableAlgoliaField
@@ -71,6 +82,7 @@ const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][] searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[] suggestions?: QuerySuggestion[]
sortOptions?: SortableField[] sortOptions?: SortableField[]
isMissingTab?: boolean
}>() }>()
const searchQuery = defineModel<string>('searchQuery') const searchQuery = defineModel<string>('searchQuery')
@@ -81,6 +93,9 @@ const sortField = defineModel<string>('sortField', {
const { t } = useI18n() const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
const hasResults = computed( const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length () => searchQuery.value?.trim() && searchResults?.length
) )

View File

@@ -0,0 +1,39 @@
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
// Same filtering logic as ManagerDialogContent.vue
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
// Filter only uninstalled packs from workflow packs
const missingNodePacks = computed(() => {
if (!workflowPacks.value.length) return []
return filterMissingPacks(workflowPacks.value)
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
isLoading,
error
}
}

View File

@@ -161,6 +161,7 @@
"lastUpdated": "Last Updated", "lastUpdated": "Last Updated",
"noDescription": "No description available", "noDescription": "No description available",
"installSelected": "Install Selected", "installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected", "packsSelected": "Packs Selected",
"status": { "status": {
"active": "Active", "active": "Active",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "En Flujo de Trabajo", "inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información", "infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado", "installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación", "installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización", "lastUpdated": "Última Actualización",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "Dans le flux de travail", "inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations", "infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné", "installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation", "installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour", "lastUpdated": "Dernière mise à jour",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "ワークフロー内", "inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します", "infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール", "installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー", "installationQueue": "インストールキュー",
"lastUpdated": "最終更新日", "lastUpdated": "最終更新日",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "워크플로우 내", "inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요", "infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치", "installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열", "installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트", "lastUpdated": "마지막 업데이트",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "В рабочем процессе", "inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию", "infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное", "installSelected": "Установить выбранное",
"installationQueue": "Очередь установки", "installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление", "lastUpdated": "Последнее обновление",

View File

@@ -586,6 +586,7 @@
}, },
"inWorkflow": "在工作流中", "inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息", "infoPanelEmpty": "点击一个项目查看信息",
"installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定", "installSelected": "安装选定",
"installationQueue": "安装队列", "installationQueue": "安装队列",
"lastUpdated": "最后更新", "lastUpdated": "最后更新",

View File

@@ -0,0 +1,233 @@
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 { useComfyManagerStore } from '@/stores/comfyManagerStore'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
useWorkflowPacks: vi.fn()
}))
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
describe('useMissingNodes', () => {
const mockWorkflowPacks = [
{
id: 'pack-1',
name: 'Test Pack 1',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-2',
name: 'Test Pack 2',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-3',
name: 'Installed Pack',
latest_version: { version: '1.5.0' }
}
]
const mockStartFetchWorkflowPacks = vi.fn()
const mockIsPackInstalled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup: pack-3 is installed, others are not
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled
} as any)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
})
describe('core filtering logic', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
const { missingNodePacks } = useMissingNodes()
// Should only include packs that are not installed (pack-1, pack-2)
expect(missingNodePacks.value).toHaveLength(2)
expect(missingNodePacks.value[0].id).toBe('pack-1')
expect(missingNodePacks.value[1].id).toBe('pack-2')
expect(
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
).toBeUndefined()
})
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
// Mock all packs as installed
mockIsPackInstalled.mockReturnValue(true)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
// Mock no packs as installed
mockIsPackInstalled.mockReturnValue(false)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toHaveLength(3)
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
})
it('returns empty array when no workflow packs exist', () => {
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
})
describe('automatic data fetching', () => {
it('fetches workflow packs automatically when none exist', async () => {
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
useMissingNodes()
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
})
it('does not fetch when already loading', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
useMissingNodes()
expect(mockStartFetchWorkflowPacks).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
const { isLoading } = useMissingNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useWorkflowPacks', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
} as any)
const { error } = useMissingNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when workflow packs change', async () => {
const workflowPacksRef = ref([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
} as any)
const { missingNodePacks } = useMissingNodes()
// Initially empty
expect(missingNodePacks.value).toEqual([])
// Update workflow packs
workflowPacksRef.value = mockWorkflowPacks as any
await nextTick()
// Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
})
})
})