Compare commits
28 Commits
test/node-
...
fix/simpli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20bc16d0cb | ||
|
|
ad6f856a31 | ||
|
|
7ed71c7769 | ||
|
|
442eff1094 | ||
|
|
dd4d36d459 | ||
|
|
69c8c84aef | ||
|
|
c5431de123 | ||
|
|
030d4fd4d5 | ||
|
|
473fa609e3 | ||
|
|
a2c8324c0a | ||
|
|
d9ce4ff5e0 | ||
|
|
e7932f2fc2 | ||
|
|
f53b0879ed | ||
|
|
5441f70cd5 | ||
|
|
0e3314bbd3 | ||
|
|
8f301ec94b | ||
|
|
17c1b1f989 | ||
|
|
a4b725b85e | ||
|
|
8283438ee6 | ||
|
|
d05e4eac58 | ||
|
|
7f509cc018 | ||
|
|
23c8757447 | ||
|
|
7d3d00858a | ||
|
|
478cfc0b5e | ||
|
|
90a701dd67 | ||
|
|
7f81e1afac | ||
|
|
e26283e754 | ||
|
|
1ca6e57ac4 |
200
.claude/skills/writing-playwright-tests/SKILL.md
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: writing-playwright-tests
|
||||
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
|
||||
---
|
||||
|
||||
# Writing Playwright Tests for ComfyUI_frontend
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
|
||||
|
||||
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
|
||||
|
||||
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
|
||||
- Assets live in `browser_tests/assets/`
|
||||
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
|
||||
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
|
||||
|
||||
## Vue Nodes vs LiteGraph: Decision Guide
|
||||
|
||||
Choose based on **what you're testing**, not personal preference:
|
||||
|
||||
| Testing... | Use | Why |
|
||||
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
|
||||
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
|
||||
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
|
||||
| Both in same test | Pick primary, minimize switching | Avoid confusion |
|
||||
|
||||
**Vue Nodes requires explicit opt-in:**
|
||||
|
||||
```typescript
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
```
|
||||
|
||||
**Vue Node state uses CSS classes:**
|
||||
|
||||
```typescript
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ---------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
|
||||
## Test Tags
|
||||
|
||||
Add appropriate tags to every test:
|
||||
|
||||
| Tag | When to Use |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| `@smoke` | Quick essential tests |
|
||||
| `@slow` | Tests > 10 seconds |
|
||||
| `@screenshot` | Visual regression tests |
|
||||
| `@canvas` | Canvas interactions |
|
||||
| `@node` | Node-related |
|
||||
| `@widget` | Widget-related |
|
||||
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
|
||||
| `@2x` | HiDPI tests (runs on 2x scale project) |
|
||||
|
||||
```typescript
|
||||
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
```
|
||||
|
||||
## Retry Patterns
|
||||
|
||||
**Never use `waitForTimeout`** - it's always wrong.
|
||||
|
||||
| Pattern | Use Case |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
|
||||
| `expect.poll()` | Single value polling |
|
||||
| `expect().toPass()` | Multiple assertions that must all pass |
|
||||
|
||||
```typescript
|
||||
// Prefer auto-retrying assertions when possible
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Single value polling
|
||||
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
|
||||
|
||||
// Multiple conditions
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 2000 })
|
||||
```
|
||||
|
||||
## Screenshot Baselines
|
||||
|
||||
- **Screenshots are Linux-only.** Don't commit local screenshots.
|
||||
- **To update baselines:** Add PR label `New Browser Test Expectations`
|
||||
- **Mask dynamic content:**
|
||||
```typescript
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
|
||||
mask: [page.locator('.timestamp')]
|
||||
})
|
||||
```
|
||||
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid these common mistakes:
|
||||
|
||||
1. **Arbitrary waits** - Use retrying assertions instead
|
||||
|
||||
```typescript
|
||||
// ❌ await page.waitForTimeout(500)
|
||||
// ✅ await expect(element).toBeVisible()
|
||||
```
|
||||
|
||||
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
|
||||
|
||||
```typescript
|
||||
// ❌ page.locator('div.container > button.btn-primary')
|
||||
// ✅ page.getByTestId('submit-button')
|
||||
```
|
||||
|
||||
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
|
||||
|
||||
```typescript
|
||||
await node.drag({ x: 50, y: 50 })
|
||||
await comfyPage.nextFrame() // Required
|
||||
```
|
||||
|
||||
4. **Shared state between tests** - Tests must be independent
|
||||
```typescript
|
||||
// ❌ let sharedData // Outside test
|
||||
// ✅ Define state inside each test
|
||||
```
|
||||
|
||||
## Quick Start Template
|
||||
|
||||
```typescript
|
||||
// Path depends on test file location - adjust '../' segments accordingly
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('myWorkflow')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
// ... test logic
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Finding Patterns
|
||||
|
||||
```bash
|
||||
# Find similar tests
|
||||
grep -r "KSampler" browser_tests/tests/
|
||||
|
||||
# Find usage of a fixture method
|
||||
grep -r "loadWorkflow" browser_tests/tests/
|
||||
|
||||
# Find tests with specific tag
|
||||
grep -r '@screenshot' browser_tests/tests/
|
||||
```
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
|
||||
**Read the fixture code directly** - it's the source of truth for available methods.
|
||||
52
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: 'CI: Dist Telemetry Scan'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
|
||||
- name: Scan dist for telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e 'Google Tag Manager' \
|
||||
-e '(?i)\bgtm\.js\b' \
|
||||
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
|
||||
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
|
||||
dist; then
|
||||
echo 'Telemetry references found in dist assets.'
|
||||
exit 1
|
||||
fi
|
||||
echo 'No telemetry references found in dist assets.'
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
# Run sharded tests with snapshot updates (browsers pre-installed in container)
|
||||
- name: Update snapshots (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright-tests
|
||||
run: pnpm exec playwright test --update-snapshots --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
run: pnpm exec playwright test --update-snapshots --grep @screenshot --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stage changed snapshot files
|
||||
|
||||
2
.gitignore
vendored
@@ -96,3 +96,5 @@ vitest.config.*.timestamp*
|
||||
|
||||
# Weekly docs check output
|
||||
/output.txt
|
||||
|
||||
.amp
|
||||
@@ -60,11 +60,6 @@
|
||||
{
|
||||
"name": "primevue/sidebar",
|
||||
"message": "Sidebar is deprecated in PrimeVue 4+. Use Drawer instead: import Drawer from 'primevue/drawer'"
|
||||
},
|
||||
{
|
||||
"name": "@/i18n--to-enable",
|
||||
"importNames": ["st", "t", "te", "d"],
|
||||
"message": "Don't import `@/i18n` directly, prefer `useI18n()`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import type { ElectronAPI } from '@comfyorg/comfyui-electron-types'
|
||||
import { nextTick, provide } from 'vue'
|
||||
import type { ElectronWindow } from '@/utils/envUtil'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import InstallView from './InstallView.vue'
|
||||
@@ -42,16 +44,21 @@ const meta: Meta<typeof InstallView> = {
|
||||
const router = createMockRouter()
|
||||
|
||||
// Mock electron API
|
||||
;(window as any).electronAPI = {
|
||||
;(window as ElectronWindow).electronAPI = {
|
||||
getPlatform: () => 'darwin',
|
||||
Config: {
|
||||
getDetectedGpu: () => Promise.resolve('mps')
|
||||
},
|
||||
Events: {
|
||||
trackEvent: (_eventName: string, _data?: any) => {}
|
||||
trackEvent: (
|
||||
_eventName: string,
|
||||
_data?: Record<string, unknown>
|
||||
) => {}
|
||||
},
|
||||
installComfyUI: (_options: any) => {},
|
||||
changeTheme: (_theme: any) => {},
|
||||
installComfyUI: (
|
||||
_options: Parameters<ElectronAPI['installComfyUI']>[0]
|
||||
) => {},
|
||||
changeTheme: (_theme: Parameters<ElectronAPI['changeTheme']>[0]) => {},
|
||||
getSystemPaths: () =>
|
||||
Promise.resolve({
|
||||
defaultInstallPath: '/Users/username/ComfyUI'
|
||||
@@ -240,8 +247,8 @@ export const DesktopSettings: Story = {
|
||||
export const WindowsPlatform: Story = {
|
||||
render: () => {
|
||||
// Override the platform to Windows
|
||||
;(window as any).electronAPI.getPlatform = () => 'win32'
|
||||
;(window as any).electronAPI.Config.getDetectedGpu = () =>
|
||||
;(window as ElectronWindow).electronAPI.getPlatform = () => 'win32'
|
||||
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
|
||||
Promise.resolve('nvidia')
|
||||
|
||||
return {
|
||||
@@ -259,8 +266,8 @@ export const MacOSPlatform: Story = {
|
||||
name: 'macOS Platform',
|
||||
render: () => {
|
||||
// Override the platform to macOS
|
||||
;(window as any).electronAPI.getPlatform = () => 'darwin'
|
||||
;(window as any).electronAPI.Config.getDetectedGpu = () =>
|
||||
;(window as ElectronWindow).electronAPI.getPlatform = () => 'darwin'
|
||||
;(window as ElectronWindow).electronAPI.Config.getDetectedGpu = () =>
|
||||
Promise.resolve('mps')
|
||||
|
||||
return {
|
||||
@@ -327,7 +334,7 @@ export const ManualInstall: Story = {
|
||||
export const ErrorState: Story = {
|
||||
render: () => {
|
||||
// Override validation to return an error
|
||||
;(window as any).electronAPI.validateInstallPath = () =>
|
||||
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
|
||||
Promise.resolve({
|
||||
isValid: false,
|
||||
exists: false,
|
||||
@@ -375,7 +382,7 @@ export const ErrorState: Story = {
|
||||
export const WarningState: Story = {
|
||||
render: () => {
|
||||
// Override validation to return a warning about non-default drive
|
||||
;(window as any).electronAPI.validateInstallPath = () =>
|
||||
;(window as ElectronWindow).electronAPI.validateInstallPath = () =>
|
||||
Promise.resolve({
|
||||
isValid: true,
|
||||
exists: false,
|
||||
|
||||
@@ -4,11 +4,40 @@ See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded fo
|
||||
|
||||
## Directory Structure
|
||||
|
||||
- `assets/` - Test data (JSON workflows, fixtures)
|
||||
- Tests use premade JSON workflows to load desired graph state
|
||||
```text
|
||||
browser_tests/
|
||||
├── assets/ - Test data (JSON workflows, images)
|
||||
├── fixtures/
|
||||
│ ├── ComfyPage.ts - Main fixture (delegates to helpers)
|
||||
│ ├── ComfyMouse.ts - Mouse interaction helper
|
||||
│ ├── VueNodeHelpers.ts - Vue Nodes 2.0 helpers
|
||||
│ ├── selectors.ts - Centralized TestIds
|
||||
│ ├── components/ - Page object components
|
||||
│ │ ├── ContextMenu.ts
|
||||
│ │ ├── SettingDialog.ts
|
||||
│ │ ├── SidebarTab.ts
|
||||
│ │ └── Topbar.ts
|
||||
│ ├── helpers/ - Focused helper classes
|
||||
│ │ ├── CanvasHelper.ts
|
||||
│ │ ├── CommandHelper.ts
|
||||
│ │ ├── KeyboardHelper.ts
|
||||
│ │ ├── NodeOperationsHelper.ts
|
||||
│ │ ├── SettingsHelper.ts
|
||||
│ │ ├── WorkflowHelper.ts
|
||||
│ │ └── ...
|
||||
│ └── utils/ - Utility functions
|
||||
├── helpers/ - Test-specific utilities
|
||||
└── tests/ - Test files (*.spec.ts)
|
||||
```
|
||||
|
||||
## After Making Changes
|
||||
|
||||
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory
|
||||
- Run `pnpm exec eslint browser_tests/path/to/file.ts` to lint specific files
|
||||
- Run `pnpm exec oxlint browser_tests/path/to/file.ts` to check with oxlint
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
A Playwright test-writing skill exists at `.claude/skills/writing-playwright-tests/SKILL.md`.
|
||||
|
||||
The skill documents **meta-level guidance only** (gotchas, anti-patterns, decision guides). It does **not** duplicate fixture APIs - agents should read the fixture code directly in `browser_tests/fixtures/`.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "T2IAdapterLoader",
|
||||
"pos": [100, 100],
|
||||
"size": { "0": 300, "1": 58 },
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONTROL_NET",
|
||||
"type": "CONTROL_NET",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "T2IAdapterLoader" },
|
||||
"widgets_values": ["t2iadapter_model.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageBatch",
|
||||
"pos": [100, 200],
|
||||
"size": { "0": 210, "1": 46 },
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "image1", "type": "IMAGE", "link": null },
|
||||
{ "name": "image2", "type": "IMAGE", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [1] }],
|
||||
"properties": { "Node name for S&R": "ImageBatch" }
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "UNKNOWN_NO_REPLACEMENT",
|
||||
"pos": [100, 300],
|
||||
"size": { "0": 210, "1": 46 },
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": 1 }],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
|
||||
"properties": { "Node name for S&R": "UNKNOWN_NO_REPLACEMENT" }
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
interface TestWindow extends Window {
|
||||
__ws__?: Record<string, WebSocket>
|
||||
}
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: unknown, url?: string): Promise<void> }
|
||||
}>({
|
||||
|
||||
@@ -34,33 +34,6 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
expect(warningText).toContain('MISSING_NODE_TYPE_IN_SUBGRAPH')
|
||||
expect(warningText).toContain('in subgraph')
|
||||
})
|
||||
|
||||
test('Should show replacement UI for replaceable missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('missing/replaceable_nodes')
|
||||
|
||||
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
|
||||
await expect(missingNodesWarning).toBeVisible()
|
||||
|
||||
// Verify "Replaceable" badges appear for nodes with replacements
|
||||
const replaceableBadges = missingNodesWarning.getByText('Replaceable')
|
||||
await expect(replaceableBadges.first()).toBeVisible()
|
||||
expect(await replaceableBadges.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify individual "Replace" buttons appear
|
||||
const replaceButtons = missingNodesWarning.getByRole('button', {
|
||||
name: 'Replace'
|
||||
})
|
||||
expect(await replaceButtons.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Verify "Replace All" button appears in footer
|
||||
const replaceAllButton = comfyPage.page.getByRole('button', {
|
||||
name: 'Replace All'
|
||||
})
|
||||
await expect(replaceAllButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
|
||||
162
browser_tests/tests/nodeGhostPlacement.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
type ComfyPage = Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
|
||||
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
|
||||
if (enabled) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
}
|
||||
|
||||
async function addGhostAtCenter(comfyPage: ComfyPage) {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
return node.id
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return { nodeId, centerX, centerY }
|
||||
}
|
||||
|
||||
function getNodeById(comfyPage: ComfyPage, nodeId: number | string) {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(id)
|
||||
if (!node) return null
|
||||
return { ghost: !!node.flags.ghost }
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
for (const mode of ['litegraph', 'vue'] as const) {
|
||||
test.describe(`Ghost node placement (${mode} mode)`, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await setVueMode(comfyPage, mode === 'vue')
|
||||
})
|
||||
|
||||
test('positions ghost node at cursor', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const viewport = comfyPage.page.viewportSize()!
|
||||
const centerX = Math.round(viewport.width / 2)
|
||||
const centerY = Math.round(viewport.height / 2)
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await comfyPage.page.evaluate(
|
||||
([clientX, clientY]) => {
|
||||
const node = window.LiteGraph!.createNode('VAEDecode')!
|
||||
const event = new MouseEvent('click', { clientX, clientY })
|
||||
window.app!.graph.add(node, { ghost: true, dragEvent: event })
|
||||
|
||||
const canvas = window.app!.canvas
|
||||
const rect = canvas.canvas.getBoundingClientRect()
|
||||
const cursorCanvasX =
|
||||
(clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0]
|
||||
const cursorCanvasY =
|
||||
(clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1]
|
||||
|
||||
return {
|
||||
diffX: node.pos[0] + node.size[0] / 2 - cursorCanvasX,
|
||||
diffY: node.pos[1] - 10 - cursorCanvasY
|
||||
}
|
||||
},
|
||||
[centerX, centerY] as const
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(Math.abs(result.diffX)).toBeLessThan(5)
|
||||
expect(Math.abs(result.diffY)).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test('left-click confirms ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.ghost).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Delete cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('Backspace cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.keyboard.press('Backspace')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
|
||||
test('right-click cancels ghost placement', async ({ comfyPage }) => {
|
||||
const { nodeId, centerX, centerY } = await addGhostAtCenter(comfyPage)
|
||||
|
||||
const before = await getNodeById(comfyPage, nodeId)
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.ghost).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.click(centerX, centerY, { button: 'right' })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await getNodeById(comfyPage, nodeId)
|
||||
expect(after).toBeNull()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 103 KiB |
@@ -31,7 +31,12 @@ test.describe('Vue Integer Widget', () => {
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Delete the node that is linked to the slot (freeing up the widget)
|
||||
await comfyPage.vueNodes.getNodeByTitle('Int').click()
|
||||
// Click on the header to select the node (clicking center may land on
|
||||
// the widget area where pointerdown.stop prevents node selection)
|
||||
await comfyPage.vueNodes
|
||||
.getNodeByTitle('Int')
|
||||
.locator('.lg-node-header')
|
||||
.click()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Test widget works when unlinked
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 79 KiB |
@@ -279,5 +279,46 @@ export default defineConfig([
|
||||
'import-x/no-duplicates': 'off',
|
||||
'import-x/consistent-type-specifier-style': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@/i18n',
|
||||
importNames: ['t', 'd', 'te'],
|
||||
message:
|
||||
"In Vue components, use `const { t } = useI18n()` instead of importing from '@/i18n'."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Non-composable .ts files must use the global t/d/te, not useI18n()
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
ignores: ['**/use[A-Z]*.ts', '**/*.test.ts', 'src/i18n.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'vue-i18n',
|
||||
importNames: ['useI18n'],
|
||||
message:
|
||||
"useI18n() requires Vue setup context. Use `import { t } from '@/i18n'` instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
7
global.d.ts
vendored
@@ -7,6 +7,7 @@ declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -30,6 +31,12 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.7",
|
||||
"version": "1.39.8",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
1
packages/design-system/src/icons/extensions-blocks.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path d="M6.66667 14V5.33333C6.66667 5.15652 6.59643 4.98695 6.4714 4.86193C6.34638 4.7369 6.17681 4.66667 6 4.66667H2.66667C2.48986 4.66667 2.32029 4.7369 2.19526 4.86193C2.07024 4.98695 2 5.15652 2 5.33333V13.3333C2 13.5101 2.07024 13.6797 2.19526 13.8047C2.32029 13.9298 2.48986 14 2.66667 14H10.6667C10.8435 14 11.013 13.9298 11.1381 13.8047C11.2631 13.6797 11.3333 13.5101 11.3333 13.3333V10C11.3333 9.82319 11.2631 9.65362 11.1381 9.5286C11.013 9.40357 10.8435 9.33333 10.6667 9.33333H2M10 2H13.3333C13.7015 2 14 2.29848 14 2.66667V6C14 6.36819 13.7015 6.66667 13.3333 6.66667H10C9.63181 6.66667 9.33333 6.36819 9.33333 6V2.66667C9.33333 2.29848 9.63181 2 10 2Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -2,10 +2,7 @@
|
||||
import { execSync } from 'child_process'
|
||||
import * as fs from 'fs'
|
||||
import { globSync } from 'glob'
|
||||
|
||||
interface LocaleData {
|
||||
[key: string]: any
|
||||
}
|
||||
import type { LocaleData } from './i18n-types'
|
||||
|
||||
// Configuration
|
||||
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
|
||||
@@ -45,7 +42,7 @@ function getStagedLocaleFiles(): string[] {
|
||||
}
|
||||
|
||||
// Extract all keys from a nested object
|
||||
function extractKeys(obj: any, prefix = ''): string[] {
|
||||
function extractKeys(obj: LocaleData, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
@@ -166,17 +163,17 @@ async function checkNewUnusedKeys() {
|
||||
|
||||
// Report results
|
||||
if (unusedNewKeys.length > 0) {
|
||||
console.log('\n⚠️ Warning: Found unused NEW i18n keys:\n')
|
||||
console.warn('\n⚠️ Warning: Found unused NEW i18n keys:\n')
|
||||
|
||||
for (const key of unusedNewKeys.sort()) {
|
||||
console.log(` - ${key}`)
|
||||
console.warn(` - ${key}`)
|
||||
}
|
||||
|
||||
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
|
||||
console.log(
|
||||
console.warn(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
|
||||
console.warn(
|
||||
'\nThese keys were added but are not used anywhere in the codebase.'
|
||||
)
|
||||
console.log('Consider using them or removing them in a future update.')
|
||||
console.warn('Consider using them or removing them in a future update.')
|
||||
|
||||
// Changed from process.exit(1) to process.exit(0) for warning only
|
||||
process.exit(0)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import type { LocaleData } from './i18n-types'
|
||||
|
||||
// Ensure directories exist
|
||||
function ensureDir(dir: string) {
|
||||
@@ -41,8 +42,8 @@ function getAllJsonFiles(dir: string): string[] {
|
||||
}
|
||||
|
||||
// Find additions in new object compared to base
|
||||
function findAdditions(base: any, updated: any): Record<string, any> {
|
||||
const additions: Record<string, any> = {}
|
||||
function findAdditions(base: LocaleData, updated: LocaleData): LocaleData {
|
||||
const additions: LocaleData = {}
|
||||
|
||||
for (const key in updated) {
|
||||
if (!(key in base)) {
|
||||
@@ -74,7 +75,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
|
||||
ensureDir(dirname(targetPath))
|
||||
writeFileSync(targetPath, readFileSync(file, 'utf8'))
|
||||
}
|
||||
console.log('Captured current locale files to temp/base/')
|
||||
console.warn('Captured current locale files to temp/base/')
|
||||
}
|
||||
|
||||
// Diff command
|
||||
@@ -94,7 +95,7 @@ function diff(srcLocaleDir: string, tempBaseDir: string, tempDiffDir: string) {
|
||||
if (Object.keys(additions).length > 0) {
|
||||
ensureDir(dirname(diffPath))
|
||||
writeFileSync(diffPath, JSON.stringify(additions, null, 2))
|
||||
console.log(`Wrote diff to ${diffPath}`)
|
||||
console.warn(`Wrote diff to ${diffPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,9 +117,9 @@ switch (command) {
|
||||
// Remove temp directory recursively
|
||||
if (existsSync('temp')) {
|
||||
rmSync('temp', { recursive: true, force: true })
|
||||
console.log('Removed temp directory')
|
||||
console.warn('Removed temp directory')
|
||||
}
|
||||
break
|
||||
default:
|
||||
console.log('Please specify either "capture" or "diff" command')
|
||||
console.error('Please specify either "capture" or "diff" command')
|
||||
}
|
||||
|
||||
5
scripts/i18n-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Shared types for i18n-related scripts
|
||||
*/
|
||||
|
||||
export type LocaleData = { [key: string]: string | LocaleData }
|
||||
@@ -19,12 +19,14 @@
|
||||
<Button
|
||||
v-tooltip.bottom="customNodesManagerTooltipConfig"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('menu.customNodesManager')"
|
||||
:aria-label="t('menu.manageExtensions')"
|
||||
class="relative"
|
||||
@click="openCustomNodeManager"
|
||||
>
|
||||
<i class="icon-[lucide--puzzle] size-4" />
|
||||
<i class="icon-[comfy--extensions-blocks] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('menu.manageExtensions') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
||||
@@ -220,7 +222,7 @@ const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
buildTooltipConfig(t('menu.manageExtensions'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
@@ -55,7 +56,7 @@ const {
|
||||
alt?: string
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: Record<string, any>
|
||||
imageStyle?: StyleValue
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
|
||||
@@ -25,25 +25,10 @@
|
||||
:key="i"
|
||||
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
v-if="node.isReplaceable"
|
||||
:label="$t('nodeReplacement.replaceable')"
|
||||
severity="default"
|
||||
/>
|
||||
<span class="text-xs">{{ node.label }}</span>
|
||||
<span v-if="node.hint" class="text-xs text-muted-foreground">
|
||||
{{ node.hint }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="node.isReplaceable"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="emit('replace', node.label)"
|
||||
>
|
||||
{{ $t('nodeReplacement.replace') }}
|
||||
</Button>
|
||||
<span class="text-xs">
|
||||
{{ node.label }}
|
||||
</span>
|
||||
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,9 +49,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
@@ -75,10 +58,6 @@ const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'replace', nodeType: string): void
|
||||
}>()
|
||||
|
||||
// Get missing core nodes for OSS mode
|
||||
const { missingCoreNodes } = useMissingNodes()
|
||||
|
||||
@@ -96,12 +75,10 @@ const uniqueNodes = computed(() => {
|
||||
return {
|
||||
label: node.type,
|
||||
hint: node.hint,
|
||||
action: node.action,
|
||||
isReplaceable: node.isReplaceable ?? false,
|
||||
replacement: node.replacement
|
||||
action: node.action
|
||||
}
|
||||
}
|
||||
return { label: node, isReplaceable: false }
|
||||
return { label: node }
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<!-- Cloud mode: Learn More + Replace All + Got It buttons -->
|
||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
||||
@@ -15,34 +15,16 @@
|
||||
<i class="icon-[lucide--info]"></i>
|
||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||
</Button>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
v-if="hasReplaceableNodes"
|
||||
variant="primary"
|
||||
size="md"
|
||||
@click="emit('replaceAll')"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceAll') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||
$t('missingNodes.cloud.gotIt')
|
||||
}}</Button>
|
||||
</div>
|
||||
|
||||
<!-- OSS mode: Open Manager + Replace All + Install All buttons -->
|
||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
||||
<Button variant="textonly" @click="openManager">{{
|
||||
$t('g.openManager')
|
||||
}}</Button>
|
||||
<Button
|
||||
v-if="hasReplaceableNodes"
|
||||
variant="primary"
|
||||
size="md"
|
||||
@click="emit('replaceAll')"
|
||||
>
|
||||
{{ $t('nodeReplacement.replaceAll') }}
|
||||
</Button>
|
||||
<PackInstallButton
|
||||
v-if="showInstallAllButton"
|
||||
type="secondary"
|
||||
@@ -69,25 +51,12 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
|
||||
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
||||
|
||||
const { missingNodeTypes = [] } = defineProps<{
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'replaceAll'): void
|
||||
}>()
|
||||
|
||||
const hasReplaceableNodes = computed(() =>
|
||||
missingNodeTypes.some((n) => typeof n === 'object' && n.isReplaceable)
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -176,8 +177,9 @@ const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
@@ -256,9 +258,15 @@ async function handleBuy() {
|
||||
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog(
|
||||
isSubscriptionEnabled() ? 'subscription' : 'credits'
|
||||
)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
: 'credits'
|
||||
dialogService.showSettingsDialog(settingsPanel)
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex py-8 items-center justify-between px-8">
|
||||
<h2 class="text-lg font-bold text-base-foreground m-0">
|
||||
{{
|
||||
isInsufficientCredits
|
||||
? $t('credits.topUp.addMoreCreditsToRun')
|
||||
: $t('credits.topUp.addMoreCredits')
|
||||
}}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
@click="() => handleClose()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="isInsufficientCredits"
|
||||
class="text-sm text-muted-foreground m-0 px-8"
|
||||
>
|
||||
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
|
||||
</p>
|
||||
|
||||
<!-- Preset amount buttons -->
|
||||
<div class="px-8">
|
||||
<h3 class="m-0 text-sm font-normal text-muted-foreground">
|
||||
{{ $t('credits.topUp.selectAmount') }}
|
||||
</h3>
|
||||
<div class="flex gap-2 pt-3">
|
||||
<Button
|
||||
v-for="amount in PRESET_AMOUNTS"
|
||||
:key="amount"
|
||||
:autofocus="amount === 50"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
|
||||
selectedPreset === amount && 'bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
@click="handlePresetClick(amount)"
|
||||
>
|
||||
${{ amount }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Amount (USD) / Credits -->
|
||||
<div class="flex gap-2 px-8 pt-8">
|
||||
<!-- You Pay -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youPay') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
:model-value="payAmount"
|
||||
:min="0"
|
||||
:max="MAX_AMOUNT"
|
||||
:step="getStepAmount"
|
||||
@update:model-value="handlePayAmountChange"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="shrink-0 text-base font-semibold text-base-foreground"
|
||||
>$</span
|
||||
>
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
|
||||
<!-- You Get -->
|
||||
<div class="flex flex-1 flex-col gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ $t('credits.topUp.youGet') }}
|
||||
</div>
|
||||
<FormattedNumberStepper
|
||||
v-model="creditsModel"
|
||||
:min="0"
|
||||
:max="usdToCredits(MAX_AMOUNT)"
|
||||
:step="getCreditsStepAmount"
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
|
||||
<p
|
||||
v-if="isBelowMin"
|
||||
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCeilingWarning"
|
||||
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
})
|
||||
}}
|
||||
<span>{{ $t('credits.topUp.needMore') }}</span>
|
||||
<a
|
||||
href="https://www.comfy.org/cloud/enterprise"
|
||||
target="_blank"
|
||||
class="ml-1 text-inherit"
|
||||
>{{ $t('credits.topUp.contactUs') }}</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
|
||||
<Button
|
||||
:disabled="!isValidAmount || loading || isPolling"
|
||||
:loading="loading || isPolling"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="h-10 justify-center"
|
||||
@click="handleBuy"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
{{ $t('credits.topUp.viewPricing') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isInsufficientCredits = false } = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
|
||||
// Constants
|
||||
const PRESET_AMOUNTS = [10, 25, 50, 100]
|
||||
const MIN_AMOUNT = 5
|
||||
const MAX_AMOUNT = 10000
|
||||
|
||||
// State
|
||||
const selectedPreset = ref<number | null>(50)
|
||||
const payAmount = ref(50)
|
||||
const showCeilingWarning = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const pricingUrl = computed(() =>
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
|
||||
)
|
||||
|
||||
const creditsModel = computed({
|
||||
get: () => usdToCredits(payAmount.value),
|
||||
set: (newCredits: number) => {
|
||||
payAmount.value = Math.round(creditsToUsd(newCredits))
|
||||
selectedPreset.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const isValidAmount = computed(
|
||||
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
|
||||
)
|
||||
|
||||
const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)
|
||||
|
||||
// Utility functions
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
// Step amount functions
|
||||
function getStepAmount(currentAmount: number): number {
|
||||
if (currentAmount < 100) return 5
|
||||
if (currentAmount < 1000) return 50
|
||||
return 100
|
||||
}
|
||||
|
||||
function getCreditsStepAmount(currentCredits: number): number {
|
||||
const usdAmount = creditsToUsd(currentCredits)
|
||||
return usdToCredits(getStepAmount(usdAmount))
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
function handlePayAmountChange(value: number) {
|
||||
payAmount.value = value
|
||||
selectedPreset.value = null
|
||||
showCeilingWarning.value = false
|
||||
}
|
||||
|
||||
function handlePresetClick(amount: number) {
|
||||
showCeilingWarning.value = false
|
||||
payAmount.value = amount
|
||||
selectedPreset.value = amount
|
||||
}
|
||||
|
||||
function handleClose(clearTracking = true) {
|
||||
if (clearTracking) {
|
||||
clearTopupTracking()
|
||||
}
|
||||
dialogStore.closeDialog({ key: 'top-up-credits' })
|
||||
}
|
||||
|
||||
async function handleBuy() {
|
||||
if (loading.value || !isValidAmount.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('credits.topUp.purchaseSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await fetchBalance()
|
||||
handleClose(false)
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else if (response.status === 'pending') {
|
||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase failed:', error)
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('credits.topUp.unknownError')
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('subscription.cancelDialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
:disabled="isLoading"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('subscription.cancelDialog.keepSubscription') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmCancel"
|
||||
>
|
||||
{{ $t('subscription.cancelDialog.confirmCancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const props = defineProps<{
|
||||
cancelAt?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
|
||||
|
||||
const isLoading = ref(false)
|
||||
|
||||
const formattedEndDate = computed(() => {
|
||||
const dateStr = props.cancelAt ?? subscription.value?.endDate
|
||||
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
|
||||
const description = computed(() =>
|
||||
t('subscription.cancelDialog.description', { date: formattedEndDate.value })
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
}
|
||||
|
||||
async function onConfirmCancel() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await cancelSubscription()
|
||||
await fetchStatus()
|
||||
dialogStore.closeDialog({ key: 'cancel-subscription' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.cancelSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="px-4">
|
||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
||||
<i class="pi pi-cog" />
|
||||
<span>{{ $t('g.settings') }}</span>
|
||||
<Tag
|
||||
@@ -16,6 +16,9 @@
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
import { isStaging } from '@/config/staging'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
const { flags } = useFeatureFlags()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -76,6 +76,13 @@
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<LinkOverlayCanvas
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@ready="onLinkOverlayReady"
|
||||
@dispose="onLinkOverlayDispose"
|
||||
/>
|
||||
|
||||
<!-- Selection rectangle overlay - rendered in DOM layer to appear above DOM widgets -->
|
||||
<SelectionRectangle v-if="comfyAppReady" />
|
||||
|
||||
@@ -104,6 +111,7 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
@@ -111,6 +119,7 @@ import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
@@ -129,7 +138,6 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
@@ -167,6 +175,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
}>()
|
||||
@@ -241,6 +250,18 @@ const allNodes = computed((): VueNodeData[] =>
|
||||
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
|
||||
)
|
||||
|
||||
function onLinkOverlayReady(el: HTMLCanvasElement) {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = el
|
||||
canvasStore.canvas.overlayCtx = el.getContext('2d')
|
||||
}
|
||||
|
||||
function onLinkOverlayDispose() {
|
||||
if (!canvasStore.canvas) return
|
||||
canvasStore.canvas.overlayCanvas = null
|
||||
canvasStore.canvas.overlayCtx = null
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
LiteGraph.nodeOpacity = settingStore.get('Comfy.Node.Opacity')
|
||||
})
|
||||
|
||||
43
src/components/graph/LinkOverlayCanvas.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="pointer-events-none absolute inset-0 size-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const { canvas } = defineProps<{
|
||||
canvas: LGraphCanvas
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [canvas: HTMLCanvasElement]
|
||||
dispose: []
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
useRafFn(() => {
|
||||
const el = canvasRef.value
|
||||
const mainCanvas = canvas.canvas
|
||||
if (!el || !mainCanvas) return
|
||||
|
||||
if (el.width !== mainCanvas.width || el.height !== mainCanvas.height) {
|
||||
el.width = mainCanvas.width
|
||||
el.height = mainCanvas.height
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) emit('ready', canvasRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emit('dispose')
|
||||
})
|
||||
</script>
|
||||
@@ -61,7 +61,7 @@ async function showTooltip(tooltip: string | null | undefined) {
|
||||
function onIdle() {
|
||||
const { canvas } = comfyApp
|
||||
const node = canvas?.node_over
|
||||
if (!node) return
|
||||
if (!node || node.flags?.ghost) return
|
||||
|
||||
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
@@ -104,11 +105,11 @@ import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneContro
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
node?: LGraphNode
|
||||
modelUrl?: string
|
||||
@@ -131,7 +132,7 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
|
||||
onModelDrop: async (file) => {
|
||||
await viewer.handleModelDrop(file)
|
||||
},
|
||||
disabled: viewer.isPreview.value || isStandaloneMode
|
||||
disabled: viewer.isPreview.value || !!isStandaloneMode
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -94,15 +94,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
hideMaterialMode = false,
|
||||
isPlyModel = false,
|
||||
|
||||
@@ -19,13 +19,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
@@ -30,10 +30,11 @@
|
||||
import Select from 'primevue/select'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const cameras = [
|
||||
{ title: t('load3d.cameraType.perspective'), value: 'perspective' },
|
||||
{ title: t('load3d.cameraType.orthographic'), value: 'orthographic' }
|
||||
|
||||
@@ -25,13 +25,14 @@
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
|
||||
hideMaterialMode?: boolean
|
||||
isPlyModel?: boolean
|
||||
|
||||
@@ -160,14 +160,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const colorInputRef = ref<HTMLInputElement>()
|
||||
|
||||
@@ -61,14 +61,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
@@ -131,12 +131,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
@@ -145,6 +145,7 @@ const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
|
||||
@@ -31,18 +31,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
|
||||
@@ -128,15 +128,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
|
||||
@@ -44,6 +44,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const {
|
||||
totalPercent,
|
||||
|
||||
@@ -158,7 +158,7 @@ export const Queued: Story = {
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -217,7 +217,7 @@ export const QueuedParallel: Story = {
|
||||
prompt_id: 'p2'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -258,7 +258,7 @@ export const Running: Story = {
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -303,7 +303,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
|
||||
prompt_id: 'p1'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
@@ -360,7 +360,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
|
||||
prompt_id: 'p2'
|
||||
}
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
return { args: { ...args, jobId } }
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ const canvasStore = useCanvasStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
||||
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
@@ -138,7 +138,6 @@ describe('flatAndCategorizeSelectedItems', () => {
|
||||
expect(result.nodes).toEqual([testNode1])
|
||||
expect(result.groups).toEqual([testGroup1, testGroup2])
|
||||
expect(result.nodeToParentGroup.get(testNode1)).toBe(testGroup2)
|
||||
expect(result.nodeToParentGroup.has(testGroup2 as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle mixed selection of nodes and groups', () => {
|
||||
|
||||
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'icon-[lucide--puzzle]',
|
||||
icon: 'icon-[comfy--extensions-blocks]',
|
||||
command: showManageExtensions
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
|
||||
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
|
||||
@@ -66,7 +67,6 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
|
||||
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
|
||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -84,6 +84,7 @@ import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
314
src/components/sidebar/tabs/AssetsSidebarTab.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
shiftKey: { value: true },
|
||||
ctrlKey: { value: false },
|
||||
metaKey: { value: false },
|
||||
outputMedia: [] as AssetItem[]
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useDebounceFn: <T extends (...args: never[]) => unknown>(fn: T) => fn,
|
||||
useElementHover: () => ref(false),
|
||||
useResizeObserver: () => undefined,
|
||||
useStorage: <T>(_key: string, initialValue: T) => ref(initialValue),
|
||||
useKeyModifier: (key: string) => {
|
||||
if (key === 'Shift') return mocks.shiftKey
|
||||
if (key === 'Control') return mocks.ctrlKey
|
||||
if (key === 'Meta') return mocks.metaKey
|
||||
return ref(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key,
|
||||
n: (value: number) => String(value)
|
||||
}),
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
|
||||
useMediaAssets: (type: 'input' | 'output') => ({
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
media: ref(type === 'output' ? mocks.outputMedia : []),
|
||||
fetchMediaList: vi.fn(async () => {}),
|
||||
hasMore: ref(false),
|
||||
isLoadingMore: ref(false),
|
||||
loadMore: vi.fn(async () => {})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
|
||||
useMediaAssetFiltering: (baseAssets: { value: AssetItem[] }) => ({
|
||||
searchQuery: ref(''),
|
||||
sortBy: ref('newest'),
|
||||
mediaTypeFilters: ref([]),
|
||||
filteredAssets: computed(() => baseAssets.value)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
|
||||
useOutputStacks: ({ assets }: { assets: { value: AssetItem[] } }) => ({
|
||||
assetItems: computed(() =>
|
||||
assets.value.map((asset) => ({ key: asset.id, asset }))
|
||||
),
|
||||
selectableAssets: computed(() => assets.value),
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: vi.fn(async () => {})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => ({
|
||||
downloadMultipleAssets: vi.fn(),
|
||||
deleteAssets: vi.fn(async () => true),
|
||||
addMultipleToWorkflow: vi.fn(async () => {}),
|
||||
openMultipleWorkflows: vi.fn(async () => {}),
|
||||
exportMultipleWorkflows: vi.fn(async () => {})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
getAssetType: (tags: unknown) =>
|
||||
Array.isArray(tags) && tags.includes('output') ? 'output' : 'input'
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => {
|
||||
class ResultItemImpl {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
nodeId: string
|
||||
mediaType: string
|
||||
|
||||
constructor({
|
||||
filename,
|
||||
subfolder,
|
||||
type,
|
||||
nodeId,
|
||||
mediaType
|
||||
}: {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
nodeId: string
|
||||
mediaType: string
|
||||
}) {
|
||||
this.filename = filename
|
||||
this.subfolder = subfolder
|
||||
this.type = type
|
||||
this.nodeId = nodeId
|
||||
this.mediaType = mediaType
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: ref(0),
|
||||
pendingTasks: []
|
||||
}),
|
||||
ResultItemImpl
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn(async () => {})
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
clearInitializationByPromptIds: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
formatDuration: (duration: number) => `${duration}ms`,
|
||||
getMediaTypeFromFilename: () => 'image'
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/tailwindUtil', () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
resolveOutputAssetItems: vi.fn(async () => [])
|
||||
}
|
||||
})
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
|
||||
const sidebarTabTemplateStub = {
|
||||
template: `
|
||||
<div>
|
||||
<slot name="alt-title" />
|
||||
<slot name="tool-buttons" />
|
||||
<slot name="header" />
|
||||
<slot name="body" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const buttonStub = {
|
||||
template: '<button @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
|
||||
const assetsSidebarGridViewStub = {
|
||||
props: {
|
||||
assets: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<button
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
:data-testid="'asset-' + asset.id"
|
||||
@click.stop="$emit('select-asset', asset, assets)"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function createAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
userMetadata?: Record<string, unknown>
|
||||
): AssetItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
user_metadata: userMetadata
|
||||
}
|
||||
}
|
||||
|
||||
describe('AssetsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mocks.shiftKey.value = true
|
||||
mocks.ctrlKey.value = false
|
||||
mocks.metaKey.value = false
|
||||
mocks.outputMedia = []
|
||||
})
|
||||
|
||||
it('shows deduplicated selected count for parent stack and selected children', async () => {
|
||||
const outputs = [
|
||||
{
|
||||
filename: 'parent.png',
|
||||
nodeId: '1',
|
||||
subfolder: 'outputs',
|
||||
url: 'https://example.com/parent.png'
|
||||
}
|
||||
]
|
||||
|
||||
const parent = createAsset('parent', 'parent.png', {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '1',
|
||||
subfolder: 'outputs',
|
||||
outputCount: 4,
|
||||
allOutputs: outputs
|
||||
})
|
||||
const child1 = createAsset('child-1', 'child-1.png', {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '2',
|
||||
subfolder: 'outputs'
|
||||
})
|
||||
const child2 = createAsset('child-2', 'child-2.png', {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '3',
|
||||
subfolder: 'outputs'
|
||||
})
|
||||
const child3 = createAsset('child-3', 'child-3.png', {
|
||||
promptId: 'prompt-1',
|
||||
nodeId: '4',
|
||||
subfolder: 'outputs'
|
||||
})
|
||||
|
||||
mocks.outputMedia = [parent, child1, child2, child3]
|
||||
|
||||
const wrapper = mount(AssetsSidebarTab, {
|
||||
global: {
|
||||
stubs: {
|
||||
SidebarTabTemplate: sidebarTabTemplateStub,
|
||||
Button: buttonStub,
|
||||
TabList: true,
|
||||
Tab: true,
|
||||
Divider: true,
|
||||
ProgressSpinner: true,
|
||||
NoResultsPlaceholder: true,
|
||||
MediaAssetFilterBar: true,
|
||||
AssetsSidebarListView: true,
|
||||
AssetsSidebarGridView: assetsSidebarGridViewStub,
|
||||
ResultGallery: true,
|
||||
MediaAssetContextMenu: true
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('[data-testid="asset-parent"]').trigger('click')
|
||||
await wrapper.find('[data-testid="asset-child-3"]').trigger('click')
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'mediaAsset.selection.selectedCount:{"count":4}'
|
||||
)
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'mediaAsset.selection.selectedCount:{"count":7}'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<Toast group="billing-operation" position="top-right">
|
||||
<template #message="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-spin pi-spinner text-primary" />
|
||||
<span>{{ slotProps.message.summary }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Toast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
|
||||
|
||||
import TopbarBadge from './TopbarBadge.vue'
|
||||
@@ -31,6 +31,8 @@ withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cloudBadge = computed<TopbarBadgeType>(() => ({
|
||||
label: t('g.beta'),
|
||||
text: 'Comfy Cloud'
|
||||
|
||||
@@ -64,10 +64,10 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the CurrentUserPopover component
|
||||
vi.mock('./CurrentUserPopover.vue', () => ({
|
||||
// Mock the CurrentUserPopoverLegacy component
|
||||
vi.mock('./CurrentUserPopoverLegacy.vue', () => ({
|
||||
default: {
|
||||
name: 'CurrentUserPopoverMock',
|
||||
name: 'CurrentUserPopoverLegacyMock',
|
||||
render() {
|
||||
return h('div', 'Popover Content')
|
||||
},
|
||||
|
||||
@@ -45,14 +45,16 @@
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
@show="onPopoverShow"
|
||||
>
|
||||
<!-- Workspace mode: workspace-aware popover (only when ready) -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled && initState === 'ready'"
|
||||
ref="workspacePopoverContent"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover
|
||||
<CurrentUserPopoverLegacy
|
||||
v-else-if="!teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
@@ -75,7 +77,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
@@ -112,8 +114,15 @@ const workspaceName = computed(() => {
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const workspacePopoverContent = ref<{
|
||||
refreshBalance: () => void
|
||||
} | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
const onPopoverShow = () => {
|
||||
workspacePopoverContent.value?.refreshBalance()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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'
|
||||
import CurrentUserPopoverLegacy from './CurrentUserPopoverLegacy.vue'
|
||||
|
||||
// Mock all firebase modules
|
||||
vi.mock('firebase/app', () => ({
|
||||
@@ -172,7 +172,7 @@ vi.mock('@/platform/cloud/subscription/components/SubscribeButton.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CurrentUserPopover', () => {
|
||||
describe('CurrentUserPopoverLegacy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuthStoreState.balance = {
|
||||
@@ -190,7 +190,7 @@ describe('CurrentUserPopover', () => {
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
return mount(CurrentUserPopover, {
|
||||
return mount(CurrentUserPopoverLegacy, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
@@ -87,18 +87,26 @@
|
||||
<SubscribeButton
|
||||
v-else-if="isPersonalWorkspace"
|
||||
:fluid="false"
|
||||
:label="$t('workspaceSwitcher.subscribe')"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Navigate to workspace settings -->
|
||||
<!-- Non-personal workspace: Show pricing table -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
{{
|
||||
isCancelled
|
||||
? $t('subscription.resubscribe')
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -196,18 +204,19 @@ import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -233,22 +242,33 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription, subscriptionStatus } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||
useBillingContext()
|
||||
|
||||
const isCancelled = computed(() => subscription.value?.isCancelled ?? false)
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const isLoadingBalance = isLoading
|
||||
|
||||
const displayedCredits = computed(() => {
|
||||
if (initState.value !== 'ready') return ''
|
||||
// Only personal workspaces have subscription status from useSubscription()
|
||||
// Team workspaces don't have backend subscription data yet
|
||||
if (isPersonalWorkspace.value) {
|
||||
// Wait for subscription status to load
|
||||
if (subscriptionStatus.value === null) return ''
|
||||
return isActiveSubscription.value ? totalCredits.value : '0'
|
||||
}
|
||||
return '0'
|
||||
// Wait for subscription to load
|
||||
if (subscription.value === null) return ''
|
||||
if (!isActiveSubscription.value) return '0'
|
||||
|
||||
// API field is named _micros but contains cents (naming inconsistency)
|
||||
const cents =
|
||||
balance.value?.effectiveBalanceMicros ?? balance.value?.amountMicros ?? 0
|
||||
return formatCreditsFromCents({
|
||||
cents,
|
||||
locale: locale.value,
|
||||
numberOptions: {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
@@ -322,7 +342,11 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
const refreshBalance = () => {
|
||||
if (isActiveSubscription.value) {
|
||||
void fetchBalance()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ refreshBalance })
|
||||
</script>
|
||||
|
||||
@@ -37,17 +37,18 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isLoggedIn, handleSignIn } = useCurrentUser()
|
||||
const { buildDocsUrl } = useExternalLink()
|
||||
const apiNodesOverviewUrl = buildDocsUrl(
|
||||
|
||||
@@ -113,24 +113,24 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
SubscriptionTier,
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
subscriptionPlan: string | null
|
||||
subscriptionTier: SubscriptionTier | null
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -140,7 +140,34 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
const tierKeyMap: Record<string, string> = {
|
||||
STANDARD: 'standard',
|
||||
CREATOR: 'creator',
|
||||
PRO: 'pro',
|
||||
FOUNDER: 'founder',
|
||||
FOUNDERS_EDITION: 'founder'
|
||||
}
|
||||
|
||||
function formatTierName(
|
||||
tier: string | null | undefined,
|
||||
isYearly: boolean
|
||||
): string {
|
||||
if (!tier) return ''
|
||||
const key = tierKeyMap[tier] ?? 'standard'
|
||||
const baseName = t(`subscription.tiers.${key}.name`)
|
||||
return isYearly
|
||||
? t('subscription.tierNameYearly', { name: baseName })
|
||||
: baseName
|
||||
}
|
||||
|
||||
const currentSubscriptionTierName = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return ''
|
||||
const isYearly = subscription.value?.duration === 'ANNUAL'
|
||||
return formatTierName(tier, isYearly)
|
||||
})
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
@@ -153,7 +180,8 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
type: w.type,
|
||||
role: w.role,
|
||||
isSubscribed: w.isSubscribed,
|
||||
subscriptionPlan: w.subscriptionPlan
|
||||
subscriptionPlan: w.subscriptionPlan,
|
||||
subscriptionTier: w.subscriptionTier
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -168,19 +196,32 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
}
|
||||
|
||||
function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
// Personal workspace: use user's subscription tier
|
||||
if (workspace.type === 'personal') {
|
||||
return userSubscriptionTierName.value || null
|
||||
// For the current/active workspace, use billing context directly
|
||||
// This ensures we always have the most up-to-date subscription info
|
||||
if (isCurrentWorkspace(workspace)) {
|
||||
return currentSubscriptionTierName.value || null
|
||||
}
|
||||
// Team workspace: use workspace subscription plan
|
||||
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
|
||||
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (workspace.subscriptionPlan === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
|
||||
// For non-active workspaces, use cached store data
|
||||
if (!workspace.isSubscribed) return null
|
||||
|
||||
if (workspace.subscriptionTier) {
|
||||
return formatTierName(workspace.subscriptionTier, false)
|
||||
}
|
||||
|
||||
if (!workspace.subscriptionPlan) return null
|
||||
|
||||
// Parse plan slug (format: TIER_DURATION, e.g. "CREATOR_MONTHLY", "PRO_YEARLY")
|
||||
const planSlug = workspace.subscriptionPlan
|
||||
|
||||
// Extract tier from plan slug (e.g., "CREATOR_MONTHLY" -> "CREATOR")
|
||||
const tierMatch = Object.keys(tierKeyMap).find((tier) =>
|
||||
planSlug.startsWith(tier)
|
||||
)
|
||||
if (!tierMatch) return null
|
||||
|
||||
const isYearly = planSlug.includes('YEARLY') || planSlug.includes('ANNUAL')
|
||||
return formatTierName(tierMatch, isYearly)
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
|
||||
@@ -2,11 +2,11 @@ import { FirebaseError } from 'firebase/app'
|
||||
import { AuthErrorCodes } from 'firebase/auth'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
@@ -83,7 +83,7 @@ export const useFirebaseAuthActions = () => {
|
||||
)
|
||||
|
||||
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
if (!isActiveSubscription.value) return
|
||||
|
||||
const response = await authStore.initiateCreditPurchase({
|
||||
|
||||
76
src/composables/billing/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type BillingType = 'legacy' | 'workspace'
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
isActive: boolean
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
renewalDate: string | null
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
amountMicros: number
|
||||
currency: string
|
||||
effectiveBalanceMicros?: number
|
||||
prepaidBalanceMicros?: number
|
||||
cloudCreditBalanceMicros?: number
|
||||
}
|
||||
|
||||
export interface BillingActions {
|
||||
initialize: () => Promise<void>
|
||||
fetchStatus: () => Promise<void>
|
||||
fetchBalance: () => Promise<void>
|
||||
subscribe: (
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) => Promise<SubscribeResponse | void>
|
||||
previewSubscribe: (
|
||||
planSlug: string
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
* Shows subscription dialog if not subscribed.
|
||||
* Use this in extensions/entry points that require active subscription.
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
isInitialized: Ref<boolean>
|
||||
subscription: ComputedRef<SubscriptionInfo | null>
|
||||
balance: ComputedRef<BalanceInfo | null>
|
||||
plans: ComputedRef<Plan[]>
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
}
|
||||
164
src/composables/billing/useBillingContext.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
const isInPersonalWorkspace = { value: true }
|
||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
||||
activeWorkspace: activeWorkspace.value,
|
||||
_setPersonalWorkspace: (value: boolean) => {
|
||||
isInPersonalWorkspace.value = value
|
||||
activeWorkspace.value = value
|
||||
? { id: 'personal-123', type: 'personal' }
|
||||
: { id: 'team-456', type: 'team' }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn().mockResolvedValue(undefined),
|
||||
showSubscriptionDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/cloud/subscription/composables/useSubscriptionDialog',
|
||||
() => ({
|
||||
useSubscriptionDialog: () => ({
|
||||
show: vi.fn(),
|
||||
hide: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
|
||||
const plans = { value: [] }
|
||||
const currentPlanSlug = { value: null }
|
||||
return {
|
||||
useBillingPlans: () => ({
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading: { value: false },
|
||||
error: { value: null },
|
||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingStatus: vi.fn().mockResolvedValue({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY'
|
||||
}),
|
||||
getBillingBalance: vi.fn().mockResolvedValue({
|
||||
amount_micros: 10000000,
|
||||
currency: 'usd'
|
||||
}),
|
||||
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
|
||||
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useBillingContext', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns legacy type for personal workspace', () => {
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('provides subscription info from legacy billing', () => {
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
expect(subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
})
|
||||
|
||||
it('provides balance info from legacy billing', () => {
|
||||
const { balance } = useBillingContext()
|
||||
|
||||
expect(balance.value).toEqual({
|
||||
amountMicros: 5000000,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 5000000,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes initialize action', async () => {
|
||||
const { initialize } = useBillingContext()
|
||||
await expect(initialize()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchStatus action', async () => {
|
||||
const { fetchStatus } = useBillingContext()
|
||||
await expect(fetchStatus()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes fetchBalance action', async () => {
|
||||
const { fetchBalance } = useBillingContext()
|
||||
await expect(fetchBalance()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes subscribe action', async () => {
|
||||
const { subscribe } = useBillingContext()
|
||||
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes manageSubscription action', async () => {
|
||||
const { manageSubscription } = useBillingContext()
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes requireActiveSubscription action', async () => {
|
||||
const { requireActiveSubscription } = useBillingContext()
|
||||
await expect(requireActiveSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('exposes showSubscriptionDialog action', () => {
|
||||
const { showSubscriptionDialog } = useBillingContext()
|
||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||
})
|
||||
})
|
||||
242
src/composables/billing/useBillingContext.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from './useWorkspaceBilling'
|
||||
|
||||
/**
|
||||
* Unified billing context that automatically switches between legacy (user-scoped)
|
||||
* and workspace billing based on the active workspace type.
|
||||
*
|
||||
* - Personal workspaces use legacy billing via /customers/* endpoints
|
||||
* - Team workspaces use workspace billing via /billing/* endpoints
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const {
|
||||
* type,
|
||||
* subscription,
|
||||
* balance,
|
||||
* isInitialized,
|
||||
* initialize,
|
||||
* subscribe
|
||||
* } = useBillingContext()
|
||||
*
|
||||
* // Wait for initialization
|
||||
* await initialize()
|
||||
*
|
||||
* // Check subscription status
|
||||
* if (subscription.value?.isActive) {
|
||||
* console.log(`Tier: ${subscription.value.tier}`)
|
||||
* }
|
||||
*
|
||||
* // Check balance
|
||||
* if (balance.value) {
|
||||
* const dollars = balance.value.amountMicros / 1_000_000
|
||||
* console.log(`Balance: $${dollars.toFixed(2)}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
)
|
||||
const workspaceBillingRef = shallowRef<
|
||||
(BillingState & BillingActions) | null
|
||||
>(null)
|
||||
|
||||
const getLegacyBilling = () => {
|
||||
if (!legacyBillingRef.value) {
|
||||
legacyBillingRef.value = useLegacyBilling()
|
||||
}
|
||||
return legacyBillingRef.value
|
||||
}
|
||||
|
||||
const getWorkspaceBilling = () => {
|
||||
if (!workspaceBillingRef.value) {
|
||||
workspaceBillingRef.value = useWorkspaceBilling()
|
||||
}
|
||||
return workspaceBillingRef.value
|
||||
}
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use:
|
||||
* - If team workspaces feature is disabled: always use legacy (/customers)
|
||||
* - If team workspaces feature is enabled:
|
||||
* - Personal workspace: use legacy (/customers)
|
||||
* - Team workspace: use workspace (/billing)
|
||||
*/
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
|
||||
})
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
|
||||
// Proxy state from active context
|
||||
const subscription = computed<SubscriptionInfo | null>(() =>
|
||||
toValue(activeContext.value.subscription)
|
||||
)
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() =>
|
||||
toValue(activeContext.value.balance)
|
||||
)
|
||||
|
||||
const plans = computed(() => toValue(activeContext.value.plans))
|
||||
|
||||
const currentPlanSlug = computed(() =>
|
||||
toValue(activeContext.value.currentPlanSlug)
|
||||
)
|
||||
|
||||
const isActiveSubscription = computed(() =>
|
||||
toValue(activeContext.value.isActiveSubscription)
|
||||
)
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
if (!sub || store.isInPersonalWorkspace) return
|
||||
|
||||
store.updateActiveWorkspace({
|
||||
isSubscribed: sub.isActive && !sub.isCancelled,
|
||||
subscriptionPlan: sub.planSlug
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Initialize billing when workspace changes
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
async (newWorkspaceId, oldWorkspaceId) => {
|
||||
if (!newWorkspaceId) {
|
||||
// No workspace selected - reset state
|
||||
isInitialized.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (newWorkspaceId !== oldWorkspaceId) {
|
||||
// Workspace changed - reinitialize
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
// Error is already captured in error ref
|
||||
console.error('Failed to initialize billing context:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
return activeContext.value.fetchStatus()
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
return activeContext.value.fetchBalance()
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
) {
|
||||
return activeContext.value.subscribe(planSlug, returnUrl, cancelUrl)
|
||||
}
|
||||
|
||||
async function previewSubscribe(planSlug: string) {
|
||||
return activeContext.value.previewSubscribe(planSlug)
|
||||
}
|
||||
|
||||
async function manageSubscription() {
|
||||
return activeContext.value.manageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription() {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
|
||||
async function requireActiveSubscription() {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
|
||||
export const useBillingContext = createSharedComposable(
|
||||
useBillingContextInternal
|
||||
)
|
||||
189
src/composables/billing/useLegacyBilling.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for legacy user-scoped billing via /customers/* endpoints.
|
||||
* Used for personal workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useLegacyBilling(): BillingState & BillingActions {
|
||||
const {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
subscribe: legacySubscribe,
|
||||
showSubscriptionDialog: legacyShowSubscriptionDialog
|
||||
} = useSubscription()
|
||||
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(() => legacyIsActiveSubscription.value)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
if (!legacyIsActiveSubscription.value && !subscriptionTier.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: legacyIsActiveSubscription.value,
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const legacyBalance = firebaseAuthStore.balance
|
||||
if (!legacyBalance) return null
|
||||
|
||||
return {
|
||||
amountMicros: legacyBalance.amount_micros ?? 0,
|
||||
currency: legacyBalance.currency ?? 'usd',
|
||||
effectiveBalanceMicros:
|
||||
legacyBalance.effective_balance_micros ??
|
||||
legacyBalance.amount_micros ??
|
||||
0,
|
||||
prepaidBalanceMicros: legacyBalance.prepaid_balance_micros ?? 0,
|
||||
cloudCreditBalanceMicros: legacyBalance.cloud_credit_balance_micros ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await legacyFetchStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await firebaseAuthStore.fetchBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
_planSlug: string,
|
||||
_returnUrl?: string,
|
||||
_cancelUrl?: string
|
||||
): Promise<SubscribeResponse | void> {
|
||||
// Legacy billing uses Stripe checkout flow via useSubscription
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
_planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
// Legacy billing doesn't support preview - returns null
|
||||
return null
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
}
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
311
src/composables/billing/useWorkspaceBilling.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBillingPlans } from '@/platform/cloud/subscription/composables/useBillingPlans'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Adapter for workspace-scoped billing via /billing/* endpoints.
|
||||
* Used for team workspaces.
|
||||
* @internal - Use useBillingContext() instead of importing directly.
|
||||
*/
|
||||
export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
const billingPlans = useBillingPlans()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const statusData = shallowRef<BillingStatusResponse | null>(null)
|
||||
const balanceData = shallowRef<BillingBalanceResponse | null>(null)
|
||||
|
||||
const isActiveSubscription = computed(
|
||||
() => statusData.value?.is_active ?? false
|
||||
)
|
||||
|
||||
const subscription = computed<SubscriptionInfo | null>(() => {
|
||||
const status = statusData.value
|
||||
if (!status) return null
|
||||
|
||||
return {
|
||||
isActive: status.is_active,
|
||||
tier: status.subscription_tier ?? null,
|
||||
duration: status.subscription_duration ?? null,
|
||||
planSlug: status.plan_slug ?? null,
|
||||
renewalDate: null, // Workspace billing uses cancel_at for end date
|
||||
endDate: status.cancel_at ?? null,
|
||||
isCancelled: status.subscription_status === 'canceled',
|
||||
hasFunds: status.has_funds
|
||||
}
|
||||
})
|
||||
|
||||
const balance = computed<BalanceInfo | null>(() => {
|
||||
const data = balanceData.value
|
||||
if (!data) return null
|
||||
|
||||
return {
|
||||
amountMicros: data.amount_micros,
|
||||
currency: data.currency,
|
||||
effectiveBalanceMicros: data.effective_balance_micros,
|
||||
prepaidBalanceMicros: data.prepaid_balance_micros,
|
||||
cloudCreditBalanceMicros: data.cloud_credit_balance_micros
|
||||
}
|
||||
})
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
)
|
||||
|
||||
const pendingCancelOpId = ref<string | null>(null)
|
||||
let cancelPollTimeout: number | null = null
|
||||
|
||||
const stopCancelPolling = () => {
|
||||
if (cancelPollTimeout !== null) {
|
||||
window.clearTimeout(cancelPollTimeout)
|
||||
cancelPollTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollCancelStatus(opId: string): Promise<void> {
|
||||
stopCancelPolling()
|
||||
|
||||
const maxAttempts = 30
|
||||
let attempt = 0
|
||||
const poll = async () => {
|
||||
if (pendingCancelOpId.value !== opId) return
|
||||
|
||||
try {
|
||||
const response = await workspaceApi.getBillingOpStatus(opId)
|
||||
if (response.status === 'succeeded') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
workspaceStore.updateActiveWorkspace({
|
||||
isSubscribed: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'failed') {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw new Error(
|
||||
response.error_message ?? 'Failed to cancel subscription'
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
if (attempt >= maxAttempts) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
await fetchStatus()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
pendingCancelOpId.value = null
|
||||
stopCancelPolling()
|
||||
throw err
|
||||
}
|
||||
|
||||
cancelPollTimeout = window.setTimeout(
|
||||
() => {
|
||||
void poll()
|
||||
},
|
||||
Math.min(1000 * 2 ** attempt, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await Promise.all([fetchStatus(), fetchBalance(), fetchPlans()])
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
statusData.value = await workspaceApi.getBillingStatus()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch billing status'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBalance(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
balanceData.value = await workspaceApi.getBillingBalance()
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
planSlug: string,
|
||||
returnUrl?: string,
|
||||
cancelUrl?: string
|
||||
): Promise<SubscribeResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.subscribe(
|
||||
planSlug,
|
||||
returnUrl,
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to subscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSubscribe(
|
||||
planSlug: string
|
||||
): Promise<PreviewSubscribeResponse | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.previewSubscribe(planSlug)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to preview subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function manageSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const returnUrl = window.location.href
|
||||
const response = await workspaceApi.getPaymentPortalUrl(returnUrl)
|
||||
if (response.url) {
|
||||
window.open(response.url, '_blank')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to open billing portal'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSubscription(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.cancelSubscription()
|
||||
pendingCancelOpId.value = response.billing_op_id
|
||||
await pollCancelStatus(response.billing_op_id)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to cancel subscription'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await billingPlans.fetchPlans()
|
||||
if (billingPlans.error.value) {
|
||||
error.value = billingPlans.error.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
subscriptionDialog.show()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isInitialized,
|
||||
subscription,
|
||||
balance,
|
||||
plans,
|
||||
currentPlanSlug,
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
|
||||
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
|
||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export function useLogsTerminalTab(): BottomPanelExtension {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
id: 'logs-terminal',
|
||||
title: t('g.logs'),
|
||||
title: 'Logs',
|
||||
titleKey: 'g.logs',
|
||||
component: markRaw(LogsTerminal),
|
||||
type: 'vue'
|
||||
@@ -17,10 +15,9 @@ export function useLogsTerminalTab(): BottomPanelExtension {
|
||||
}
|
||||
|
||||
export function useCommandTerminalTab(): BottomPanelExtension {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
id: 'command-terminal',
|
||||
title: t('g.terminal'),
|
||||
title: 'Terminal',
|
||||
titleKey: 'g.terminal',
|
||||
component: markRaw(CommandTerminal),
|
||||
type: 'vue'
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface SafeWidgetData {
|
||||
isDOMWidget?: boolean
|
||||
label?: string
|
||||
nodeType?: string
|
||||
options?: IWidgetOptions<unknown>
|
||||
options?: IWidgetOptions
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export interface VueNodeData {
|
||||
color?: string
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
pinned?: boolean
|
||||
}
|
||||
hasErrors?: boolean
|
||||
@@ -145,7 +146,7 @@ interface SharedWidgetEnhancements {
|
||||
/** Widget label */
|
||||
label?: string
|
||||
/** Widget options */
|
||||
options?: Record<string, any>
|
||||
options?: IWidgetOptions
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +171,7 @@ export function getSharedWidgetEnhancements(
|
||||
? 'ring ring-component-node-widget-advanced'
|
||||
: undefined,
|
||||
label: widget.label,
|
||||
options: widget.options
|
||||
options: widget.options as IWidgetOptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +433,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
} else {
|
||||
// Not during workflow loading - initialize layout immediately
|
||||
// This handles individual node additions during normal operation
|
||||
requestAnimationFrame(initializeVueNodeLayout)
|
||||
initializeVueNodeLayout()
|
||||
}
|
||||
|
||||
// Call original callback if provided
|
||||
@@ -526,6 +527,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.ghost':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
flags: {
|
||||
...currentData.flags,
|
||||
ghost: Boolean(propertyEvent.newValue)
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'flags.pinned':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
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 { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
@@ -33,10 +32,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
id: node.id.toString(),
|
||||
pos: [node.pos[0], node.pos[1]] as [number, number],
|
||||
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
|
||||
number,
|
||||
number
|
||||
]
|
||||
size: [node.size[0], node.size[1]] as [number, number]
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const useComputedWithWidgetWatch = (
|
||||
const { widgetNames, triggerCanvasRedraw = false } = options
|
||||
|
||||
// Create a reactive trigger based on widget values
|
||||
const widgetValues = ref<Record<string, any>>({})
|
||||
const widgetValues = ref<Record<string, unknown>>({})
|
||||
|
||||
// Initialize widget observers
|
||||
if (node.widgets) {
|
||||
@@ -56,7 +56,7 @@ export const useComputedWithWidgetWatch = (
|
||||
: node.widgets
|
||||
|
||||
// Initialize current values
|
||||
const currentValues: Record<string, any> = {}
|
||||
const currentValues: Record<string, unknown> = {}
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
currentValues[widget.name] = widget.value
|
||||
})
|
||||
|
||||
46
src/composables/sidebarTabs/useAssetsSidebarTab.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
|
||||
const { mockGetSetting, mockPendingTasks } = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockPendingTasks: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mockGetSetting
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
pendingTasks: mockPendingTasks
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetsSidebarTab', () => {
|
||||
it('hides icon badge when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockReturnValue(false)
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows pending task count when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockPendingTasks.splice(0, mockPendingTasks.length, {}, {}, {})
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
@@ -14,6 +15,12 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
component: markRaw(AssetsSidebarTab),
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
if (!settingStore.get('Comfy.Queue.QPOV2')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
return queueStore.pendingTasks.length > 0
|
||||
? queueStore.pendingTasks.length.toString()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -33,7 +33,7 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
|
||||
if (node.data instanceof ComfyNodeDefImpl) {
|
||||
const nodeDef = node.data
|
||||
const pos = [...basePos]
|
||||
const pos: Point = [...basePos]
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
@@ -127,6 +127,13 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: true },
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useCoreCommands', () => {
|
||||
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
|
||||
const baseNode = createMockLGraphNode({ id })
|
||||
|
||||