-
@@ -55,7 +64,9 @@ import AutoComplete, {
import { computed } from 'vue'
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 { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -71,6 +82,7 @@ const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
+ isMissingTab?: boolean
}>()
const searchQuery = defineModel('searchQuery')
@@ -81,6 +93,9 @@ const sortField = defineModel('sortField', {
const { t } = useI18n()
+// Get missing node packs from workflow with loading and error states
+const { missingNodePacks, isLoading, error } = useMissingNodes()
+
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
diff --git a/src/composables/nodePack/useMissingNodes.ts b/src/composables/nodePack/useMissingNodes.ts
new file mode 100644
index 000000000..a856c1dc2
--- /dev/null
+++ b/src/composables/nodePack/useMissingNodes.ts
@@ -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
+ }
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 0d92c933f..9ab695b88 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -161,6 +161,7 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
+ "installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index 56b2b3809..5e18cd9e9 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
+ "installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index 31f8bc324..c0b2914a3 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
+ "installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 35f792980..1efde2d96 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
+ "installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 1a9b28c84..8389c923e 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
+ "installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index f638adfb1..1371f5719 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
+ "installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index f0d4c361f..8a9b4b2cf 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -586,6 +586,7 @@
},
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
+ "installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
diff --git a/tests-ui/tests/composables/useMissingNodes.test.ts b/tests-ui/tests/composables/useMissingNodes.test.ts
new file mode 100644
index 000000000..2190c321a
--- /dev/null
+++ b/tests-ui/tests/composables/useMissingNodes.test.ts
@@ -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)
+ })
+ })
+})