Compare commits

..

1 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
3230e600e6 fix: trigger widget callback when model is selected
Fixes model dropdown not properly updating when a model is selected from the model library or dropped onto canvas by calling the widget callback and forcing reactivity.
2025-12-10 02:56:48 +01:00
55 changed files with 344 additions and 890 deletions

View File

@@ -87,8 +87,6 @@
}
},
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},

View File

@@ -40,8 +40,7 @@
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step from 'primevue/step'
import type { StepPassThroughOptions } from 'primevue/step'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{

View File

@@ -155,14 +155,12 @@ export async function loadLocale(locale: string): Promise<void> {
}
// Only include English in the initial bundle
const enMessages = buildLocale(en, enNodes, enCommands, enSettings)
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)
}
// Type for locale messages - inferred from the English locale structure
type LocaleMessages = typeof enMessages
const messages: Record<string, LocaleMessages> = {
en: enMessages
}
type LocaleMessages = typeof messages.en
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2

View File

@@ -1,6 +1,5 @@
import { useTimeout } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { type Ref, computed, ref, watch } from 'vue'
/**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.

View File

@@ -29,8 +29,7 @@ import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import { useRoute } from 'vue-router'
import { getDialog } from '@/constants/desktopDialogs'
import type { DialogAction } from '@/constants/desktopDialogs'
import { type DialogAction, getDialog } from '@/constants/desktopDialogs'
import { t } from '@/i18n'
import { electronAPI } from '@/utils/envUtil'

View File

@@ -5,7 +5,7 @@
<img
class="sad-girl"
src="/assets/images/sad_girl.png"
:alt="$t('notSupported.illustrationAlt')"
alt="Sad girl illustration"
/>
<div class="no-drag sad-text flex items-center">

View File

@@ -126,20 +126,6 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -256,9 +242,6 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {

View File

@@ -137,13 +137,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
await this.page.keyboard.type(newName)
await this.page.keyboard.press('Enter')
// Wait for workflow service to finish renaming
await this.page.waitForFunction(
() => !window['app']?.extensionManager?.workflow?.isBusy,
undefined,
{ timeout: 3000 }
)
}
async insertWorkflow(locator: Locator) {

View File

@@ -92,26 +92,9 @@ export class Topbar {
)
// Wait for the dialog to close.
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
// Check if a confirmation dialog appeared (e.g., "Overwrite existing file?")
// If so, return early to let the test handle the confirmation
const confirmationDialog = this.page.locator(
'.p-dialog:has-text("Overwrite")'
)
if (await confirmationDialog.isVisible()) {
return
}
}
async openTopbarMenu() {
// If menu is already open, close it first to reset state
const isAlreadyOpen = await this.menuLocator.isVisible()
if (isAlreadyOpen) {
// Click outside the menu to close it properly
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
}
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
return this.menuLocator
@@ -179,36 +162,15 @@ export class Topbar {
await topLevelMenu.hover()
// Hover over top-level menu with retry logic for flaky submenu appearance
const submenu = this.getVisibleSubmenu()
try {
await submenu.waitFor({ state: 'visible', timeout: 1000 })
} catch {
// Click outside to reset, then reopen menu
await this.page.locator('body').click({ position: { x: 500, y: 300 } })
await this.menuLocator.waitFor({ state: 'hidden', timeout: 1000 })
await this.menuTrigger.click()
await this.menuLocator.waitFor({ state: 'visible' })
// Re-hover on top-level menu to trigger submenu
await topLevelMenu.hover()
await submenu.waitFor({ state: 'visible', timeout: 1000 })
}
let currentMenu = topLevelMenu
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = submenu
.locator(`.p-tieredmenu-item:has-text("${commandName}")`)
const menuItem = currentMenu
.locator(
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
)
.first()
await menuItem.waitFor({ state: 'visible' })
// For the last item, click it
if (i === path.length - 1) {
await menuItem.click()
return
}
// Otherwise, hover to open nested submenu
await menuItem.hover()
currentMenu = menuItem
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -340,11 +340,6 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.getGraphNodesCount()
// Get the bounding box of the canvas element
@@ -363,10 +358,6 @@ test.describe('Workflows sidebar', () => {
'#graph-canvas',
{ targetPosition }
)
// Wait for nodes to be inserted after drag-drop with retryable assertion
await expect
.poll(() => comfyPage.getGraphNodesCount(), { timeout: 3000 })
.toBe(nodeCount * 2)
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
})
})

View File

@@ -15,7 +15,6 @@ import {
parser as tseslintParser
} from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
import path from 'node:path'
const extraFileExtensions = ['.vue']
@@ -293,9 +292,6 @@ export default defineConfig([
'no-console': 'off'
}
},
// Turn off ESLint rules that are already handled by oxlint
...oxlint.buildFromOxlintConfigFile(
path.resolve(import.meta.dirname, '.oxlintrc.json')
)
...oxlint.buildFromOxlintConfigFile('./.oxlintrc.json')
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.1",
"version": "1.35.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -34,7 +34,6 @@
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
"lint": "oxlint src --type-aware && eslint src --cache",
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
@@ -47,7 +46,6 @@
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
"zipdist": "node scripts/zipdist.js",
"clean": "nx reset"
},

View File

@@ -7,12 +7,7 @@
<Skeleton width="8rem" height="2rem" />
</div>
<div v-else class="flex items-center gap-1">
<Tag
v-if="!showCreditsOnly"
severity="secondary"
rounded
class="p-1 text-amber-400"
>
<Tag severity="secondary" rounded class="p-1 text-amber-400">
<template #icon>
<i
:class="
@@ -23,9 +18,7 @@
/>
</template>
</Tag>
<div :class="textClass">
{{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
</div>
<div :class="textClass">{{ formattedBalance }}</div>
</div>
</template>
@@ -39,9 +32,8 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { textClass, showCreditsOnly } = defineProps<{
const { textClass } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
@@ -58,14 +50,4 @@ const formattedBalance = computed(() => {
})
return `${amount} ${t('credits.credits')}`
})
const formattedCreditsOnly = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
})
return amount
})
</script>

View File

@@ -1,43 +1,35 @@
<template>
<!-- New Credits Design (default) -->
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
<div
v-if="useNewDesign"
class="flex w-96 flex-col gap-8 p-8 bg-node-component-surface rounded-2xl border border-border-primary"
>
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-white m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
<h1 class="text-2xl font-semibold text-foreground-primary m-0">
{{ $t('credits.topUp.addMoreCredits') }}
</h1>
<div v-if="isInsufficientCredits" class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0 w-96">
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>
</div>
<div v-else class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
<p class="text-sm text-foreground-secondary m-0">
{{ $t('credits.topUp.creditsDescription') }}
</p>
</div>
<!-- Current Balance Section -->
<div class="flex flex-col gap-4">
<div class="flex items-baseline gap-2">
<UserCredit text-class="text-3xl font-bold" show-credits-only />
<span class="text-sm text-muted-foreground">{{
<UserCredit text-class="text-3xl font-bold" />
<span class="text-sm text-foreground-secondary">{{
$t('credits.creditsAvailable')
}}</span>
</div>
<div v-if="formattedRenewalDate" class="text-sm text-muted-foreground">
{{ $t('credits.refreshes', { date: formattedRenewalDate }) }}
<div v-if="refreshDate" class="text-sm text-foreground-secondary">
{{ $t('credits.refreshes', { date: refreshDate }) }}
</div>
</div>
<!-- Credit Options Section -->
<div class="flex flex-col gap-4">
<span class="text-sm text-muted-foreground">
<span class="text-sm text-foreground-secondary">
{{ $t('credits.topUp.howManyCredits') }}
</span>
<div class="flex flex-col gap-2">
@@ -50,7 +42,7 @@
@select="selectedCredits = option.credits"
/>
</div>
<div class="text-xs text-muted-foreground w-96">
<div class="text-xs text-foreground-secondary">
{{ $t('credits.topUp.templateNote') }}
</div>
</div>
@@ -61,8 +53,7 @@
:loading="loading"
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-white' } }"
class="w-full"
@click="handleBuy"
/>
</div>
@@ -130,7 +121,6 @@ import {
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import CreditTopUpOption from './credit/CreditTopUpOption.vue'
@@ -142,17 +132,18 @@ interface CreditOption {
}
const {
refreshDate,
isInsufficientCredits = false,
amountOptions = [5, 10, 20, 50],
preselectedAmountOption = 10
} = defineProps<{
refreshDate?: string
isInsufficientCredits?: boolean
amountOptions?: number[]
preselectedAmountOption?: number
}>()
const { flags } = useFeatureFlags()
const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
@@ -166,20 +157,20 @@ const loading = ref(false)
const creditOptions: CreditOption[] = [
{
credits: 1055, // $5.00
description: t('credits.topUp.videosEstimate', { count: 41 })
credits: 1000,
description: t('credits.topUp.videosEstimate', { count: 100 })
},
{
credits: 2110, // $10.00
description: t('credits.topUp.videosEstimate', { count: 82 })
credits: 5000,
description: t('credits.topUp.videosEstimate', { count: 500 })
},
{
credits: 4220, // $20.00
description: t('credits.topUp.videosEstimate', { count: 184 })
credits: 10000,
description: t('credits.topUp.videosEstimate', { count: 1000 })
},
{
credits: 10550, // $50.00
description: t('credits.topUp.videosEstimate', { count: 412 })
credits: 20000,
description: t('credits.topUp.videosEstimate', { count: 2000 })
}
]

View File

@@ -3,17 +3,19 @@
class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all duration-200"
:class="[
selected
? 'bg-secondary-background border-2 border-border-default'
: 'bg-component-node-disabled hover:bg-secondary-background border-2 border-transparent'
? 'bg-surface-secondary border-2 border-primary'
: 'bg-surface-tertiary border border-border-primary hover:bg-surface-secondary'
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-white">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-white">
{{ description }}
</span>
<div class="flex flex-col">
<span class="text-base font-medium text-foreground-primary">
{{ formattedCredits }}
</span>
<span class="text-sm text-foreground-secondary">
{{ description }}
</span>
</div>
</div>
</template>
@@ -36,10 +38,6 @@ defineEmits<{
const { locale } = useI18n()
const formattedCredits = computed(() => {
return formatCredits({
value: credits,
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
return formatCredits({ value: credits, locale: locale.value })
})
</script>

View File

@@ -1,6 +1,5 @@
<template>
<TabPanel value="Credits" class="credits-container h-full">
<!-- Legacy Design -->
<div class="flex h-full flex-col">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}

View File

@@ -150,6 +150,13 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (widget) {
// @ts-expect-error fixme ts strict error
widget.value = model.file_name
if (model) {
widget.callback?.(model!.file_name)
// Force the reactive widgets array to update
if (node.widgets) {
node.widgets = [...node.widgets]
}
}
}
}
} else {

View File

@@ -1,10 +1,10 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CurrentUserPopover from './CurrentUserPopover.vue'
@@ -74,9 +74,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),
balance: { amount_micros: 100_000 }, // 100,000 cents = ~211,000 credits
isFetchingBalance: false
.mockResolvedValue({ Authorization: 'Bearer mock-token' })
}))
}))
@@ -109,39 +107,6 @@ vi.mock('@/components/common/UserCredit.vue', () => ({
}
}))
// Mock formatCreditsFromCents
vi.mock('@/base/credits/comfyCredits', () => ({
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))
// Mock useExternalLink
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
buildDocsUrl: vi.fn((path) => `https://docs.comfy.org${path}`)
}))
}))
// Mock useFeatureFlags
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: {
subscriptionTiersEnabled: true
}
}))
}))
// Mock useTelemetry
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackAddApiCreditButtonClicked: vi.fn()
}))
}))
// Mock isCloud
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
default: {
name: 'SubscribeButtonMock',
@@ -180,37 +145,27 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('calls formatCreditsFromCents with correct parameters and displays formatted credits', () => {
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
expect(formatCreditsFromCents).toHaveBeenCalledWith({
cents: 100_000,
locale: 'en',
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
// Verify the formatted credit string (1000) is rendered in the DOM
expect(wrapper.text()).toContain('1000')
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
})
it('renders logout menu item with correct text', () => {
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
expect(wrapper.text()).toContain('Log Out')
})
// Find all buttons and get the settings button (third button)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[2]
it('opens user settings and emits close event when settings item is clicked', async () => {
const wrapper = mountComponent()
const settingsItem = wrapper.find('[data-testid="user-settings-menu-item"]')
expect(settingsItem.exists()).toBe(true)
await settingsItem.trigger('click')
// Click the settings button
await settingsButton.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
@@ -220,13 +175,15 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout item is clicked', async () => {
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
const logoutItem = wrapper.find('[data-testid="logout-menu-item"]')
expect(logoutItem.exists()).toBe(true)
// Find all buttons and get the logout button (last button)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[4]
await logoutItem.trigger('click')
// Click the logout button
await logoutButton.trigger('click')
// Verify handleSignOut was called
expect(mockHandleSignOut).toHaveBeenCalled()
@@ -236,15 +193,15 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when partner nodes item is clicked', async () => {
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
const partnerNodesItem = wrapper.find(
'[data-testid="partner-nodes-menu-item"]'
)
expect(partnerNodesItem.exists()).toBe(true)
// Find all buttons and get the Partner Nodes info button (first one)
const buttons = wrapper.findAllComponents(Button)
const partnerNodesButton = buttons[0]
await partnerNodesItem.trigger('click')
// Click the Partner Nodes button
await partnerNodesButton.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
@@ -260,9 +217,11 @@ describe('CurrentUserPopover', () => {
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
const topUpButton = wrapper.find('[data-testid="add-credits-button"]')
expect(topUpButton.exists()).toBe(true)
// Find all buttons and get the top-up button (second one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[1]
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called

View File

@@ -1,131 +1,120 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<div class="current-user-popover w-72">
<!-- User Info Section -->
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<div class="p-3">
<div class="flex flex-col items-center">
<UserAvatar
class="mb-3"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-lg font-semibold">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<div v-if="isActiveSubscription" class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<UserCredit text-class="text-2xl" />
<div
v-if="flags.subscriptionTiersEnabled"
class="flex items-center gap-2"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] text-muted cursor-help text-xs"
/>
<span class="text-xs text-muted">{{
$t('credits.unified.message')
}}</span>
</div>
<Button
:label="$t('subscription.partnerNodesCredits')"
severity="secondary"
text
size="small"
class="pl-6 p-0 h-auto justify-start"
:pt="{
root: {
class: 'hover:bg-transparent active:bg-transparent'
}
}"
@click="handleOpenPartnerNodesInfo"
/>
</div>
<Button
:label="$t('subscription.addCredits')"
:label="$t('credits.topUp.topUp')"
severity="secondary"
size="small"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
/>
</div>
<SubscribeButton
v-else
class="mx-4"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<!-- Credits info row -->
<div
v-if="flags.subscriptionTiersEnabled && isActiveSubscription"
class="flex items-center gap-2 px-4 py-0"
>
<i
v-tooltip="{
value: $t('credits.unified.tooltip'),
showDelay: 300,
hideDelay: 300
}"
class="icon-[lucide--circle-help] cursor-help text-xs text-muted-foreground"
/>
<span class="text-sm text-muted-foreground">{{
$t('credits.unified.message')
}}</span>
</div>
<Divider class="my-2" />
<Divider class="my-2 mx-0" />
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="plan-credits-menu-item"
@click="handleOpenPlanAndCreditsSettings"
>
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t(planSettingsLabel)
}}</span>
</div>
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
<Button
class="justify-start"
:label="$t('userSettings.title')"
icon="pi pi-cog"
text
fluid
severity="secondary"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('userSettings.title')
}}</span>
</div>
/>
<Divider class="my-2 mx-0" />
<Button
v-if="isActiveSubscription"
class="justify-start"
:label="$t(planSettingsLabel)"
icon="pi pi-receipt"
text
fluid
severity="secondary"
@click="handleOpenPlanAndCreditsSettings"
/>
<div
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
@click="handleLogout"
>
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
<span class="text-sm text-base-foreground flex-1">{{
$t('auth.signOut.signOut')
}}</span>
</div>
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { onMounted } from 'vue'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
@@ -135,7 +124,6 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const emit = defineEmits<{
close: []
@@ -150,24 +138,9 @@ const planSettingsLabel = isCloud
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
const formattedBalance = computed(() => {
// Backend returns cents despite the *_micros naming convention.
const cents = authStore.balance?.amount_micros ?? 0
return formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 2
}
})
})
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')

View File

@@ -69,7 +69,6 @@ export interface VueNodeData {
}
color?: string
bgcolor?: string
shape?: number
}
export interface GraphNodeManager {
@@ -235,8 +234,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
shape: node.shape
bgcolor: node.bgcolor || undefined
}
}
@@ -573,15 +571,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
}
}
},

View File

@@ -73,6 +73,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
)
if (widget) {
widget.value = model.file_name
widget.callback?.(model.file_name)
// Force the reactive widgets array to update
if (targetGraphNode.widgets) {
targetGraphNode.widgets = [...targetGraphNode.widgets]
}
}
}
} else if (node.data instanceof ComfyWorkflow) {

View File

@@ -495,7 +495,6 @@ export class LGraphNode
}
set shape(v: RenderShape | 'default' | 'box' | 'round' | 'circle' | 'card') {
const oldValue = this._shape
switch (v) {
case 'default':
this._shape = undefined
@@ -515,14 +514,6 @@ export class LGraphNode
default:
this._shape = v
}
if (oldValue !== this._shape) {
this.graph?.trigger('node:property:changed', {
nodeId: this.id,
property: 'shape',
oldValue,
newValue: this._shape
})
}
}
/**
@@ -860,12 +851,13 @@ export class LGraphNode
}
if (info.widgets_values) {
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
if (i >= info.widgets_values.length) break
widget.value = info.widgets_values[i++]
}
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
}
}

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "دليل مستخدم سطح المكتب",
"docs": "الوثائق",
"github": "GitHub",
"helpFeedback": "المساعدة والتعليقات",
"loadingReleases": "جارٍ تحميل الإصدارات...",
"managerExtension": "المدير الموسع",
"more": "المزيد...",

View File

@@ -1,40 +1,4 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Check for Updates"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Open Custom Nodes Folder"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Open Inputs Folder"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Open Logs Folder"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Open extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Open Models Folder"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Open Outputs Folder"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Open DevTools"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Desktop User Guide"
},
"Comfy-Desktop_Quit": {
"label": "Quit"
},
"Comfy-Desktop_Reinstall": {
"label": "Reinstall"
},
"Comfy-Desktop_Restart": {
"label": "Restart"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Open 3D Viewer (Beta) for Selected Node"
},

View File

@@ -203,7 +203,7 @@
"relativeTime": {
"now": "now",
"yearsAgo": "{count}y ago",
"monthsAgo": "{count}mo ago",
"monthsAgo": "{count}mo ago",
"weeksAgo": "{count}w ago",
"daysAgo": "{count}d ago",
"hoursAgo": "{count}h ago",
@@ -475,7 +475,6 @@
"notSupported": {
"title": "Your device is not supported",
"message": "Only following devices are supported:",
"illustrationAlt": "Sad girl illustration",
"learnMore": "Learn More",
"reportIssue": "Report Issue",
"supportedDevices": {
@@ -1054,18 +1053,6 @@
"Edit": "Edit",
"View": "View",
"Help": "Help",
"Check for Updates": "Check for Updates",
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
"Open Inputs Folder": "Open Inputs Folder",
"Open Logs Folder": "Open Logs Folder",
"Open extra_model_paths_yaml": "Open extra_model_paths.yaml",
"Open Models Folder": "Open Models Folder",
"Open Outputs Folder": "Open Outputs Folder",
"Open DevTools": "Open DevTools",
"Desktop User Guide": "Desktop User Guide",
"Quit": "Quit",
"Reinstall": "Reinstall",
"Restart": "Restart",
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
"Experimental: Browse Model Assets": "Experimental: Browse Model Assets",
"Browse Templates": "Browse Templates",
@@ -1860,8 +1847,6 @@
"seeDetails": "See details",
"topUp": "Top Up",
"addMoreCredits": "Add more credits",
"addMoreCreditsToRun": "Add more credits to run",
"insufficientWorkflowMessage": "You don't have enough credits to run this workflow.",
"creditsDescription": "Credits are used to run workflows or partner nodes.",
"howManyCredits": "How many credits would you like to add?",
"videosEstimate": "~{count} videos*",
@@ -1884,7 +1869,7 @@
"accountInitialized": "Account initialized",
"unified": {
"message": "Credits have been unified",
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits."
"tooltip": "We've unified payments across Comfy. Everything now runs on Comfy Credits:\n- Partner Nodes (formerly API nodes)\n- Cloud workflows\n\nYour existing Partner node balance has been converted into credits.\nLearn more here."
}
},
"subscription": {
@@ -1893,7 +1878,7 @@
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "/ month",
"perMonth": "USD / month",
"renewsDate": "Renews {date}",
"refreshesOn": "Refreshes to ${monthlyCreditBonusUsd} on {date}",
"expiresDate": "Expires {date}",
@@ -1908,10 +1893,6 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsRemainingThisMonth": "Credits remaining for this month",
"creditsYouveAdded": "Credits you've added",
"monthlyCreditsInfo": "These credits refresh monthly and don't roll over",
"viewMoreDetailsPlans": "View more details about plans & pricing",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
@@ -1922,55 +1903,6 @@
"benefit1": "$10 in monthly credits for Partner Nodes — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"tiers": {
"founder": {
"name": "Founder's Edition Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "5,460 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever"
}
},
"standard": {
"name": "Standard",
"price": "20.00",
"benefits": {
"monthlyCredits": "4,200 monthly credits",
"maxDuration": "30 min max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "164"
}
},
"creator": {
"name": "Creator",
"price": "35.00",
"benefits": {
"monthlyCredits": "7,400",
"monthlyCreditsLabel": "monthly credits",
"maxDuration": "30 min",
"maxDurationLabel": "max duration of each workflow run",
"gpuLabel": "RTX 6000 Pro (96GB VRAM)",
"addCreditsLabel": "Add more credits whenever",
"customLoRAsLabel": "Import your own LoRAs"
}
},
"pro": {
"name": "Pro",
"price": "100.00",
"benefits": {
"monthlyCredits": "21,100 monthly credits",
"maxDuration": "1 hr max duration of each workflow run",
"rtx6000": "RTX 6000 Pro (96GB VRAM)",
"addCredits": "Add more credits whenever",
"customLoRAs": "Import your own LoRAs",
"videoEstimate": "821"
}
}
},
"required": {
"title": "Subscribe to",
"waitingForSubscription": "Complete your subscription in the new tab. We'll automatically detect when you're done!",
@@ -1990,10 +1922,10 @@
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
"viewEnterprise": "view enterprise",
"partnerNodesCredits": "Partner nodes pricing"
"partnerNodesCredits": "Partner Nodes pricing table"
},
"userSettings": {
"title": "My Account Settings",
"title": "User Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
@@ -2392,4 +2324,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -1,30 +1,4 @@
{
"Comfy-Desktop_AutoUpdate": {
"name": "Automatically check for updates"
},
"Comfy-Desktop_SendStatistics": {
"name": "Send anonymous usage metrics"
},
"Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror",
"tooltip": "Default pip install mirror"
},
"Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
},
"Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror",
"tooltip": "Pip install mirror for pytorch"
},
"Comfy-Desktop_WindowStyle": {
"name": "Window Style",
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
"options": {
"default": "default",
"custom": "custom"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "Guía de usuario de escritorio",
"docs": "Documentación",
"github": "Github",
"helpFeedback": "Ayuda y comentarios",
"loadingReleases": "Cargando versiones...",
"managerExtension": "Extensión del Administrador",
"more": "Más...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "Guide utilisateur de bureau",
"docs": "Docs",
"github": "Github",
"helpFeedback": "Aide & Retour",
"loadingReleases": "Chargement des versions...",
"managerExtension": "Manager Extension",
"more": "Plus...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "デスクトップユーザーガイド",
"docs": "ドキュメント",
"github": "Github",
"helpFeedback": "ヘルプとフィードバック",
"loadingReleases": "リリースを読み込み中...",
"managerExtension": "Manager Extension",
"more": "もっと見る...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "데스크톱 사용자 가이드",
"docs": "문서",
"github": "Github",
"helpFeedback": "도움말 및 피드백",
"loadingReleases": "릴리즈 불러오는 중...",
"managerExtension": "관리자 확장",
"more": "더보기...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "Руководство пользователя для Desktop",
"docs": "Документация",
"github": "Github",
"helpFeedback": "Помощь и обратная связь",
"loadingReleases": "Загрузка релизов...",
"managerExtension": "Расширение менеджера",
"more": "Ещё...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "Masaüstü Kullanıcı Kılavuzu",
"docs": "Belgeler",
"github": "Github",
"helpFeedback": "Yardım ve Geri Bildirim",
"loadingReleases": "Sürümler yükleniyor...",
"managerExtension": "Yönetici Uzantısı",
"more": "Daha Fazla...",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "桌面版使用指南",
"docs": "文件",
"github": "Github",
"helpFeedback": "幫助與回饋",
"loadingReleases": "正在載入版本資訊…",
"managerExtension": "管理器擴充功能",
"more": "更多…",

View File

@@ -761,6 +761,7 @@
"desktopUserGuide": "桌面端用户指南",
"docs": "文档",
"github": "Github",
"helpFeedback": "帮助与反馈",
"loadingReleases": "加载发布信息...",
"managerExtension": "管理扩展",
"more": "更多...",

View File

@@ -1,22 +1,13 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<div
class="flex items-center gap-3 bg-secondary-background p-3 rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<p class="m-0 text-base-foreground">
{{ metadata?.filename || metadata?.name }}
</p>
</div>
<p class="mt-0 text-base-foreground rounded-lg">
{{ metadata?.filename || metadata?.name }}
</p>
</div>
<!-- Model Type Selection -->
@@ -49,8 +40,7 @@ import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
metadata?: AssetMetadata
previewImage?: string
metadata: AssetMetadata | null
}>()
const modelValue = defineModel<string | undefined>()

View File

@@ -14,7 +14,6 @@
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
:preview-image="wizardData.previewImage"
/>
<!-- Step 3: Upload Progress -->
@@ -24,7 +23,6 @@
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
:preview-image="wizardData.previewImage"
/>
<!-- Navigation Footer -->

View File

@@ -25,14 +25,8 @@
</p>
<div
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
@@ -69,8 +63,7 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string
previewImage?: string
metadata: AssetMetadata | null
modelType: string | undefined
}>()
</script>

View File

@@ -9,10 +9,9 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface WizardData {
url: string
metadata?: AssetMetadata
metadata: AssetMetadata | null
name: string
tags: string[]
previewImage?: string
}
interface ModelTypeOption {
@@ -31,6 +30,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
@@ -91,9 +91,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Store preview image if available
wizardData.value.previewImage = metadata.preview_image
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
@@ -137,34 +134,6 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
let previewId: string | undefined
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
@@ -173,8 +142,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
preview_id: previewId
}
})
uploadStatus.value = 'success'

View File

@@ -54,7 +54,6 @@ const zAssetMetadata = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
preview_image: z.string().optional(),
validation: zValidationResult.optional()
})

View File

@@ -392,59 +392,6 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset from base64 data
*
* @param params - Upload parameters
* @param params.data - Base64 data URL (e.g., "data:image/png;base64,...")
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromBase64(params: {
data: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
}): Promise<AssetItem & { created_new: boolean }> {
// Validate that data is a data URL
if (!params.data || !params.data.startsWith('data:')) {
throw new Error(
'Invalid data URL: expected a string starting with "data:"'
)
}
// Convert base64 data URL to Blob
const blob = await fetch(params.data).then((r) => r.blob())
// Create FormData and append the blob
const formData = new FormData()
formData.append('file', blob, params.name)
if (params.tags) {
formData.append('tags', JSON.stringify(params.tags))
}
if (params.user_metadata) {
formData.append('user_metadata', JSON.stringify(params.user_metadata))
}
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
body: formData
})
if (!res.ok) {
throw new Error(
`Failed to upload asset from base64: ${res.status} ${res.statusText}`
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -455,8 +402,7 @@ function createAssetService() {
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64
uploadAssetFromUrl
}
}

View File

@@ -1,19 +1,42 @@
<template>
<div class="flex flex-col items-start gap-0 self-stretch">
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-center gap-2 py-2">
<i class="pi pi-check text-xs text-text-primary" />
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
<Button
:label="$t('subscription.viewMoreDetails')"
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
@click="handleViewMoreDetails"
/>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import Button from 'primevue/button'
const handleViewMoreDetails = () => {
window.open('https://www.comfy.org/cloud/pricing', '_blank')
}
</script>

View File

@@ -19,12 +19,9 @@
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ tierName }}
</div>
<div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
@@ -62,7 +59,7 @@
class: 'text-text-primary'
}
}"
@click="showSubscriptionDialog"
@click="manageSubscription"
/>
<SubscribeButton
v-else
@@ -78,6 +75,17 @@
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
@@ -104,7 +112,7 @@
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
<div class="text-sm text-text-secondary">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
@@ -125,18 +133,12 @@
width="3rem"
height="1rem"
/>
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
<div v-else class="text-sm text-text-secondary font-bold">
{{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsRemainingThisMonth')"
>
{{ $t('subscription.creditsRemainingThisMonth') }}
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
</div>
<Button
v-tooltip="refreshTooltip"
@@ -144,7 +146,7 @@
text
rounded
size="small"
class="h-4 w-4 shrink-0"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -159,18 +161,12 @@
width="3rem"
height="1rem"
/>
<div
v-else
class="text-sm font-bold w-12 shrink-0 text-left text-muted"
>
<div v-else class="text-sm text-text-secondary font-bold">
{{ prepaidCredits }}
</div>
<div class="flex items-center gap-1 min-w-0">
<div
class="text-sm truncate text-muted"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
@@ -178,7 +174,7 @@
text
rounded
size="small"
class="h-4 w-4 shrink-0"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
@@ -194,7 +190,8 @@
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
@@ -219,47 +216,14 @@
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="text-sm text-text-primary">
<div class="text-sm">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
<SubscriptionBenefits />
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<div
@@ -343,79 +307,28 @@
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { cn } from '@/utils/tailwindUtil'
const { buildDocsUrl } = useExternalLink()
const { t } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleInvoiceHistory
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
// Tier data - hardcoded for Creator tier as requested
const tierName = computed(() => t('subscription.tiers.creator.name'))
const tierPrice = computed(() => t('subscription.tiers.creator.price'))
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed(() => {
const baseBenefits: Benefit[] = [
{
key: 'monthlyCredits',
type: 'metric',
value: t('subscription.tiers.creator.benefits.monthlyCredits'),
label: t('subscription.tiers.creator.benefits.monthlyCreditsLabel')
},
{
key: 'maxDuration',
type: 'metric',
value: t('subscription.tiers.creator.benefits.maxDuration'),
label: t('subscription.tiers.creator.benefits.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.tiers.creator.benefits.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.tiers.creator.benefits.addCreditsLabel')
},
{
key: 'customLoRAs',
type: 'feature',
label: t('subscription.tiers.creator.benefits.customLoRAsLabel')
}
]
return baseBenefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()

View File

@@ -9,20 +9,16 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const { locale } = useI18n()
const { t, locale } = useI18n()
const formatBalance = (maybeCents?: number) => {
// Backend returns cents despite the *_micros naming convention.
const cents = maybeCents ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value,
numberOptions: {
minimumFractionDigits: 0,
maximumFractionDigits: 0
}
locale: locale.value
})
return amount
return `${amount} ${t('credits.credits')}`
}
const totalCredits = computed(() =>

View File

@@ -85,7 +85,7 @@ export function useSettingUI(
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/LegacyCreditsPanel.vue')
() => import('@/components/dialog/content/setting/CreditsPanel.vue')
)
}

View File

@@ -29,16 +29,24 @@
</p>
</div>
<!-- Loading State -->
<div v-if="showLoader && !imageError" class="size-full">
<Skeleton border-radius="5px" width="100%" height="100%" />
</div>
<Skeleton
v-if="isLoading && !imageError"
border-radius="5px"
width="100%"
height="100%"
/>
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none"
:class="
cn(
'block size-full object-contain pointer-events-none',
isLoading && 'invisible'
)
"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -83,7 +91,7 @@
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="showLoader" class="text-base-foreground">
<span v-else-if="isLoading" class="text-base-foreground">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -109,7 +117,6 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useToast } from 'primevue'
import Skeleton from 'primevue/skeleton'
import { computed, ref, watch } from 'vue'
@@ -119,6 +126,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -141,19 +149,10 @@ const currentIndex = ref(0)
const isHovered = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const isLoading = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
showLoader.value = true
},
250,
// Make sure it doesnt run on component mount
{ immediate: false }
)
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
@@ -170,19 +169,17 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
isLoading.value = newUrls.length > 0
},
{ deep: true, immediate: true }
{ deep: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
stopDelayedLoader()
showLoader.value = false
isLoading.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
@@ -190,8 +187,7 @@ const handleImageLoad = (event: Event) => {
}
const handleImageError = () => {
stopDelayedLoader()
showLoader.value = false
isLoading.value = false
imageError.value = true
actualDimensions.value = null
}
@@ -234,7 +230,8 @@ const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
startDelayedLoader()
actualDimensions.value = null
isLoading.value = true
imageError.value = false
}
}

View File

@@ -11,10 +11,9 @@
'bg-component-node-background lg-node absolute pb-1',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
shapeClass,
'touch-none flex flex-col',
'rounded-2xl touch-none flex flex-col',
'border-1 border-solid border-component-node-border',
// hover (only when node should handle events)1
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2',
@@ -22,9 +21,9 @@
outlineClass,
cursorClass,
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0`]:
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
@@ -141,8 +140,7 @@ import { st } from '@/i18n'
import {
LGraphCanvas,
LGraphEventMode,
LiteGraph,
RenderShape
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -385,28 +383,6 @@ const cursorClass = computed(() => {
)
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
default:
return 'before:rounded-2xl'
}
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -6,9 +6,9 @@
v-else
:class="
cn(
'lg-node-header py-2 pl-2 pr-3 text-sm w-full min-w-0',
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
headerShapeClass
collapsed && 'rounded-2xl'
)
"
:style="headerStyle"
@@ -109,7 +109,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
@@ -216,28 +216,6 @@ const nodeBadges = computed<NodeBadgeProps[]>(() =>
)
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
default:
return 'rounded-2xl'
}
}
switch (nodeData?.shape) {
case RenderShape.BOX:
return 'rounded-t-none'
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-tr-none'
default:
return 'rounded-t-2xl'
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false

View File

@@ -1,7 +1,6 @@
import { storeToRefs } from 'pinia'
import { toValue } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -14,14 +13,13 @@ import type {
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import { createSharedComposable } from '@vueuse/core'
export const useNodeDrag = createSharedComposable(useNodeDragIndividual)
function useNodeDragIndividual() {
const mutations = useLayoutMutations()
const { selectedNodeIds, selectedItems } = storeToRefs(useCanvasStore())
const { selectedNodeIds } = storeToRefs(useCanvasStore())
// Get transform utilities from TransformPane if available
const transformState = useTransformState()
@@ -39,10 +37,6 @@ function useNodeDragIndividual() {
let rafId: number | null = null
let stopShiftSync: (() => void) | null = null
// For groups: track the last applied canvas delta to compute frame delta
let lastCanvasDelta: Point | null = null
let selectedGroups: LGraphGroup[] | null = null
function startDrag(event: PointerEvent, nodeId: NodeId) {
const layout = toValue(layoutStore.getNodeLayoutRef(nodeId))
if (!layout) return
@@ -73,10 +67,6 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
mutations.setSource(LayoutSource.Vue)
}
@@ -137,21 +127,6 @@ function useNodeDragIndividual() {
mutations.moveNode(otherNodeId, newOtherPosition)
}
}
// Move selected groups using frame delta (difference from last frame)
// This matches LiteGraph's behavior which uses delta-based movement
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
const frameDelta = {
x: canvasDelta.x - lastCanvasDelta.x,
y: canvasDelta.y - lastCanvasDelta.y
}
for (const group of selectedGroups) {
group.move(frameDelta.x, frameDelta.y, true)
}
}
lastCanvasDelta = canvasDelta
})
}
@@ -220,8 +195,6 @@ function useNodeDragIndividual() {
dragStartPos = null
dragStartMouse = null
otherSelectedNodesStartPositions = null
selectedGroups = null
lastCanvasDelta = null
// Stop tracking shift key state
stopShiftSync?.()

View File

@@ -386,12 +386,11 @@ export const useDialogService = () => {
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
headerComponent: ComfyOrgHeader,
props: options,
dialogComponentProps: {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0!' }
header: { class: 'p-3!' }
}
}
})

View File

@@ -32,12 +32,16 @@ describe('CreditTopUpOption', () => {
expect(wrapper.text()).toContain('~500 videos*')
})
it('applies selected styling when selected', () => {
const wrapper = mountOption({ selected: true })
expect(wrapper.find('div').classes()).toContain('bg-surface-secondary')
expect(wrapper.find('div').classes()).toContain('border-primary')
})
it('applies unselected styling when not selected', () => {
const wrapper = mountOption({ selected: false })
expect(wrapper.find('div').classes()).toContain(
'bg-component-node-disabled'
)
expect(wrapper.find('div').classes()).toContain('border-transparent')
expect(wrapper.find('div').classes()).toContain('bg-surface-tertiary')
expect(wrapper.find('div').classes()).toContain('border-border-primary')
})
it('emits select event when clicked', async () => {

View File

@@ -84,22 +84,22 @@ describe('useSubscriptionCredits', () => {
})
describe('totalCredits', () => {
it('should return "0" when balance is null', () => {
it('should return "0.00 Credits" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
expect(totalCredits.value).toBe('0.00 Credits')
})
it('should return "0" when amount_micros is missing', () => {
it('should return "0.00 Credits" when amount_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0')
expect(totalCredits.value).toBe('0.00 Credits')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('211')
expect(totalCredits.value).toBe('211.00 Credits')
})
it('should handle formatting errors by throwing', async () => {
@@ -116,10 +116,10 @@ describe('useSubscriptionCredits', () => {
})
describe('monthlyBonusCredits', () => {
it('should return "0" when cloud_credit_balance_micros is missing', () => {
it('should return "0.00 Credits" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0')
expect(monthlyBonusCredits.value).toBe('0.00 Credits')
})
it('should format cloud_credit_balance_micros correctly', () => {
@@ -127,15 +127,15 @@ describe('useSubscriptionCredits', () => {
cloud_credit_balance_micros: 200
} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('422')
expect(monthlyBonusCredits.value).toBe('422.00 Credits')
})
})
describe('prepaidCredits', () => {
it('should return "0" when prepaid_balance_micros is missing', () => {
it('should return "0.00 Credits" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0')
expect(prepaidCredits.value).toBe('0.00 Credits')
})
it('should format prepaid_balance_micros correctly', () => {
@@ -143,7 +143,7 @@ describe('useSubscriptionCredits', () => {
prepaid_balance_micros: 300
} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('633')
expect(prepaidCredits.value).toBe('633.00 Credits')
})
})

View File

@@ -208,6 +208,11 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
@@ -260,6 +265,11 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)

View File

@@ -6,6 +6,7 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
"DOM",
"DOM.Iterable"
],