mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-01 03:04:11 +00:00
Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates
This commit is contained in:
@@ -79,6 +79,8 @@ const sidebarStateKey = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
:deep(.p-splitter-gutter) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ const positionCSS = computed<CSSProperties>(() =>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../assets/css/style.css';
|
||||
|
||||
.comfy-menu-hamburger {
|
||||
@apply fixed z-[9999] flex flex-row;
|
||||
@apply fixed z-9999 flex flex-row;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
|
||||
>
|
||||
<div ref="panelRef" class="actionbar-content flex items-center select-none">
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2 p-0!" />
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2" />
|
||||
<ComfyQueueButton />
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -228,6 +228,8 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.actionbar {
|
||||
pointer-events: all;
|
||||
position: fixed;
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<!-- h-0 to force the div to flex-grow -->
|
||||
<div class="flex-grow h-0">
|
||||
<!-- h-0 to force the div to grow -->
|
||||
<div class="grow h-0">
|
||||
<ExtensionSlot
|
||||
v-if="
|
||||
bottomPanelStore.bottomPanelVisible &&
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
>
|
||||
<div class="shortcut-info flex-grow pr-4">
|
||||
<div class="shortcut-info grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
{{ t(`commands.${normalizeI18nKey(command.id)}.label`) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keybinding-display flex-shrink-0">
|
||||
<div class="keybinding-display shrink-0">
|
||||
<div
|
||||
class="keybinding-combo flex gap-1"
|
||||
:aria-label="`Keyboard shortcut: ${command.keybinding!.combo.getKeySequences().join(' + ')}`"
|
||||
|
||||
@@ -156,6 +156,8 @@ onUpdated(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
@@ -196,6 +198,8 @@ onUpdated(() => {
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
v-if="isEditing"
|
||||
ref="itemInputRef"
|
||||
v-model="itemLabel"
|
||||
class="fixed z-[10000] text-[.8rem] px-2 py-2"
|
||||
class="fixed z-10000 text-[.8rem] px-2 py-2"
|
||||
@blur="inputBlur(true)"
|
||||
@click.stop
|
||||
@keydown.enter="inputBlur(true)"
|
||||
@@ -107,6 +107,7 @@ const rename = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isRoot = props.item.key === 'root'
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -120,7 +121,27 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflow')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.SaveWorkflowAs')
|
||||
},
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
@@ -134,7 +155,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root'
|
||||
visible: isRoot
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
@@ -142,7 +163,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
visible: isRoot
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -190,6 +211,8 @@ const inputBlur = async (doRename: boolean) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
@@ -16,6 +16,14 @@ const meta: Meta<typeof IconButton> = {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
onClick: { action: 'clicked' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" @click="onClick">
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<slot></slot>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonTypeClasses,
|
||||
getIconButtonSizeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
@@ -22,6 +23,8 @@ interface IconButtonProps extends BaseButtonProps {
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
onClick
|
||||
} = defineProps<IconButtonProps>()
|
||||
@@ -29,7 +32,9 @@ const {
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} p-0`
|
||||
const sizeClasses = getIconButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-center items-center flex-shrink-0 outline-none border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
|
||||
class="flex justify-center items-center shrink-0 outline-hidden border-none p-0 bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white rounded-lg cursor-pointer"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,14 @@ const meta: Meta<typeof IconTextButton> = {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent']
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
iconPosition: {
|
||||
control: { type: 'select' },
|
||||
options: ['left', 'right']
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" @click="onClick">
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
<slot v-if="iconPosition === 'right'" name="icon"></slot>
|
||||
@@ -13,6 +13,7 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
@@ -26,6 +27,8 @@ interface IconTextButtonProps extends BaseButtonProps {
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
iconPosition = 'left',
|
||||
label,
|
||||
@@ -33,9 +36,11 @@ const {
|
||||
} = defineProps<IconTextButtonProps>()
|
||||
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = `${getBaseButtonClasses()} !justify-start gap-2`
|
||||
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -24,22 +24,22 @@ export const Basic: Story = {
|
||||
<MoreButton>
|
||||
<template #default="{ close }">
|
||||
<IconTextButton
|
||||
type="secondary"
|
||||
type="transparent"
|
||||
label="Settings"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<Download />
|
||||
<Download :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
type="primary"
|
||||
type="transparent"
|
||||
label="Profile"
|
||||
@click="() => { close() }"
|
||||
>
|
||||
<template #icon>
|
||||
<ScrollText />
|
||||
<ScrollText :size="16" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,14 @@ const meta: Meta<typeof TextButton> = {
|
||||
options: ['sm', 'md'],
|
||||
defaultValue: 'md'
|
||||
},
|
||||
border: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border attribute'
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle disable status'
|
||||
},
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'transparent'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Button unstyled :class="buttonStyle" role="button" @click="onClick">
|
||||
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick">
|
||||
<span>{{ label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ import { computed } from 'vue'
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import {
|
||||
getBaseButtonClasses,
|
||||
getBorderButtonTypeClasses,
|
||||
getButtonSizeClasses,
|
||||
getButtonTypeClasses
|
||||
} from '@/types/buttonTypes'
|
||||
@@ -23,6 +24,8 @@ interface TextButtonProps extends BaseButtonProps {
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'primary',
|
||||
border = false,
|
||||
disabled = false,
|
||||
class: className,
|
||||
label,
|
||||
onClick
|
||||
@@ -31,7 +34,9 @@ const {
|
||||
const buttonStyle = computed(() => {
|
||||
const baseClasses = getBaseButtonClasses()
|
||||
const sizeClasses = getButtonSizeClasses(size)
|
||||
const typeClasses = getButtonTypeClasses(type)
|
||||
const typeClasses = border
|
||||
? getBorderButtonTypeClasses(type)
|
||||
: getButtonTypeClasses(type)
|
||||
|
||||
return [baseClasses, sizeClasses, typeClasses, className]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -23,9 +23,9 @@ const containerClasses = computed(() => {
|
||||
'flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
const ratioClasses = {
|
||||
square: 'aspect-[256/308]',
|
||||
portrait: 'aspect-[256/325]',
|
||||
tallPortrait: 'aspect-[256/353]'
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
|
||||
@@ -31,8 +31,8 @@ const topStyle = computed(() => {
|
||||
const baseClasses = 'relative p-0'
|
||||
|
||||
const ratioClasses = {
|
||||
square: 'aspect-[1/1]',
|
||||
landscape: 'aspect-[48/27]'
|
||||
square: 'aspect-square',
|
||||
landscape: 'aspect-48/27'
|
||||
}
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex justify-center items-center gap-1 flex-shrink-0 py-1 px-2 text-xs bg-[#D9D9D966]/40 rounded font-bold text-white/90"
|
||||
class="inline-flex justify-center items-center gap-1 shrink-0 py-1 px-2 text-xs bg-[#D9D9D966]/40 rounded font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<span>{{ label }}</span>
|
||||
|
||||
131
src/components/common/DotSpinner.vue
Normal file
131
src/components/common/DotSpinner.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center justify-center"
|
||||
:style="{ width: size + 'px', height: size + 'px' }"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
class="animate-spin"
|
||||
:style="{ animationDuration: duration }"
|
||||
>
|
||||
<g clip-path="url(#clip0_776_9582)">
|
||||
<!-- Top dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.25s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.5s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.75s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Top-left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.125s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom-right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.625s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Bottom-left dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.875s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
|
||||
:fill="color"
|
||||
/>
|
||||
<!-- Top-right dot -->
|
||||
<path
|
||||
class="dot-animation"
|
||||
style="animation-delay: 0.375s"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
|
||||
:fill="color"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_776_9582">
|
||||
<rect width="14" height="14" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const { size = 24, duration = '2s' } = defineProps<{
|
||||
size?: number
|
||||
duration?: string
|
||||
}>()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const color = computed(() =>
|
||||
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dot-animation {
|
||||
animation: dot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -68,4 +68,73 @@ describe('EditableText', () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
|
||||
})
|
||||
|
||||
it('cancels editing on escape key', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
|
||||
// Change the input value
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
|
||||
// Should emit cancel event
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
|
||||
// Should NOT emit edit event
|
||||
expect(wrapper.emitted('edit')).toBeFalsy()
|
||||
|
||||
// Input value should be reset to original
|
||||
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
|
||||
'Original Text'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not save changes when escape is pressed and blur occurs', async () => {
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
|
||||
// Change the input value
|
||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||
|
||||
// Press escape (which triggers blur internally)
|
||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
|
||||
// Manually trigger blur to simulate the blur that happens after escape
|
||||
await wrapper.findComponent(InputText).trigger('blur')
|
||||
|
||||
// Should emit cancel but not edit
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(wrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('saves changes on enter but not on escape', async () => {
|
||||
// Test Enter key saves changes
|
||||
const enterWrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
||||
// Trigger blur that happens after enter
|
||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
|
||||
|
||||
// Test Escape key cancels changes with a fresh wrapper
|
||||
const escapeWrapper = mountComponent({
|
||||
modelValue: 'Original Text',
|
||||
isEditing: true
|
||||
})
|
||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
fluid
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing
|
||||
onBlur: finishEditing,
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
@@ -27,21 +29,41 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
const { modelValue, isEditing = false } = defineProps<{
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {}
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit'])
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
|
||||
const isCanceling = ref(false)
|
||||
|
||||
const blurInputElement = () => {
|
||||
// @ts-expect-error - $el is an internal property of the InputText component
|
||||
inputRef.value?.$el.blur()
|
||||
}
|
||||
const finishEditing = () => {
|
||||
emit('edit', inputValue.value)
|
||||
// Don't save if we're canceling
|
||||
if (!isCanceling.value) {
|
||||
emit('edit', inputValue.value)
|
||||
}
|
||||
isCanceling.value = false
|
||||
}
|
||||
const cancelEditing = () => {
|
||||
// Set canceling flag to prevent blur from saving
|
||||
isCanceling.value = true
|
||||
// Reset to original value
|
||||
inputValue.value = modelValue
|
||||
// Emit cancel event
|
||||
emit('cancel')
|
||||
// Blur the input to exit edit mode
|
||||
blurInputElement()
|
||||
}
|
||||
watch(
|
||||
() => isEditing,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- A generalized form item for rendering in a form. -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex flex-grow items-center">
|
||||
<div class="form-label flex grow items-center">
|
||||
<span
|
||||
:id="`${props.id}-label`"
|
||||
class="text-muted"
|
||||
@@ -112,6 +112,8 @@ function getFormComponent(item: FormItem): Component {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.form-input :deep(.input-slider) .p-inputnumber input,
|
||||
.form-input :deep(.input-slider) .slider-part {
|
||||
@apply w-20;
|
||||
|
||||
@@ -91,6 +91,8 @@ const clearSearch = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
--p-form-field-padding-x: 0.625rem;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ defineEmits(['remove'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.i-badge) {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
|
||||
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
|
||||
<Divider :align="align" :type="type" :layout="layout" class="grow" />
|
||||
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
/>
|
||||
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" />
|
||||
<component :is="item.footerComponent" v-bind="item.footerProps" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -43,6 +43,8 @@ const dialogStore = useDialogStore()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.global-dialog .p-dialog-header {
|
||||
@apply p-2 2xl:p-[var(--p-dialog-header-padding)];
|
||||
@apply pb-0;
|
||||
|
||||
@@ -21,16 +21,9 @@
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!sendReportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
<Button
|
||||
v-if="authStore.currentUser"
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('issueReport.contactSupportTitle')"
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showContactSupport"
|
||||
/>
|
||||
</div>
|
||||
@@ -41,16 +34,6 @@
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<ReportIssuePanel
|
||||
v-if="sendReportOpen"
|
||||
:title="$t('issueReport.submitErrorReport')"
|
||||
:error-type="error.reportType ?? 'unknownError'"
|
||||
:extra-fields="[stackTraceField]"
|
||||
:tags="{
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
nodeType: error.nodeType ?? 'UNKNOWN'
|
||||
}"
|
||||
/>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<FindIssueButton
|
||||
:error-message="error.exceptionMessage"
|
||||
@@ -81,18 +64,12 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
import {
|
||||
type ErrorReportData,
|
||||
generateErrorReport
|
||||
} from '@/utils/errorReportUtil'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
/**
|
||||
@@ -114,10 +91,6 @@ const reportOpen = ref(false)
|
||||
const showReport = () => {
|
||||
reportOpen.value = true
|
||||
}
|
||||
const sendReportOpen = ref(false)
|
||||
const showSendReport = () => {
|
||||
sendReportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
@@ -126,15 +99,6 @@ const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const stackTraceField = computed<ReportField>(() => {
|
||||
return {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
getData: () => error.traceback
|
||||
}
|
||||
})
|
||||
|
||||
const showContactSupport = async () => {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="p-2 h-full" aria-labelledby="issue-report-title">
|
||||
<Panel
|
||||
:pt="{
|
||||
root: 'border-none',
|
||||
content: 'p-0'
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<header class="flex flex-col items-center w-full">
|
||||
<h2 id="issue-report-title" class="text-4xl">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
|
||||
</header>
|
||||
</template>
|
||||
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Panel from 'primevue/panel'
|
||||
|
||||
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
|
||||
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
panelProps: IssueReportPanelProps
|
||||
}>()
|
||||
</script>
|
||||
@@ -31,12 +31,20 @@
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div v-if="isManagerInstalled" class="flex justify-end py-3">
|
||||
<div v-if="showManagerButtons" class="flex justify-end py-3">
|
||||
<PackInstallButton
|
||||
:disabled="isLoading || !!error || missingNodePacks.length === 0"
|
||||
v-if="showInstallAllButton"
|
||||
size="md"
|
||||
:disabled="
|
||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||
"
|
||||
:is-loading="isLoading"
|
||||
:node-packs="missingNodePacks"
|
||||
variant="black"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
:label="
|
||||
isLoading
|
||||
? $t('manager.gettingInfo')
|
||||
: $t('manager.installAllMissingNodes')
|
||||
"
|
||||
/>
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
@@ -46,34 +54,39 @@
|
||||
import Button from 'primevue/button'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import {
|
||||
ManagerUIState,
|
||||
useManagerStateStore
|
||||
} from '@/stores/managerStateStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { ManagerTab } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackInstallButton from './manager/button/PackInstallButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
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
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
|
||||
// Check if any of the missing packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
if (!missingNodePacks.value?.length) return false
|
||||
return missingNodePacks.value.some((pack) =>
|
||||
comfyManagerStore.isPackInstalling(pack.id)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,10 +111,47 @@ const uniqueNodes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const openManager = () => {
|
||||
useDialogService().showManagerDialog({
|
||||
initialTab: ManagerTab.Missing
|
||||
})
|
||||
const managerStateStore = useManagerStateStore()
|
||||
|
||||
// Show manager buttons unless manager is disabled
|
||||
const showManagerButtons = computed(() => {
|
||||
return managerStateStore.managerUIState !== ManagerUIState.DISABLED
|
||||
})
|
||||
|
||||
// Only show Install All button for NEW_UI (new manager with v4 support)
|
||||
const showInstallAllButton = computed(() => {
|
||||
return managerStateStore.managerUIState === ManagerUIState.NEW_UI
|
||||
})
|
||||
|
||||
const openManager = async () => {
|
||||
const state = managerStateStore.managerUIState
|
||||
|
||||
switch (state) {
|
||||
case ManagerUIState.DISABLED:
|
||||
useDialogService().showSettingsDialog('extension')
|
||||
break
|
||||
|
||||
case ManagerUIState.LEGACY_UI:
|
||||
try {
|
||||
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
|
||||
} catch {
|
||||
// If legacy command doesn't exist, show toast
|
||||
const { t } = useI18n()
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.legacyMenuNotAvailable'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case ManagerUIState.NEW_UI:
|
||||
useDialogService().showManagerDialog({
|
||||
initialTab: ManagerTab.Missing
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,11 +30,20 @@ const defaultMockTaskLogs = [
|
||||
|
||||
vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
useComfyManagerStore: vi.fn(() => ({
|
||||
taskLogs: [...defaultMockTaskLogs]
|
||||
taskLogs: [...defaultMockTaskLogs],
|
||||
succeededTasksLogs: [...defaultMockTaskLogs],
|
||||
failedTasksLogs: [...defaultMockTaskLogs],
|
||||
managerQueue: { historyCount: 2 },
|
||||
isLoading: false
|
||||
})),
|
||||
useManagerProgressDialogStore: vi.fn(() => ({
|
||||
isExpanded: true,
|
||||
collapse: mockCollapse
|
||||
activeTabIndex: 0,
|
||||
getActiveTabIndex: vi.fn(() => 0),
|
||||
setActiveTabIndex: vi.fn(),
|
||||
toggle: vi.fn(),
|
||||
collapse: mockCollapse,
|
||||
expand: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
'max-h-0': !isExpanded
|
||||
}"
|
||||
>
|
||||
<div v-for="(panel, index) in taskPanels" :key="index">
|
||||
<div v-for="(log, index) in focusedLogs" :key="index">
|
||||
<Panel
|
||||
:expanded="collapsedPanels[index] || false"
|
||||
:expanded="collapsedPanels[index] === true"
|
||||
toggleable
|
||||
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full py-2">
|
||||
<div class="flex flex-col text-sm font-medium leading-normal">
|
||||
<span>{{ panel.taskName }}</span>
|
||||
<span>{{ log.taskName }}</span>
|
||||
<span class="text-muted">
|
||||
{{
|
||||
isInProgress(index)
|
||||
@@ -52,24 +52,24 @@
|
||||
</template>
|
||||
<div
|
||||
:ref="
|
||||
index === taskPanels.length - 1
|
||||
index === focusedLogs.length - 1
|
||||
? (el) => (lastPanelRef = el as HTMLElement)
|
||||
: undefined
|
||||
"
|
||||
class="overflow-y-auto h-64 rounded-lg bg-black"
|
||||
:class="{
|
||||
'h-64': index !== taskPanels.length - 1,
|
||||
'flex-grow': index === taskPanels.length - 1
|
||||
'h-64': index !== focusedLogs.length - 1,
|
||||
grow: index === focusedLogs.length - 1
|
||||
}"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="h-full">
|
||||
<div
|
||||
v-for="(log, logIndex) in panel.logs"
|
||||
v-for="(logLine, logIndex) in log.logs"
|
||||
:key="logIndex"
|
||||
class="text-neutral-400 dark-theme:text-muted"
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
|
||||
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,14 +90,31 @@ import {
|
||||
useManagerProgressDialogStore
|
||||
} from '@/stores/comfyManagerStore'
|
||||
|
||||
const { taskLogs } = useComfyManagerStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const isInProgress = (index: number) =>
|
||||
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
|
||||
const isInProgress = (index: number) => {
|
||||
const log = focusedLogs.value[index]
|
||||
if (!log) return false
|
||||
|
||||
const taskPanels = computed(() => taskLogs)
|
||||
// Check if this task is in the running or pending queue
|
||||
const taskQueue = comfyManagerStore.taskQueue
|
||||
if (!taskQueue) return false
|
||||
|
||||
const allQueueTasks = [
|
||||
...(taskQueue.running_queue || []),
|
||||
...(taskQueue.pending_queue || [])
|
||||
]
|
||||
|
||||
return allQueueTasks.some((task) => task.ui_id === log.taskId)
|
||||
}
|
||||
|
||||
const focusedLogs = computed(() => {
|
||||
if (progressDialogContent.getActiveTabIndex() === 0) {
|
||||
return comfyManagerStore.succeededTasksLogs
|
||||
}
|
||||
return comfyManagerStore.failedTasksLogs
|
||||
})
|
||||
const isExpanded = computed(() => progressDialogContent.isExpanded)
|
||||
const isCollapsed = computed(() => !isExpanded.value)
|
||||
|
||||
@@ -115,7 +132,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
|
||||
|
||||
const lastPanelRef = ref<HTMLElement | null>(null)
|
||||
const isUserScrolling = ref(false)
|
||||
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
|
||||
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
|
||||
|
||||
const isAtBottom = (el: HTMLElement | null) => {
|
||||
if (!el) return false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-48 2xl:w-64">
|
||||
<ScrollPanel class="settings-sidebar shrink-0 p-2 w-48 2xl:w-64">
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="settings-search-box w-full mb-2"
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMesages from '@/locales/en/main.json'
|
||||
import { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from './ReportIssuePanel.vue'
|
||||
|
||||
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
|
||||
const CUSTOM_FIELDS = [
|
||||
{
|
||||
label: 'Custom Field',
|
||||
value: 'CustomField',
|
||||
optIn: true,
|
||||
getData: () => 'mock data'
|
||||
}
|
||||
]
|
||||
|
||||
async function getSubmittedContext() {
|
||||
const { captureMessage } = (await import('@sentry/core')) as any
|
||||
return captureMessage.mock.calls[0][1]
|
||||
}
|
||||
|
||||
async function submitForm(wrapper: any) {
|
||||
await wrapper.findComponent(Form).trigger('submit')
|
||||
return getSubmittedContext()
|
||||
}
|
||||
|
||||
async function findAndUpdateCheckbox(
|
||||
wrapper: any,
|
||||
value: string,
|
||||
checked = true
|
||||
) {
|
||||
const checkbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
.find((c: any) => c.props('value') === value)
|
||||
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
|
||||
|
||||
await checkbox.vm.$emit('update:modelValue', checked)
|
||||
return checkbox
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMesages
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: vi.fn().mockResolvedValue('mock logs'),
|
||||
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
|
||||
getSettings: vi.fn().mockResolvedValue('mock settings'),
|
||||
fetchApi: vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue('')
|
||||
}),
|
||||
apiURL: vi.fn().mockReturnValue('https://test.com')
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
asSerialisable: vi.fn().mockReturnValue({})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@sentry/core', () => ({
|
||||
captureMessage: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/forms', () => ({
|
||||
Form: {
|
||||
name: 'Form',
|
||||
template:
|
||||
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
|
||||
props: ['resolver'],
|
||||
data() {
|
||||
return {
|
||||
formValues: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.$emit('submit', {
|
||||
valid: true,
|
||||
// @ts-expect-error fixme ts strict error
|
||||
values: this.formValues
|
||||
})
|
||||
},
|
||||
updateFieldValue(name: string, value: any) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.formValues[name] = value
|
||||
}
|
||||
}
|
||||
},
|
||||
FormField: {
|
||||
name: 'FormField',
|
||||
template:
|
||||
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
|
||||
props: ['name'],
|
||||
data() {
|
||||
return {
|
||||
modelValue: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
updateValue(value) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.modelValue = value
|
||||
// @ts-expect-error fixme ts strict error
|
||||
let parent = this.$parent
|
||||
while (parent && parent.$options.name !== 'Form') {
|
||||
parent = parent.$parent
|
||||
}
|
||||
if (parent) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
parent.updateFieldValue(this.name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ReportIssuePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
|
||||
return mount(ReportIssuePanel, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the panel with all required components', () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
expect(wrapper.find('.p-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates selection when checkboxes are selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error'
|
||||
})
|
||||
|
||||
const checkboxes = wrapper.findAllComponents(Checkbox)
|
||||
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
const checkbox = checkboxes.find(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) => checkbox.props('value') === field
|
||||
)
|
||||
expect(checkbox).toBeDefined()
|
||||
|
||||
await checkbox?.vm.$emit('update:modelValue', [field])
|
||||
expect(wrapper.vm.selection).toContain(field)
|
||||
}
|
||||
})
|
||||
|
||||
it('updates contactInfo when input is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
await input.vm.$emit('update:modelValue', 'test@example.com')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.user.email).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('updates additional details when textarea is changed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.details).toBe('This is a test detail.')
|
||||
})
|
||||
|
||||
it('set contact preferences back to false if email is removed', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const input = wrapper.findComponent(InputText)
|
||||
|
||||
// Set a valid email, enabling the contact preferences to be changed
|
||||
await input.vm.$emit('update:modelValue', 'name@example.com')
|
||||
|
||||
// Enable both contact preferences
|
||||
for (const pref of ['followUp', 'notifyOnResolution']) {
|
||||
await findAndUpdateCheckbox(wrapper, pref)
|
||||
}
|
||||
|
||||
// Change the email back to empty
|
||||
await input.vm.$emit('update:modelValue', '')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Check that the contact preferences are back to false automatically
|
||||
expect(context.tags.followUp).toBe(false)
|
||||
expect(context.tags.notifyOnResolution).toBe(false)
|
||||
})
|
||||
|
||||
it('renders with overridden default fields', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
defaultFields: ['Settings']
|
||||
})
|
||||
|
||||
// Filter out the contact preferences checkboxes
|
||||
const fieldCheckboxes = wrapper.findAllComponents(Checkbox).filter(
|
||||
// @ts-expect-error fixme ts strict error
|
||||
(checkbox) =>
|
||||
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
|
||||
)
|
||||
expect(fieldCheckboxes.length).toBe(1)
|
||||
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
|
||||
})
|
||||
|
||||
it('renders additional fields when extraFields prop is provided', () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
const customCheckbox = wrapper
|
||||
.findAllComponents(Checkbox)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
.find((checkbox) => checkbox.props('value') === 'CustomField')
|
||||
expect(customCheckbox).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows custom fields to be selected', async () => {
|
||||
const wrapper = mountComponent({
|
||||
errorType: 'Test Error',
|
||||
extraFields: CUSTOM_FIELDS
|
||||
})
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'CustomField')
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra.CustomField).toBe('mock data')
|
||||
})
|
||||
|
||||
it('does not submit unchecked fields', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
const textarea = wrapper.findComponent(Textarea)
|
||||
|
||||
// Set details but don't check any field checkboxes
|
||||
await textarea.vm.$emit(
|
||||
'update:modelValue',
|
||||
'Report with only text but no fields selected'
|
||||
)
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
// Verify none of the optional fields were included
|
||||
for (const field of DEFAULT_FIELDS) {
|
||||
expect(context.extra[field]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
checkbox: 'Logs',
|
||||
apiMethod: 'getLogs',
|
||||
expectedKey: 'Logs',
|
||||
mockValue: 'mock logs'
|
||||
},
|
||||
{
|
||||
checkbox: 'SystemStats',
|
||||
apiMethod: 'getSystemStats',
|
||||
expectedKey: 'SystemStats',
|
||||
mockValue: 'mock stats'
|
||||
},
|
||||
{
|
||||
checkbox: 'Settings',
|
||||
apiMethod: 'getSettings',
|
||||
expectedKey: 'Settings',
|
||||
mockValue: 'mock settings'
|
||||
}
|
||||
])(
|
||||
'submits $checkbox data when checkbox is selected',
|
||||
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { api } = (await import('@/scripts/api')) as any
|
||||
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, checkbox)
|
||||
const context = await submitForm(wrapper)
|
||||
expect(context.extra[expectedKey]).toBe(mockValue)
|
||||
}
|
||||
)
|
||||
|
||||
it('submits workflow when the Workflow checkbox is selected', async () => {
|
||||
const wrapper = mountComponent({ errorType: 'Test Error' })
|
||||
|
||||
const { app } = (await import('@/scripts/app')) as any
|
||||
const mockWorkflow = { nodes: [], edges: [] }
|
||||
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
|
||||
|
||||
await findAndUpdateCheckbox(wrapper, 'Workflow')
|
||||
const context = await submitForm(wrapper)
|
||||
|
||||
expect(context.extra.Workflow).toEqual(mockWorkflow)
|
||||
})
|
||||
})
|
||||
@@ -1,348 +0,0 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
:resolver="zodResolver(issueReportSchema)"
|
||||
@submit="submit"
|
||||
>
|
||||
<Panel :pt="$attrs.pt as any">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-4">
|
||||
<Button
|
||||
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
|
||||
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
|
||||
:severity="submitted ? 'secondary' : 'primary'"
|
||||
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
|
||||
:disabled="submitted"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-2 border border-round surface-border shadow-1">
|
||||
<div class="flex flex-col gap-6">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
name="contactInfo"
|
||||
:initial-value="authStore.currentUser?.email"
|
||||
>
|
||||
<div class="self-stretch inline-flex justify-start items-center">
|
||||
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.email')
|
||||
}}</label>
|
||||
</div>
|
||||
<InputText
|
||||
id="contactInfo"
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="$field" name="helpType">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatDoYouNeedHelpWith')
|
||||
}}</label>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-bind="$field"
|
||||
v-model="$field.value"
|
||||
:options="helpTypes"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:placeholder="$t('issueReport.selectIssue')"
|
||||
class="w-full"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.selectIssueType') }}
|
||||
</Message>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<span class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatCanWeInclude')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="selection"
|
||||
:input-id="field.value"
|
||||
:value="field.value"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormField v-slot="$field" name="details">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="details" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.describeTheProblem')
|
||||
}}</label>
|
||||
</div>
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
id="details"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{
|
||||
$field.value
|
||||
? t('issueReport.validation.maxLength')
|
||||
: t('issueReport.validation.descriptionRequired')
|
||||
}}
|
||||
</Message>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="contactPrefs"
|
||||
:input-id="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { cloneDeep } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Panel from 'primevue/panel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
type IssueReportFormData,
|
||||
issueReportSchema
|
||||
} from '@/schemas/issueReportSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type {
|
||||
DefaultField,
|
||||
IssueReportPanelProps,
|
||||
ReportField
|
||||
} from '@/types/issueReportTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_ISSUE_NAME = 'User reported issue'
|
||||
|
||||
const props = defineProps<IssueReportPanelProps>()
|
||||
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
props
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const selection = ref<string[]>([])
|
||||
const contactPrefs = ref<string[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
const contactCheckboxes = [
|
||||
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
|
||||
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
|
||||
]
|
||||
|
||||
const helpTypes = [
|
||||
{
|
||||
label: t('issueReport.helpTypes.billingPayments'),
|
||||
value: 'billingPayments'
|
||||
},
|
||||
{
|
||||
label: t('issueReport.helpTypes.loginAccessIssues'),
|
||||
value: 'loginAccessIssues'
|
||||
},
|
||||
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
|
||||
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
|
||||
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
|
||||
]
|
||||
|
||||
const defaultFieldsConfig: ReportField[] = [
|
||||
{
|
||||
label: t('issueReport.systemStats'),
|
||||
value: 'SystemStats',
|
||||
getData: () => api.getSystemStats(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.workflow'),
|
||||
value: 'Workflow',
|
||||
getData: () => cloneDeep(app.graph.asSerialisable()),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.logs'),
|
||||
value: 'Logs',
|
||||
getData: () => api.getLogs(),
|
||||
optIn: true
|
||||
},
|
||||
{
|
||||
label: t('g.settings'),
|
||||
value: 'Settings',
|
||||
getData: () => api.getSettings(),
|
||||
optIn: true
|
||||
}
|
||||
]
|
||||
|
||||
const fields = computed(() => [
|
||||
...defaultFieldsConfig.filter(({ value }) =>
|
||||
defaultFields.includes(value as DefaultField)
|
||||
),
|
||||
...(props.extraFields ?? [])
|
||||
])
|
||||
|
||||
const createUser = (formData: IssueReportFormData): User => ({
|
||||
email: formData.contactInfo || undefined
|
||||
})
|
||||
|
||||
const createExtraData = async (formData: IssueReportFormData) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
const isChecked = (fieldValue: string) => formData[fieldValue]
|
||||
|
||||
await Promise.all(
|
||||
fields.value
|
||||
.filter((field) => !field.optIn || isChecked(field.value))
|
||||
.map(async (field) => {
|
||||
try {
|
||||
result[field.value] = await field.getData()
|
||||
} catch (error) {
|
||||
console.error(`Failed to collect ${field.value}:`, error)
|
||||
result[field.value] = { error: String(error) }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const createCaptureContext = async (
|
||||
formData: IssueReportFormData
|
||||
): Promise<CaptureContext> => {
|
||||
return {
|
||||
user: createUser(formData),
|
||||
level: 'error',
|
||||
tags: {
|
||||
errorType: props.errorType,
|
||||
helpType: formData.helpType,
|
||||
followUp: formData.contactInfo ? formData.followUp : false,
|
||||
notifyOnResolution: formData.contactInfo
|
||||
? formData.notifyOnResolution
|
||||
: false,
|
||||
isElectron: isElectron(),
|
||||
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
|
||||
},
|
||||
extra: {
|
||||
details: formData.details,
|
||||
...(await createExtraData(formData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
|
||||
|
||||
const submit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
try {
|
||||
const captureContext = await createCaptureContext(event.values)
|
||||
|
||||
// If it's billing or access issue, generate unique id to be used by customer service ticketing
|
||||
const isValidContactInfo = event.values.contactInfo?.length
|
||||
const isCustomerServiceIssue =
|
||||
isValidContactInfo &&
|
||||
['billingPayments', 'loginAccessIssues'].includes(
|
||||
event.values.helpType || ''
|
||||
)
|
||||
const issueName = isCustomerServiceIssue
|
||||
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
|
||||
: DEFAULT_ISSUE_NAME
|
||||
captureMessage(issueName, captureContext)
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reportSent'),
|
||||
life: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -26,6 +26,35 @@
|
||||
}"
|
||||
>
|
||||
<div class="px-6 flex flex-col h-full">
|
||||
<!-- Conflict Warning Banner -->
|
||||
<div
|
||||
v-if="shouldShowManagerBanner"
|
||||
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<p class="text-sm font-bold m-0">
|
||||
{{ $t('manager.conflicts.warningBanner.title') }}
|
||||
</p>
|
||||
<p class="text-xs m-0">
|
||||
{{ $t('manager.conflicts.warningBanner.message') }}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm font-bold m-0 cursor-pointer"
|
||||
@click="onClickWarningLink"
|
||||
>
|
||||
{{ $t('manager.conflicts.warningBanner.button') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="dismissWarningBanner"
|
||||
>
|
||||
<i class="pi pi-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
@@ -34,6 +63,7 @@
|
||||
:suggestions="suggestions"
|
||||
:is-missing-tab="isMissingTab"
|
||||
:sort-options="sortOptions"
|
||||
:is-update-available-tab="isUpdateAvailableTab"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
@@ -69,7 +99,9 @@
|
||||
:is-selected="
|
||||
selectedNodePacks.some((pack) => pack.id === item.id)
|
||||
"
|
||||
@click.stop="(event) => selectNodePack(item, event)"
|
||||
@click.stop="
|
||||
(event: MouseEvent) => selectNodePack(item, event)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -101,7 +133,8 @@ import {
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -119,6 +152,7 @@ import { useManagerStatePersistence } from '@/composables/manager/useManagerStat
|
||||
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useRegistrySearch } from '@/composables/useRegistrySearch'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
@@ -133,12 +167,13 @@ const { initialTab } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const { getPackById } = useComfyRegistryStore()
|
||||
const conflictAcknowledgment = useConflictAcknowledgment()
|
||||
const persistedState = useManagerStatePersistence()
|
||||
const initialState = persistedState.loadStoredState()
|
||||
|
||||
const GRID_STYLE = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(17rem, 1fr))',
|
||||
padding: '0.5rem',
|
||||
gap: '1.5rem'
|
||||
} as const
|
||||
@@ -149,6 +184,13 @@ const {
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
// Use conflict acknowledgment state from composable
|
||||
const {
|
||||
shouldShowManagerBanner,
|
||||
dismissWarningBanner,
|
||||
dismissRedDotNotification
|
||||
} = conflictAcknowledgment
|
||||
|
||||
const tabs = ref<TabItem[]>([
|
||||
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
|
||||
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
|
||||
@@ -312,6 +354,13 @@ watch([isAllTab, searchResults], () => {
|
||||
displayPacks.value = searchResults.value
|
||||
})
|
||||
|
||||
const onClickWarningLink = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/troubleshooting/custom-node-issues',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const onResultsChange = () => {
|
||||
switch (selectedTab.value?.id) {
|
||||
case ManagerTab.Installed:
|
||||
@@ -472,6 +521,10 @@ watch([searchQuery, selectedTab], () => {
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
dismissRedDotNotification()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
persistedState.persistState({
|
||||
selectedTabId: selectedTab.value?.id,
|
||||
|
||||
82
src/components/dialog/content/manager/ManagerHeader.test.ts
Normal file
82
src/components/dialog/content/manager/ManagerHeader.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tag from 'primevue/tag'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ManagerHeader from './ManagerHeader.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: enMessages
|
||||
}
|
||||
})
|
||||
|
||||
describe('ManagerHeader', () => {
|
||||
const createWrapper = () => {
|
||||
return mount(ManagerHeader, {
|
||||
global: {
|
||||
plugins: [createPinia(), PrimeVue, i18n],
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
components: {
|
||||
Tag
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the component title', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe(
|
||||
enMessages.manager.discoverCommunityContent
|
||||
)
|
||||
})
|
||||
|
||||
it('displays the legacy manager UI tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
|
||||
})
|
||||
|
||||
it('applies info severity to the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('p-tag-info')
|
||||
})
|
||||
|
||||
it('displays info icon in the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const icon = wrapper.find('.pi-info-circle')
|
||||
expect(icon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('has cursor-help class on the tag', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const tag = wrapper.find('[data-pc-name="tag"]')
|
||||
expect(tag.classes()).toContain('cursor-help')
|
||||
})
|
||||
|
||||
it('has proper structure with flex container', () => {
|
||||
const wrapper = createWrapper()
|
||||
|
||||
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
|
||||
expect(flexContainer.exists()).toBe(true)
|
||||
|
||||
const tag = flexContainer.find('[data-pc-name="tag"]')
|
||||
expect(tag.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,22 @@
|
||||
<h2 class="text-lg font-normal text-left">
|
||||
{{ $t('manager.discoverCommunityContent') }}
|
||||
</h2>
|
||||
<div class="flex justify-end ml-auto pr-4 pl-2">
|
||||
<Tag
|
||||
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
:value="$t('manager.legacyManagerUI')"
|
||||
class="cursor-help ml-2"
|
||||
:pt="{
|
||||
root: { class: 'text-xs' }
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag'
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="w-[552px] flex flex-col">
|
||||
<ContentDivider :width="1" />
|
||||
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
|
||||
<!-- Description -->
|
||||
<div v-if="showAfterWhatsNew">
|
||||
<p
|
||||
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
|
||||
>
|
||||
{{ $t('manager.conflicts.description') }}
|
||||
<br /><br />
|
||||
{{ $t('manager.conflicts.info') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Import Failed List Wrapper -->
|
||||
<div
|
||||
v-if="importFailedConflicts.length > 0"
|
||||
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
|
||||
@click="toggleImportFailedPanel"
|
||||
>
|
||||
<div class="flex-1 flex">
|
||||
<span
|
||||
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
|
||||
>{{ importFailedConflicts.length }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
|
||||
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:icon="
|
||||
importFailedExpanded
|
||||
? 'pi pi-chevron-down text-xs'
|
||||
: 'pi pi-chevron-right text-xs'
|
||||
"
|
||||
text
|
||||
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import failed list -->
|
||||
<div
|
||||
v-if="importFailedExpanded"
|
||||
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<div
|
||||
v-for="(packageName, i) in importFailedConflicts"
|
||||
:key="i"
|
||||
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
|
||||
>
|
||||
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
|
||||
{{ packageName }}
|
||||
</span>
|
||||
<span class="pi pi-info-circle text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Conflict List Wrapper -->
|
||||
<div
|
||||
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
|
||||
@click="toggleConflictsPanel"
|
||||
>
|
||||
<div class="flex-1 flex">
|
||||
<span
|
||||
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
|
||||
>{{ allConflictDetails.length }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
|
||||
>{{ $t('manager.conflicts.conflicts') }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:icon="
|
||||
conflictsExpanded
|
||||
? 'pi pi-chevron-down text-xs'
|
||||
: 'pi pi-chevron-right text-xs'
|
||||
"
|
||||
text
|
||||
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Conflicts list -->
|
||||
<div
|
||||
v-if="conflictsExpanded"
|
||||
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<div
|
||||
v-for="(conflict, i) in allConflictDetails"
|
||||
:key="i"
|
||||
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
|
||||
>
|
||||
<span
|
||||
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
|
||||
>{{ getConflictMessage(conflict, t) }}</span
|
||||
>
|
||||
<span class="pi pi-info-circle text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Extension List Wrapper -->
|
||||
<div
|
||||
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
|
||||
>
|
||||
<div
|
||||
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
|
||||
@click="toggleExtensionsPanel"
|
||||
>
|
||||
<div class="flex-1 flex">
|
||||
<span
|
||||
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
|
||||
>{{ conflictData.length }}</span
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
|
||||
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:icon="
|
||||
extensionsExpanded
|
||||
? 'pi pi-chevron-down text-xs'
|
||||
: 'pi pi-chevron-right text-xs'
|
||||
"
|
||||
text
|
||||
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Extension list -->
|
||||
<div
|
||||
v-if="extensionsExpanded"
|
||||
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
<div
|
||||
v-for="conflictResult in conflictData"
|
||||
:key="conflictResult.package_id"
|
||||
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
|
||||
>
|
||||
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
|
||||
{{ conflictResult.package_name }}
|
||||
</span>
|
||||
<span class="pi pi-info-circle text-sm"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContentDivider :width="1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { filter, flatMap, map, some } from 'es-toolkit/compat'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import {
|
||||
ConflictDetail,
|
||||
ConflictDetectionResult
|
||||
} from '@/types/conflictDetectionTypes'
|
||||
import { getConflictMessage } from '@/utils/conflictMessageUtil'
|
||||
|
||||
const { showAfterWhatsNew = false, conflictedPackages } = defineProps<{
|
||||
showAfterWhatsNew?: boolean
|
||||
conflictedPackages?: ConflictDetectionResult[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { conflictedPackages: globalConflictPackages } = useConflictDetection()
|
||||
|
||||
const conflictsExpanded = ref<boolean>(false)
|
||||
const extensionsExpanded = ref<boolean>(false)
|
||||
const importFailedExpanded = ref<boolean>(false)
|
||||
|
||||
const conflictData = computed(
|
||||
() => conflictedPackages || globalConflictPackages.value
|
||||
)
|
||||
|
||||
const allConflictDetails = computed(() => {
|
||||
const allConflicts = flatMap(
|
||||
conflictData.value,
|
||||
(result: ConflictDetectionResult) => result.conflicts
|
||||
)
|
||||
return filter(
|
||||
allConflicts,
|
||||
(conflict: ConflictDetail) => conflict.type !== 'import_failed'
|
||||
)
|
||||
})
|
||||
|
||||
const packagesWithImportFailed = computed(() => {
|
||||
return filter(conflictData.value, (result: ConflictDetectionResult) =>
|
||||
some(
|
||||
result.conflicts,
|
||||
(conflict: ConflictDetail) => conflict.type === 'import_failed'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const importFailedConflicts = computed(() => {
|
||||
return map(
|
||||
packagesWithImportFailed.value,
|
||||
(result: ConflictDetectionResult) =>
|
||||
result.package_name || result.package_id
|
||||
)
|
||||
})
|
||||
|
||||
const toggleImportFailedPanel = () => {
|
||||
importFailedExpanded.value = !importFailedExpanded.value
|
||||
conflictsExpanded.value = false
|
||||
extensionsExpanded.value = false
|
||||
}
|
||||
|
||||
const toggleConflictsPanel = () => {
|
||||
conflictsExpanded.value = !conflictsExpanded.value
|
||||
extensionsExpanded.value = false
|
||||
importFailedExpanded.value = false
|
||||
}
|
||||
|
||||
const toggleExtensionsPanel = () => {
|
||||
extensionsExpanded.value = !extensionsExpanded.value
|
||||
conflictsExpanded.value = false
|
||||
importFailedExpanded.value = false
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.conflict-list-item:hover {
|
||||
background-color: rgba(0, 122, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
54
src/components/dialog/content/manager/NodeConflictFooter.vue
Normal file
54
src/components/dialog/content/manager/NodeConflictFooter.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full px-3 py-4">
|
||||
<div class="w-full flex items-center justify-between gap-2 pr-1">
|
||||
<Button
|
||||
:label="$t('manager.conflicts.conflictInfoTitle')"
|
||||
text
|
||||
severity="secondary"
|
||||
size="small"
|
||||
icon="pi pi-info-circle"
|
||||
:pt="{
|
||||
label: { class: 'text-sm' }
|
||||
}"
|
||||
@click="handleConflictInfoClick"
|
||||
/>
|
||||
<Button
|
||||
v-if="props.buttonText"
|
||||
:label="props.buttonText"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface Props {
|
||||
buttonText?: string
|
||||
onButtonClick?: () => void
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonText: undefined,
|
||||
onButtonClick: undefined
|
||||
})
|
||||
const dialogStore = useDialogStore()
|
||||
const handleConflictInfoClick = () => {
|
||||
window.open(
|
||||
'https://docs.comfy.org/troubleshooting/custom-node-issues',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
const handleButtonClick = () => {
|
||||
// Close the conflict dialog
|
||||
dialogStore.closeDialog({ key: 'global-node-conflict' })
|
||||
// Execute the custom button action if provided
|
||||
if (props.onButtonClick) {
|
||||
props.onButtonClick()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
12
src/components/dialog/content/manager/NodeConflictHeader.vue
Normal file
12
src/components/dialog/content/manager/NodeConflictHeader.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="h-12 flex items-center justify-between w-full pl-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Warning Icon -->
|
||||
<i class="pi pi-exclamation-triangle text-lg"></i>
|
||||
<!-- Title -->
|
||||
<p class="text-base font-bold">
|
||||
{{ $t('manager.conflicts.title') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,9 +17,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
type PackVersionStatus = components['schemas']['NodeVersionStatus']
|
||||
type PackStatus = components['schemas']['NodeStatus']
|
||||
@@ -32,10 +33,15 @@ type StatusProps = {
|
||||
severity: MessageSeverity
|
||||
}
|
||||
|
||||
const { statusType } = defineProps<{
|
||||
const { statusType, hasCompatibilityIssues } = defineProps<{
|
||||
statusType: Status
|
||||
hasCompatibilityIssues?: boolean
|
||||
}>()
|
||||
|
||||
// Inject import failed context from parent
|
||||
const importFailedContext = inject(ImportFailedKey)
|
||||
const importFailed = importFailedContext?.importFailed
|
||||
|
||||
const statusPropsMap: Record<Status, StatusProps> = {
|
||||
NodeStatusActive: {
|
||||
label: 'active',
|
||||
@@ -71,10 +77,13 @@ const statusPropsMap: Record<Status, StatusProps> = {
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = computed(
|
||||
() => statusPropsMap[statusType]?.label || 'unknown'
|
||||
)
|
||||
const statusSeverity = computed(
|
||||
() => statusPropsMap[statusType]?.severity || 'secondary'
|
||||
)
|
||||
const statusLabel = computed(() => {
|
||||
if (importFailed?.value) return 'importFailed'
|
||||
if (hasCompatibilityIssues) return 'conflicting'
|
||||
return statusPropsMap[statusType]?.label || 'unknown'
|
||||
})
|
||||
const statusSeverity = computed(() => {
|
||||
if (hasCompatibilityIssues || importFailed?.value) return 'error'
|
||||
return statusPropsMap[statusType]?.severity || 'secondary'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -6,11 +6,18 @@ import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackVersionBadge from './PackVersionBadge.vue'
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
|
||||
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
|
||||
vi.mock('@/config', () => ({
|
||||
default: {
|
||||
app_title: 'ComfyUI',
|
||||
app_version: '1.0.0'
|
||||
}
|
||||
}))
|
||||
|
||||
const mockNodePack = {
|
||||
id: 'test-pack',
|
||||
name: 'Test Pack',
|
||||
@@ -120,7 +127,7 @@ describe('PackVersionBadge', () => {
|
||||
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
expect(badge.find('span').text()).toBe('nightly')
|
||||
})
|
||||
|
||||
it('falls back to NIGHTLY when nodePack.id is missing', () => {
|
||||
@@ -134,7 +141,7 @@ describe('PackVersionBadge', () => {
|
||||
|
||||
const badge = wrapper.find('[role="button"]')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
|
||||
expect(badge.find('span').text()).toBe('nightly')
|
||||
})
|
||||
|
||||
it('toggles the popover when button is clicked', async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
|
||||
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -12,17 +12,16 @@
|
||||
>
|
||||
<i
|
||||
v-if="isUpdateAvailable"
|
||||
class="pi pi-arrow-circle-up text-blue-600"
|
||||
style="font-size: 8px"
|
||||
class="pi pi-arrow-circle-up text-blue-600 text-xs"
|
||||
/>
|
||||
<span>{{ installedVersion }}</span>
|
||||
<i class="pi pi-chevron-right" style="font-size: 8px" />
|
||||
<i class="pi pi-chevron-right text-xxs" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:pt="{
|
||||
content: { class: 'px-0' }
|
||||
content: { class: 'p-0 shadow-lg' }
|
||||
}"
|
||||
>
|
||||
<PackVersionSelectorPopover
|
||||
@@ -42,8 +41,7 @@ import { computed, ref, watch } from 'vue'
|
||||
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
const TRUNCATED_HASH_LENGTH = 7
|
||||
@@ -64,11 +62,11 @@ const popoverRef = ref()
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const installedVersion = computed(() => {
|
||||
if (!nodePack.id) return SelectedVersion.NIGHTLY
|
||||
if (!nodePack.id) return 'nightly'
|
||||
const version =
|
||||
managerStore.installedPacks[nodePack.id]?.ver ??
|
||||
nodePack.latest_version?.version ??
|
||||
SelectedVersion.NIGHTLY
|
||||
'nightly'
|
||||
|
||||
// If Git hash, truncate to 7 characters
|
||||
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)
|
||||
|
||||
@@ -3,18 +3,32 @@ import { createPinia } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import Select from 'primevue/select'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
|
||||
// SelectedVersion is now using direct strings instead of enum
|
||||
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
|
||||
// Default mock versions for reference
|
||||
const defaultMockVersions = [
|
||||
{ version: '1.0.0', createdAt: '2023-01-01' },
|
||||
{
|
||||
version: '1.0.0',
|
||||
createdAt: '2023-01-01',
|
||||
supported_os: ['windows', 'linux'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true
|
||||
},
|
||||
{ version: '0.9.0', createdAt: '2022-12-01' },
|
||||
{ version: '0.8.0', createdAt: '2022-11-01' }
|
||||
]
|
||||
@@ -22,13 +36,24 @@ const defaultMockVersions = [
|
||||
const mockNodePack = {
|
||||
id: 'test-pack',
|
||||
name: 'Test Pack',
|
||||
latest_version: { version: '1.0.0' },
|
||||
repository: 'https://github.com/user/repo'
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
supported_os: ['windows', 'linux'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true
|
||||
},
|
||||
repository: 'https://github.com/user/repo',
|
||||
has_registry_data: true
|
||||
}
|
||||
|
||||
// Create mock functions
|
||||
const mockGetPackVersions = vi.fn()
|
||||
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
|
||||
const mockCheckNodeCompatibility = vi.fn()
|
||||
|
||||
// Mock the registry service
|
||||
vi.mock('@/services/comfyRegistryService', () => ({
|
||||
@@ -49,6 +74,13 @@ vi.mock('@/stores/comfyManagerStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock the conflict detection composable
|
||||
vi.mock('@/composables/useConflictDetection', () => ({
|
||||
useConflictDetection: vi.fn(() => ({
|
||||
checkNodeCompatibility: mockCheckNodeCompatibility
|
||||
}))
|
||||
}))
|
||||
|
||||
const waitForPromises = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 16))
|
||||
await nextTick()
|
||||
@@ -59,6 +91,9 @@ describe('PackVersionSelectorPopover', () => {
|
||||
vi.clearAllMocks()
|
||||
mockGetPackVersions.mockReset()
|
||||
mockInstallPack.mockReset().mockResolvedValue(undefined)
|
||||
mockCheckNodeCompatibility
|
||||
.mockReset()
|
||||
.mockReturnValue({ hasConflict: false, conflicts: [] })
|
||||
})
|
||||
|
||||
const mountComponent = ({
|
||||
@@ -78,7 +113,12 @@ describe('PackVersionSelectorPopover', () => {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
components: {
|
||||
Listbox
|
||||
Listbox,
|
||||
VerifiedIcon,
|
||||
Select
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -120,14 +160,15 @@ describe('PackVersionSelectorPopover', () => {
|
||||
|
||||
const options = listbox.props('options')!
|
||||
// Check that we have both special options and version options
|
||||
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
|
||||
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
|
||||
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
|
||||
|
||||
// Check that special options exist
|
||||
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
|
||||
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
|
||||
expect(options.some((o) => o.value === 'nightly')).toBe(true)
|
||||
expect(options.some((o) => o.value === 'latest')).toBe(true)
|
||||
|
||||
// Check that version options exist
|
||||
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
|
||||
// Check that version options exist (excluding latest version 1.0.0)
|
||||
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
|
||||
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
|
||||
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
|
||||
})
|
||||
@@ -304,7 +345,7 @@ describe('PackVersionSelectorPopover', () => {
|
||||
await waitForPromises()
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
expect(listbox.exists()).toBe(true)
|
||||
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
|
||||
expect(listbox.props('modelValue')).toBe('nightly')
|
||||
})
|
||||
|
||||
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
|
||||
@@ -325,7 +366,343 @@ describe('PackVersionSelectorPopover', () => {
|
||||
await waitForPromises()
|
||||
const listbox = wrapper.findComponent(Listbox)
|
||||
expect(listbox.exists()).toBe(true)
|
||||
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
|
||||
expect(listbox.props('modelValue')).toBe('nightly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('version compatibility checking', () => {
|
||||
it('shows warning icon for incompatible versions', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Mock compatibility check to return conflict for specific version
|
||||
mockCheckNodeCompatibility.mockImplementation((versionData) => {
|
||||
if (versionData.supported_os?.includes('linux')) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
conflicts: [
|
||||
{
|
||||
type: 'os',
|
||||
current_value: 'windows',
|
||||
required_value: 'linux'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return { hasConflict: false, conflicts: [] }
|
||||
})
|
||||
|
||||
const nodePackWithCompatibility = {
|
||||
...mockNodePack,
|
||||
supported_os: ['linux'],
|
||||
supported_accelerators: ['CUDA']
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: nodePackWithCompatibility }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
// Check that compatibility checking function was called
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
|
||||
|
||||
// The warning icon should be shown for incompatible versions
|
||||
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
|
||||
expect(warningIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows verified icon for compatible versions', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Mock compatibility check to return no conflicts
|
||||
mockCheckNodeCompatibility.mockReturnValue({
|
||||
hasConflict: false,
|
||||
conflicts: []
|
||||
})
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await waitForPromises()
|
||||
|
||||
// Check that compatibility checking function was called
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
|
||||
|
||||
// The verified icon should be shown for compatible versions
|
||||
// Look for the VerifiedIcon component or SVG elements
|
||||
const verifiedIcons = wrapper.findAll('svg')
|
||||
expect(verifiedIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calls checkVersionCompatibility with correct version data', async () => {
|
||||
// Set up the mock for versions with specific supported data
|
||||
const versionsWithCompatibility = [
|
||||
{
|
||||
version: '1.0.0',
|
||||
supported_os: ['windows', 'linux'],
|
||||
supported_accelerators: ['CUDA', 'CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0'
|
||||
}
|
||||
]
|
||||
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
|
||||
|
||||
const nodePackWithCompatibility = {
|
||||
...mockNodePack,
|
||||
supported_os: ['windows'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
latest_version: {
|
||||
version: '1.0.0',
|
||||
supported_os: ['windows', 'linux'],
|
||||
supported_accelerators: ['CPU'], // latest_version data takes precedence
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: nodePackWithCompatibility }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
// Clear previous calls from component mounting/rendering
|
||||
mockCheckNodeCompatibility.mockClear()
|
||||
|
||||
// Trigger compatibility check by accessing getVersionCompatibility
|
||||
const vm = wrapper.vm as any
|
||||
vm.getVersionCompatibility('1.0.0')
|
||||
|
||||
// Verify that checkNodeCompatibility was called with correct data
|
||||
// Since 1.0.0 is the latest version, it should use latest_version data
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
|
||||
supported_os: ['windows', 'linux'],
|
||||
supported_accelerators: ['CPU'], // latest_version data takes precedence
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true,
|
||||
version: '1.0.0'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Mock compatibility check to return version conflicts
|
||||
mockCheckNodeCompatibility.mockImplementation((versionData) => {
|
||||
const conflicts = []
|
||||
if (versionData.supported_comfyui_version) {
|
||||
conflicts.push({
|
||||
type: 'comfyui_version',
|
||||
current_value: '0.5.0',
|
||||
required_value: versionData.supported_comfyui_version
|
||||
})
|
||||
}
|
||||
if (versionData.supported_comfyui_frontend_version) {
|
||||
conflicts.push({
|
||||
type: 'frontend_version',
|
||||
current_value: '1.0.0',
|
||||
required_value: versionData.supported_comfyui_frontend_version
|
||||
})
|
||||
}
|
||||
return {
|
||||
hasConflict: conflicts.length > 0,
|
||||
conflicts
|
||||
}
|
||||
})
|
||||
|
||||
const nodePackWithVersionRequirements = {
|
||||
...mockNodePack,
|
||||
supported_comfyui_version: '>=1.0.0',
|
||||
supported_comfyui_frontend_version: '>=2.0.0'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: nodePackWithVersionRequirements }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
// Check that compatibility checking function was called
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
|
||||
|
||||
// The warning icon should be shown for version incompatible packages
|
||||
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
|
||||
expect(warningIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('handles latest and nightly versions using nodePack data', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
const nodePackWithCompatibility = {
|
||||
...mockNodePack,
|
||||
supported_os: ['windows'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
latest_version: {
|
||||
...mockNodePack.latest_version,
|
||||
supported_os: ['windows'], // Match nodePack data for test consistency
|
||||
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: nodePackWithCompatibility }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
const vm = wrapper.vm as any
|
||||
|
||||
// Clear previous calls from component mounting/rendering
|
||||
mockCheckNodeCompatibility.mockClear()
|
||||
|
||||
// Test latest version
|
||||
vm.getVersionCompatibility('latest')
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
|
||||
supported_os: ['windows'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true,
|
||||
version: '1.0.0'
|
||||
})
|
||||
|
||||
// Clear for next test call
|
||||
mockCheckNodeCompatibility.mockClear()
|
||||
|
||||
// Test nightly version
|
||||
vm.getVersionCompatibility('nightly')
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
|
||||
id: 'test-pack',
|
||||
name: 'Test Pack',
|
||||
supported_os: ['windows'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0',
|
||||
repository: 'https://github.com/user/repo',
|
||||
has_registry_data: true,
|
||||
latest_version: {
|
||||
supported_os: ['windows'],
|
||||
supported_accelerators: ['CPU'],
|
||||
supported_python_version: '>=3.8',
|
||||
is_banned: false,
|
||||
has_registry_data: true,
|
||||
version: '1.0.0',
|
||||
supported_comfyui_version: '>=0.1.0',
|
||||
supported_comfyui_frontend_version: '>=1.0.0'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('shows banned package warnings', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Mock compatibility check to return banned conflicts
|
||||
mockCheckNodeCompatibility.mockImplementation((versionData) => {
|
||||
if (versionData.is_banned === true) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
conflicts: [
|
||||
{
|
||||
type: 'banned',
|
||||
current_value: 'installed',
|
||||
required_value: 'not_banned'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return { hasConflict: false, conflicts: [] }
|
||||
})
|
||||
|
||||
const bannedNodePack = {
|
||||
...mockNodePack,
|
||||
is_banned: true,
|
||||
latest_version: {
|
||||
...mockNodePack.latest_version,
|
||||
is_banned: true
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: bannedNodePack }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
// Check that compatibility checking function was called
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
|
||||
|
||||
// Open the dropdown to see the options
|
||||
const select = wrapper.find('.p-select')
|
||||
if (!select.exists()) {
|
||||
// Try alternative selector
|
||||
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
|
||||
if (selectButton.exists()) {
|
||||
await selectButton.trigger('click')
|
||||
}
|
||||
} else {
|
||||
await select.trigger('click')
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// The warning icon should be shown for banned packages in the dropdown options
|
||||
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
|
||||
expect(warningIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows security pending warnings', async () => {
|
||||
// Set up the mock for versions
|
||||
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
|
||||
|
||||
// Mock compatibility check to return security pending conflicts
|
||||
mockCheckNodeCompatibility.mockImplementation((versionData) => {
|
||||
if (versionData.has_registry_data === false) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
conflicts: [
|
||||
{
|
||||
type: 'pending',
|
||||
current_value: 'no_registry_data',
|
||||
required_value: 'registry_data_available'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return { hasConflict: false, conflicts: [] }
|
||||
})
|
||||
|
||||
const securityPendingNodePack = {
|
||||
...mockNodePack,
|
||||
has_registry_data: false,
|
||||
latest_version: {
|
||||
...mockNodePack.latest_version,
|
||||
has_registry_data: false
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mountComponent({
|
||||
props: { nodePack: securityPendingNodePack }
|
||||
})
|
||||
await waitForPromises()
|
||||
|
||||
// Check that compatibility checking function was called
|
||||
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
|
||||
|
||||
// The warning icon should be shown for security pending packages
|
||||
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
|
||||
expect(warningIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="w-64 mt-2">
|
||||
<span class="pl-3 text-muted text-md font-semibold opacity-70">
|
||||
{{ $t('manager.selectVersion') }}
|
||||
</span>
|
||||
<div class="w-64 pt-1">
|
||||
<div class="py-2">
|
||||
<span class="pl-3 text-md font-semibold text-neutral-500">
|
||||
{{ $t('manager.selectVersion') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoadingVersions || isQueueing"
|
||||
class="text-center text-muted py-4 flex flex-col items-center"
|
||||
@@ -23,24 +25,44 @@
|
||||
v-model="selectedVersion"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:options="versionOptions"
|
||||
:options="processedVersionOptions"
|
||||
:highlight-on-select="false"
|
||||
class="my-3 w-full max-h-[50vh] border-none shadow-none"
|
||||
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
|
||||
:pt="{
|
||||
listContainer: { class: 'scrollbar-hide' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex justify-between items-center w-full p-1">
|
||||
<span>{{ slotProps.option.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="slotProps.option.value === 'nightly'">
|
||||
<div class="w-4"></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i
|
||||
v-if="slotProps.option.hasConflict"
|
||||
v-tooltip="{
|
||||
value: slotProps.option.conflictMessage,
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-exclamation-triangle text-yellow-500"
|
||||
/>
|
||||
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
|
||||
</template>
|
||||
<span>{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="selectedVersion === slotProps.option.value"
|
||||
v-if="slotProps.option.isSelected"
|
||||
class="pi pi-check text-highlight"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Listbox>
|
||||
<ContentDivider class="my-2" />
|
||||
<div class="flex justify-end gap-2 p-1 px-3">
|
||||
<div class="flex justify-end gap-2 py-1 px-3">
|
||||
<Button
|
||||
text
|
||||
class="text-sm"
|
||||
severity="secondary"
|
||||
:label="$t('g.cancel')"
|
||||
:disabled="isQueueing"
|
||||
@@ -49,7 +71,7 @@
|
||||
<Button
|
||||
severity="secondary"
|
||||
:label="$t('g.install')"
|
||||
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
|
||||
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
|
||||
:disabled="isQueueing"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
@@ -62,21 +84,42 @@ import { whenever } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Listbox from 'primevue/listbox'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
ManagerChannel,
|
||||
ManagerDatabaseSource,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
|
||||
import { isSemVer } from '@/utils/formatUtil'
|
||||
|
||||
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
|
||||
type ManagerDatabaseSource =
|
||||
ManagerComponents['schemas']['ManagerDatabaseSource']
|
||||
type SelectedVersion = ManagerComponents['schemas']['SelectedVersion']
|
||||
|
||||
// Enum values for runtime use
|
||||
const SelectedVersionValues = {
|
||||
LATEST: 'latest' as SelectedVersion,
|
||||
NIGHTLY: 'nightly' as SelectedVersion
|
||||
}
|
||||
|
||||
const ManagerChannelValues: Record<string, ManagerChannel> = {
|
||||
DEFAULT: 'default',
|
||||
DEV: 'dev'
|
||||
}
|
||||
|
||||
const ManagerDatabaseSourceValues: Record<string, ManagerDatabaseSource> = {
|
||||
CACHE: 'cache',
|
||||
REMOTE: 'remote',
|
||||
LOCAL: 'local'
|
||||
}
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
@@ -89,22 +132,25 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const registryService = useComfyRegistryService()
|
||||
const managerStore = useComfyManagerStore()
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
const isQueueing = ref(false)
|
||||
|
||||
const selectedVersion = ref<string>(SelectedVersion.LATEST)
|
||||
const selectedVersion = ref<string>(SelectedVersionValues.LATEST)
|
||||
onMounted(() => {
|
||||
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
|
||||
const initialVersion =
|
||||
getInitialSelectedVersion() ?? SelectedVersionValues.LATEST
|
||||
selectedVersion.value =
|
||||
// Use NIGHTLY when version is a Git hash
|
||||
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
|
||||
isSemVer(initialVersion) ? initialVersion : SelectedVersionValues.NIGHTLY
|
||||
})
|
||||
|
||||
const getInitialSelectedVersion = () => {
|
||||
if (!nodePack.id) return
|
||||
|
||||
// If unclaimed, set selected version to nightly
|
||||
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
|
||||
if (nodePack.publisher?.name === 'Unclaimed')
|
||||
return SelectedVersionValues.NIGHTLY
|
||||
|
||||
// If node pack is installed, set selected version to the installed version
|
||||
if (managerStore.isPackInstalled(nodePack.id))
|
||||
@@ -126,6 +172,8 @@ const versionOptions = ref<
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
|
||||
|
||||
const isLoadingVersions = ref(false)
|
||||
|
||||
const onNodePackChange = async () => {
|
||||
@@ -133,25 +181,34 @@ const onNodePackChange = async () => {
|
||||
|
||||
// Fetch versions from the registry
|
||||
const versions = await fetchVersions()
|
||||
fetchedVersions.value = versions
|
||||
|
||||
const latestVersionNumber = nodePack.latest_version?.version
|
||||
|
||||
const availableVersionOptions = versions
|
||||
.map((version) => ({
|
||||
value: version.version ?? '',
|
||||
label: version.version ?? ''
|
||||
}))
|
||||
.filter((option) => option.value)
|
||||
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
|
||||
|
||||
// Add Latest option with actual version number
|
||||
const latestLabel = latestVersionNumber
|
||||
? `${t('manager.latestVersion')} (${latestVersionNumber})`
|
||||
: t('manager.latestVersion')
|
||||
|
||||
// Add Latest option
|
||||
const defaultVersions = [
|
||||
{
|
||||
value: SelectedVersion.LATEST,
|
||||
label: t('manager.latestVersion')
|
||||
value: SelectedVersionValues.LATEST,
|
||||
label: latestLabel
|
||||
}
|
||||
]
|
||||
|
||||
// Add Nightly option if there is a non-empty `repository` field
|
||||
if (nodePack.repository?.length) {
|
||||
defaultVersions.push({
|
||||
value: SelectedVersion.NIGHTLY,
|
||||
value: SelectedVersionValues.NIGHTLY,
|
||||
label: t('manager.nightlyVersion')
|
||||
})
|
||||
}
|
||||
@@ -172,16 +229,86 @@ whenever(
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isQueueing.value = true
|
||||
|
||||
if (!nodePack.id) {
|
||||
throw new Error('Node ID is required for installation')
|
||||
}
|
||||
// Convert 'latest' to actual version number for installation
|
||||
const actualVersion =
|
||||
selectedVersion.value === 'latest'
|
||||
? nodePack.latest_version?.version ?? 'latest'
|
||||
: selectedVersion.value
|
||||
|
||||
await managerStore.installPack.call({
|
||||
id: nodePack.id,
|
||||
repository: nodePack.repository ?? '',
|
||||
channel: ManagerChannel.DEFAULT,
|
||||
mode: ManagerDatabaseSource.CACHE,
|
||||
version: selectedVersion.value,
|
||||
channel: ManagerChannelValues.DEFAULT,
|
||||
mode: ManagerDatabaseSourceValues.CACHE,
|
||||
version: actualVersion,
|
||||
selected_version: selectedVersion.value
|
||||
})
|
||||
|
||||
isQueueing.value = false
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
const getVersionData = (version: string) => {
|
||||
const latestVersionNumber = nodePack.latest_version?.version
|
||||
const useLatestVersionData =
|
||||
version === 'latest' || version === latestVersionNumber
|
||||
if (useLatestVersionData) {
|
||||
const latestVersionData = nodePack.latest_version
|
||||
return {
|
||||
...latestVersionData
|
||||
}
|
||||
}
|
||||
const versionData = fetchedVersions.value.find((v) => v.version === version)
|
||||
if (versionData) {
|
||||
return {
|
||||
...versionData
|
||||
}
|
||||
}
|
||||
// Fallback to nodePack data
|
||||
return {
|
||||
...nodePack
|
||||
}
|
||||
}
|
||||
// Main function to get version compatibility info
|
||||
const getVersionCompatibility = (version: string) => {
|
||||
const versionData = getVersionData(version)
|
||||
const compatibility = checkNodeCompatibility(versionData)
|
||||
const conflictMessage = compatibility.hasConflict
|
||||
? getJoinedConflictMessages(compatibility.conflicts, t)
|
||||
: ''
|
||||
return {
|
||||
hasConflict: compatibility.hasConflict,
|
||||
conflictMessage
|
||||
}
|
||||
}
|
||||
// Helper to determine if an option is selected.
|
||||
const isOptionSelected = (optionValue: string) => {
|
||||
if (selectedVersion.value === optionValue) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
optionValue === 'latest' &&
|
||||
selectedVersion.value === nodePack.latest_version?.version
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
|
||||
const processedVersionOptions = computed(() => {
|
||||
return versionOptions.value.map((option) => {
|
||||
const compatibility = getVersionCompatibility(option.value)
|
||||
const isSelected = isOptionSelected(option.value)
|
||||
return {
|
||||
...option,
|
||||
hasConflict: compatibility.hasConflict,
|
||||
conflictMessage: compatibility.conflictMessage,
|
||||
isSelected: isSelected
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
outlined
|
||||
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
|
||||
:class="[
|
||||
variant === 'black'
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'border-neutral-700',
|
||||
fullWidth ? 'w-full' : 'w-min-content'
|
||||
]"
|
||||
:disabled="loading"
|
||||
v-bind="$attrs"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="py-2 px-3 whitespace-nowrap">
|
||||
<template v-if="loading">
|
||||
{{ loadingMessage ?? $t('g.loading') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ label }}
|
||||
</template>
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const {
|
||||
label,
|
||||
loadingMessage,
|
||||
fullWidth = false,
|
||||
variant = 'default'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
fullWidth?: boolean
|
||||
variant?: 'default' | 'black'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const onClick = (): void => {
|
||||
emit('action')
|
||||
}
|
||||
</script>
|
||||
@@ -12,9 +12,13 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import PackEnableToggle from './PackEnableToggle.vue'
|
||||
|
||||
// Mock debounce to execute immediately
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
|
||||
}))
|
||||
vi.mock('es-toolkit/compat', async () => {
|
||||
const actual = await vi.importActual('es-toolkit/compat')
|
||||
return {
|
||||
...actual,
|
||||
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
|
||||
}
|
||||
})
|
||||
|
||||
const mockNodePack = {
|
||||
id: 'test-pack',
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="hasConflict"
|
||||
v-tooltip="{
|
||||
value: $t('manager.conflicts.warningTooltip'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="flex items-center justify-center w-6 h-6 cursor-pointer"
|
||||
@click="showConflictModal(true)"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
v-if="!canToggleDirectly"
|
||||
:model-value="isEnabled"
|
||||
:disabled="isLoading"
|
||||
:readonly="!canToggleDirectly"
|
||||
aria-label="Enable or disable pack"
|
||||
@focus="handleToggleInteraction"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
v-else
|
||||
:model-value="isEnabled"
|
||||
:disabled="isLoading"
|
||||
aria-label="Enable or disable pack"
|
||||
@@ -8,57 +28,110 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
InstallPackParams,
|
||||
ManagerChannel,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
|
||||
const TOGGLE_DEBOUNCE_MS = 256
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
const { nodePack, hasConflict } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
hasConflict?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isPackEnabled, enablePack, disablePack, installedPacks } =
|
||||
useComfyManagerStore()
|
||||
const { getConflictsForPackageByID } = useConflictDetectionStore()
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isEnabled = computed(() => isPackEnabled(nodePack.id))
|
||||
const version = computed(() => {
|
||||
const id = nodePack.id
|
||||
if (!id) return SelectedVersion.NIGHTLY
|
||||
if (!id) return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
|
||||
return (
|
||||
installedPacks[id]?.ver ??
|
||||
nodePack.latest_version?.version ??
|
||||
SelectedVersion.NIGHTLY
|
||||
('nightly' as ManagerComponents['schemas']['SelectedVersion'])
|
||||
)
|
||||
})
|
||||
|
||||
const handleEnable = () =>
|
||||
enablePack.call({
|
||||
id: nodePack.id,
|
||||
version: version.value,
|
||||
selected_version: version.value,
|
||||
repository: nodePack.repository ?? '',
|
||||
channel: ManagerChannel.DEFAULT,
|
||||
mode: 'default' as InstallPackParams['mode']
|
||||
})
|
||||
const packageConflict = computed(() =>
|
||||
getConflictsForPackageByID(nodePack.id || '')
|
||||
)
|
||||
const canToggleDirectly = computed(() => {
|
||||
return !(
|
||||
hasConflict &&
|
||||
!acknowledgmentState.value.modal_dismissed &&
|
||||
packageConflict.value
|
||||
)
|
||||
})
|
||||
|
||||
const handleDisable = () =>
|
||||
disablePack({
|
||||
const showConflictModal = (skipModalDismissed: boolean) => {
|
||||
let modal_dismissed = acknowledgmentState.value.modal_dismissed
|
||||
if (skipModalDismissed) modal_dismissed = false
|
||||
if (packageConflict.value && !modal_dismissed) {
|
||||
showNodeConflictDialog({
|
||||
conflictedPackages: [packageConflict.value],
|
||||
buttonText: !isEnabled.value
|
||||
? t('manager.conflicts.enableAnyway')
|
||||
: t('manager.conflicts.understood'),
|
||||
onButtonClick: async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleEnable()
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => {
|
||||
markConflictsAsSeen()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnable = () => {
|
||||
if (!nodePack.id) {
|
||||
throw new Error('Node ID is required for enabling')
|
||||
}
|
||||
return enablePack.call({
|
||||
id: nodePack.id,
|
||||
version: version.value
|
||||
version:
|
||||
version.value ??
|
||||
('latest' as ManagerComponents['schemas']['SelectedVersion']),
|
||||
selected_version:
|
||||
version.value ??
|
||||
('latest' as ManagerComponents['schemas']['SelectedVersion']),
|
||||
repository: nodePack.repository ?? '',
|
||||
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
|
||||
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
|
||||
skip_post_install: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleDisable = () => {
|
||||
if (!nodePack.id) {
|
||||
throw new Error('Node ID is required for disabling')
|
||||
}
|
||||
return disablePack({
|
||||
id: nodePack.id,
|
||||
version:
|
||||
version.value ??
|
||||
('latest' as ManagerComponents['schemas']['SelectedVersion'])
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggle = async (enable: boolean) => {
|
||||
if (isLoading.value) return
|
||||
@@ -67,10 +140,22 @@ const handleToggle = async (enable: boolean) => {
|
||||
if (enable) {
|
||||
await handleEnable()
|
||||
} else {
|
||||
handleDisable()
|
||||
await handleDisable()
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
|
||||
const onToggle = debounce(
|
||||
(enable: boolean) => {
|
||||
void handleToggle(enable)
|
||||
},
|
||||
TOGGLE_DEBOUNCE_MS,
|
||||
{ trailing: true }
|
||||
)
|
||||
const handleToggleInteraction = async (event: Event) => {
|
||||
if (!canToggleDirectly.value) {
|
||||
event.preventDefault()
|
||||
showConflictModal(false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,59 +1,87 @@
|
||||
<template>
|
||||
<PackActionButton
|
||||
<IconTextButton
|
||||
v-bind="$attrs"
|
||||
:label="
|
||||
label ??
|
||||
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
|
||||
"
|
||||
:severity="variant === 'black' ? undefined : 'secondary'"
|
||||
:variant="variant"
|
||||
:loading="isInstalling"
|
||||
:loading-message="$t('g.installing')"
|
||||
@action="installAllPacks"
|
||||
@click="onClick"
|
||||
/>
|
||||
type="transparent"
|
||||
:label="computedLabel"
|
||||
:border="true"
|
||||
:size="size"
|
||||
:disabled="isLoading || isInstalling"
|
||||
@click="installAllPacks"
|
||||
>
|
||||
<template #icon>
|
||||
<i
|
||||
v-if="hasConflict && !isInstalling && !isLoading"
|
||||
class="pi pi-exclamation-triangle text-yellow-500"
|
||||
/>
|
||||
<DotSpinner
|
||||
v-else-if="isLoading || isInstalling"
|
||||
duration="1s"
|
||||
:size="size === 'sm' ? 12 : 16"
|
||||
/>
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
IsInstallingKey,
|
||||
ManagerChannel,
|
||||
ManagerDatabaseSource,
|
||||
SelectedVersion
|
||||
} from '@/types/comfyManagerTypes'
|
||||
import { ButtonSize } from '@/types/buttonTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import {
|
||||
type ConflictDetail,
|
||||
ConflictDetectionResult
|
||||
} from '@/types/conflictDetectionTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks, variant, label } = defineProps<{
|
||||
const {
|
||||
nodePacks,
|
||||
isLoading = false,
|
||||
label = 'Install',
|
||||
size = 'sm',
|
||||
hasConflict,
|
||||
conflictInfo
|
||||
} = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
variant?: 'default' | 'black'
|
||||
isLoading?: boolean
|
||||
label?: string
|
||||
size?: ButtonSize
|
||||
hasConflict?: boolean
|
||||
conflictInfo?: ConflictDetail[]
|
||||
}>()
|
||||
|
||||
const isInstalling = inject(IsInstallingKey, ref(false))
|
||||
|
||||
const onClick = (): void => {
|
||||
isInstalling.value = true
|
||||
}
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
const { showNodeConflictDialog } = useDialogService()
|
||||
|
||||
// Check if any of the packs are currently being installed
|
||||
const isInstalling = computed(() => {
|
||||
if (!nodePacks?.length) return false
|
||||
return nodePacks.some((pack) => managerStore.isPackInstalling(pack.id))
|
||||
})
|
||||
|
||||
const createPayload = (installItem: NodePack) => {
|
||||
if (!installItem.id) {
|
||||
throw new Error('Node ID is required for installation')
|
||||
}
|
||||
|
||||
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
|
||||
const versionToInstall = isUnclaimedPack
|
||||
? SelectedVersion.NIGHTLY
|
||||
: installItem.latest_version?.version ?? SelectedVersion.LATEST
|
||||
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
|
||||
: installItem.latest_version?.version ??
|
||||
('latest' as ManagerComponents['schemas']['SelectedVersion'])
|
||||
|
||||
return {
|
||||
id: installItem.id,
|
||||
repository: installItem.repository ?? '',
|
||||
channel: ManagerChannel.DEV,
|
||||
mode: ManagerDatabaseSource.CACHE,
|
||||
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
|
||||
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
|
||||
selected_version: versionToInstall,
|
||||
version: versionToInstall
|
||||
}
|
||||
@@ -65,14 +93,54 @@ const installPack = (item: NodePack) =>
|
||||
const installAllPacks = async () => {
|
||||
if (!nodePacks?.length) return
|
||||
|
||||
isInstalling.value = true
|
||||
if (hasConflict && conflictInfo) {
|
||||
// Check each package individually for conflicts
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const conflictedPackages: ConflictDetectionResult[] = nodePacks
|
||||
.map((pack) => {
|
||||
const compatibilityCheck = checkNodeCompatibility(pack)
|
||||
return {
|
||||
package_id: pack.id || '',
|
||||
package_name: pack.name || '',
|
||||
has_conflict: compatibilityCheck.hasConflict,
|
||||
conflicts: compatibilityCheck.conflicts,
|
||||
is_compatible: !compatibilityCheck.hasConflict
|
||||
}
|
||||
})
|
||||
.filter((result) => result.has_conflict) // Only show packages with conflicts
|
||||
|
||||
showNodeConflictDialog({
|
||||
conflictedPackages,
|
||||
buttonText: t('manager.conflicts.installAnyway'),
|
||||
onButtonClick: async () => {
|
||||
// Proceed with installation of uninstalled packages
|
||||
const uninstalledPacks = nodePacks.filter(
|
||||
(pack) => !managerStore.isPackInstalled(pack.id)
|
||||
)
|
||||
if (!uninstalledPacks.length) return
|
||||
await performInstallation(uninstalledPacks)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// No conflicts or conflicts acknowledged - proceed with installation
|
||||
const uninstalledPacks = nodePacks.filter(
|
||||
(pack) => !managerStore.isPackInstalled(pack.id)
|
||||
)
|
||||
if (!uninstalledPacks.length) return
|
||||
await performInstallation(uninstalledPacks)
|
||||
}
|
||||
|
||||
await Promise.all(uninstalledPacks.map(installPack))
|
||||
const performInstallation = async (packs: NodePack[]) => {
|
||||
await Promise.all(packs.map(installPack))
|
||||
managerStore.installPack.clear()
|
||||
}
|
||||
|
||||
const computedLabel = computed(() =>
|
||||
isInstalling.value
|
||||
? t('g.installing')
|
||||
: label ??
|
||||
(nodePacks.length > 1 ? t('manager.installSelected') : t('g.install'))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
<template>
|
||||
<PackActionButton
|
||||
<IconTextButton
|
||||
v-bind="$attrs"
|
||||
type="transparent"
|
||||
:label="
|
||||
nodePacks.length > 1
|
||||
? $t('manager.uninstallSelected')
|
||||
: $t('manager.uninstall')
|
||||
"
|
||||
severity="danger"
|
||||
:loading-message="$t('manager.uninstalling')"
|
||||
@action="uninstallItems"
|
||||
:border="true"
|
||||
:size="size"
|
||||
class="border-red-500"
|
||||
@click="uninstallItems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
|
||||
import { ButtonSize } from '@/types/buttonTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, size } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
size?: ButtonSize
|
||||
}>()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
|
||||
const createPayload = (
|
||||
uninstallItem: NodePack
|
||||
): ManagerComponents['schemas']['ManagerPackInfo'] => {
|
||||
if (!uninstallItem.id) {
|
||||
throw new Error('Node ID is required for uninstallation')
|
||||
}
|
||||
|
||||
return {
|
||||
id: uninstallItem.id,
|
||||
version: uninstallItem.latest_version?.version
|
||||
version: uninstallItem.latest_version?.version || 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<IconTextButton
|
||||
v-bind="$attrs"
|
||||
type="transparent"
|
||||
:label="$t('manager.updateAll')"
|
||||
:border="true"
|
||||
size="sm"
|
||||
:disabled="isUpdating"
|
||||
@click="updateAllPacks"
|
||||
>
|
||||
<template v-if="isUpdating" #icon>
|
||||
<DotSpinner duration="1s" :size="12" />
|
||||
</template>
|
||||
</IconTextButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: NodePack[]
|
||||
}>()
|
||||
|
||||
const isUpdating = ref<boolean>(false)
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
const createPayload = (updateItem: NodePack) => {
|
||||
return {
|
||||
id: updateItem.id!,
|
||||
version: updateItem.latest_version!.version!
|
||||
}
|
||||
}
|
||||
|
||||
const updatePack = async (item: NodePack) => {
|
||||
if (!item.id || !item.latest_version?.version) {
|
||||
console.warn('Pack missing required id or version:', item)
|
||||
return
|
||||
}
|
||||
await managerStore.updatePack.call(createPayload(item))
|
||||
}
|
||||
|
||||
const updateAllPacks = async () => {
|
||||
if (!nodePacks?.length) {
|
||||
console.warn('No packs provided for update')
|
||||
return
|
||||
}
|
||||
isUpdating.value = true
|
||||
const updatablePacks = nodePacks.filter((pack) =>
|
||||
managerStore.isPackInstalled(pack.id)
|
||||
)
|
||||
if (!updatablePacks.length) {
|
||||
console.info('No installed packs available for update')
|
||||
isUpdating.value = false
|
||||
return
|
||||
}
|
||||
console.info(`Starting update of ${updatablePacks.length} packs`)
|
||||
try {
|
||||
await Promise.all(updatablePacks.map(updatePack))
|
||||
managerStore.updatePack.clear()
|
||||
console.info('All packs updated successfully')
|
||||
} catch (error) {
|
||||
console.error('Pack update failed:', error)
|
||||
console.error(
|
||||
'Failed packs info:',
|
||||
updatablePacks.map((p) => p.id)
|
||||
)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,20 +2,26 @@
|
||||
<template v-if="nodePack">
|
||||
<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]" />
|
||||
<InfoPanelHeader
|
||||
:node-packs="[nodePack]"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
|
||||
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<MetadataRow
|
||||
v-if="isPackInstalled(nodePack.id)"
|
||||
v-if="!importFailed && isPackInstalled(nodePack.id)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
class="flex"
|
||||
style="align-items: center"
|
||||
>
|
||||
<PackEnableToggle :node-pack="nodePack" />
|
||||
<PackEnableToggle
|
||||
:node-pack="nodePack"
|
||||
:has-conflict="hasCompatibilityIssues"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
v-for="item in infoItems"
|
||||
@@ -29,6 +35,7 @@
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
:has-compatibility-issues="hasCompatibilityIssues"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
@@ -36,7 +43,11 @@
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
<InfoTabs :node-pack="nodePack" />
|
||||
<InfoTabs
|
||||
:node-pack="nodePack"
|
||||
:has-compatibility-issues="hasCompatibilityIssues"
|
||||
:conflict-result="conflictResult"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,9 +70,14 @@ import PackEnableToggle from '@/components/dialog/content/manager/button/PackEna
|
||||
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
|
||||
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
|
||||
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
interface InfoItem {
|
||||
key: string
|
||||
@@ -75,18 +91,55 @@ const { nodePack } = defineProps<{
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack.id))
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
whenever(isInstalled, () => {
|
||||
isInstalling.value = false
|
||||
})
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const { getConflictsForPackageByID } = useConflictDetectionStore()
|
||||
|
||||
const { t, d, n } = useI18n()
|
||||
|
||||
// Check compatibility once and pass to children
|
||||
const conflictResult = computed((): ConflictDetectionResult | null => {
|
||||
// For installed packages, use stored conflict data
|
||||
if (isInstalled.value && nodePack.id) {
|
||||
return getConflictsForPackageByID(nodePack.id) || null
|
||||
}
|
||||
|
||||
// For non-installed packages, perform compatibility check
|
||||
const compatibility = checkNodeCompatibility(nodePack)
|
||||
|
||||
if (compatibility.hasConflict) {
|
||||
return {
|
||||
package_id: nodePack.id || '',
|
||||
package_name: nodePack.name || '',
|
||||
has_conflict: true,
|
||||
conflicts: compatibility.conflicts,
|
||||
is_compatible: false
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const hasCompatibilityIssues = computed(() => {
|
||||
return conflictResult.value?.has_conflict
|
||||
})
|
||||
|
||||
const packageId = computed(() => nodePack.id || '')
|
||||
const { importFailed, showImportFailedDialog } =
|
||||
useImportFailedDetection(packageId)
|
||||
|
||||
provide(ImportFailedKey, {
|
||||
importFailed,
|
||||
showImportFailedDialog
|
||||
})
|
||||
|
||||
const infoItems = computed<InfoItem[]>(() => [
|
||||
{
|
||||
key: 'publisher',
|
||||
@@ -128,17 +181,3 @@ whenever(
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
<template>
|
||||
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
|
||||
<div v-if="nodePacks?.length" class="flex flex-col items-center">
|
||||
<slot name="thumbnail">
|
||||
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
|
||||
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
|
||||
</slot>
|
||||
<h2
|
||||
class="text-2xl font-bold text-center mt-4 mb-2"
|
||||
style="word-break: break-all"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ nodePacks[0].name }}
|
||||
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
|
||||
</slot>
|
||||
</h2>
|
||||
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
|
||||
<div
|
||||
v-if="!importFailed"
|
||||
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
|
||||
>
|
||||
<slot name="install-button">
|
||||
<PackUninstallButton
|
||||
v-if="isAllInstalled"
|
||||
v-bind="$attrs"
|
||||
size="md"
|
||||
:node-packs="nodePacks"
|
||||
/>
|
||||
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
|
||||
<PackInstallButton
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
size="md"
|
||||
:node-packs="nodePacks"
|
||||
:has-conflict="hasConflict || computedHasConflict"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center mb-6">
|
||||
<div v-else class="flex flex-col items-center">
|
||||
<NoResultsPlaceholder
|
||||
:message="$t('manager.status.unknown')"
|
||||
:title="$t('manager.tryAgainLater')"
|
||||
@@ -31,21 +42,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
const { nodePacks, hasConflict } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
hasConflict?: boolean
|
||||
}>()
|
||||
|
||||
const managerStore = useComfyManagerStore()
|
||||
|
||||
// Inject import failed context from parent
|
||||
const importFailedContext = inject(ImportFailedKey)
|
||||
const importFailed = importFailedContext?.importFailed
|
||||
|
||||
const isAllInstalled = ref(false)
|
||||
watch(
|
||||
[() => nodePacks, () => managerStore.installedPacks],
|
||||
@@ -56,4 +75,23 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Add conflict detection for install button dialog
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
// Compute conflict info for all node packs
|
||||
const conflictInfo = computed<ConflictDetail[]>(() => {
|
||||
if (!nodePacks?.length) return []
|
||||
|
||||
const allConflicts: ConflictDetail[] = []
|
||||
for (const nodePack of nodePacks) {
|
||||
const compatibilityCheck = checkNodeCompatibility(nodePack)
|
||||
if (compatibilityCheck.conflicts) {
|
||||
allConflicts.push(...compatibilityCheck.conflicts)
|
||||
}
|
||||
}
|
||||
return allConflicts
|
||||
})
|
||||
|
||||
const computedHasConflict = computed(() => conflictInfo.value.length > 0)
|
||||
</script>
|
||||
|
||||
@@ -6,16 +6,40 @@
|
||||
<PackIconStacked :node-packs="nodePacks" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ nodePacks.length }}
|
||||
{{ $t('manager.packsSelected') }}
|
||||
<div class="mt-5">
|
||||
<span class="inline-block mr-2 text-blue-500 text-base">{{
|
||||
nodePacks.length
|
||||
}}</span>
|
||||
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #install-button>
|
||||
<PackInstallButton :full-width="true" :node-packs="nodePacks" />
|
||||
<!-- Mixed: Don't show any button -->
|
||||
<div v-if="isMixed" class="text-sm text-neutral-500">
|
||||
{{ $t('manager.mixedSelectionMessage') }}
|
||||
</div>
|
||||
<!-- All installed: Show uninstall button -->
|
||||
<PackUninstallButton
|
||||
v-else-if="isAllInstalled"
|
||||
size="md"
|
||||
:node-packs="installedPacks"
|
||||
/>
|
||||
<!-- None installed: Show install button -->
|
||||
<PackInstallButton
|
||||
v-else-if="isNoneInstalled"
|
||||
size="md"
|
||||
:node-packs="notInstalledPacks"
|
||||
:has-conflict="hasConflicts"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
</template>
|
||||
</InfoPanelHeader>
|
||||
<div class="mb-6">
|
||||
<MetadataRow :label="$t('g.status')">
|
||||
<PackStatusMessage status-type="NodeVersionStatusActive" />
|
||||
<PackStatusMessage
|
||||
:status-type="overallStatus"
|
||||
:has-compatibility-issues="hasConflicts"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
:label="$t('manager.totalNodes')"
|
||||
@@ -31,22 +55,80 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
import { computed, onUnmounted, provide, toRef } from 'vue'
|
||||
|
||||
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import PackUninstallButton from '@/components/dialog/content/manager/button/PackUninstallButton.vue'
|
||||
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
|
||||
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
|
||||
import PackIconStacked from '@/components/dialog/content/manager/packIcon/PackIconStacked.vue'
|
||||
import { usePacksSelection } from '@/composables/nodePack/usePacksSelection'
|
||||
import { usePacksStatus } from '@/composables/nodePack/usePacksStatus'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
const { nodePacks } = defineProps<{
|
||||
nodePacks: components['schemas']['Node'][]
|
||||
}>()
|
||||
|
||||
const nodePacksRef = toRef(() => nodePacks)
|
||||
|
||||
// Use new composables for cleaner code
|
||||
const {
|
||||
installedPacks,
|
||||
notInstalledPacks,
|
||||
isAllInstalled,
|
||||
isNoneInstalled,
|
||||
isMixed
|
||||
} = usePacksSelection(nodePacksRef)
|
||||
|
||||
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
|
||||
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
const { getNodeDefs } = useComfyRegistryStore()
|
||||
|
||||
// Provide import failed context for PackStatusMessage
|
||||
provide(ImportFailedKey, {
|
||||
importFailed: hasImportFailed,
|
||||
showImportFailedDialog: () => {} // No-op for multi-selection
|
||||
})
|
||||
|
||||
// Check for conflicts in not-installed packages - keep original logic but simplified
|
||||
const packageConflicts = computed(() => {
|
||||
const conflictsByPackage = new Map<string, ConflictDetail[]>()
|
||||
|
||||
for (const pack of notInstalledPacks.value) {
|
||||
const compatibilityCheck = checkNodeCompatibility(pack)
|
||||
if (compatibilityCheck.hasConflict && pack.id) {
|
||||
conflictsByPackage.set(pack.id, compatibilityCheck.conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
return conflictsByPackage
|
||||
})
|
||||
|
||||
// Aggregate all unique conflicts for display
|
||||
const conflictInfo = computed<ConflictDetail[]>(() => {
|
||||
const conflictMap = new Map<string, ConflictDetail>()
|
||||
|
||||
packageConflicts.value.forEach((conflicts) => {
|
||||
conflicts.forEach((conflict) => {
|
||||
const key = `${conflict.type}-${conflict.current_value}-${conflict.required_value}`
|
||||
if (!conflictMap.has(key)) {
|
||||
conflictMap.set(key, conflict)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(conflictMap.values())
|
||||
})
|
||||
|
||||
const hasConflicts = computed(() => conflictInfo.value.length > 0)
|
||||
|
||||
const getPackNodes = async (pack: components['schemas']['Node']) => {
|
||||
if (!pack.latest_version?.version) return []
|
||||
const nodeDefs = await getNodeDefs.call({
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<Tabs :value="activeTab">
|
||||
<TabList>
|
||||
<Tab value="description">
|
||||
<TabList class="overflow-x-auto scrollbar-hide">
|
||||
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>⚠️</span>
|
||||
{{ importFailed ? $t('g.error') : $t('g.warning') }}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="description" class="p-2 mr-6">
|
||||
{{ $t('g.description') }}
|
||||
</Tab>
|
||||
<Tab value="nodes">
|
||||
<Tab value="nodes" class="p-2">
|
||||
{{ $t('g.nodes') }}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels class="overflow-auto">
|
||||
<TabPanels class="overflow-auto py-4 px-2">
|
||||
<TabPanel
|
||||
v-if="hasCompatibilityIssues"
|
||||
value="warning"
|
||||
class="bg-transparent"
|
||||
>
|
||||
<WarningTabPanel
|
||||
:node-pack="nodePack"
|
||||
:conflict-result="conflictResult"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="description">
|
||||
<DescriptionTabPanel :node-pack="nodePack" />
|
||||
</TabPanel>
|
||||
@@ -27,16 +43,25 @@ import TabList from 'primevue/tablist'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, inject, ref, watchEffect } from 'vue'
|
||||
|
||||
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
|
||||
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
|
||||
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
|
||||
import { ImportFailedKey } from '@/types/importFailedTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
hasCompatibilityIssues?: boolean
|
||||
conflictResult?: ConflictDetectionResult | null
|
||||
}>()
|
||||
|
||||
// Inject import failed context from parent
|
||||
const importFailedContext = inject(ImportFailedKey)
|
||||
const importFailed = importFailedContext?.importFailed
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
// @ts-expect-error comfy_nodes is an Algolia-specific field
|
||||
const { comfy_nodes } = nodePack
|
||||
@@ -44,4 +69,17 @@ const nodeNames = computed(() => {
|
||||
})
|
||||
|
||||
const activeTab = ref('description')
|
||||
|
||||
// Watch for compatibility issues and automatically switch to warning tab
|
||||
watchEffect(
|
||||
() => {
|
||||
if (hasCompatibilityIssues) {
|
||||
activeTab.value = 'warning'
|
||||
} else if (activeTab.value === 'warning') {
|
||||
// If currently on warning tab but no issues, switch to description
|
||||
activeTab.value = 'description'
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<div v-for="(section, index) in sections" :key="index" class="mb-4">
|
||||
<div class="mb-1">
|
||||
<div class="mb-3">
|
||||
{{ section.title }}
|
||||
</div>
|
||||
<div class="text-muted break-words">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex py-1.5 text-xs">
|
||||
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
|
||||
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
|
||||
<div class="w-2/3">
|
||||
<slot>{{ value }}</slot>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-4 overflow-hidden">
|
||||
<div class="overflow-hidden">
|
||||
<InfoTextSection
|
||||
v-if="nodePack?.description"
|
||||
:sections="descriptionSections"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 mt-4 text-sm">
|
||||
<div class="flex flex-col gap-4 text-sm">
|
||||
<template v-if="mappedNodeDefs?.length">
|
||||
<div
|
||||
v-for="nodeDef in mappedNodeDefs"
|
||||
:key="createNodeDefKey(nodeDef)"
|
||||
class="border rounded-lg p-4"
|
||||
>
|
||||
<NodePreview :node-def="nodeDef" class="!text-[.625rem] !min-w-full" />
|
||||
<NodePreview :node-def="nodeDef" class="text-[.625rem]! min-w-full!" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
v-if="importFailedInfo"
|
||||
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
|
||||
@click="showImportFailedDialog"
|
||||
>
|
||||
<i class="pi pi-code text-base"></i>
|
||||
<span class="dark-theme:text-white text-sm">{{
|
||||
t('serverStart.openLogs')
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-for="(conflict, index) in conflictResult?.conflicts || []"
|
||||
:key="index"
|
||||
class="p-3 bg-yellow-800/20 rounded-md"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm break-words flex-1">
|
||||
{{ getConflictMessage(conflict, $t) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
|
||||
import { t } from '@/i18n'
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
|
||||
import { getConflictMessage } from '@/utils/conflictMessageUtil'
|
||||
|
||||
const { nodePack, conflictResult } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
conflictResult: ConflictDetectionResult | null | undefined
|
||||
}>()
|
||||
const packageId = computed(() => nodePack?.id || '')
|
||||
const { importFailedInfo, showImportFailedDialog } =
|
||||
useImportFailedDetection(packageId)
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full aspect-[7/3] overflow-hidden">
|
||||
<div class="w-full aspect-7/3 overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
|
||||
@@ -21,73 +21,58 @@
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<template v-if="isInstalling">
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
|
||||
>
|
||||
<ProgressSpinner />
|
||||
<div
|
||||
class="self-stretch text-center justify-start text-sm font-medium leading-none"
|
||||
<div class="pt-4 px-4 pb-3 w-full h-full">
|
||||
<div class="flex flex-col gap-y-1 w-full h-full">
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ $t('g.installing') }}...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="pt-4 px-4 pb-3 w-full h-full">
|
||||
<div class="flex flex-col gap-y-1 w-full h-full">
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
:fill="false"
|
||||
/>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
<PackVersionBadge
|
||||
:node-pack="nodePack"
|
||||
:is-selected="isSelected"
|
||||
:fill="false"
|
||||
:class="isInstalling ? 'pointer-events-none' : ''"
|
||||
/>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<PackCardFooter :node-pack="nodePack" />
|
||||
<PackCardFooter :node-pack="nodePack" :is-installing="isInstalling" />
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
@@ -114,18 +99,17 @@ const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
const { isPackInstalled, isPackEnabled, isPackInstalling } =
|
||||
useComfyManagerStore()
|
||||
|
||||
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
|
||||
const isInstalling = computed(() => isPackInstalling(nodePack?.id))
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const isDisabled = computed(
|
||||
() => isInstalled.value && !isPackEnabled(nodePack?.id)
|
||||
)
|
||||
|
||||
whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
const nodesCount = computed(() =>
|
||||
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
|
||||
)
|
||||
|
||||
@@ -6,19 +6,28 @@
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
|
||||
<PackInstallButton
|
||||
v-if="!isInstalled"
|
||||
:node-packs="[nodePack]"
|
||||
:is-installing="isInstalling"
|
||||
:has-conflict="hasConflicts"
|
||||
:conflict-info="conflictInfo"
|
||||
/>
|
||||
<PackEnableToggle v-else :node-pack="nodePack" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
@@ -26,10 +35,23 @@ const { nodePack } = defineProps<{
|
||||
|
||||
const { isPackInstalled } = useComfyManagerStore()
|
||||
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
|
||||
const isInstalling = inject(IsInstallingKey)
|
||||
|
||||
const { n } = useI18n()
|
||||
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
|
||||
// Add conflict detection for the card button
|
||||
const { checkNodeCompatibility } = useConflictDetection()
|
||||
|
||||
// Check for conflicts with this specific node pack
|
||||
const conflictInfo = computed<ConflictDetail[]>(() => {
|
||||
if (!nodePack) return []
|
||||
const compatibilityCheck = checkNodeCompatibility(nodePack)
|
||||
return compatibilityCheck.conflicts || []
|
||||
})
|
||||
|
||||
const hasConflicts = computed(() => conflictInfo.value.length > 0)
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_ICON : imgSrc"
|
||||
:alt="nodePack.name + ' icon'"
|
||||
class="object-contain rounded-lg max-h-72 max-w-72"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
alt="default banner"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner_url or icon show -->
|
||||
<div v-else class="relative w-full h-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
filter: 'blur(10px)'
|
||||
}"
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative w-full h-full object-cover z-10'
|
||||
: 'relative w-full h-full object-contain z-10'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -13,29 +39,14 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '4.5rem',
|
||||
height = '4.5rem'
|
||||
} = defineProps<{
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
|
||||
)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
</script>
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
<template>
|
||||
<div class="relative w-24 h-24">
|
||||
<div class="relative w-[224px] h-[104px] shadow-xl">
|
||||
<div
|
||||
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
|
||||
:key="pack.id"
|
||||
class="absolute"
|
||||
class="absolute w-[210px] h-[90px]"
|
||||
:style="{
|
||||
bottom: `${index * offset}px`,
|
||||
right: `${index * offset}px`,
|
||||
zIndex: maxVisible - index
|
||||
}"
|
||||
>
|
||||
<div class="border rounded-lg p-0.5">
|
||||
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
|
||||
<div class="border rounded-lg shadow-lg p-0.5">
|
||||
<PackIcon :node-pack="pack" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePacks.length > maxVisible"
|
||||
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
|
||||
>
|
||||
+{{ nodePacks.length - maxVisible }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -28,11 +28,14 @@
|
||||
</div>
|
||||
<PackInstallButton
|
||||
v-if="isMissingTab && missingNodePacks.length > 0"
|
||||
variant="black"
|
||||
:disabled="isLoading || !!error"
|
||||
:node-packs="missingNodePacks"
|
||||
:label="$t('manager.installAllMissingNodes')"
|
||||
/>
|
||||
<PackUpdateButton
|
||||
v-if="isUpdateAvailableTab && hasUpdateAvailable"
|
||||
:node-packs="updateAvailableNodePacks"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex mt-3 text-sm">
|
||||
<div class="flex gap-6 ml-1">
|
||||
@@ -65,8 +68,10 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
|
||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
|
||||
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
|
||||
import {
|
||||
type SearchOption,
|
||||
SortableAlgoliaField
|
||||
@@ -83,6 +88,7 @@ const { searchResults, sortOptions } = defineProps<{
|
||||
suggestions?: QuerySuggestion[]
|
||||
sortOptions?: SortableField[]
|
||||
isMissingTab?: boolean
|
||||
isUpdateAvailableTab?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
@@ -96,6 +102,10 @@ const { t } = useI18n()
|
||||
// Get missing node packs from workflow with loading and error states
|
||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||
|
||||
// Use the composable to get update available nodes
|
||||
const { hasUpdateAvailable, updateAvailableNodePacks } =
|
||||
useUpdateAvailableNodes()
|
||||
|
||||
const hasResults = computed(
|
||||
() => searchQuery.value?.trim() && searchResults?.length
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg border shadow-sm h-full overflow-hidden flex flex-col"
|
||||
class="rounded-lg shadow-sm h-full overflow-hidden flex flex-col"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<!-- Card header - flush with top, approximately 15% of height -->
|
||||
<div class="w-full px-4 py-3 flex justify-between items-center border-b">
|
||||
<div class="w-full px-4 py-3 flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 flex items-center justify-center">
|
||||
<Skeleton shape="circle" width="1.5rem" height="1.5rem" />
|
||||
@@ -17,7 +17,7 @@
|
||||
<!-- Card content with icon on left and text on right -->
|
||||
<div class="flex-1 p-4 flex">
|
||||
<!-- Left icon - 64x64 -->
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<div class="shrink-0 mr-4">
|
||||
<Skeleton width="4rem" height="4rem" border-radius="0.5rem" />
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Card footer - similar to header -->
|
||||
<div class="w-full px-5 py-4 flex justify-between items-center border-t">
|
||||
<div class="w-full px-5 py-4 flex justify-between items-center">
|
||||
<Skeleton width="4rem" height="0.8rem" />
|
||||
<Skeleton width="6rem" height="0.8rem" />
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="flex-grow">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
@@ -112,12 +112,12 @@ import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
@@ -128,10 +128,10 @@ interface CreditHistoryItemData {
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
@@ -160,15 +160,8 @@ const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = () => {
|
||||
dialogService.showIssueReportDialog({
|
||||
title: t('issueReport.contactSupportTitle'),
|
||||
subtitle: t('issueReport.contactSupportDescription'),
|
||||
panelProps: {
|
||||
errorType: 'BillingSupport',
|
||||
defaultFields: ['Workflow', 'Logs', 'SystemStats', 'Settings']
|
||||
}
|
||||
})
|
||||
const handleMessageSupport = async () => {
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
|
||||
@@ -295,6 +295,8 @@ async function resetAllKeybindings() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-datatable-tbody) > tr > td {
|
||||
@apply p-1;
|
||||
min-height: 2rem;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
||||
<div class="flex flex-col h-full w-full gap-2">
|
||||
<slot name="header" />
|
||||
<ScrollPanel class="flex-grow h-0 pr-2">
|
||||
<ScrollPanel class="grow h-0 pr-2">
|
||||
<slot />
|
||||
</ScrollPanel>
|
||||
<slot name="footer" />
|
||||
|
||||
@@ -57,14 +57,23 @@
|
||||
class="w-8 h-8 mt-4"
|
||||
style="--pc-spinner-color: #000"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="mt-4 w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<div v-else class="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
class="w-32"
|
||||
severity="secondary"
|
||||
:label="$t('auth.signOut.signOut')"
|
||||
icon="pi pi-sign-out"
|
||||
@click="handleSignOut"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isApiKeyLogin"
|
||||
class="w-32"
|
||||
severity="danger"
|
||||
:label="$t('auth.deleteAccount.deleteAccount')"
|
||||
icon="pi pi-trash"
|
||||
@click="handleDeleteAccount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Section -->
|
||||
@@ -100,6 +109,7 @@ const dialogService = useDialogService()
|
||||
const {
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isApiKeyLogin,
|
||||
isEmailProvider,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
@@ -107,6 +117,7 @@ const {
|
||||
providerName,
|
||||
providerIcon,
|
||||
handleSignOut,
|
||||
handleSignIn
|
||||
handleSignIn,
|
||||
handleDeleteAccount
|
||||
} = useCurrentUser()
|
||||
</script>
|
||||
|
||||
@@ -123,6 +123,8 @@ const handleForgotPassword = async (
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
|
||||
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
|
||||
:class="{
|
||||
'rounded-t-none': progressDialogContent.isExpanded,
|
||||
'rounded-lg': !progressDialogContent.isExpanded
|
||||
}"
|
||||
>
|
||||
<div class="justify-center text-sm font-bold leading-none">
|
||||
<div class="flex items-center text-base leading-none">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isInProgress">
|
||||
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
|
||||
<DotSpinner duration="1s" class="mr-2" />
|
||||
<span>{{ currentTaskName }}</span>
|
||||
</template>
|
||||
<template v-else-if="isRestartCompleted">
|
||||
<span class="mr-2">🎉</span>
|
||||
<span>{{ currentTaskName }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="pi pi-check-circle mr-2 text-green-500" />
|
||||
<span class="leading-none">{{
|
||||
$t('manager.restartToApplyChanges')
|
||||
}}</span>
|
||||
<span class="mr-2">✅</span>
|
||||
<span>{{ $t('manager.restartToApplyChanges') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
|
||||
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
|
||||
{{ comfyManagerStore.taskLogs.length }}
|
||||
<span v-if="isInProgress" class="text-sm text-neutral-700">
|
||||
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
|
||||
{{ totalTasksCount }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
v-if="!isInProgress"
|
||||
v-if="!isInProgress && !isRestartCompleted"
|
||||
rounded
|
||||
outlined
|
||||
class="px-4 py-2 rounded-md mr-4"
|
||||
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
|
||||
@click="handleRestart"
|
||||
>
|
||||
{{ $t('g.restart') }}
|
||||
{{ $t('manager.applyChanges') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="!isRestartCompleted"
|
||||
:icon="
|
||||
progressDialogContent.isExpanded
|
||||
? 'pi pi-chevron-up'
|
||||
: 'pi pi-chevron-right'
|
||||
: 'pi pi-chevron-down'
|
||||
"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="font-bold"
|
||||
severity="secondary"
|
||||
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
|
||||
@click.stop="progressDialogContent.toggle"
|
||||
@@ -53,6 +57,7 @@
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
class="font-bold"
|
||||
severity="secondary"
|
||||
aria-label="Close"
|
||||
@click.stop="closeDialog"
|
||||
@@ -65,9 +70,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DotSpinner from '@/components/common/DotSpinner.vue'
|
||||
import { useConflictDetection } from '@/composables/useConflictDetection'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useComfyManagerService } from '@/services/comfyManagerService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
@@ -77,41 +84,107 @@ import {
|
||||
} from '@/stores/comfyManagerStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { performConflictDetection } = useConflictDetection()
|
||||
|
||||
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
|
||||
// State management for restart process
|
||||
const isRestarting = ref<boolean>(false)
|
||||
const isRestartCompleted = ref<boolean>(false)
|
||||
|
||||
const isInProgress = computed(
|
||||
() => comfyManagerStore.isProcessingTasks || isRestarting.value
|
||||
)
|
||||
|
||||
const completedTasksCount = computed(() => {
|
||||
return (
|
||||
comfyManagerStore.succeededTasksIds.length +
|
||||
comfyManagerStore.failedTasksIds.length
|
||||
)
|
||||
})
|
||||
|
||||
const totalTasksCount = computed(() => {
|
||||
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
|
||||
const taskQueue = comfyManagerStore.taskQueue
|
||||
const queuedTasks = taskQueue
|
||||
? (taskQueue.running_queue?.length || 0) +
|
||||
(taskQueue.pending_queue?.length || 0)
|
||||
: 0
|
||||
return completedTasks + queuedTasks
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
|
||||
}
|
||||
|
||||
const fallbackTaskName = t('g.installing')
|
||||
const fallbackTaskName = t('manager.installingDependencies')
|
||||
const currentTaskName = computed(() => {
|
||||
if (isRestarting.value) {
|
||||
return t('manager.restartingBackend')
|
||||
}
|
||||
if (isRestartCompleted.value) {
|
||||
return t('manager.extensionsSuccessfullyInstalled')
|
||||
}
|
||||
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
|
||||
const task = comfyManagerStore.taskLogs.at(-1)
|
||||
return task?.taskName ?? fallbackTaskName
|
||||
})
|
||||
|
||||
const handleRestart = async () => {
|
||||
const onReconnect = async () => {
|
||||
// Refresh manager state
|
||||
// Store original toast setting value
|
||||
const originalToastSetting = settingStore.get(
|
||||
'Comfy.Toast.DisableReconnectingToast'
|
||||
)
|
||||
|
||||
comfyManagerStore.clearLogs()
|
||||
comfyManagerStore.setStale()
|
||||
try {
|
||||
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
|
||||
|
||||
// Refresh node definitions
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
isRestarting.value = true
|
||||
|
||||
// Reload workflow
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
const onReconnect = async () => {
|
||||
try {
|
||||
comfyManagerStore.setStale()
|
||||
|
||||
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
|
||||
await useWorkflowService().reloadCurrentWorkflow()
|
||||
|
||||
// Run conflict detection after restart completion
|
||||
await performConflictDetection()
|
||||
} finally {
|
||||
await settingStore.set(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
originalToastSetting
|
||||
)
|
||||
|
||||
isRestarting.value = false
|
||||
isRestartCompleted.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
closeDialog()
|
||||
comfyManagerStore.resetTaskState()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||
|
||||
await useComfyManagerService().rebootComfyUI()
|
||||
} catch (error) {
|
||||
// If restart fails, restore settings and reset state
|
||||
await settingStore.set(
|
||||
'Comfy.Toast.DisableReconnectingToast',
|
||||
originalToastSetting
|
||||
)
|
||||
isRestarting.value = false
|
||||
isRestartCompleted.value = false
|
||||
closeDialog() // Close dialog on error
|
||||
throw error
|
||||
}
|
||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||
|
||||
await useComfyManagerService().rebootComfyUI()
|
||||
closeDialog()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,16 +18,27 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import TabMenu from 'primevue/tabmenu'
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
|
||||
import {
|
||||
useComfyManagerStore,
|
||||
useManagerProgressDialogStore
|
||||
} from '@/stores/comfyManagerStore'
|
||||
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const activeTabIndex = ref(0)
|
||||
const comfyManagerStore = useComfyManagerStore()
|
||||
const activeTabIndex = computed({
|
||||
get: () => progressDialogContent.getActiveTabIndex(),
|
||||
set: (value) => progressDialogContent.setActiveTabIndex(value)
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const tabs = [
|
||||
const tabs = computed(() => [
|
||||
{ label: t('manager.installationQueue') },
|
||||
{ label: t('manager.failed', { count: 0 }) }
|
||||
]
|
||||
{
|
||||
label: t('manager.failed', {
|
||||
count: comfyManagerStore.failedTasksIds.length
|
||||
})
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -31,6 +31,35 @@
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
<TransformPane
|
||||
v-if="isVueNodesEnabled && canvasStore.canvas && comfyAppReady"
|
||||
:canvas="canvasStore.canvas as LGraphCanvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
v-for="nodeData in nodesToRender"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:selected="nodeData.selected"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@update:collapsed="handleNodeCollapse"
|
||||
@update:title="handleNodeTitleUpdate"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -39,13 +68,22 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<DomWidgets />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -55,10 +93,17 @@ import MiniMap from '@/components/graph/MiniMap.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import TransformPane from '@/components/graph/TransformPane.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
@@ -66,11 +111,19 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -102,6 +155,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -118,6 +172,281 @@ const selectionToolboxEnabled = computed(() =>
|
||||
|
||||
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags (Vue-related)
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
|
||||
|
||||
// Vue node lifecycle management - initialize after graph is ready
|
||||
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
|
||||
let cleanupNodeManager: (() => void) | null = null
|
||||
|
||||
// Slot layout sync management
|
||||
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
|
||||
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
|
||||
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
|
||||
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
|
||||
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
|
||||
new Map()
|
||||
)
|
||||
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
|
||||
new Map()
|
||||
)
|
||||
let detectChangesInRAF = () => {}
|
||||
|
||||
// Initialize node manager when graph becomes available
|
||||
// Add a reactivity trigger to force computed re-evaluation
|
||||
const nodeDataTrigger = ref(0)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
if (!comfyApp.graph || nodeManager) return
|
||||
nodeManager = useGraphNodeManager(comfyApp.graph)
|
||||
cleanupNodeManager = nodeManager.cleanup
|
||||
// Use the manager's reactive maps directly
|
||||
vueNodeData.value = nodeManager.vueNodeData
|
||||
nodeState.value = nodeManager.nodeState
|
||||
nodePositions.value = nodeManager.nodePositions
|
||||
nodeSizes.value = nodeManager.nodeSizes
|
||||
detectChangesInRAF = nodeManager.detectChangesInRAF
|
||||
|
||||
// Initialize layout system with existing nodes
|
||||
const nodes = comfyApp.graph._nodes.map((node: any) => ({
|
||||
id: node.id.toString(),
|
||||
pos: node.pos,
|
||||
size: node.size
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||
for (const reroute of comfyApp.graph.reroutes.values()) {
|
||||
const [x, y] = reroute.pos
|
||||
const parent = reroute.parentId ?? undefined
|
||||
const linkIds = Array.from(reroute.linkIds)
|
||||
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
|
||||
}
|
||||
|
||||
// Seed existing links into the Layout Store (topology only)
|
||||
for (const link of comfyApp.graph._links.values()) {
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
link.origin_id,
|
||||
link.origin_slot,
|
||||
link.target_id,
|
||||
link.target_slot
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
const { startSync } = useLayoutSync()
|
||||
startSync(canvasStore.canvas)
|
||||
|
||||
// Initialize slot layout sync for hit detection
|
||||
slotSync = useSlotLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
slotSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
}
|
||||
|
||||
// Initialize link layout sync for event-driven updates
|
||||
linkSync = useLinkLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
linkSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
}
|
||||
|
||||
// Force computed properties to re-evaluate
|
||||
nodeDataTrigger.value++
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
if (!nodeManager) return
|
||||
try {
|
||||
cleanupNodeManager?.()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
nodeManager = null
|
||||
cleanupNodeManager = null
|
||||
|
||||
// Clean up slot layout sync
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
}
|
||||
|
||||
// Clean up link layout sync
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
linkSync = null
|
||||
}
|
||||
|
||||
// Reset reactive maps to inert defaults
|
||||
vueNodeData.value = new Map()
|
||||
nodeState.value = new Map()
|
||||
nodePositions.value = new Map()
|
||||
nodeSizes.value = new Map()
|
||||
}
|
||||
|
||||
// Watch for transformPaneEnabled to gate the node manager lifecycle
|
||||
watch(
|
||||
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
} else {
|
||||
disposeNodeManagerAndSyncs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Transform state for viewport culling
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
const nodesToRender = computed(() => {
|
||||
// Early return for zero overhead when Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = Array.from(vueNodeData.value.values())
|
||||
|
||||
// Apply viewport culling - check if node bounds intersect with viewport
|
||||
if (nodeManager && canvasStore.canvas && comfyApp.canvas) {
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager
|
||||
|
||||
// Ensure transform is synced before checking visibility
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
|
||||
const ds = canvas.ds
|
||||
|
||||
// Work in screen space - viewport is simply the canvas element size
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
|
||||
// Add margin that represents a constant distance in canvas space
|
||||
// Convert canvas units to screen pixels by multiplying by scale
|
||||
const canvasMarginDistance = 200 // Fixed margin in canvas units
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const filtered = allNodes.filter((nodeData) => {
|
||||
const node = manager.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
// Transform node position to screen space (same as DOM widgets)
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
// Check if node bounds intersect with expanded viewport (in screen space)
|
||||
const isVisible = !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
|
||||
return isVisible
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return allNodes
|
||||
})
|
||||
|
||||
let lastScale = 1
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
// Skip all work if Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync transform state only when it changes (avoids reflows)
|
||||
if (comfyApp.canvas?.ds) {
|
||||
const currentScale = comfyApp.canvas.ds.scale
|
||||
const currentOffsetX = comfyApp.canvas.ds.offset[0]
|
||||
const currentOffsetY = comfyApp.canvas.ds.offset[1]
|
||||
|
||||
if (
|
||||
currentScale !== lastScale ||
|
||||
currentOffsetX !== lastOffsetX ||
|
||||
currentOffsetY !== lastOffsetY
|
||||
) {
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
lastScale = currentScale
|
||||
lastOffsetX = currentOffsetX
|
||||
lastOffsetY = currentOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Detect node changes during transform updates
|
||||
detectChangesInRAF()
|
||||
|
||||
// Trigger reactivity for nodesToRender
|
||||
void nodesToRender.value.length
|
||||
}
|
||||
|
||||
// Node event handlers
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
if (!canvasStore.canvas || !nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
|
||||
canvasStore.canvas.selectNode(node)
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned
|
||||
if (!node.flags?.pinned) {
|
||||
layoutMutations.setSource(LayoutSource.Vue)
|
||||
layoutMutations.bringNodeToFront(nodeData.id)
|
||||
}
|
||||
node.selected = true
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
// Handle node collapse state changes
|
||||
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
|
||||
if (!nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Use LiteGraph's collapse method if the state needs to change
|
||||
const currentCollapsed = node.flags?.collapsed ?? false
|
||||
if (currentCollapsed !== collapsed) {
|
||||
node.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle node title updates
|
||||
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
|
||||
if (!nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Update the node title in LiteGraph for persistence
|
||||
node.title = newTitle
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
@@ -286,6 +615,7 @@ onMounted(async () => {
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useVueFeatureFlags()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
@@ -298,9 +628,6 @@ onMounted(async () => {
|
||||
await settingStore.loadSettingValues()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log(
|
||||
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
|
||||
)
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
@@ -326,6 +653,30 @@ onMounted(async () => {
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
// Set up Vue node initialization only when enabled
|
||||
if (isVueNodesEnabled.value) {
|
||||
// Set up a one-time listener for when the first node is added
|
||||
// This handles the case where Vue nodes are enabled but the graph starts empty
|
||||
// TODO: Replace this with a reactive graph mutations observer when available
|
||||
if (comfyApp.graph && !nodeManager && comfyApp.graph._nodes.length === 0) {
|
||||
const originalOnNodeAdded = comfyApp.graph.onNodeAdded
|
||||
comfyApp.graph.onNodeAdded = function (node: any) {
|
||||
// Restore original handler
|
||||
comfyApp.graph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (isVueNodesEnabled.value && !nodeManager) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
// Call original handler
|
||||
if (originalOnNodeAdded) {
|
||||
originalOnNodeAdded.call(this, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -366,4 +717,19 @@ onMounted(async () => {
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (nodeManager) {
|
||||
nodeManager.cleanup()
|
||||
nodeManager = null
|
||||
}
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
}
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
linkSync = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
v-if="hasActivePopup"
|
||||
class="fixed inset-0 z-[1200]"
|
||||
class="fixed inset-0 z-1200"
|
||||
@click="hideModal"
|
||||
></div>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
icon="pi pi-expand"
|
||||
:aria-label="fitViewTooltip"
|
||||
:style="stringifiedMinimapStyles.buttonStyles"
|
||||
class="hover:dark-theme:!bg-[#444444] hover:!bg-[#E7E6E6]"
|
||||
class="dark-theme:hover:bg-[#444444]! hover:bg-[#E7E6E6]!"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -205,27 +205,27 @@ const focusCommandText = computed(() =>
|
||||
// Computed properties for button classes and states
|
||||
const selectButtonClass = computed(() =>
|
||||
isCanvasUnlocked.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: ''
|
||||
)
|
||||
|
||||
const handButtonClass = computed(() =>
|
||||
isCanvasReadOnly.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: ''
|
||||
)
|
||||
|
||||
const zoomButtonClass = computed(() => [
|
||||
'!w-16',
|
||||
'w-16!',
|
||||
isModalVisible.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: '',
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
|
||||
])
|
||||
|
||||
const focusButtonClass = computed(() => ({
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]': true,
|
||||
'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]':
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!': true,
|
||||
'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!':
|
||||
workspaceStore.focusMode
|
||||
}))
|
||||
|
||||
@@ -254,9 +254,9 @@ const linkVisibilityAriaLabel = computed(() =>
|
||||
)
|
||||
const linkVisibleClass = computed(() => [
|
||||
linkHidden.value
|
||||
? 'dark-theme:[&:not(:active)]:!bg-[#262729] [&:not(:active)]:!bg-[#E7E6E6]'
|
||||
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
|
||||
: '',
|
||||
'hover:dark-theme:!bg-[#262729] hover:!bg-[#E7E6E6]'
|
||||
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="minimapRef"
|
||||
class="minimap-main-container flex absolute bottom-[66px] right-2 md:right-11 z-[1000]"
|
||||
class="minimap-main-container flex absolute bottom-[66px] right-2 md:right-11 z-1000"
|
||||
>
|
||||
<MiniMapPanel
|
||||
v-if="showOptionsPanel"
|
||||
|
||||
350
src/components/graph/TransformPane.spec.ts
Normal file
350
src/components/graph/TransformPane.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import TransformPane from './TransformPane.vue'
|
||||
|
||||
// Mock the transform state composable
|
||||
const mockTransformState = {
|
||||
camera: ref({ x: 0, y: 0, z: 1 }),
|
||||
transformStyle: ref({
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
}),
|
||||
syncWithCanvas: vi.fn(),
|
||||
canvasToScreen: vi.fn(),
|
||||
screenToCanvas: vi.fn(),
|
||||
isNodeInViewport: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/composables/element/useTransformState', () => ({
|
||||
useTransformState: () => mockTransformState
|
||||
}))
|
||||
|
||||
// Mock requestAnimationFrame/cancelAnimationFrame
|
||||
global.requestAnimationFrame = vi.fn((cb) => {
|
||||
setTimeout(cb, 16)
|
||||
return 1
|
||||
})
|
||||
global.cancelAnimationFrame = vi.fn()
|
||||
|
||||
describe('TransformPane', () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
let mockCanvas: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create mock canvas with LiteGraph interface
|
||||
mockCanvas = {
|
||||
canvas: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
},
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
}
|
||||
}
|
||||
|
||||
// Reset mock transform state
|
||||
mockTransformState.camera.value = { x: 0, y: 0, z: 1 }
|
||||
mockTransformState.transformStyle.value = {
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (wrapper) {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
describe('component mounting', () => {
|
||||
it('should mount successfully with minimal props', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-pane').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should apply transform style from composable', () => {
|
||||
mockTransformState.transformStyle.value = {
|
||||
transform: 'scale(2) translate(100px, 50px)',
|
||||
transformOrigin: '0 0'
|
||||
}
|
||||
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
const transformPane = wrapper.find('.transform-pane')
|
||||
const style = transformPane.attributes('style')
|
||||
expect(style).toContain('transform: scale(2) translate(100px, 50px)')
|
||||
})
|
||||
|
||||
it('should render slot content', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
},
|
||||
slots: {
|
||||
default: '<div class="test-content">Test Node</div>'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.test-content').exists()).toBe(true)
|
||||
expect(wrapper.find('.test-content').text()).toBe('Test Node')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RAF synchronization', () => {
|
||||
it('should start RAF sync on mount', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Should emit RAF status change to true
|
||||
expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
|
||||
expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true])
|
||||
})
|
||||
|
||||
it('should call syncWithCanvas during RAF updates', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Allow RAF to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas)
|
||||
})
|
||||
|
||||
it('should emit transform update timing', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Allow RAF to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(wrapper.emitted('transformUpdate')).toBeTruthy()
|
||||
const updateEvent = wrapper.emitted('transformUpdate')?.[0]
|
||||
expect(typeof updateEvent?.[0]).toBe('number')
|
||||
expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should stop RAF sync on unmount', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
|
||||
const events = wrapper.emitted('rafStatusChange') as any[]
|
||||
expect(events[events.length - 1]).toEqual([false])
|
||||
expect(global.cancelAnimationFrame).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas event listeners', () => {
|
||||
it('should add event listeners to canvas on mount', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove event listeners on unmount', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'wheel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction state management', () => {
|
||||
it('should apply interacting class during interactions', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate interaction start by checking internal state
|
||||
// Note: This tests the CSS class application logic
|
||||
const transformPane = wrapper.find('.transform-pane')
|
||||
|
||||
// Initially should not have interacting class
|
||||
expect(transformPane.classes()).not.toContain(
|
||||
'transform-pane--interacting'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle pointer events for node delegation', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
const transformPane = wrapper.find('.transform-pane')
|
||||
|
||||
// Simulate pointer down - we can't test the exact delegation logic
|
||||
// in unit tests due to vue-test-utils limitations, but we can verify
|
||||
// the event handler is set up correctly
|
||||
await transformPane.trigger('pointerdown')
|
||||
|
||||
// The test passes if no errors are thrown during event handling
|
||||
expect(transformPane.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform state integration', () => {
|
||||
it('should provide transform utilities to child components', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// The component should provide transform state via Vue's provide/inject
|
||||
// This is tested indirectly through the composable integration
|
||||
expect(mockTransformState.syncWithCanvas).toBeDefined()
|
||||
expect(mockTransformState.canvasToScreen).toBeDefined()
|
||||
expect(mockTransformState.screenToCanvas).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle null canvas gracefully', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: undefined
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
expect(wrapper.find('.transform-pane').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle missing canvas properties', () => {
|
||||
const incompleteCanvas = {} as any
|
||||
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: incompleteCanvas
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
// Should not throw errors during mount
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance optimizations', () => {
|
||||
it('should use contain CSS property for layout optimization', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
const transformPane = wrapper.find('.transform-pane')
|
||||
|
||||
// This test verifies the CSS contains the performance optimization
|
||||
// Note: In JSDOM, computed styles might not reflect all CSS properties
|
||||
expect(transformPane.element.className).toContain('transform-pane')
|
||||
})
|
||||
|
||||
it('should disable pointer events on container but allow on children', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
},
|
||||
slots: {
|
||||
default: '<div data-node-id="test">Test Node</div>'
|
||||
}
|
||||
})
|
||||
|
||||
const transformPane = wrapper.find('.transform-pane')
|
||||
|
||||
// The CSS should handle pointer events optimization
|
||||
// This is primarily a CSS concern, but we verify the structure
|
||||
expect(transformPane.exists()).toBe(true)
|
||||
expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
91
src/components/graph/TransformPane.vue
Normal file
91
src/components/graph/TransformPane.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
provide('transformState', {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
|
||||
if (nodeElement) {
|
||||
// TODO: Emit event for node interaction
|
||||
// Node interaction with nodeId will be handled in future implementation
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
rafStatusChange: [active: boolean]
|
||||
transformUpdate: [time: number]
|
||||
}>()
|
||||
|
||||
useCanvasTransformSync(props.canvas, syncWithCanvas, {
|
||||
onStart: () => emit('rafStatusChange', true),
|
||||
onUpdate: (duration) => emit('transformUpdate', duration),
|
||||
onStop: () => emit('rafStatusChange', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Allow pointer events on nodes */
|
||||
.transform-pane :deep([data-node-id]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="w-[250px] absolute flex justify-center right-2 md:right-11 z-[1300] bottom-[66px] !bg-inherit !border-0"
|
||||
class="w-[250px] absolute flex justify-center right-2 md:right-11 z-1300 bottom-[66px] bg-inherit! border-0!"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark-theme:bg-[#2b2b2b] border border-gray-200 dark-theme:border-gray-700 rounded-lg shadow-lg p-4 w-4/5"
|
||||
@@ -15,7 +15,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
@@ -41,7 +41,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
@@ -67,7 +67,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
@@ -92,7 +92,7 @@
|
||||
:pt="{
|
||||
root: {
|
||||
class:
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:!bg-transparent focus:!bg-transparent active:!bg-transparent'
|
||||
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
|
||||
},
|
||||
label: {
|
||||
class: 'flex flex-col items-start w-full'
|
||||
@@ -122,7 +122,7 @@
|
||||
:show-buttons="false"
|
||||
:use-grouping="false"
|
||||
:unstyled="true"
|
||||
input-class="flex-1 bg-transparent border-none outline-none text-sm shadow-none my-0 "
|
||||
input-class="flex-1 bg-transparent border-none outline-hidden text-sm shadow-none my-0 "
|
||||
fluid
|
||||
@input="applyZoom"
|
||||
@keyup.enter="applyZoom"
|
||||
|
||||
@@ -152,6 +152,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.color-picker-container {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="!p-1 !h-4 !w-4 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
|
||||
pt:icon:class="!text-xs"
|
||||
class="p-1! h-4! w-4! text-gray-400 hover:text-gray-600 hover:dark-theme:text-gray-200 transition"
|
||||
pt:icon:class="text-xs!"
|
||||
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
|
||||
:aria-label="
|
||||
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
|
||||
|
||||
@@ -189,6 +189,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../assets/css/style.css';
|
||||
|
||||
.dom-widget > * {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 break-all flex items-center gap-2">
|
||||
<span v-html="formattedText"></span>
|
||||
<Skeleton v-if="isParentNodeExecuting" class="!flex-1 !h-4" />
|
||||
<Skeleton v-if="isParentNodeExecuting" class="flex-1! h-4!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"
|
||||
text
|
||||
rounded
|
||||
class="!p-1 !h-4 !w-6 text-gray-400 hover:text-gray-600 dark-theme:hover:text-gray-200 transition"
|
||||
pt:icon:class="!text-xs"
|
||||
class="p-1! h-4! w-6! text-gray-400 hover:text-gray-600 hover:dark-theme:text-gray-200 transition"
|
||||
pt:icon:class="text-xs!"
|
||||
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
|
||||
:aria-label="
|
||||
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
|
||||
|
||||
@@ -14,7 +14,17 @@
|
||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||
@mouseleave="onMenuItemLeave(menuItem.key)"
|
||||
>
|
||||
<i :class="menuItem.icon" class="help-menu-icon" />
|
||||
<div class="help-menu-icon-container">
|
||||
<div class="help-menu-icon">
|
||||
<component
|
||||
:is="menuItem.icon"
|
||||
v-if="typeof menuItem.icon === 'object'"
|
||||
:size="16"
|
||||
/>
|
||||
<i v-else :class="menuItem.icon" />
|
||||
</div>
|
||||
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
|
||||
</div>
|
||||
<span class="menu-label">{{ menuItem.label }}</span>
|
||||
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
|
||||
</button>
|
||||
@@ -120,9 +130,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Component,
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { type ReleaseNote } from '@/services/releaseService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
@@ -133,12 +153,13 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
// Types
|
||||
interface MenuItem {
|
||||
key: string
|
||||
icon?: string
|
||||
icon?: string | Component
|
||||
label?: string
|
||||
action?: () => void
|
||||
visible?: boolean
|
||||
type?: 'item' | 'divider'
|
||||
items?: MenuItem[]
|
||||
showRedDot?: boolean
|
||||
}
|
||||
|
||||
// Constants
|
||||
@@ -170,6 +191,7 @@ const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
@@ -188,6 +210,10 @@ const showVersionUpdates = computed(() =>
|
||||
settingStore.get('Comfy.Notification.ShowVersionUpdates')
|
||||
)
|
||||
|
||||
// Use conflict acknowledgment state from composable
|
||||
const { shouldShowRedDot: shouldShowManagerRedDot } =
|
||||
useConflictAcknowledgment()
|
||||
|
||||
const moreItems = computed<MenuItem[]>(() => {
|
||||
const allMoreItems: MenuItem[] = [
|
||||
{
|
||||
@@ -277,7 +303,18 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
icon: 'pi pi-question-circle',
|
||||
label: t('helpCenter.helpFeedback'),
|
||||
action: () => {
|
||||
void commandStore.execute('Comfy.Feedback')
|
||||
void commandStore.execute('Comfy.ContactSupport')
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
type: 'item',
|
||||
icon: PuzzleIcon,
|
||||
label: t('helpCenter.managerExtension'),
|
||||
showRedDot: shouldShowManagerRedDot.value,
|
||||
action: () => {
|
||||
dialogService.showManagerDialog()
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
@@ -516,6 +553,13 @@ onMounted(async () => {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.help-menu-icon-container {
|
||||
position: relative;
|
||||
margin-right: 0.75rem;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-menu-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1rem;
|
||||
@@ -523,9 +567,26 @@ onMounted(async () => {
|
||||
width: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-menu-icon svg {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.menu-red-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff3b30;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--p-content-background);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
const { locale, t } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
'whats-new-dismissed': []
|
||||
}>()
|
||||
|
||||
// Local state for dismissed status
|
||||
const isDismissed = ref(false)
|
||||
|
||||
@@ -126,6 +131,7 @@ const show = () => {
|
||||
|
||||
const hide = () => {
|
||||
isDismissed.value = true
|
||||
emit('whats-new-dismissed')
|
||||
}
|
||||
|
||||
const closePopup = async () => {
|
||||
|
||||
41
src/components/icons/PuzzleIcon.vue
Normal file
41
src/components/icons/PuzzleIcon.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
:class="iconClass"
|
||||
>
|
||||
<g clip-path="url(#clip0_1099_16244)">
|
||||
<path
|
||||
d="M4.99992 3.00016C4.99992 2.07969 5.74611 1.3335 6.66658 1.3335C7.58706 1.3335 8.33325 2.07969 8.33325 3.00016V4.00016H8.99992C9.9318 4.00016 10.3977 4.00016 10.7653 4.1524C11.2553 4.35539 11.6447 4.74474 11.8477 5.2348C11.9999 5.60234 11.9999 6.06828 11.9999 7.00016H12.9999C13.9204 7.00016 14.6666 7.74635 14.6666 8.66683C14.6666 9.5873 13.9204 10.3335 12.9999 10.3335H11.9999V11.4668C11.9999 12.5869 11.9999 13.147 11.7819 13.5748C11.5902 13.9511 11.2842 14.2571 10.9079 14.4488C10.4801 14.6668 9.92002 14.6668 8.79992 14.6668H8.33325V13.5002C8.33325 12.6717 7.66168 12.0002 6.83325 12.0002C6.00482 12.0002 5.33325 12.6717 5.33325 13.5002V14.6668H4.53325C3.41315 14.6668 2.85309 14.6668 2.42527 14.4488C2.04895 14.2571 1.74299 13.9511 1.55124 13.5748C1.33325 13.147 1.33325 12.5869 1.33325 11.4668V10.3335H2.33325C3.25373 10.3335 3.99992 9.5873 3.99992 8.66683C3.99992 7.74635 3.25373 7.00016 2.33325 7.00016H1.33325C1.33325 6.06828 1.33325 5.60234 1.48549 5.2348C1.68848 4.74474 2.07783 4.35539 2.56789 4.1524C2.93543 4.00016 3.40137 4.00016 4.33325 4.00016H4.99992V3.00016Z"
|
||||
:stroke="color"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1099_16244">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
size?: number | string
|
||||
color?: string
|
||||
class?: string
|
||||
}
|
||||
const {
|
||||
size = 16,
|
||||
color = 'currentColor',
|
||||
class: className
|
||||
} = defineProps<Props>()
|
||||
const iconClass = computed(() => className || '')
|
||||
</script>
|
||||
27
src/components/icons/VerifiedIcon.vue
Normal file
27
src/components/icons/VerifiedIcon.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
:class="iconClass"
|
||||
>
|
||||
<path
|
||||
d="M8.00049 1.3335C8.73661 1.33367 9.33332 1.93038 9.3335 2.6665V2.83447C9.82278 2.96041 10.2851 3.15405 10.7095 3.40479L10.8286 3.28564C11.3493 2.76525 12.1937 2.76519 12.7144 3.28564C13.235 3.80626 13.2348 4.65067 12.7144 5.17139L12.5952 5.29053C12.846 5.71486 13.0396 6.17725 13.1655 6.6665H13.3335C14.0699 6.6665 14.6665 7.26411 14.6665 8.00049C14.6663 8.73672 14.0698 9.3335 13.3335 9.3335H13.1655C13.0396 9.82284 12.846 10.2851 12.5952 10.7095L12.7144 10.8286C13.235 11.3493 13.235 12.1937 12.7144 12.7144C12.1937 13.235 11.3493 13.235 10.8286 12.7144L10.7095 12.5952C10.2851 12.846 9.82284 13.0396 9.3335 13.1655V13.3335C9.3335 14.0698 8.73672 14.6663 8.00049 14.6665C7.26411 14.6665 6.6665 14.0699 6.6665 13.3335V13.1655C6.17725 13.0396 5.71486 12.846 5.29053 12.5952L5.17139 12.7144C4.65067 13.2348 3.80626 13.235 3.28564 12.7144C2.76519 12.1937 2.76525 11.3493 3.28564 10.8286L3.40479 10.7095C3.15405 10.2851 2.96041 9.82278 2.83447 9.3335H2.6665C1.93038 9.33332 1.33367 8.73661 1.3335 8.00049C1.3335 7.26422 1.93027 6.66668 2.6665 6.6665H2.83447C2.96043 6.17722 3.15403 5.71488 3.40479 5.29053L3.28564 5.17139C2.76536 4.65065 2.76508 3.80621 3.28564 3.28564C3.80621 2.76508 4.65065 2.76536 5.17139 3.28564L5.29053 3.40479C5.71488 3.15403 6.17722 2.96043 6.6665 2.83447V2.6665C6.66668 1.93027 7.26422 1.3335 8.00049 1.3335ZM7.3335 8.00049L6.00049 6.6665L4.6665 8.00049L7.3335 10.6665L11.3335 6.6665L10.0005 5.3335L7.3335 8.00049Z"
|
||||
:fill="color"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
size?: number | string
|
||||
color?: string
|
||||
class?: string
|
||||
}
|
||||
const { size = 16, color = '#60A5FA', class: className } = defineProps<Props>()
|
||||
const iconClass = computed(() => className || '')
|
||||
</script>
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
// Combine our component props with PrimeVue MultiSelect props
|
||||
// Since we use v-bind="$attrs", all PrimeVue props are available
|
||||
interface ExtendedProps extends Partial<MultiSelectProps> {
|
||||
// Our custom props
|
||||
label?: string
|
||||
showSearchBox?: boolean
|
||||
showSelectedCount?: boolean
|
||||
showClearButton?: boolean
|
||||
searchPlaceholder?: string
|
||||
// Override modelValue type to match our Option type
|
||||
modelValue?: Array<{ name: string; value: string }>
|
||||
}
|
||||
|
||||
const meta: Meta<ExtendedProps> = {
|
||||
title: 'Components/Input/MultiSelect',
|
||||
component: MultiSelect,
|
||||
tags: ['autodocs'],
|
||||
@@ -13,7 +27,35 @@ const meta: Meta<typeof MultiSelect> = {
|
||||
},
|
||||
options: {
|
||||
control: 'object'
|
||||
},
|
||||
showSearchBox: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle searchBar visibility'
|
||||
},
|
||||
showSelectedCount: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle selected count visibility'
|
||||
},
|
||||
showClearButton: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle clear button visibility'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: 'text'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
label: 'Select',
|
||||
options: [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
{ name: 'Svelte', value: 'svelte' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +67,7 @@ export const Default: Story = {
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const selected = ref([])
|
||||
const options = [
|
||||
const options = args.options || [
|
||||
{ name: 'Vue', value: 'vue' },
|
||||
{ name: 'React', value: 'react' },
|
||||
{ name: 'Angular', value: 'angular' },
|
||||
@@ -38,8 +80,11 @@ export const Default: Story = {
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
label="Select Frameworks"
|
||||
v-bind="args"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
@@ -50,10 +95,10 @@ export const Default: Story = {
|
||||
}
|
||||
|
||||
export const WithPreselectedValues: Story = {
|
||||
render: () => ({
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const options = [
|
||||
const options = args.options || [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
@@ -61,25 +106,43 @@ export const WithPreselectedValues: Story = {
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
]
|
||||
const selected = ref([options[0], options[1]])
|
||||
return { selected, options }
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<MultiSelect
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
label="Select Languages"
|
||||
:label="args.label"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}),
|
||||
args: {
|
||||
label: 'Select Languages',
|
||||
options: [
|
||||
{ name: 'JavaScript', value: 'js' },
|
||||
{ name: 'TypeScript', value: 'ts' },
|
||||
{ name: 'Python', value: 'python' },
|
||||
{ name: 'Go', value: 'go' },
|
||||
{ name: 'Rust', value: 'rust' }
|
||||
],
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const MultipleSelectors: Story = {
|
||||
render: () => ({
|
||||
render: (args) => ({
|
||||
components: { MultiSelect },
|
||||
setup() {
|
||||
const frameworkOptions = ref([
|
||||
@@ -114,7 +177,8 @@ export const MultipleSelectors: Story = {
|
||||
tagOptions,
|
||||
selectedFrameworks,
|
||||
selectedProjects,
|
||||
selectedTags
|
||||
selectedTags,
|
||||
args
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -124,22 +188,34 @@ export const MultipleSelectors: Story = {
|
||||
v-model="selectedFrameworks"
|
||||
:options="frameworkOptions"
|
||||
label="Select Frameworks"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedProjects"
|
||||
:options="projectOptions"
|
||||
label="Select Projects"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="selectedTags"
|
||||
:options="tagOptions"
|
||||
label="Select Tags"
|
||||
:showSearchBox="args.showSearchBox"
|
||||
:showSelectedCount="args.showSelectedCount"
|
||||
:showClearButton="args.showClearButton"
|
||||
:searchPlaceholder="args.searchPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<h4 class="font-medium mb-2">Current Selection:</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
<h4 class="font-medium mt-0">Current Selection:</h4>
|
||||
<div class="flex flex-col text-sm">
|
||||
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Projects: {{ selectedProjects.length > 0 ? selectedProjects.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
<p>Tags: {{ selectedTags.length > 0 ? selectedTags.map(s => s.name).join(', ') : 'None' }}</p>
|
||||
@@ -147,5 +223,54 @@ export const MultipleSelectors: Story = {
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}),
|
||||
args: {
|
||||
showSearchBox: false,
|
||||
showSelectedCount: false,
|
||||
showClearButton: false,
|
||||
searchPlaceholder: 'Search...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSearchBox: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithSelectedCount: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSelectedCount: true
|
||||
}
|
||||
}
|
||||
|
||||
export const WithClearButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllHeaderFeatures: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
showSelectedCount: true,
|
||||
showClearButton: true
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomSearchPlaceholder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearchBox: true,
|
||||
searchPlaceholder: 'Filter packages...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,96 +1,104 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
:options="filteredOptions"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
<!--
|
||||
Note: Unlike SingleSelect, we don't need an explicit options prop because:
|
||||
1. Our value template only shows a static label (not dynamic based on selection)
|
||||
2. We display a count badge instead of actual selected labels
|
||||
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
max-selected-labels="0" is required to show count badge instead of selected item labels
|
||||
-->
|
||||
<MultiSelect
|
||||
v-model="selectedItems"
|
||||
v-bind="$attrs"
|
||||
option-label="name"
|
||||
unstyled
|
||||
:max-selected-labels="0"
|
||||
:pt="pt"
|
||||
>
|
||||
<template
|
||||
v-if="showSearchBox || showSelectedCount || showClearButton"
|
||||
#header
|
||||
>
|
||||
<template
|
||||
v-if="hasSearchBox || showSelectedCount || hasClearButton"
|
||||
#header
|
||||
>
|
||||
<div class="p-2 flex flex-col gap-y-4 pb-0">
|
||||
<SearchBox
|
||||
v-if="hasSearchBox"
|
||||
v-model="searchQuery"
|
||||
:has-border="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
|
||||
>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="hasClearButton"
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
size="fit-content"
|
||||
class="text-sm !text-blue-500 !dark-theme:text-blue-600"
|
||||
@click.stop="selectedItems = []"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down class="text-lg text-neutral-400" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-4 w-4 p-0.5 flex-shrink-0 items-center justify-center rounded border-[3px] transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
"
|
||||
<div class="p-2 flex flex-col pb-0">
|
||||
<SearchBox
|
||||
v-if="showSearchBox"
|
||||
v-model="searchQuery"
|
||||
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
|
||||
:show-order="true"
|
||||
:place-holder="searchPlaceholder"
|
||||
/>
|
||||
<div
|
||||
v-if="showSelectedCount || showClearButton"
|
||||
class="mt-2 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
v-if="showSelectedCount"
|
||||
class="text-sm text-neutral-400 dark-theme:text-zinc-500 px-1"
|
||||
>
|
||||
<i-lucide:check
|
||||
v-if="slotProps.selected"
|
||||
class="text-xs text-bold text-white"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate" :title="slotProps.option.name">{{
|
||||
slotProps.option.name
|
||||
}}</span>
|
||||
{{
|
||||
selectedCount > 0
|
||||
? $t('g.itemsSelected', { selectedCount })
|
||||
: $t('g.itemSelected', { selectedCount })
|
||||
}}
|
||||
</span>
|
||||
<TextButton
|
||||
v-if="showClearButton"
|
||||
:label="$t('g.clearAll')"
|
||||
type="transparent"
|
||||
size="fit-content"
|
||||
class="text-sm text-blue-500! dark-theme:text-blue-600!"
|
||||
@click.stop="selectedItems = []"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
<div class="mt-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Selected count badge -->
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Trigger value (keep text scale identical) -->
|
||||
<template #value>
|
||||
<span class="text-sm text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedCount > 0"
|
||||
class="pointer-events-none absolute -right-2 -top-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 dark-theme:bg-blue-500 text-xs font-semibold text-white"
|
||||
>
|
||||
{{ selectedCount }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Chevron size identical to current -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down class="text-lg text-neutral-400" />
|
||||
</template>
|
||||
|
||||
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-4 w-4 p-0.5 shrink-0 items-center justify-center rounded transition-all duration-200"
|
||||
:class="
|
||||
slotProps.selected
|
||||
? 'border-[3px] border-blue-400 bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
|
||||
: 'border-[1px] border-neutral-300 dark-theme:border-zinc-600 bg-neutral-100 dark-theme:bg-zinc-700'
|
||||
"
|
||||
>
|
||||
<i-lucide:check
|
||||
v-if="slotProps.selected"
|
||||
class="text-xs text-bold text-white"
|
||||
/>
|
||||
</div>
|
||||
<Button class="border-none outline-none bg-transparent" unstyled>{{
|
||||
slotProps.option.name
|
||||
}}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Fuse from 'fuse.js'
|
||||
import Button from 'primevue/button'
|
||||
import MultiSelect, {
|
||||
MultiSelectPassThroughMethodOptions
|
||||
} from 'primevue/multiselect'
|
||||
@@ -102,26 +110,29 @@ import TextButton from '../button/TextButton.vue'
|
||||
|
||||
type Option = { name: string; value: string }
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
interface Props {
|
||||
/** Input label shown on the trigger button */
|
||||
label?: string
|
||||
/** Static options for the multiselect (when not using async search) */
|
||||
options: Option[]
|
||||
/** Show search box in the panel header */
|
||||
hasSearchBox?: boolean
|
||||
showSearchBox?: boolean
|
||||
/** Show selected count text in the panel header */
|
||||
showSelectedCount?: boolean
|
||||
/** Show "Clear all" action in the panel header */
|
||||
hasClearButton?: boolean
|
||||
showClearButton?: boolean
|
||||
/** Placeholder for the search input */
|
||||
searchPlaceholder?: string
|
||||
// Note: options prop is intentionally omitted.
|
||||
// It's passed via $attrs to maximize PrimeVue API compatibility
|
||||
}
|
||||
const {
|
||||
label,
|
||||
options,
|
||||
hasSearchBox = false,
|
||||
showSearchBox = false,
|
||||
showSelectedCount = false,
|
||||
hasClearButton = false,
|
||||
showClearButton = false,
|
||||
searchPlaceholder = 'Search...'
|
||||
} = defineProps<Props>()
|
||||
|
||||
@@ -131,40 +142,10 @@ const selectedItems = defineModel<Option[]>({
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'value'],
|
||||
threshold: 0.3,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
// Create Fuse instance
|
||||
const fuse = computed(() => new Fuse(options, fuseOptions))
|
||||
|
||||
// Filtered options based on search
|
||||
const filteredOptions = computed(() => {
|
||||
if (!hasSearchBox || !searchQuery.value?.trim()) {
|
||||
return options
|
||||
}
|
||||
|
||||
const searchResults = fuse.value.search(searchQuery.value)
|
||||
const matchingOptions = searchResults.map((result) => result.item)
|
||||
|
||||
// Always include selected items, even if they don't match the search
|
||||
const selectedValues = selectedItems.value.map((item) => item.value)
|
||||
const selectedOptionsNotInResults = options.filter(
|
||||
(option) =>
|
||||
selectedValues.includes(option.value) &&
|
||||
!matchingOptions.some((matching) => matching.value === option.value)
|
||||
)
|
||||
|
||||
return [...selectedOptionsNotInResults, ...matchingOptions]
|
||||
})
|
||||
|
||||
const pt = computed(() => ({
|
||||
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'relative inline-flex cursor-pointer select-none w-full',
|
||||
'relative inline-flex cursor-pointer select-none',
|
||||
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'border-[2.5px] border-solid',
|
||||
@@ -186,18 +167,25 @@ const pt = computed(() => ({
|
||||
},
|
||||
header: () => ({
|
||||
class:
|
||||
hasSearchBox || showSelectedCount || hasClearButton ? 'block' : 'hidden'
|
||||
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
|
||||
}),
|
||||
// Overlay & list visuals unchanged
|
||||
overlay:
|
||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700 shadow-lg max-h-64 overflow-hidden',
|
||||
'mt-2 bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white rounded-lg border border-solid border-zinc-100 dark-theme:border-zinc-700',
|
||||
list: {
|
||||
class:
|
||||
'flex flex-col gap-1 p-0 list-none border-none text-xs max-h-52 overflow-y-auto'
|
||||
class: 'flex flex-col gap-1 p-0 list-none border-none text-xs'
|
||||
},
|
||||
// Option row hover tone identical
|
||||
option:
|
||||
'flex gap-1 items-center p-2 hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
// Option row hover and focus tone
|
||||
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
|
||||
class: [
|
||||
'flex gap-1 items-center p-2',
|
||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
||||
// Add focus/highlight state for keyboard navigation
|
||||
{
|
||||
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
|
||||
}
|
||||
]
|
||||
}),
|
||||
// Hide built-in checkboxes entirely via PT (no :deep)
|
||||
pcHeaderCheckbox: {
|
||||
root: { class: 'hidden' },
|
||||
|
||||
@@ -10,7 +10,15 @@ const meta: Meta<typeof SearchBox> = {
|
||||
argTypes: {
|
||||
placeHolder: {
|
||||
control: 'text'
|
||||
},
|
||||
showBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Toggle border prop'
|
||||
}
|
||||
},
|
||||
args: {
|
||||
placeHolder: 'Search...',
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +33,23 @@ export const Default: Story = {
|
||||
return { searchText, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SearchBox v-model:="searchQuery" />
|
||||
<div style="max-width: 320px;">
|
||||
<SearchBox v-bind="args" v-model="searchText" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: true
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
showBorder: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:placeholder="placeHolder || 'Search...'"
|
||||
type="text"
|
||||
unstyled
|
||||
class="w-full p-0 border-none outline-none bg-transparent text-xs text-neutral dark-theme:text-white"
|
||||
class="w-full p-0 border-none outline-hidden bg-transparent text-xs text-neutral dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,22 +15,20 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { placeHolder, hasBorder = false } = defineProps<{
|
||||
const { placeHolder, showBorder = false } = defineProps<{
|
||||
placeHolder?: string
|
||||
hasBorder?: boolean
|
||||
showBorder?: boolean
|
||||
}>()
|
||||
|
||||
const searchQuery = defineModel<string>({
|
||||
default: ''
|
||||
})
|
||||
// defineModel without arguments uses 'modelValue' as the prop name
|
||||
const searchQuery = defineModel<string>()
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
return hasBorder
|
||||
return showBorder
|
||||
? 'flex w-full items-center rounded gap-2 bg-white dark-theme:bg-zinc-800 p-1 border border-solid border-zinc-200 dark-theme:border-zinc-700'
|
||||
: 'flex w-full items-center rounded px-2 py-1.5 gap-2 bg-white dark-theme:bg-zinc-800'
|
||||
})
|
||||
|
||||
const iconColorStyle = computed(() => {
|
||||
return !hasBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
||||
return !showBorder ? 'text-neutral' : 'text-zinc-300 dark-theme:text-zinc-700'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,12 +4,24 @@ import { ref } from 'vue'
|
||||
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
|
||||
// SingleSelect already includes options prop, so no need to extend
|
||||
const meta: Meta<typeof SingleSelect> = {
|
||||
title: 'Components/Input/SingleSelect',
|
||||
component: SingleSelect,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' }
|
||||
label: { control: 'text' },
|
||||
options: { control: 'object' }
|
||||
},
|
||||
args: {
|
||||
label: 'Sorting Type',
|
||||
options: [
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Newest', value: 'newest' },
|
||||
{ name: 'Oldest', value: 'oldest' },
|
||||
{ name: 'A → Z', value: 'az' },
|
||||
{ name: 'Z → A', value: 'za' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +41,18 @@ export const Default: Story = {
|
||||
components: { SingleSelect },
|
||||
setup() {
|
||||
const selected = ref<string | null>(null)
|
||||
const options = sampleOptions
|
||||
const options = args.options || sampleOptions
|
||||
return { selected, options, args }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label || 'Sorting Type'" />
|
||||
<SingleSelect v-model="selected" :options="options" :label="args.label" />
|
||||
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
|
||||
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}),
|
||||
args: { label: 'Sorting Type' }
|
||||
})
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
|
||||
@@ -1,58 +1,73 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:placeholder="label"
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!--
|
||||
Note: We explicitly pass options here (not just via $attrs) because:
|
||||
1. Our custom value template needs options to look up labels from values
|
||||
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
|
||||
3. We need to maintain the icon slot functionality in the value template
|
||||
|
||||
option-label="name" is required because our option template directly accesses option.name
|
||||
-->
|
||||
<Select
|
||||
v-model="selectedItem"
|
||||
v-bind="$attrs"
|
||||
:options="options"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
>
|
||||
<!-- Trigger value -->
|
||||
<template #value="slotProps">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<slot name="icon" />
|
||||
<span
|
||||
v-if="slotProps.value !== null && slotProps.value !== undefined"
|
||||
class="text-zinc-700 dark-theme:text-gray-200"
|
||||
>
|
||||
{{ getLabel(slotProps.value) }}
|
||||
</span>
|
||||
<span v-else class="text-zinc-700 dark-theme:text-gray-200">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
<!-- Trigger caret -->
|
||||
<template #dropdownicon>
|
||||
<i-lucide:chevron-down
|
||||
class="text-base text-neutral-400 dark-theme:text-gray-300"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i-lucide:check
|
||||
v-if="selected"
|
||||
class="text-neutral-900 dark-theme:text-white"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Option row -->
|
||||
<template #option="{ option, selected }">
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<i-lucide:check
|
||||
v-if="selected"
|
||||
class="text-neutral-900 dark-theme:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select, { SelectPassThroughMethodOptions } from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { label, options } = defineProps<{
|
||||
label?: string
|
||||
options: {
|
||||
/**
|
||||
* Required for displaying the selected item's label.
|
||||
* Cannot rely on $attrs alone because we need to access options
|
||||
* in getLabel() to map values to their display names.
|
||||
*/
|
||||
options?: {
|
||||
name: string
|
||||
value: string
|
||||
}[]
|
||||
@@ -60,8 +75,14 @@ const { label, options } = defineProps<{
|
||||
|
||||
const selectedItem = defineModel<string | null>({ required: true })
|
||||
|
||||
/**
|
||||
* Maps a value to its display label.
|
||||
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
|
||||
* only the raw value. We need this to show the correct text when an item is selected.
|
||||
*/
|
||||
const getLabel = (val: string | null | undefined) => {
|
||||
if (val == null) return label ?? ''
|
||||
if (!options) return label ?? ''
|
||||
const found = options.find((o) => o.value === val)
|
||||
return found ? found.name : label ?? ''
|
||||
}
|
||||
@@ -77,7 +98,7 @@ const pt = computed(() => ({
|
||||
}: SelectPassThroughMethodOptions<{ name: string; value: string }>) => ({
|
||||
class: [
|
||||
// container
|
||||
'relative inline-flex w-full cursor-pointer select-none items-center',
|
||||
'relative inline-flex cursor-pointer select-none items-center',
|
||||
// trigger surface
|
||||
'rounded-md',
|
||||
'bg-transparent text-neutral dark-theme:text-white',
|
||||
@@ -89,7 +110,7 @@ const pt = computed(() => ({
|
||||
label: {
|
||||
class:
|
||||
// Align with MultiSelect labelContainer spacing
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-none'
|
||||
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 outline-hidden'
|
||||
},
|
||||
dropdown: {
|
||||
class:
|
||||
@@ -113,9 +134,11 @@ const pt = computed(() => ({
|
||||
class: [
|
||||
// Row layout
|
||||
'flex items-center justify-between gap-3 px-3 py-2',
|
||||
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
|
||||
'hover:bg-neutral-100/50 hover:dark-theme:bg-zinc-700/50',
|
||||
// Selected state + check icon
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected }
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
|
||||
// Add focus state for keyboard navigation
|
||||
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
|
||||
]
|
||||
}),
|
||||
optionLabel: {
|
||||
|
||||
@@ -160,6 +160,8 @@ const pickGpu = (value: typeof selected.value) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-tag {
|
||||
--p-tag-gap: 0.5rem;
|
||||
}
|
||||
@@ -184,10 +186,10 @@ div.selected {
|
||||
}
|
||||
|
||||
.gpu-button {
|
||||
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800 bg-opacity-50 hover:bg-opacity-75 transition-colors;
|
||||
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
|
||||
|
||||
&.selected {
|
||||
@apply opacity-100 bg-neutral-700 bg-opacity-50 hover:bg-opacity-60;
|
||||
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-12 left-2 flex flex-col pointer-events-auto z-20 bg-gray-700 bg-opacity-30 rounded-lg"
|
||||
class="absolute top-12 left-2 flex flex-col pointer-events-auto z-20 bg-gray-700/30 rounded-lg"
|
||||
>
|
||||
<div class="relative show-menu">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text bg-opacity-30"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<Button class="p-button-rounded p-button-text" @click="toggleMenu">
|
||||
<i class="pi pi-bars text-white text-lg" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="isMenuOpen"
|
||||
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
|
||||
class="absolute left-12 top-0 bg-black/50 rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
@@ -29,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeCategory" class="bg-gray-700 bg-opacity-30 rounded-lg">
|
||||
<div v-show="activeCategory" class="bg-gray-700/30 rounded-lg">
|
||||
<SceneControls
|
||||
v-if="activeCategory === 'scene'"
|
||||
ref="sceneControlsRef"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="modelLoading"
|
||||
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
class="absolute inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="spinner" />
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</Button>
|
||||
<div
|
||||
v-show="showFOV"
|
||||
class="absolute left-12 top-0 bg-black bg-opacity-50 p-4 rounded-lg shadow-lg"
|
||||
class="absolute left-12 top-0 bg-black/50 p-4 rounded-lg shadow-lg"
|
||||
style="width: 150px"
|
||||
>
|
||||
<Slider
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</Button>
|
||||
<div
|
||||
v-show="showExportFormats"
|
||||
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
|
||||
class="absolute left-12 top-0 bg-black/50 rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user