mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-24 08:44:55 +00:00
Compare commits
1 Commits
add-cla-wo
...
fix/linear
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7256ae081f |
62
.github/workflows/cla.yml
vendored
62
.github/workflows/cla.yml
vendored
@@ -1,62 +0,0 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read # 'read' is enough because signatures live in a REMOTE repo
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT required to write to the centralized signatures repo.
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
# Where the CLA document lives (shown to contributors)
|
||||
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
|
||||
|
||||
# Centralized signature storage
|
||||
remote-organization-name: comfy-org
|
||||
remote-repository-name: comfy-cla
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
🎉 Thank you for your contribution, we really appreciate it! 🎉
|
||||
|
||||
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
|
||||
|
||||
- Confirm that you own your contribution.
|
||||
- Keep the right to reuse your own code.
|
||||
- Grant us a copyright license to include and share it within our projects.
|
||||
|
||||
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
|
||||
|
||||
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
|
||||
|
||||
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.
|
||||
@@ -5,6 +5,7 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -41,6 +42,7 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -36,11 +36,9 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
@@ -231,22 +231,6 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_interrupted` WS event (user-initiated stop). */
|
||||
executionInterrupted(jobId: string, nodeId: string): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_interrupted',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
|
||||
@@ -38,6 +38,7 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const dialogs = page.getByRole('dialog')
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await dialogs.count()) === 0) break
|
||||
if ((await mask.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await dialogs
|
||||
await mask
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
@@ -612,23 +612,18 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
|
||||
async function runOnBackgroundTab(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute
|
||||
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await comfyPage.workflow.waitForActiveWorkflow()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(topbar.getActiveTab()).toContainText('(2)')
|
||||
|
||||
const backgroundTab = topbar.getTab(0)
|
||||
exec.executionStart(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toBeVisible()
|
||||
|
||||
return { exec, jobId, backgroundTab }
|
||||
}
|
||||
|
||||
test.describe('Workflow tab status indicator', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('replaces the running indicator with completed when the job finishes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows failed when the background job errors', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
|
||||
|
||||
// The error opens a modal dialog that aria-hides the rest of the app
|
||||
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
|
||||
// so the badge is reachable by role.
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(errorDialog).toBeHidden()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Failed' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('drops the indicator on user interrupt rather than showing an error', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionInterrupted(jobId, KSAMPLER_NODE)
|
||||
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clears the indicator once the tab is activated', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
|
||||
const currentTab = comfyPage.menu.topbar.getActiveTab()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await backgroundTab.click()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
|
||||
await currentTab.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -44,30 +44,14 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -12,9 +11,7 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -26,13 +23,11 @@ export function showConfirmDialog(
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -194,15 +195,20 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
|
||||
if (
|
||||
item.state === 'running' ||
|
||||
item.state === 'initialization' ||
|
||||
item.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(jobId)
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -286,8 +292,17 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
if (!jobIds.length) return
|
||||
|
||||
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
|
||||
await api.cancelJobs(jobIds)
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { markRaw } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import type * as ExecutionStoreModule from '@/stores/executionStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockWorkflowStatus, mockCloseWorkflow } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockWorkflowStatus: shallowRef<Map<object, WorkflowExecutionStatus>>(
|
||||
new Map()
|
||||
),
|
||||
mockCloseWorkflow: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
currentUser: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: true
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ExecutionStoreModule>()
|
||||
return {
|
||||
WORKFLOW_STATUS_I18N_KEYS: actual.WORKFLOW_STATUS_I18N_KEYS,
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus(workflow: object | undefined | null) {
|
||||
if (!workflow) return undefined
|
||||
return mockWorkflowStatus.value.get(workflow)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDraggable: vi.fn(),
|
||||
usePragmaticDroppable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
|
||||
useWorkflowActionsMenu: () => ({
|
||||
menuItems: { value: [] }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
closeWorkflow: mockCloseWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
useWorkflowThumbnail: () => ({
|
||||
getThumbnail: vi.fn(() => null)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./WorkflowTabPopover.vue', () => ({
|
||||
default: {
|
||||
render: () => null,
|
||||
methods: {
|
||||
showPopover: () => {},
|
||||
hidePopover: () => {},
|
||||
togglePopover: () => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import WorkflowTab from './WorkflowTab.vue'
|
||||
|
||||
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
|
||||
|
||||
const statusAriaLabels: Record<WorkflowExecutionStatus, string> = {
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { close: 'Close', ...statusAriaLabels }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type WorkflowOption = WorkflowTabProps['workflowOption']
|
||||
type Workflow = WorkflowOption['workflow']
|
||||
type WorkflowOverrides = Partial<Workflow>
|
||||
|
||||
// ComfyWorkflow has many required fields the component never reads (file
|
||||
// IO, change tracking). Validate the fields we *do* set against the real
|
||||
// type via Partial<Workflow>, then cast — adding/renaming a read field in
|
||||
// the component will fail typecheck on the override map.
|
||||
function makeWorkflowOption(overrides: WorkflowOverrides = {}): WorkflowOption {
|
||||
const workflow = {
|
||||
key: 'test-key',
|
||||
path: '/workflows/test.json',
|
||||
filename: 'test.json',
|
||||
isPersisted: true,
|
||||
isModified: false,
|
||||
activeMode: 'graph',
|
||||
changeTracker: null,
|
||||
...overrides
|
||||
} satisfies WorkflowOverrides
|
||||
// markRaw keeps a stable identity through prop reactivity so the store's
|
||||
// identity-based status lookup resolves against the same object.
|
||||
return { value: 'test-key', workflow: markRaw(workflow) as Workflow }
|
||||
}
|
||||
|
||||
function renderTab({
|
||||
workflowOption = makeWorkflowOption(),
|
||||
activeWorkflowKey = 'other-key'
|
||||
}: {
|
||||
workflowOption?: WorkflowOption
|
||||
activeWorkflowKey?: string
|
||||
} = {}) {
|
||||
return render(WorkflowTab, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
workspace: { shiftDown: false },
|
||||
workflow: {
|
||||
activeWorkflow: { key: activeWorkflowKey }
|
||||
},
|
||||
setting: { settingValues: { 'Comfy.Workflow.AutoSave': 'off' } }
|
||||
}
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
WorkflowActionsList: true,
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
workflowOption,
|
||||
isFirst: false,
|
||||
isLast: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowTab - workflow status indicator', () => {
|
||||
beforeEach(() => {
|
||||
mockWorkflowStatus.value = new Map()
|
||||
})
|
||||
|
||||
it.for(['running', 'completed', 'failed'] as const)(
|
||||
'labels the %s indicator with a translated status name',
|
||||
(status) => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, status]])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels[status] })
|
||||
).toBeTruthy()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not badge the active tab with its own status', () => {
|
||||
const workflowOption = makeWorkflowOption()
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption, activeWorkflowKey: 'test-key' })
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows unsaved dot when no workflow status and workflow is unsaved', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isPersisted: false }) })
|
||||
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('shows the unsaved dot when modified and autosave is off', () => {
|
||||
renderTab({ workflowOption: makeWorkflowOption({ isModified: true }) })
|
||||
|
||||
expect(screen.getByTestId('workflow-dirty-indicator').textContent).toBe('•')
|
||||
})
|
||||
|
||||
it('workflow status replaces the unsaved dot', () => {
|
||||
const workflowOption = makeWorkflowOption({ isPersisted: false })
|
||||
mockWorkflowStatus.value = new Map([[workflowOption.workflow, 'running']])
|
||||
|
||||
renderTab({ workflowOption })
|
||||
expect(
|
||||
screen.getByRole('img', { name: statusAriaLabels.running })
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByTestId('workflow-dirty-indicator')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowTab - close button', () => {
|
||||
beforeEach(() => {
|
||||
mockCloseWorkflow.mockClear()
|
||||
})
|
||||
|
||||
it('delegates close to workflow service with the tab workflow', async () => {
|
||||
renderTab()
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('close-workflow-button'))
|
||||
|
||||
expect(mockCloseWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'test-key' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -21,19 +21,8 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<i
|
||||
v-if="workflowStatus"
|
||||
role="img"
|
||||
:aria-label="workflowStatusLabel"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
|
||||
workflowStatusIconClasses[workflowStatus]
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-else-if="shouldShowUnsavedIndicator"
|
||||
v-if="shouldShowStatusIndicator"
|
||||
data-testid="workflow-dirty-indicator"
|
||||
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
|
||||
>•</span
|
||||
@@ -43,7 +32,6 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.close')"
|
||||
data-testid="close-workflow-button"
|
||||
@click.stop="onCloseWorkflow(workflowOption)"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
@@ -97,14 +85,8 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
import {
|
||||
useExecutionStore,
|
||||
WORKFLOW_STATUS_I18N_KEYS
|
||||
} from '@/stores/executionStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import WorkflowTabPopover from './WorkflowTabPopover.vue'
|
||||
|
||||
@@ -131,7 +113,6 @@ const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const workflowTabRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
@@ -144,7 +125,7 @@ const autoSaveDelay = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.AutoSaveDelay')
|
||||
)
|
||||
|
||||
const shouldShowUnsavedIndicator = computed(() => {
|
||||
const shouldShowStatusIndicator = computed(() => {
|
||||
if (workspaceStore.shiftDown) {
|
||||
// Branch 1: Shift key is held down, do not show the status indicator.
|
||||
return false
|
||||
@@ -179,27 +160,6 @@ const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
const workflowStatusIconClasses: Record<WorkflowExecutionStatus, string> = {
|
||||
running:
|
||||
'text-base-foreground icon-[lucide--loader-circle] motion-safe:animate-spin',
|
||||
completed: 'icon-[lucide--circle-check] text-success-background',
|
||||
failed: 'icon-[lucide--octagon-alert] text-destructive-background'
|
||||
}
|
||||
|
||||
// The active tab doesn't badge its own status - the user is already looking
|
||||
// at it. Background tabs surface the recorded execution status.
|
||||
const workflowStatus = computed(() =>
|
||||
isActiveTab.value
|
||||
? undefined
|
||||
: executionStore.getWorkflowStatus(props.workflowOption.workflow)
|
||||
)
|
||||
|
||||
const workflowStatusLabel = computed(() =>
|
||||
workflowStatus.value
|
||||
? t(WORKFLOW_STATUS_I18N_KEYS[workflowStatus.value])
|
||||
: undefined
|
||||
)
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
|
||||
})
|
||||
|
||||
@@ -43,10 +43,6 @@ vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: { showSignInButton: false } })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowStatusDismissal', () => ({
|
||||
useWorkflowStatusDismissal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useOverflowObserver', () => ({
|
||||
useOverflowObserver: () => ({
|
||||
isOverflowing: { value: false },
|
||||
|
||||
@@ -117,7 +117,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useWorkflowStatusDismissal } from '@/composables/useWorkflowStatusDismissal'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
|
||||
@@ -146,9 +145,6 @@ const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
// Dismiss a tab's terminal status badge once it has been viewed
|
||||
useWorkflowStatusDismissal()
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
@@ -15,14 +15,7 @@ export interface PositionConfig {
|
||||
scale?: number
|
||||
}
|
||||
|
||||
interface UseAbsolutePositionReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updatePosition: (config: PositionConfig) => void
|
||||
}
|
||||
|
||||
export function useAbsolutePosition(
|
||||
options: { useTransform?: boolean } = {}
|
||||
): UseAbsolutePositionReturn {
|
||||
export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
const { useTransform = false } = options
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Rect {
|
||||
@@ -28,26 +28,7 @@ interface ClippingOptions {
|
||||
margin?: number
|
||||
}
|
||||
|
||||
interface UseDomClippingReturn {
|
||||
style: Ref<CSSProperties>
|
||||
updateClipPath: (
|
||||
element: HTMLElement,
|
||||
canvasElement: HTMLCanvasElement,
|
||||
isSelected: boolean,
|
||||
selectedArea?: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
offset: [number, number]
|
||||
}
|
||||
) => void
|
||||
}
|
||||
|
||||
export function useDomClipping(
|
||||
options: ClippingOptions = {}
|
||||
): UseDomClippingReturn {
|
||||
export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
const style = ref<CSSProperties>({})
|
||||
const { margin = 4 } = options
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComputedRef, CSSProperties, Ref } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -8,23 +8,10 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_MARGIN = 16
|
||||
|
||||
interface UseNodePreviewAndDragReturn {
|
||||
previewRef: Ref<HTMLElement | null>
|
||||
isHovered: Ref<boolean>
|
||||
isDragging: Ref<boolean>
|
||||
showPreview: ComputedRef<boolean>
|
||||
nodePreviewStyle: Ref<CSSProperties>
|
||||
sidebarLocation: ComputedRef<'left' | 'right'>
|
||||
handleMouseEnter: (e: MouseEvent) => void
|
||||
handleMouseLeave: () => void
|
||||
handleDragStart: (e: DragEvent) => void
|
||||
handleDragEnd: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useNodePreviewAndDrag(
|
||||
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
|
||||
panelRef?: Ref<HTMLElement | null>
|
||||
): UseNodePreviewAndDragReturn {
|
||||
) {
|
||||
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
|
||||
const settingStore = useSettingStore()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
|
||||
@@ -68,10 +68,12 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: class {}
|
||||
}))
|
||||
|
||||
const cancelJobMock = vi.fn()
|
||||
const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
cancelJob: (jobId: string) => cancelJobMock(jobId)
|
||||
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -195,7 +197,6 @@ describe('useJobMenu', () => {
|
||||
}))
|
||||
queueStoreMock.update.mockResolvedValue(undefined)
|
||||
queueStoreMock.delete.mockResolvedValue(undefined)
|
||||
cancelJobMock.mockResolvedValue(undefined)
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
|
||||
task,
|
||||
@@ -280,18 +281,29 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
it.for([
|
||||
['running', interruptMock, deleteItemMock],
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async ([state]) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob()
|
||||
await cancelJob()
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
@@ -299,22 +311,11 @@ describe('useJobMenu', () => {
|
||||
|
||||
await cancelJob()
|
||||
|
||||
expect(cancelJobMock).not.toHaveBeenCalled()
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates cancel failures from the API', async () => {
|
||||
cancelJobMock.mockRejectedValueOnce(new Error('Failed to cancel job'))
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
await expect(cancelJob()).rejects.toThrow('Failed to cancel job')
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
// Queue refresh is skipped when the cancel request itself fails.
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -859,7 +860,7 @@ describe('useJobMenu', () => {
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
@@ -82,13 +83,14 @@ export function useJobMenu(
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (
|
||||
target.state === 'running' ||
|
||||
target.state === 'initialization' ||
|
||||
target.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(target.id)
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', target.id)
|
||||
} else {
|
||||
await api.interrupt(target.id)
|
||||
}
|
||||
} else if (target.state === 'pending') {
|
||||
await api.deleteItem('queue', target.id)
|
||||
}
|
||||
executionStore.clearInitializationByJobId(target.id)
|
||||
await queueStore.update()
|
||||
|
||||
@@ -9,13 +9,19 @@ export const useQueueClearHistoryDialog = () => {
|
||||
key: 'queue-clear-history',
|
||||
component: QueueClearHistoryDialog,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
closable: false,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
// The content draws its own panel — neutralize the chrome box.
|
||||
contentClass: 'w-fit max-w-90 border-none bg-transparent shadow-none'
|
||||
pt: {
|
||||
root: {
|
||||
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
|
||||
},
|
||||
content: {
|
||||
class: 'bg-transparent',
|
||||
style: 'padding: 0'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export type ResizeDirection =
|
||||
type ResizeDirection =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
@@ -17,17 +17,6 @@ export type ResizeDirection =
|
||||
| 'sw'
|
||||
| 'se'
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
@@ -275,6 +264,17 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
height: `${cropHeight.value * scaleFactor.value}px`
|
||||
}))
|
||||
|
||||
interface ResizeHandle {
|
||||
direction: ResizeDirection
|
||||
class: string
|
||||
style: {
|
||||
left: string
|
||||
top: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CORNER_DIRECTIONS = new Set<ResizeDirection>(['nw', 'ne', 'sw', 'se'])
|
||||
|
||||
const allResizeHandles = computed<ResizeHandle[]>(() => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick } from 'vue'
|
||||
|
||||
import type { WorkflowExecutionStatus } from '@/stores/executionStore'
|
||||
|
||||
const { mockActiveWorkflow, statusMap } = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockActiveWorkflow: shallowRef<object | null>(null),
|
||||
statusMap: shallowRef<Map<object, WorkflowExecutionStatus>>(new Map())
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
getWorkflowStatus: (workflow: object | null | undefined) =>
|
||||
workflow ? statusMap.value.get(workflow) : undefined,
|
||||
clearWorkflowStatus: (workflow: object) => {
|
||||
const next = new Map(statusMap.value)
|
||||
next.delete(workflow)
|
||||
statusMap.value = next
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
import { useWorkflowStatusDismissal } from './useWorkflowStatusDismissal'
|
||||
|
||||
const workflowA = { path: '/a.json' }
|
||||
const workflowB = { path: '/b.json' }
|
||||
|
||||
function mount() {
|
||||
const scope = effectScope()
|
||||
scope.run(() => useWorkflowStatusDismissal())
|
||||
return () => scope.stop()
|
||||
}
|
||||
|
||||
describe('useWorkflowStatusDismissal', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
statusMap.value = new Map()
|
||||
})
|
||||
|
||||
it('clears a terminal status when its workflow becomes active', async () => {
|
||||
statusMap.value = new Map([[workflowA, 'completed']])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('clears a terminal status that arrives while the workflow is active', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'failed']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps a running status on the active workflow', async () => {
|
||||
mockActiveWorkflow.value = workflowA
|
||||
const stop = mount()
|
||||
|
||||
statusMap.value = new Map([[workflowA, 'running']])
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.get(workflowA)).toBe('running')
|
||||
stop()
|
||||
})
|
||||
|
||||
it('leaves other workflows untouched', async () => {
|
||||
statusMap.value = new Map([
|
||||
[workflowA, 'completed'],
|
||||
[workflowB, 'completed']
|
||||
])
|
||||
const stop = mount()
|
||||
|
||||
mockActiveWorkflow.value = workflowA
|
||||
await nextTick()
|
||||
|
||||
expect(statusMap.value.has(workflowA)).toBe(false)
|
||||
expect(statusMap.value.get(workflowB)).toBe('completed')
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export function useWorkflowStatusDismissal() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -36,15 +36,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
options?.afterClose?.()
|
||||
},
|
||||
initialCategory
|
||||
},
|
||||
// The template browser is a wide layout. Without an explicit size the
|
||||
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the
|
||||
// filter bar so the Clear Filters button lands outside the viewport.
|
||||
// Size it like the other large dialogs (Settings/Manager).
|
||||
dialogComponentProps: {
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[80vh] rounded-2xl overflow-hidden'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,10 +154,8 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const t: (typeof i18n.global)['t'] = i18n.global.t
|
||||
export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -2474,19 +2474,6 @@
|
||||
"confirmCancel": "Cancel subscription",
|
||||
"failed": "Failed to cancel subscription"
|
||||
},
|
||||
"downgrade": {
|
||||
"title": "Change to {plan} plan?",
|
||||
"body": "All other members of this workspace will be immediately removed.",
|
||||
"confirmationPhrase": "I understand",
|
||||
"confirmationPrompt": "Type \"{phrase}\" to confirm.",
|
||||
"confirm": "Change plan",
|
||||
"failed": "Failed to change plan",
|
||||
"notAllowed": "This plan change is not available",
|
||||
"paymentMethodRequired": "A payment method is required to change plans",
|
||||
"paymentPageBlocked": "Couldn't open the payment page — please try again",
|
||||
"memberRemovalFailed": "Couldn't remove {email} from the team — some members may already be removed and your plan was not changed",
|
||||
"failedAfterMemberRemoval": "Team members were removed, but the plan change didn't complete — please try again or contact support"
|
||||
},
|
||||
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
|
||||
"partnerNodesDescription": "For running commercial/proprietary models",
|
||||
"totalCredits": "Total credits",
|
||||
@@ -3353,7 +3340,7 @@
|
||||
"mediaLabel": "{count} Media File | {count} Media Files",
|
||||
"modelsLabel": "{count} Model | {count} Models",
|
||||
"checkingAssets": "Checking media visibility…",
|
||||
"acknowledgeCheckbox": "I understand anyone with the link can view these files",
|
||||
"acknowledgeCheckbox": "I understand these media items will be published and made public",
|
||||
"inLibrary": "In library",
|
||||
"comfyHubTitle": "Upload to ComfyHub",
|
||||
"comfyHubDescription": "ComfyHub is ComfyUI's official community hub.\nYour workflow will have a public page viewable by all.",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { initializeApp } from 'firebase/app'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
@@ -132,6 +133,7 @@ app
|
||||
}
|
||||
}
|
||||
})
|
||||
.use(ConfirmationService)
|
||||
.use(ToastService)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
|
||||
@@ -144,6 +144,7 @@ import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import {
|
||||
formatDuration,
|
||||
@@ -157,10 +158,7 @@ import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import {
|
||||
getAssetDisplayName,
|
||||
resolveDisplayImageDimensions
|
||||
} from '../utils/assetMetadataUtils'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey, MIME_ASSET_INFO } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
@@ -281,15 +279,12 @@ const formattedDuration = computed(() => {
|
||||
return formatDuration(Number(duration))
|
||||
})
|
||||
|
||||
const displayImageDimensions = computed(() =>
|
||||
resolveDisplayImageDimensions(asset, imageDimensions.value)
|
||||
)
|
||||
|
||||
// Get metadata info based on file kind
|
||||
const metaInfo = computed(() => {
|
||||
if (!asset) return ''
|
||||
if (fileKind.value === 'image' && displayImageDimensions.value) {
|
||||
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
|
||||
// TODO(assets): Re-enable once /assets API returns original image dimensions in metadata (#10590)
|
||||
if (fileKind.value === 'image' && imageDimensions.value && !isCloud) {
|
||||
return `${imageDimensions.value.width}x${imageDimensions.value.height}`
|
||||
}
|
||||
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
|
||||
return formatSize(asset.size)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
interface ShowOptions {
|
||||
@@ -24,10 +23,6 @@ interface BrowseOptions {
|
||||
}
|
||||
|
||||
const DIALOG_KEY = 'global-asset-browser'
|
||||
const ASSET_BROWSER_DIALOG_PROPS = {
|
||||
contentClass:
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
|
||||
} satisfies DialogComponentProps
|
||||
|
||||
export const useAssetBrowserDialog = () => {
|
||||
const dialogService = useDialogService()
|
||||
@@ -52,8 +47,7 @@ export const useAssetBrowserDialog = () => {
|
||||
currentValue: props.currentValue,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,8 +66,7 @@ export const useAssetBrowserDialog = () => {
|
||||
title: options.title,
|
||||
onSelect: handleAssetSelected,
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: ASSET_BROWSER_DIALOG_PROPS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -774,10 +774,6 @@ export function useMediaAssetActions() {
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,15 +13,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
type UploadModelContextResolver = () => UploadModelDialogContext | undefined
|
||||
|
||||
// Contents bring their own width and padding — shrink-wrap the chrome and
|
||||
// zero the section padding (the PrimeVue `pt` overrides this replaces).
|
||||
const uploadDialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
contentClass: 'w-fit max-w-[calc(100vw-1rem)]',
|
||||
headerClass: 'py-0 pl-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden'
|
||||
} as const
|
||||
|
||||
export function useModelUpload(
|
||||
onUploadSuccess?: (result: UploadModelSuccess) => Promise<unknown> | void,
|
||||
uploadContext?: UploadModelDialogContext | UploadModelContextResolver
|
||||
@@ -40,7 +31,12 @@ export function useModelUpload(
|
||||
key: 'upload-model-upgrade',
|
||||
headerComponent: UploadModelUpgradeModalHeader,
|
||||
component: UploadModelUpgradeModal,
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
dialogStore.showDialog({
|
||||
@@ -53,7 +49,12 @@ export function useModelUpload(
|
||||
await onUploadSuccess?.(result)
|
||||
}
|
||||
},
|
||||
dialogComponentProps: uploadDialogComponentProps
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! pl-0!',
|
||||
content: 'p-0! overflow-y-hidden!'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,12 @@ import {
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions
|
||||
getSourceName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -419,124 +417,4 @@ describe('assetMetadataUtils', () => {
|
||||
expect(getAssetCardTitle(asset)).toBe('pretty.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetMetadataDimensions', () => {
|
||||
it('returns dimensions when width/height are positive integers', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1024, height: 768 } }
|
||||
expect(getAssetMetadataDimensions(asset)).toEqual({
|
||||
width: 1024,
|
||||
height: 768
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'NaN width', width: Number.NaN, height: 768 },
|
||||
{
|
||||
name: 'Infinity height',
|
||||
width: 1024,
|
||||
height: Number.POSITIVE_INFINITY
|
||||
},
|
||||
{ name: 'zero width', width: 0, height: 768 },
|
||||
{ name: 'negative height', width: 1024, height: -1 },
|
||||
{ name: 'fractional width', width: 1024.5, height: 768 },
|
||||
{ name: 'string width', width: '1024', height: 768 },
|
||||
{ name: 'missing width', width: undefined, height: 768 }
|
||||
])('returns undefined for invalid shape: $name', ({ width, height }) => {
|
||||
const asset = { ...mockAsset, metadata: { width, height } }
|
||||
expect(getAssetMetadataDimensions(asset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when metadata is absent', () => {
|
||||
expect(getAssetMetadataDimensions(mockAsset)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when asset itself is undefined', () => {
|
||||
expect(getAssetMetadataDimensions(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDisplayImageDimensions', () => {
|
||||
const rendered = { width: 512, height: 288 }
|
||||
|
||||
it('prefers server metadata dimensions over the rendered natural size', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 1920, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers metadata even when a downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 1920, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual({
|
||||
width: 1920,
|
||||
height: 1080
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when no thumbnail was shown (original served)', () => {
|
||||
const asset = { ...mockAsset }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size on OSS where thumbnail_url equals preview_url (full-res)', () => {
|
||||
const fullResUrl =
|
||||
'http://localhost:8188/view?filename=output.png&type=output'
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: fullResUrl,
|
||||
preview_url: fullResUrl
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined (no label) when metadata is absent and a distinct downscaled thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback for an invalid metadata shape when a distinct thumbnail was rendered', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp?res=512',
|
||||
preview_url: 'https://cdn.example/original.webp',
|
||||
metadata: { width: 0, height: 1080 }
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses the fallback when thumbnail_url is present but preview_url is absent', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
thumbnail_url: 'https://cdn.example/thumb.webp'
|
||||
}
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the rendered natural size when metadata is invalid and no thumbnail guard applies', () => {
|
||||
const asset = { ...mockAsset, metadata: { width: 0, height: 1080 } }
|
||||
expect(resolveDisplayImageDimensions(asset, rendered)).toEqual(rendered)
|
||||
})
|
||||
|
||||
it('returns undefined when neither metadata nor a rendered size is available', () => {
|
||||
expect(
|
||||
resolveDisplayImageDimensions(mockAsset, undefined)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the rendered size when asset is undefined (no thumbnail to guard against)', () => {
|
||||
expect(resolveDisplayImageDimensions(undefined, rendered)).toEqual(
|
||||
rendered
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -216,64 +216,6 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
return getAssetDisplayFilename(asset)
|
||||
}
|
||||
|
||||
export interface ImageDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: a pixel dimension is a finite positive integer. `metadata` is
|
||||
* typed as `Record<string, unknown>`, so `typeof === 'number'` alone admits
|
||||
* NaN, Infinity, 0, negatives, and fractional values.
|
||||
*/
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original image dimensions from `asset.metadata.{width,height}`
|
||||
* when both pass shape validation, otherwise `undefined`. Callers should fall
|
||||
* back to the locally-computed `<img>.naturalWidth/Height`, which is correct
|
||||
* on runtimes that serve the original file but reports preview size on
|
||||
* runtimes that serve a downscaled preview.
|
||||
*/
|
||||
export function getAssetMetadataDimensions(
|
||||
asset: AssetItem | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const w = asset?.metadata?.width
|
||||
const h = asset?.metadata?.height
|
||||
if (isValidDimension(w) && isValidDimension(h)) {
|
||||
return { width: w, height: h }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image dimensions an asset card should display.
|
||||
*
|
||||
* Prefers the server-provided original dimensions from
|
||||
* {@link getAssetMetadataDimensions}. Only when those are absent does it fall
|
||||
* back to `renderedNaturalSize` — the natural size of the `<img>` the card
|
||||
* actually rendered — and only when that rendered image was the original file.
|
||||
*
|
||||
* A distinct `thumbnail_url` (one that differs from `preview_url`) means the
|
||||
* card rendered a downscaled preview, so `renderedNaturalSize` reflects the
|
||||
* preview's dimensions rather than the asset's. In that case this returns
|
||||
* `undefined` so the card shows no label rather than a wrong resolution.
|
||||
* On OSS, `thumbnail_url` and `preview_url` are the same URL (full-res),
|
||||
* so the guard correctly passes through `renderedNaturalSize`.
|
||||
*/
|
||||
export function resolveDisplayImageDimensions(
|
||||
asset: AssetItem | undefined,
|
||||
renderedNaturalSize: ImageDimensions | undefined
|
||||
): ImageDimensions | undefined {
|
||||
const fromMetadata = getAssetMetadataDimensions(asset)
|
||||
if (fromMetadata) return fromMetadata
|
||||
if (asset?.thumbnail_url && asset.thumbnail_url !== asset.preview_url)
|
||||
return undefined
|
||||
return renderedNaturalSize
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
|
||||
@@ -49,9 +49,13 @@ export const useSubscriptionDialog = () => {
|
||||
),
|
||||
props: { onClose: hide },
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass:
|
||||
'w-[min(360px,95vw)] max-w-[min(360px,95vw)] sm:max-w-[min(360px,95vw)] border-0 bg-transparent shadow-none'
|
||||
style: 'width: min(360px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'bg-transparent border-none rounded-none shadow-none'
|
||||
},
|
||||
content: { class: '!p-0 bg-transparent border-none shadow-none' }
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -85,14 +89,16 @@ export const useSubscriptionDialog = () => {
|
||||
component,
|
||||
props: useWorkspaceVariant ? workspaceProps : personalProps,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
// The pricing tables host a PrimeVue Popover teleported to body.
|
||||
// Reka's modal mode traps focus and disables body pointer-events,
|
||||
// making the popover unclickable. Mirrors Settings/Manager.
|
||||
modal: false,
|
||||
contentClass:
|
||||
'w-[min(1328px,95vw)] max-w-[min(1328px,95vw)] sm:max-w-[min(1328px,95vw)] h-full max-h-[958px] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
style: 'width: min(1328px, 95vw); max-height: 958px;',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent h-full'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)] h-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -116,10 +122,16 @@ export const useSubscriptionDialog = () => {
|
||||
}
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-[min(640px,95vw)] max-w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)] overflow-hidden rounded-2xl border-border-default bg-base-background/60 shadow-[0_25px_80px_rgba(5,6,12,0.45)] backdrop-blur-md'
|
||||
style: 'width: min(640px, 95vw);',
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl bg-transparent'
|
||||
},
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
@@ -66,7 +66,11 @@ export function useShareDialog() {
|
||||
onClose: hide
|
||||
},
|
||||
dialogComponentProps: {
|
||||
contentClass: 'sm:max-w-144 rounded-2xl overflow-hidden'
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-144 max-w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ export function useSharedWorkflowUrlLoader() {
|
||||
},
|
||||
dialogComponentProps: {
|
||||
onClose: () => resolve({ action: 'cancel' }),
|
||||
contentClass: 'sm:max-w-176 rounded-2xl overflow-hidden'
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden w-full sm:w-176 max-w-full'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,11 +31,6 @@ export interface Member {
|
||||
email: string
|
||||
joined_at: string
|
||||
role: WorkspaceRole
|
||||
// True when this member is the workspace's original owner/creator
|
||||
// (member.id == workspace.created_by_user_id). Gates the creator-only
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
class="current-user-popover -m-3 w-fit max-w-96 min-w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
class="current-user-popover -m-3 w-80 rounded-lg border border-border-default bg-base-background p-2 shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="mb-4 flex flex-col items-center px-0 py-3">
|
||||
@@ -113,11 +113,7 @@
|
||||
button-variant="gradient"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
showSubscribeAction &&
|
||||
!isPersonalWorkspace &&
|
||||
(!isCancelled || permissions.canManageSubscriptionLifecycle)
|
||||
"
|
||||
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
|
||||
@@ -128,10 +128,9 @@
|
||||
v-if="isActiveSubscription && permissions.canManageSubscription"
|
||||
class="flex flex-wrap gap-2 md:ml-auto"
|
||||
>
|
||||
<!-- Cancelled state: reactivation is original-owner-only. -->
|
||||
<!-- Cancelled state: show only Resubscribe button -->
|
||||
<template v-if="isCancelled">
|
||||
<Button
|
||||
v-if="permissions.canManageSubscriptionLifecycle"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="rounded-lg px-4 text-sm font-normal"
|
||||
@@ -162,7 +161,7 @@
|
||||
{{ $t('subscription.upgradePlan') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isFreeTierPlan && planMenuItems.length > 0"
|
||||
v-if="!isFreeTierPlan"
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@@ -514,23 +513,15 @@ const subscriptionTierName = computed(() => {
|
||||
|
||||
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
// Cancel is original-owner-only (creator); a promoted owner gets no menu items
|
||||
// and the "more options" button is hidden (see template).
|
||||
const planMenuItems = computed(() =>
|
||||
permissions.value.canManageSubscriptionLifecycle
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
const planMenuItems = computed(() => [
|
||||
{
|
||||
label: t('subscription.cancelSubscription'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscriptionTier.value
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import DowngradeRemoveMembersDialogContent from './DowngradeRemoveMembersDialogContent.vue'
|
||||
|
||||
const mockCloseDialog = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: mockCloseDialog
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {
|
||||
g: { cancel: 'Cancel', close: 'Close', unknownError: 'Unknown error' },
|
||||
subscription: {
|
||||
downgrade: {
|
||||
title: 'Change to {plan} plan?',
|
||||
body: 'All other members of this workspace will be immediately removed.',
|
||||
confirmationPhrase: 'I understand',
|
||||
confirmationPrompt: 'Type "{phrase}" to confirm.',
|
||||
confirm: 'Change plan',
|
||||
failed: 'Failed to change plan'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function mountComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
render(DowngradeRemoveMembersDialogContent, {
|
||||
props: {
|
||||
planName: 'Founder',
|
||||
planSlug: 'founder-monthly',
|
||||
onConfirm,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { user, onConfirm }
|
||||
}
|
||||
|
||||
const getPhraseInput = () => screen.getByRole('textbox')
|
||||
const getChangePlanButton = () =>
|
||||
screen.getByRole('button', { name: 'Change plan' })
|
||||
const getCancelButton = () => screen.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
describe('DowngradeRemoveMembersDialogContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('disables Change plan until the exact phrase is typed', async () => {
|
||||
const { user } = mountComponent()
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'I understan')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
|
||||
await user.type(getPhraseInput(), 'd')
|
||||
expect(getChangePlanButton()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps Change plan disabled for a case-mismatched phrase', async () => {
|
||||
const { user } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'i understand')
|
||||
expect(getChangePlanButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('invokes onConfirm with the plan slug and closes when confirmed', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('founder-monthly')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes without calling onConfirm when cancelled', async () => {
|
||||
const { user, onConfirm } = mountComponent()
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getCancelButton())
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'downgrade-remove-members'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error toast and stays open when onConfirm rejects', async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const { user } = mountComponent({ onConfirm })
|
||||
await user.type(getPhraseInput(), 'I understand')
|
||||
await user.click(getChangePlanButton())
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(mockCloseDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
<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.downgrade.title', { plan: planName }) }}
|
||||
</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
: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 p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.body') }}
|
||||
</p>
|
||||
<label class="flex flex-col gap-2 text-sm text-muted-foreground">
|
||||
{{ $t('subscription.downgrade.confirmationPrompt', { phrase }) }}
|
||||
<Input
|
||||
v-model="typedValue"
|
||||
type="text"
|
||||
:placeholder="phrase"
|
||||
:disabled="isLoading"
|
||||
autofocus
|
||||
@keyup.enter="onConfirmDowngrade"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 p-4">
|
||||
<Button variant="muted-textonly" :disabled="isLoading" @click="onClose">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
:disabled="!isConfirmed"
|
||||
:loading="isLoading"
|
||||
@click="onConfirmDowngrade"
|
||||
>
|
||||
{{ $t('subscription.downgrade.confirm') }}
|
||||
</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 Input from '@/components/ui/input/Input.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { planName, planSlug, onConfirm } = defineProps<{
|
||||
planName: string
|
||||
planSlug: string
|
||||
onConfirm: (planSlug: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
|
||||
const phrase = t('subscription.downgrade.confirmationPhrase')
|
||||
|
||||
const typedValue = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const isConfirmed = computed(() => typedValue.value === phrase)
|
||||
|
||||
function onClose() {
|
||||
if (isLoading.value) return
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
}
|
||||
|
||||
async function onConfirmDowngrade() {
|
||||
if (!isConfirmed.value || isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
await onConfirm(planSlug)
|
||||
dialogStore.closeDialog({ key: 'downgrade-remove-members' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -82,8 +82,7 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
name: 'Owner User',
|
||||
email: 'owner@example.com',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
joinDate: new Date(0)
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
@@ -154,7 +153,6 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import { useDowngradeToPersonal } from './useDowngradeToPersonal'
|
||||
|
||||
const mockMembers = ref<WorkspaceMember[]>([])
|
||||
const mockUserEmail = ref<string | null>(null)
|
||||
const mockRemoveMember = vi.hoisted(() => vi.fn())
|
||||
const mockFetchMembers = vi.hoisted(() => vi.fn())
|
||||
const mockSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockPreviewSubscribe = vi.hoisted(() => vi.fn())
|
||||
const mockStartOperation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
members: mockMembers,
|
||||
removeMember: mockRemoveMember,
|
||||
fetchMembers: mockFetchMembers
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
|
||||
useBillingOperationStore: () => ({
|
||||
startOperation: mockStartOperation
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
subscribe: mockSubscribe,
|
||||
previewSubscribe: mockPreviewSubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: mockUserEmail
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.test'
|
||||
}))
|
||||
|
||||
function createMember(
|
||||
overrides: Partial<WorkspaceMember> = {}
|
||||
): WorkspaceMember {
|
||||
return {
|
||||
id: 'member-1',
|
||||
name: 'Member One',
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function teamWithOwnerAnd(...memberIds: string[]) {
|
||||
return [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: true
|
||||
}),
|
||||
...memberIds.map((id) => createMember({ id, email: `${id}@example.com` }))
|
||||
]
|
||||
}
|
||||
|
||||
describe('useDowngradeToPersonal', () => {
|
||||
let windowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockMembers.value = []
|
||||
mockUserEmail.value = null
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: true })
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
windowOpen = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
describe('removableMembers / hasOtherMembers', () => {
|
||||
it('protects only the original owner, removing promoted owners and members', () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'creator', role: 'owner', isOriginalOwner: true }),
|
||||
createMember({
|
||||
id: 'promoted-owner',
|
||||
role: 'owner',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({ id: 'member', role: 'member', isOriginalOwner: false })
|
||||
]
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual([
|
||||
'promoted-owner',
|
||||
'member'
|
||||
])
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no other members when only the original owner is present', () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
const { removableMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value).toEqual([])
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to protecting owners and the current user when the flag is absent', () => {
|
||||
mockUserEmail.value = 'me@example.com'
|
||||
mockMembers.value = [
|
||||
createMember({
|
||||
id: 'owner',
|
||||
role: 'owner',
|
||||
email: 'owner@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'me',
|
||||
role: 'member',
|
||||
email: 'me@example.com',
|
||||
isOriginalOwner: false
|
||||
}),
|
||||
createMember({
|
||||
id: 'plain',
|
||||
role: 'member',
|
||||
email: 'plain@example.com',
|
||||
isOriginalOwner: false
|
||||
})
|
||||
]
|
||||
const { removableMembers } = useDowngradeToPersonal()
|
||||
expect(removableMembers.value.map((m) => m.id)).toEqual(['plain'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downgradeToPersonal', () => {
|
||||
it('removes every non-creator member then initiates the tier change', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).toHaveBeenCalledTimes(2)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m2')
|
||||
expect(mockRemoveMember).not.toHaveBeenCalledWith('owner')
|
||||
expect(mockSubscribe).toHaveBeenCalledWith(
|
||||
'founder-monthly',
|
||||
'https://platform.test/payment/success',
|
||||
'https://platform.test/payment/failed'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never removes the original owner', async () => {
|
||||
mockMembers.value = [
|
||||
createMember({ id: 'me', role: 'owner', isOriginalOwner: true })
|
||||
]
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates the transition before removing, then removes, then subscribes', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
const calls: string[] = []
|
||||
mockPreviewSubscribe.mockImplementation(() => {
|
||||
calls.push('preview')
|
||||
return Promise.resolve({ allowed: true })
|
||||
})
|
||||
mockRemoveMember.mockImplementation(() => {
|
||||
calls.push('remove')
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockSubscribe.mockImplementation(() => {
|
||||
calls.push('subscribe')
|
||||
return Promise.resolve({ billing_op_id: 'op-1', status: 'subscribed' })
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(calls).toEqual(['preview', 'remove', 'subscribe'])
|
||||
})
|
||||
|
||||
it('throws the BE reason and removes nobody when the transition is disallowed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: 'Outstanding balance'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'Outstanding balance'
|
||||
)
|
||||
expect(mockRemoveMember).not.toHaveBeenCalled()
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the payment-method page and polls when subscribe needs a payment method', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-2',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith(
|
||||
'https://pay.test/method',
|
||||
'_blank'
|
||||
)
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-2', 'subscription')
|
||||
})
|
||||
|
||||
it('falls back to the generic message when the transition is disallowed without a reason', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockPreviewSubscribe.mockResolvedValue({ allowed: false })
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.notAllowed'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and skips polling when the payment tab is popup-blocked', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-5',
|
||||
status: 'needs_payment_method',
|
||||
payment_method_url: 'https://pay.test/method'
|
||||
})
|
||||
windowOpen.mockReturnValue(null)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentPageBlocked'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a payment method is needed but no url is provided', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-3',
|
||||
status: 'needs_payment_method'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.paymentMethodRequired'
|
||||
)
|
||||
expect(mockStartOperation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('polls without opening a tab when the payment is pending', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-4',
|
||||
status: 'pending_payment'
|
||||
})
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await downgradeToPersonal('founder-monthly')
|
||||
|
||||
expect(windowOpen).not.toHaveBeenCalled()
|
||||
expect(mockStartOperation).toHaveBeenCalledWith('op-4', 'subscription')
|
||||
})
|
||||
|
||||
it('reports the generic failure when subscribe fails and no members were removed', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd()
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
/^subscription\.downgrade\.failed$/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports members were already removed when subscribe fails after removal', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
mockSubscribe.mockResolvedValue(undefined)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'subscription.downgrade.failedAfterMemberRemoval'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces which member failed and skips the plan change when removal throws', async () => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
|
||||
mockRemoveMember.mockImplementation((id: string) =>
|
||||
id === 'm2' ? Promise.reject(new Error('network')) : Promise.resolve()
|
||||
)
|
||||
const { downgradeToPersonal } = useDowngradeToPersonal()
|
||||
|
||||
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
|
||||
'm2@example.com'
|
||||
)
|
||||
expect(mockRemoveMember).toHaveBeenCalledWith('m1')
|
||||
expect(mockSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMembers', () => {
|
||||
it('refetches members so a stale empty list cannot skip the confirm gate', async () => {
|
||||
mockMembers.value = []
|
||||
mockFetchMembers.mockImplementation(() => {
|
||||
mockMembers.value = teamWithOwnerAnd('m1')
|
||||
return Promise.resolve(mockMembers.value)
|
||||
})
|
||||
const { refreshMembers, hasOtherMembers } = useDowngradeToPersonal()
|
||||
expect(hasOtherMembers.value).toBe(false)
|
||||
|
||||
await refreshMembers()
|
||||
|
||||
expect(hasOtherMembers.value).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
/**
|
||||
* Team-plan downgrade to personal: validate via `previewSubscribe`, remove
|
||||
* every member except the original owner, then initiate the tier change.
|
||||
* BE seam (BE-1337): removal email and an atomic downgrade endpoint are
|
||||
* BE-owned; until then the FE orchestrates the two steps non-atomically.
|
||||
*/
|
||||
export function useDowngradeToPersonal() {
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(workspaceStore)
|
||||
const { subscribe, previewSubscribe } = useBillingContext()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const { userEmail } = useCurrentUser()
|
||||
|
||||
const removableMembers = computed(() => {
|
||||
const hasFlag = members.value.some((m) => m.isOriginalOwner)
|
||||
if (hasFlag) return members.value.filter((m) => !m.isOriginalOwner)
|
||||
const email = userEmail.value?.toLowerCase() ?? null
|
||||
return members.value.filter(
|
||||
(m) => m.role !== 'owner' && m.email.toLowerCase() !== email
|
||||
)
|
||||
})
|
||||
|
||||
const hasOtherMembers = computed(() => removableMembers.value.length > 0)
|
||||
|
||||
async function refreshMembers(): Promise<void> {
|
||||
await workspaceStore.fetchMembers()
|
||||
}
|
||||
|
||||
async function downgradeToPersonal(planSlug: string): Promise<void> {
|
||||
const preview = await previewSubscribe(planSlug)
|
||||
if (!preview?.allowed) {
|
||||
throw new Error(preview?.reason || t('subscription.downgrade.notAllowed'))
|
||||
}
|
||||
|
||||
const membersToRemove = removableMembers.value
|
||||
for (const member of membersToRemove) {
|
||||
try {
|
||||
await workspaceStore.removeMember(member.id)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
t('subscription.downgrade.memberRemovalFailed', {
|
||||
email: member.email
|
||||
}),
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await subscribe(
|
||||
planSlug,
|
||||
`${getComfyPlatformBaseUrl()}/payment/success`,
|
||||
`${getComfyPlatformBaseUrl()}/payment/failed`
|
||||
)
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
membersToRemove.length > 0
|
||||
? t('subscription.downgrade.failedAfterMemberRemoval')
|
||||
: t('subscription.downgrade.failed')
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status === 'needs_payment_method') {
|
||||
if (!response.payment_method_url) {
|
||||
throw new Error(t('subscription.downgrade.paymentMethodRequired'))
|
||||
}
|
||||
const paymentTab = window.open(response.payment_method_url, '_blank')
|
||||
if (!paymentTab) {
|
||||
throw new Error(t('subscription.downgrade.paymentPageBlocked'))
|
||||
}
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'pending_payment') {
|
||||
void billingOperationStore.startOperation(
|
||||
response.billing_op_id,
|
||||
'subscription'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
removableMembers,
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ function createMember(
|
||||
email: 'member1@example.com',
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,7 @@ export function useMembersPanel() {
|
||||
name: userDisplayName.value ?? '',
|
||||
email: userEmail.value ?? '',
|
||||
role: 'owner' as const,
|
||||
joinDate: new Date(0),
|
||||
isOriginalOwner: true
|
||||
joinDate: new Date(0)
|
||||
}))
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -274,27 +274,6 @@ describe('useWorkspaceBilling', () => {
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
})
|
||||
|
||||
it('returns the successful response when the post-subscribe refresh fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockResolvedValue({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
mockWorkspaceApi.getBillingStatus.mockRejectedValue(
|
||||
new Error('refresh down')
|
||||
)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.subscribe('pro')).resolves.toStrictEqual({
|
||||
billing_op_id: 'op-1',
|
||||
status: 'subscribed'
|
||||
})
|
||||
expect(billing.error.value).toBe(
|
||||
'Subscription succeeded, but billing state refresh failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates error and records message when subscribe fails', async () => {
|
||||
mockWorkspaceApi.subscribe.mockRejectedValue(new Error('denied'))
|
||||
|
||||
|
||||
@@ -146,18 +146,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
cancelUrl
|
||||
)
|
||||
|
||||
// Refresh is non-fatal: the subscribe write already succeeded, so a failed
|
||||
// refresh must not reject and prompt a retry of an active subscription.
|
||||
const [statusResult, balanceResult] = await Promise.allSettled([
|
||||
fetchStatus(),
|
||||
fetchBalance()
|
||||
])
|
||||
if (
|
||||
statusResult.status === 'rejected' ||
|
||||
balanceResult.status === 'rejected'
|
||||
) {
|
||||
error.value = 'Subscription succeeded, but billing state refresh failed'
|
||||
}
|
||||
// Refresh status and balance after subscription
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,21 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockStore = vi.hoisted(() => ({
|
||||
activeWorkspace: null as WorkspaceWithRole | null,
|
||||
isCurrentUserOriginalOwner: false,
|
||||
ensureMembersLoaded: vi.fn()
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockStore.activeWorkspace
|
||||
},
|
||||
get isCurrentUserOriginalOwner() {
|
||||
return mockStore.isCurrentUserOriginalOwner
|
||||
},
|
||||
ensureMembersLoaded: mockStore.ensureMembersLoaded
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -52,20 +46,14 @@ async function loadComposable() {
|
||||
return module.useWorkspaceUI()
|
||||
}
|
||||
|
||||
function resetStore() {
|
||||
mockStore.activeWorkspace = null
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
mockStore.ensureMembersLoaded.mockReset()
|
||||
}
|
||||
|
||||
describe('useWorkspaceUI', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
resetStore()
|
||||
mockActiveWorkspace.value = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetStore()
|
||||
mockActiveWorkspace.value = null
|
||||
})
|
||||
|
||||
describe('when no active workspace', () => {
|
||||
@@ -83,7 +71,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('personal workspace', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
mockActiveWorkspace.value = personalWorkspace
|
||||
})
|
||||
|
||||
it('grants billing access but disables team management', async () => {
|
||||
@@ -131,7 +119,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as owner', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
})
|
||||
|
||||
it('grants full management permissions', async () => {
|
||||
@@ -171,7 +159,7 @@ describe('useWorkspaceUI', () => {
|
||||
|
||||
describe('team workspace as member', () => {
|
||||
beforeEach(() => {
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
mockActiveWorkspace.value = teamMemberWorkspace
|
||||
})
|
||||
|
||||
it('restricts management actions while allowing leave', async () => {
|
||||
@@ -207,60 +195,9 @@ describe('useWorkspaceUI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Drives off the members-list self-row original-owner signal, surfaced by the
|
||||
// store getter `isCurrentUserOriginalOwner`.
|
||||
describe('subscription lifecycle (creator-only)', () => {
|
||||
it('grants lifecycle to the personal-workspace sole owner', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('grants lifecycle to a team owner who is the original owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = true
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from a promoted (non-creator) team owner', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscription).toBe(true)
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed while the members list is still loading', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockStore.isCurrentUserOriginalOwner = false
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds lifecycle from members', async () => {
|
||||
mockStore.activeWorkspace = teamMemberWorkspace
|
||||
const ui = await loadComposable()
|
||||
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false)
|
||||
})
|
||||
|
||||
it('delegates member loading to the store when a team workspace becomes active', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
mockStore.activeWorkspace = personalWorkspace
|
||||
await loadComposable()
|
||||
expect(mockStore.ensureMembersLoaded).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared instance', () => {
|
||||
it('returns the same composable state for multiple callers within a test', async () => {
|
||||
mockStore.activeWorkspace = teamOwnerWorkspace
|
||||
mockActiveWorkspace.value = teamOwnerWorkspace
|
||||
const first = await loadComposable()
|
||||
const second = await loadComposable()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
@@ -14,10 +14,6 @@ interface WorkspacePermissions {
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
// Creator-only subscription lifecycle: cancel / reactivate / downgrade.
|
||||
// Any owner has `canManageSubscription` (manage payment, top-up, change
|
||||
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
}
|
||||
|
||||
@@ -38,8 +34,7 @@ interface WorkspaceUIConfig {
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
role: WorkspaceRole
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
@@ -51,8 +46,6 @@ function getPermissions(
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: false,
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -67,7 +60,6 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
}
|
||||
}
|
||||
@@ -82,7 +74,6 @@ function getPermissions(
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
}
|
||||
}
|
||||
@@ -154,26 +145,8 @@ function useWorkspaceUIInternal() {
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
// The original-owner signal lives on the members-list self-row, so a team
|
||||
// workspace's members must be loaded before its lifecycle gate can resolve.
|
||||
// The store dedupes in-flight/already-loaded requests and logs failures;
|
||||
// until members arrive the getter fails closed.
|
||||
watch(
|
||||
() => store.activeWorkspace?.id,
|
||||
() => {
|
||||
if (store.activeWorkspace?.type === 'team') {
|
||||
void store.ensureMembersLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(
|
||||
workspaceType.value,
|
||||
workspaceRole.value,
|
||||
store.isCurrentUserOriginalOwner
|
||||
)
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
|
||||
@@ -29,15 +29,6 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||
}))
|
||||
|
||||
// Mock current user (drives the original-owner self-row match by email)
|
||||
const mockCurrentUser = vi.hoisted(() => ({
|
||||
userEmail: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail })
|
||||
}))
|
||||
|
||||
// Mock workspaceApi
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -131,7 +122,6 @@ describe('useTeamWorkspaceStore', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||
sessionStorage.clear()
|
||||
mockCurrentUser.userEmail.value = null
|
||||
|
||||
// Reset workspaceAuthStore mock state
|
||||
mockWorkspaceAuthStore.currentWorkspace = null
|
||||
@@ -690,193 +680,6 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureMembersLoaded', () => {
|
||||
const memberRow = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
|
||||
function mockMembersResponse() {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [memberRow],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTeamWorkspace() {
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
return store
|
||||
}
|
||||
|
||||
it('loads members for a team workspace that is not yet loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
expect(store.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not load members again once loaded', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls into a single request', async () => {
|
||||
mockMembersResponse()
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await Promise.all([
|
||||
store.ensureMembersLoaded(),
|
||||
store.ensureMembersLoaded()
|
||||
])
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not load members for a personal workspace', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a failed request and retries on the next call', async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {})
|
||||
mockWorkspaceApi.listMembers.mockRejectedValueOnce(new Error('boom'))
|
||||
const store = await activateTeamWorkspace()
|
||||
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
expect(store.members).toHaveLength(0)
|
||||
|
||||
mockMembersResponse()
|
||||
await store.ensureMembersLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.listMembers).toHaveBeenCalledTimes(2)
|
||||
expect(store.members).toHaveLength(1)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCurrentUserOriginalOwner', () => {
|
||||
async function loadTeamWithMembers(
|
||||
members: Array<{
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
is_original_owner?: boolean
|
||||
}>
|
||||
) {
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
await store.fetchMembers()
|
||||
return store
|
||||
}
|
||||
|
||||
const ownerSelf = {
|
||||
id: 'user-1',
|
||||
name: 'Owner',
|
||||
email: 'owner@test.com',
|
||||
joined_at: '2024-01-01T00:00:00Z',
|
||||
role: 'owner' as const,
|
||||
is_original_owner: true
|
||||
}
|
||||
const promotedSelf = { ...ownerSelf, is_original_owner: false }
|
||||
|
||||
it('is true when the self-row is the original owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the self-row by email case-insensitively', async () => {
|
||||
mockCurrentUser.userEmail.value = 'OWNER@TEST.COM'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the self-row is a promoted (non-creator) owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const store = await loadTeamWithMembers([promotedSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the self-row omits is_original_owner', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf
|
||||
const store = await loadTeamWithMembers([selfWithoutFlag])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when no member row matches the current user', async () => {
|
||||
mockCurrentUser.userEmail.value = 'someone-else@test.com'
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when members are not loaded', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the current user email is unknown', async () => {
|
||||
mockCurrentUser.userEmail.value = null
|
||||
const store = await loadTeamWithMembers([ownerSelf])
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
})
|
||||
|
||||
it('recomputes reactively when the self-row arrives after an empty read', async () => {
|
||||
mockCurrentUser.userEmail.value = 'owner@test.com'
|
||||
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||
members: [ownerSelf],
|
||||
pagination: { offset: 0, limit: 50, total: 1 }
|
||||
})
|
||||
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(false)
|
||||
|
||||
await store.fetchMembers()
|
||||
|
||||
expect(store.isCurrentUserOriginalOwner).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite actions', () => {
|
||||
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||
const mockInvites = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
@@ -22,7 +21,6 @@ export interface WorkspaceMember {
|
||||
email: string
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -51,8 +49,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
role: member.role
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,18 +146,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
() => activeWorkspace.value?.members ?? []
|
||||
)
|
||||
|
||||
// True when the current user is the active workspace's original owner,
|
||||
// resolved from the self-row of the loaded members list. Matches by email
|
||||
// (the stable current-user join key; member.id is a cloud user id, not the
|
||||
// Firebase uid). Fails closed when members are not loaded or no self-row
|
||||
// matches, so lifecycle gating stays hidden until the real signal arrives.
|
||||
const isCurrentUserOriginalOwner = computed(() => {
|
||||
const email = useCurrentUser().userEmail.value?.toLowerCase()
|
||||
if (!email) return false
|
||||
const selfRow = members.value.find((m) => m.email.toLowerCase() === email)
|
||||
return selfRow?.isOriginalOwner ?? false
|
||||
})
|
||||
|
||||
const pendingInvites = computed<PendingInvite[]>(
|
||||
() => activeWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
@@ -522,36 +507,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
return members
|
||||
}
|
||||
|
||||
// Tracks which team workspaces have already loaded their members so the
|
||||
// lifecycle gate resolves without redundant or duplicate fetches.
|
||||
const loadedMemberWorkspaceIds = new Set<string>()
|
||||
let inFlightMembersWorkspaceId: string | null = null
|
||||
|
||||
/**
|
||||
* Load the active team workspace's members once. No-ops for personal or
|
||||
* already-loaded workspaces and dedupes concurrent calls. A failed request is
|
||||
* logged and leaves the workspace unloaded so a later call retries.
|
||||
*/
|
||||
async function ensureMembersLoaded(): Promise<void> {
|
||||
const workspaceId = activeWorkspaceId.value
|
||||
if (!workspaceId) return
|
||||
if (activeWorkspace.value?.type === 'personal') return
|
||||
if (loadedMemberWorkspaceIds.has(workspaceId)) return
|
||||
if (inFlightMembersWorkspaceId === workspaceId) return
|
||||
|
||||
inFlightMembersWorkspaceId = workspaceId
|
||||
try {
|
||||
await fetchMembers()
|
||||
loadedMemberWorkspaceIds.add(workspaceId)
|
||||
} catch (e) {
|
||||
console.error('Failed to load workspace members', e)
|
||||
} finally {
|
||||
if (inFlightMembersWorkspaceId === workspaceId) {
|
||||
inFlightMembersWorkspaceId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
*/
|
||||
@@ -697,7 +652,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
isCurrentUserOriginalOwner,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
@@ -721,7 +675,6 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
ensureMembersLoaded,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, provide, ref, toRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -151,17 +150,6 @@ function handleIsOpenUpdate(isOpen: boolean) {
|
||||
void outputMediaAssets.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
outputMediaAssets.hasMore.value &&
|
||||
!outputMediaAssets.loading.value &&
|
||||
!outputMediaAssets.isLoadingMore.value
|
||||
) {
|
||||
await outputMediaAssets.loadMore()
|
||||
}
|
||||
}, 300)
|
||||
|
||||
const isUploading = ref(false)
|
||||
async function updateFiles(files: File[]) {
|
||||
isUploading.value = true
|
||||
@@ -191,12 +179,10 @@ async function updateFiles(files: File[]) {
|
||||
:base-model-options
|
||||
:is-uploading
|
||||
v-bind="combinedProps"
|
||||
:loading-more="outputMediaAssets.isLoadingMore.value"
|
||||
class="w-full"
|
||||
@update:selected="updateSelectedItems"
|
||||
@update:files="updateFiles"
|
||||
@update:is-open="handleIsOpenUpdate"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -37,7 +37,6 @@ interface Props {
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
baseModelOptions?: FilterOption[]
|
||||
loadingMore?: boolean
|
||||
isSelected?: (
|
||||
selected: Set<string>,
|
||||
item: FormDropdownItem,
|
||||
@@ -65,16 +64,11 @@ const {
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
loadingMore = false,
|
||||
isSelected = (selected, item, _index) => selected.has(item.id),
|
||||
searcher = defaultSearcher,
|
||||
items
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const placeholderText = computed(
|
||||
() => placeholder ?? t('widgets.uploadSelect.placeholder')
|
||||
)
|
||||
@@ -322,11 +316,9 @@ function handleSearchEnter() {
|
||||
:candidate-label
|
||||
:is-selected="internalIsSelected"
|
||||
:max-selectable
|
||||
:loading-more="loadingMore"
|
||||
@close="closeDropdown"
|
||||
@search-enter="handleSearchEnter"
|
||||
@item-click="handleSelection"
|
||||
@approach-end="emit('approach-end')"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -8,9 +7,8 @@ import type { FormDropdownItem, LayoutMode } from './types'
|
||||
const VirtualGridStub = {
|
||||
name: 'VirtualGrid',
|
||||
props: ['items', 'maxColumns', 'itemHeight', 'scrollerHeight'],
|
||||
emits: ['approach-end'],
|
||||
template:
|
||||
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" @click="$emit(\'approach-end\')" />'
|
||||
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" />'
|
||||
}
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
@@ -95,31 +93,6 @@ describe('FormDropdownMenu', () => {
|
||||
expect(virtualGrid.getAttribute('data-max-columns')).toBe('1')
|
||||
})
|
||||
|
||||
it('forwards approach-end from the virtual grid', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('virtual-grid'))
|
||||
|
||||
expect(emitted()['approach-end']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the loading-more row only while loadingMore is set', async () => {
|
||||
const { rerender } = render(FormDropdownMenu, {
|
||||
props: { ...defaultProps, loadingMore: true },
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('form-dropdown-loading-more')).toBeTruthy()
|
||||
|
||||
await rerender({ ...defaultProps, loadingMore: false })
|
||||
|
||||
expect(screen.queryByTestId('form-dropdown-loading-more')).toBeNull()
|
||||
})
|
||||
|
||||
it('has data-capture-wheel="true" on the root element', () => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
|
||||
@@ -27,7 +27,6 @@ interface Props {
|
||||
baseModelOptions?: FilterOption[]
|
||||
candidateIndex?: number
|
||||
candidateLabel?: string
|
||||
loadingMore?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -40,13 +39,11 @@ const {
|
||||
showBaseModelFilter,
|
||||
baseModelOptions,
|
||||
candidateIndex = -1,
|
||||
candidateLabel,
|
||||
loadingMore = false
|
||||
candidateLabel
|
||||
} = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
(e: 'search-enter'): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const filterSelected = defineModel<string>('filterSelected')
|
||||
@@ -161,7 +158,6 @@ const onWheel = (event: WheelEvent) => {
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<FormDropdownMenuItem
|
||||
@@ -176,15 +172,5 @@ const onWheel = (event: WheelEvent) => {
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
<div
|
||||
v-if="loadingMore"
|
||||
class="flex items-center justify-center py-2"
|
||||
data-testid="form-dropdown-loading-more"
|
||||
>
|
||||
<i
|
||||
:aria-label="$t('g.loading')"
|
||||
class="icon-[lucide--loader] size-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Tests for api.cancelJob and api.cancelJobs; fetchApi is stubbed.
|
||||
const okResponse = () => ({ ok: true, status: 200 }) as Response
|
||||
const errorResponse = (status: number, body = '') =>
|
||||
({
|
||||
ok: false,
|
||||
status,
|
||||
text: () => Promise.resolve(body)
|
||||
}) as unknown as Response
|
||||
|
||||
describe('api jobs-namespace cancel', () => {
|
||||
let fetchApiSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchApiSpy = vi.spyOn(api, 'fetchApi').mockResolvedValue(okResponse())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cancelJob (single)', () => {
|
||||
it('POSTs to the single-job cancel endpoint', async () => {
|
||||
await api.cancelJob('abc-123')
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/abc-123/cancel', {
|
||||
method: 'POST'
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes the job id in the path', async () => {
|
||||
await api.cancelJob('a/b c')
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/a%2Fb%20c/cancel', {
|
||||
method: 'POST'
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when the request fails', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
|
||||
|
||||
await expect(api.cancelJob('abc-123')).rejects.toThrow(
|
||||
'Failed to cancel job abc-123: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the response body in the error when present', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(404, 'job not found'))
|
||||
|
||||
await expect(api.cancelJob('abc-123')).rejects.toThrow(
|
||||
'Failed to cancel job abc-123: 404 — job not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelJobs (batch)', () => {
|
||||
it('POSTs the job_ids array to the batch cancel endpoint', async () => {
|
||||
await api.cancelJobs(['id-1', 'id-2'])
|
||||
|
||||
expect(fetchApiSpy).toHaveBeenCalledWith('/jobs/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ job_ids: ['id-1', 'id-2'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call the API for an empty list', async () => {
|
||||
await api.cancelJobs([])
|
||||
|
||||
expect(fetchApiSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when the batch request fails', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(500))
|
||||
|
||||
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
|
||||
'Failed to cancel jobs: 500'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes the response body in the error when present', async () => {
|
||||
fetchApiSpy.mockResolvedValueOnce(errorResponse(422, 'invalid job ids'))
|
||||
|
||||
await expect(api.cancelJobs(['id-1'])).rejects.toThrow(
|
||||
'Failed to cancel jobs: 422 — invalid job ids'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1110,52 +1110,6 @@ export class ComfyApi extends EventTarget {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a single job by id via `POST /api/jobs/{job_id}/cancel` (idempotent:
|
||||
* already-terminal jobs are a no-op). Requires runtime parity — not every
|
||||
* runtime exposes this endpoint yet; do not merge callers before parity lands.
|
||||
*
|
||||
* @param {string} jobId The id of the job to cancel
|
||||
*/
|
||||
async cancelJob(jobId: string) {
|
||||
const res = await this.fetchApi(
|
||||
`/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to cancel job ${jobId}: ${res.status}${body ? ` — ${body}` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels multiple jobs in a single request via `POST /api/jobs/cancel` with
|
||||
* body `{ job_ids: [...] }`. Already-terminal jobs are no-ops. Same runtime
|
||||
* parity requirement as {@link cancelJob}.
|
||||
*
|
||||
* @param {string[]} jobIds The ids of the jobs to cancel
|
||||
*/
|
||||
async cancelJobs(jobIds: string[]) {
|
||||
if (!jobIds.length) return
|
||||
const res = await this.fetchApi('/jobs/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ job_ids: jobIds })
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(
|
||||
`Failed to cancel jobs: ${res.status}${body ? ` — ${body}` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user configuration data and where data should be stored
|
||||
*/
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* showDowngradeToPersonalDialog must refresh members before the no-members
|
||||
* fast path and stay non-dismissable (ESC derives from `closable` in
|
||||
* dialogStore); fast-path failures must toast.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
const toastAdd = vi.hoisted(() => vi.fn())
|
||||
const refreshMembers = vi.hoisted(() => vi.fn())
|
||||
const downgradeToPersonal = vi.hoisted(() => vi.fn())
|
||||
const hasOtherMembers = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackEvent: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
isFreeTier: { value: false },
|
||||
type: { value: 'legacy' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useDowngradeToPersonal', () => ({
|
||||
useDowngradeToPersonal: () => ({
|
||||
hasOtherMembers,
|
||||
refreshMembers,
|
||||
downgradeToPersonal
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue',
|
||||
() => ({ default: { name: 'DowngradeRemoveMembersDialogContent' } })
|
||||
)
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
describe('showDowngradeToPersonalDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
hasOtherMembers.value = false
|
||||
refreshMembers.mockResolvedValue(undefined)
|
||||
downgradeToPersonal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const options = { planName: 'Standard', planSlug: 'standard-monthly' }
|
||||
|
||||
it('refreshes members before deciding the no-members fast path', async () => {
|
||||
const calls: string[] = []
|
||||
refreshMembers.mockImplementation(() => {
|
||||
calls.push('refresh')
|
||||
return Promise.resolve()
|
||||
})
|
||||
downgradeToPersonal.mockImplementation(() => {
|
||||
calls.push('downgrade')
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(calls).toEqual(['refresh', 'downgrade'])
|
||||
expect(downgradeToPersonal).toHaveBeenCalledWith('standard-monthly')
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a non-dismissable confirm dialog when other members exist', async () => {
|
||||
hasOtherMembers.value = true
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('downgrade-remove-members')
|
||||
expect(args.props.onConfirm).toBe(downgradeToPersonal)
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.dismissableMask).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts and does not rethrow when the fast-path downgrade fails', async () => {
|
||||
downgradeToPersonal.mockRejectedValue(new Error('Outstanding balance'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
detail: 'Outstanding balance'
|
||||
})
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toasts and aborts when the member refresh fails', async () => {
|
||||
hasOtherMembers.value = true
|
||||
refreshMembers.mockRejectedValue(new Error('network'))
|
||||
|
||||
await useDialogService().showDowngradeToPersonalDialog(options)
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error', detail: 'network' })
|
||||
)
|
||||
expect(showDialog).not.toHaveBeenCalled()
|
||||
expect(downgradeToPersonal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -79,56 +79,4 @@ describe('dialogService Reka renderer opt-in', () => {
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('lg')
|
||||
})
|
||||
|
||||
it("showTopUpCreditsDialog() sets renderer 'reka' with a transparent shrink-wrapped chrome", async () => {
|
||||
await useDialogService().showTopUpCreditsDialog()
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.contentClass).toContain('bg-transparent')
|
||||
})
|
||||
|
||||
it("showLayoutDialog() defaults to renderer 'reka' headless without pt", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-test',
|
||||
component: Component,
|
||||
props: {}
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.headless).toBe(true)
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('showLayoutDialog() lets callers override the defaults', () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showLayoutDialog({
|
||||
key: 'layout-override-test',
|
||||
component: Component,
|
||||
props: {},
|
||||
dialogComponentProps: { closable: false, contentClass: 'w-170' }
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.closable).toBe(false)
|
||||
expect(args.dialogComponentProps.contentClass).toBe('w-170')
|
||||
})
|
||||
|
||||
it("showSmallLayoutDialog() sets renderer 'reka' with zeroed section padding", () => {
|
||||
const Component = { template: '<div />' }
|
||||
useDialogService().showSmallLayoutDialog({
|
||||
key: 'small-layout-test',
|
||||
component: Component
|
||||
})
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
expect(args.dialogComponentProps.contentClass).toContain('w-fit')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0 overflow-y-hidden')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
@@ -34,20 +33,6 @@ const lazyCloudNotificationContent = () =>
|
||||
const lazyPublishDialog = () =>
|
||||
import('@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue')
|
||||
|
||||
/**
|
||||
* Shrink-wrap the Reka DialogContent around the content's intrinsic width,
|
||||
* like the auto-sized PrimeVue root it replaces.
|
||||
*/
|
||||
const HUG_CONTENT_CLASS =
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)]'
|
||||
|
||||
/**
|
||||
* Reka chrome for headless dialogs whose content draws its own panel
|
||||
* (background/border/rounding) — neutralize the DialogContent box and
|
||||
* shrink-wrap it around the content.
|
||||
*/
|
||||
const SELF_STYLED_PANEL_CONTENT_CLASS = `${HUG_CONTENT_CLASS} border-none bg-transparent shadow-none`
|
||||
|
||||
export type ConfirmationDialogType =
|
||||
| 'default'
|
||||
| 'overwrite'
|
||||
@@ -214,8 +199,6 @@ export const useDialogService = () => {
|
||||
},
|
||||
headerComponent: ComfyOrgHeader,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: false,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -239,10 +222,6 @@ export const useDialogService = () => {
|
||||
onSuccess: () => resolve(true)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
// SignInContent is a fixed w-96 — size 'sm' (max-w-sm) leaves only
|
||||
// 352px after the body padding; hug the intrinsic width instead.
|
||||
contentClass: HUG_CONTENT_CLASS,
|
||||
closable: true,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
@@ -348,9 +327,12 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -369,10 +351,6 @@ export const useDialogService = () => {
|
||||
props: {
|
||||
onSuccess: () =>
|
||||
dialogStore.closeDialog({ key: 'global-update-password' })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
contentClass: HUG_CONTENT_CLASS
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -402,10 +380,20 @@ export const useDialogService = () => {
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
const layoutDefaultProps: DialogComponentProps = {
|
||||
renderer: 'reka',
|
||||
headless: true,
|
||||
modal: true,
|
||||
closable: true
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'rounded-2xl overflow-hidden'
|
||||
},
|
||||
header: {
|
||||
class: 'p-0! hidden'
|
||||
},
|
||||
content: {
|
||||
class: 'p-0! m-0!'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dialogStore.showDialog({
|
||||
@@ -427,15 +415,18 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
...rest,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
closable: true,
|
||||
// Contents bring their own width and separators — shrink-wrap the
|
||||
// chrome and zero the section padding.
|
||||
contentClass:
|
||||
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-border-default',
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0 overflow-y-hidden',
|
||||
footerClass: 'p-0',
|
||||
pt: {
|
||||
root: { class: 'bg-base-background border-border-default' },
|
||||
header: { class: '!p-0 !m-0' },
|
||||
content: { class: '!p-0 overflow-y-hidden' },
|
||||
footer: { class: '!p-0' },
|
||||
pcCloseButton: {
|
||||
root: {
|
||||
class: '!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
...callerProps
|
||||
}
|
||||
})
|
||||
@@ -455,10 +446,13 @@ export const useDialogService = () => {
|
||||
}
|
||||
|
||||
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||
const workspaceDialogProps = {
|
||||
renderer: 'reka',
|
||||
const workspaceDialogPt = {
|
||||
headless: true,
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
pt: {
|
||||
header: { class: 'p-0! hidden' },
|
||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||
root: { class: 'rounded-2xl' }
|
||||
}
|
||||
} as const
|
||||
|
||||
async function showDeleteWorkspaceDialog(options?: {
|
||||
@@ -471,7 +465,7 @@ export const useDialogService = () => {
|
||||
key: 'delete-workspace',
|
||||
component,
|
||||
props: options,
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -485,7 +479,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -504,7 +498,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { onConfirm },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -515,7 +509,7 @@ export const useDialogService = () => {
|
||||
return dialogStore.showDialog({
|
||||
key: 'leave-workspace',
|
||||
component,
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -526,7 +520,7 @@ export const useDialogService = () => {
|
||||
key: 'edit-workspace',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -538,7 +532,7 @@ export const useDialogService = () => {
|
||||
key: 'remove-member',
|
||||
component,
|
||||
props: { memberId },
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -549,7 +543,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -561,7 +555,7 @@ export const useDialogService = () => {
|
||||
key: 'invite-member-upsell',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -573,7 +567,7 @@ export const useDialogService = () => {
|
||||
key: 'revoke-invite',
|
||||
component,
|
||||
props: { inviteId },
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
dialogComponentProps: workspaceDialogPt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -603,54 +597,7 @@ export const useDialogService = () => {
|
||||
component,
|
||||
props: { cancelAt },
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade a team plan to a personal plan (FE-977). Skips the type-"I
|
||||
* understand" confirm dialog when the workspace has no other members;
|
||||
* failures on that path surface as an error toast.
|
||||
*/
|
||||
async function showDowngradeToPersonalDialog(options: {
|
||||
planName: string
|
||||
planSlug: string
|
||||
}) {
|
||||
const { useDowngradeToPersonal } =
|
||||
await import('@/platform/workspace/composables/useDowngradeToPersonal')
|
||||
const { hasOtherMembers, refreshMembers, downgradeToPersonal } =
|
||||
useDowngradeToPersonal()
|
||||
|
||||
try {
|
||||
await refreshMembers()
|
||||
if (!hasOtherMembers.value) {
|
||||
await downgradeToPersonal(options.planSlug)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.downgrade.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/DowngradeRemoveMembersDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'downgrade-remove-members',
|
||||
component,
|
||||
props: {
|
||||
planName: options.planName,
|
||||
planSlug: options.planSlug,
|
||||
onConfirm: downgradeToPersonal
|
||||
},
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps,
|
||||
closable: false,
|
||||
dismissableMask: false
|
||||
...workspaceDialogPt
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -665,8 +612,9 @@ export const useDialogService = () => {
|
||||
props: {},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
contentClass:
|
||||
'w-170 max-w-[calc(100vw-1rem)] sm:max-w-[42.5rem] rounded-2xl overflow-hidden',
|
||||
pt: {
|
||||
root: { class: 'w-170 max-h-[85vh]' }
|
||||
},
|
||||
onClose: () => resolve()
|
||||
}
|
||||
})
|
||||
@@ -680,13 +628,12 @@ export const useDialogService = () => {
|
||||
key,
|
||||
component: ComfyHubPublishDialog,
|
||||
props: {
|
||||
onClose: () => dialogStore.closeDialog({ key }),
|
||||
// Falls through to the BaseModalLayout root — keeps the e2e
|
||||
// publish-dialog selector working without the PrimeVue pt hook.
|
||||
'data-testid': 'publish-dialog'
|
||||
onClose: () => dialogStore.closeDialog({ key })
|
||||
},
|
||||
dialogComponentProps: {
|
||||
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
|
||||
pt: {
|
||||
root: { 'data-testid': 'publish-dialog' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -716,7 +663,6 @@ export const useDialogService = () => {
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog,
|
||||
showDowngradeToPersonalDialog
|
||||
showCancelSubscriptionDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type {
|
||||
AssetItem,
|
||||
AssetResponse
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
|
||||
@@ -28,7 +25,6 @@ vi.mock('@/scripts/api', () => ({
|
||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
assetService: {
|
||||
getAssetsByTag: vi.fn(),
|
||||
getAssetsPageByTag: vi.fn(),
|
||||
getAllAssetsByTag: vi.fn(),
|
||||
getAssetsForNodeType: vi.fn(),
|
||||
invalidateInputAssetsIncludingPublic: vi.fn(),
|
||||
@@ -1521,50 +1517,44 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
const makePage = (
|
||||
assets: AssetItem[],
|
||||
{
|
||||
hasMore = false,
|
||||
nextCursor
|
||||
}: { hasMore?: boolean; nextCursor?: string } = {}
|
||||
): AssetResponse => ({
|
||||
assets,
|
||||
total: assets.length,
|
||||
has_more: hasMore,
|
||||
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetches the first page via getAssetsPageByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
)
|
||||
it('fetches outputs via getAssetsByTag with the output tag and page size', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'image1.png', 'hash1.png'),
|
||||
makeAsset('a2', 'image2.png', 'hash2.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenCalledWith(
|
||||
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
offset: 0
|
||||
}
|
||||
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it('trusts server has_more over page size for a short page', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'one.png')], { hasMore: true })
|
||||
it('marks hasMore=false when the page is short', async () => {
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
makeAsset('a1', 'one.png')
|
||||
])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('marks hasMore=true when a full page is returned', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
@@ -1572,94 +1562,6 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('marks hasMore=false when the server reports the last page', async () => {
|
||||
const fullPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage(fullPage, { hasMore: false })
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('threads the minted cursor into after on loadMore and omits offset', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to offset paging when the server mints no cursor', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png'), makeAsset('a2', 'f2.png')], {
|
||||
hasMore: true
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a3', 'f3.png')]))
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 2 }
|
||||
)
|
||||
})
|
||||
|
||||
it('stops when the server returns a non-advancing cursor', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a2', 'f2.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty page as terminal even when has_more is true', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
|
||||
makePage([], { hasMore: true })
|
||||
)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('appends and dedupes on loadMoreFlatOutputs', async () => {
|
||||
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
@@ -1668,9 +1570,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
makeAsset('a0', 'duplicate.png'),
|
||||
makeAsset('newId', 'new.png')
|
||||
]
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(makePage(firstPage, { hasMore: true }))
|
||||
.mockResolvedValueOnce(makePage(secondPage))
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage)
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
@@ -1680,9 +1582,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
|
||||
})
|
||||
|
||||
it('records error and resolves to an empty list on initial-fetch failure', async () => {
|
||||
it('records error and clears media on initial-fetch failure', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err)
|
||||
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
@@ -1697,105 +1599,37 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the cursor for retry when loadMore fails', async () => {
|
||||
const err = new Error('network down')
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockRejectedValueOnce(err)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(store.flatOutputError).toBe(err)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1'])
|
||||
expect(store.flatOutputHasMore).toBe(true)
|
||||
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, after: 'cursor-1' }
|
||||
)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('restarts from the head when loadMore follows a failed refresh', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('network down'))
|
||||
.mockResolvedValueOnce(makePage([makeAsset('a2', 'f2.png')]))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
await store.loadMoreFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
|
||||
)
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('refresh resets pagination', async () => {
|
||||
vi.mocked(assetService.getAssetsPageByTag)
|
||||
vi.mocked(assetService.getAssetsByTag)
|
||||
.mockResolvedValueOnce(
|
||||
makePage([makeAsset('a1', 'f1.png')], {
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor-1'
|
||||
})
|
||||
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
|
||||
makeAsset(`a${i}`, `f${i}.png`)
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(makePage([makeAsset('fresh', 'fresh.png')]))
|
||||
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
|
||||
|
||||
const store = useAssetsStore()
|
||||
await store.updateFlatOutputs()
|
||||
await store.updateFlatOutputs()
|
||||
|
||||
expect(assetService.getAssetsPageByTag).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
true,
|
||||
{ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 }
|
||||
)
|
||||
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh'])
|
||||
expect(store.flatOutputHasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('dedupes concurrent fetches into a single request', async () => {
|
||||
let resolvePage!: (page: AssetResponse) => void
|
||||
const pagePromise = new Promise<AssetResponse>((res) => {
|
||||
let resolvePage!: (assets: AssetItem[]) => void
|
||||
const pagePromise = new Promise<AssetItem[]>((res) => {
|
||||
resolvePage = res
|
||||
})
|
||||
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(pagePromise)
|
||||
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
|
||||
|
||||
const store = useAssetsStore()
|
||||
const p1 = store.updateFlatOutputs()
|
||||
const p2 = store.updateFlatOutputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolvePage(makePage([makeAsset('shared-1', 'shared.png', 'h.png')]))
|
||||
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
|
||||
|
||||
@@ -267,7 +267,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
const flatOutputHasMore = ref(true)
|
||||
const flatOutputIsLoadingMore = ref(false)
|
||||
const flatOutputSeenIds = new Set<string>()
|
||||
let flatOutputNextCursor: string | undefined
|
||||
let flatOutputInFlight: Promise<AssetItem[]> | null = null
|
||||
|
||||
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
|
||||
@@ -279,36 +278,26 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
} else {
|
||||
flatOutputLoading.value = true
|
||||
flatOutputOffset.value = 0
|
||||
flatOutputNextCursor = undefined
|
||||
flatOutputHasMore.value = true
|
||||
flatOutputSeenIds.clear()
|
||||
}
|
||||
flatOutputError.value = null
|
||||
|
||||
flatOutputInFlight = (async () => {
|
||||
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
|
||||
try {
|
||||
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
|
||||
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
|
||||
limit: FLAT_OUTPUT_PAGE_SIZE,
|
||||
...(requestedAfter
|
||||
? { after: requestedAfter }
|
||||
: { offset: flatOutputOffset.value })
|
||||
offset: flatOutputOffset.value
|
||||
})
|
||||
const batch = page.assets
|
||||
const fresh = loadMore
|
||||
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: batch
|
||||
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
|
||||
: page
|
||||
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
|
||||
flatOutputAssets.value = loadMore
|
||||
? [...flatOutputAssets.value, ...fresh]
|
||||
: batch
|
||||
flatOutputOffset.value += batch.length
|
||||
const nextCursor = page.next_cursor || undefined
|
||||
const cursorStuck =
|
||||
nextCursor !== undefined && nextCursor === requestedAfter
|
||||
flatOutputNextCursor = cursorStuck ? undefined : nextCursor
|
||||
flatOutputHasMore.value =
|
||||
fresh.length > 0 && page.has_more && !cursorStuck
|
||||
: page
|
||||
flatOutputOffset.value += page.length
|
||||
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
|
||||
return flatOutputAssets.value
|
||||
} catch (err) {
|
||||
flatOutputError.value = err
|
||||
|
||||
@@ -21,10 +21,10 @@ type DialogPosition =
|
||||
| 'bottomright'
|
||||
|
||||
/**
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'reka'` (the default)
|
||||
* renders the Reka-UI primitive set under `src/components/ui/dialog/`.
|
||||
* `'primevue'` is the legacy PrimeVue `Dialog` escape hatch, kept only until
|
||||
* the branch is deleted in the Phase 6 cleanup (FE-578).
|
||||
* Selects the dialog renderer used by `GlobalDialog`. `'primevue'` is the
|
||||
* current default and runs the legacy PrimeVue `Dialog` path. `'reka'` opts
|
||||
* into the Reka-UI primitive set under `src/components/ui/dialog/`. Migration
|
||||
* tracked in `temp/plans/adr-0009-dialog-reka-migration-DRAFT.md`.
|
||||
*/
|
||||
type DialogRenderer = 'primevue' | 'reka'
|
||||
|
||||
@@ -201,7 +201,6 @@ export const useDialogStore = defineStore('dialog', () => {
|
||||
closable: true,
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
renderer: 'reka' as DialogRenderer,
|
||||
...options.dialogComponentProps,
|
||||
maximized: false,
|
||||
onMaximize: () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -12,30 +11,24 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
|
||||
// Create mock functions that will be shared
|
||||
const {
|
||||
mockNodeExecutionIdToNodeLocatorId,
|
||||
mockNodeIdToNodeLocatorId,
|
||||
mockNodeLocatorIdToNodeExecutionId,
|
||||
mockActiveWorkflow,
|
||||
mockOpenWorkflows,
|
||||
mockShowTextPreview,
|
||||
mockTrackExecutionError,
|
||||
mockTrackExecutionSuccess,
|
||||
mockTrackSharedWorkflowRun
|
||||
} = await vi.hoisted(async () => {
|
||||
const { shallowRef } = await import('vue')
|
||||
return {
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockActiveWorkflow: shallowRef<{ path?: string } | null>(null),
|
||||
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}
|
||||
})
|
||||
} = vi.hoisted(() => ({
|
||||
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeIdToNodeLocatorId: vi.fn(),
|
||||
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
|
||||
mockShowTextPreview: vi.fn(),
|
||||
mockTrackExecutionError: vi.fn(),
|
||||
mockTrackExecutionSuccess: vi.fn(),
|
||||
mockTrackSharedWorkflowRun: vi.fn()
|
||||
}))
|
||||
|
||||
const mockAppModeState = vi.hoisted(() => ({
|
||||
mode: { value: 'graph' },
|
||||
@@ -54,6 +47,7 @@ beforeEach(() => {
|
||||
mockAppModeState.mode.value = 'graph'
|
||||
mockAppModeState.isAppMode.value = false
|
||||
})
|
||||
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -67,15 +61,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: mockNodeExecutionIdToNodeLocatorId,
|
||||
nodeIdToNodeLocatorId: mockNodeIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId,
|
||||
get activeWorkflow() {
|
||||
return mockActiveWorkflow.value
|
||||
},
|
||||
get openWorkflows() {
|
||||
return mockOpenWorkflows.value
|
||||
},
|
||||
isOpen: (workflow: { path?: string }) =>
|
||||
mockOpenWorkflows.value.some((w) => w.path === workflow.path)
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -149,11 +135,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mockActiveWorkflow.value = null
|
||||
mockOpenWorkflows.value = []
|
||||
})
|
||||
|
||||
function createQueuedWorkflow(path: string = 'workflows/test.json') {
|
||||
return {
|
||||
activeState: { id: 'workflow-id' },
|
||||
@@ -520,254 +501,6 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - workflowStatus', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
type Workflow = Parameters<typeof store.storeJob>[0]['workflow']
|
||||
const makeWorkflow = (path: string): Workflow => {
|
||||
const workflow: Partial<Workflow> = {
|
||||
path,
|
||||
filename: path.split('/').pop()
|
||||
}
|
||||
return workflow as Workflow
|
||||
}
|
||||
const workflowA = makeWorkflow('/workflows/a.json')
|
||||
const workflowB = makeWorkflow('/workflows/b.json')
|
||||
|
||||
function fireExecutionStart(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_start')
|
||||
if (!handler) throw new Error('execution_start handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_start', { detail: { prompt_id: jobId } })
|
||||
)
|
||||
}
|
||||
|
||||
function fireExecutionSuccess(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_success')
|
||||
if (!handler) throw new Error('execution_success handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_success', { detail: { prompt_id: jobId } })
|
||||
)
|
||||
}
|
||||
|
||||
function fireExecutionError(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_error')
|
||||
if (!handler) throw new Error('execution_error handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: jobId,
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'fail',
|
||||
exception_type: 'Error',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function fireExecutionInterrupted(jobId: string) {
|
||||
const handler = apiEventHandlers.get('execution_interrupted')
|
||||
if (!handler) throw new Error('execution_interrupted handler not bound')
|
||||
handler(
|
||||
new CustomEvent('execution_interrupted', {
|
||||
detail: { prompt_id: jobId }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function callStoreJob(jobId: string, workflow: Workflow) {
|
||||
store.storeJob({
|
||||
nodes: ['1'],
|
||||
id: jobId,
|
||||
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
|
||||
workflow
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
mockOpenWorkflows.value = [workflowA, workflowB]
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('sets running on execution_start when storeJob already ran', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
})
|
||||
|
||||
it('flushes running status when storeJob arrives after WS', () => {
|
||||
fireExecutionStart('job-1')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
})
|
||||
|
||||
it('flushes terminal completed when WS finishes before storeJob', () => {
|
||||
// Instant-finish race: WS fires start+success before HTTP response.
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
it('flushes terminal failed when WS errors before storeJob', () => {
|
||||
// Invalid-workflow path: execution_error fires before HTTP response.
|
||||
fireExecutionError('job-1')
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
|
||||
})
|
||||
|
||||
it('drops pending status on interrupt before storeJob', () => {
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionInterrupted('job-1')
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sets completed on execution_success', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
})
|
||||
|
||||
it('sets failed on execution_error', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionError('job-1')
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
|
||||
})
|
||||
|
||||
it('skips status badge on user-initiated interrupt', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionInterrupted('job-1')
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
|
||||
// Each start with no matching storeJob buffers a 'running' status. One
|
||||
// past the cap evicts the oldest so the buffer can't grow unbounded.
|
||||
for (let i = 0; i <= MAX_PROGRESS_JOBS; i++) fireExecutionStart(`job-${i}`)
|
||||
|
||||
callStoreJob('job-0', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
|
||||
callStoreJob(`job-${MAX_PROGRESS_JOBS}`, workflowB)
|
||||
expect(store.getWorkflowStatus(workflowB)).toBe('running')
|
||||
})
|
||||
|
||||
it('overwrites stale terminal with running on re-queue', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
|
||||
// Re-queue the same workflow under a fresh jobId.
|
||||
callStoreJob('job-2', workflowA)
|
||||
fireExecutionStart('job-2')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
})
|
||||
|
||||
it('ignores status events for unknown prompt ids', () => {
|
||||
fireExecutionSuccess('unknown-job')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
expect(store.getWorkflowStatus(workflowB)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prunes only closed workflows, leaving open ones intact', async () => {
|
||||
callStoreJob('job-a', workflowA)
|
||||
callStoreJob('job-b', workflowB)
|
||||
fireExecutionSuccess('job-a')
|
||||
fireExecutionSuccess('job-b')
|
||||
|
||||
mockOpenWorkflows.value = [workflowB]
|
||||
await nextTick()
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
expect(store.getWorkflowStatus(workflowB)).toBe('completed')
|
||||
})
|
||||
|
||||
it('ignores terminal events for a workflow closed mid-run', async () => {
|
||||
callStoreJob('job-a', workflowA)
|
||||
fireExecutionStart('job-a')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
|
||||
// Close the tab while the job is still running.
|
||||
mockOpenWorkflows.value = [workflowB]
|
||||
await nextTick()
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
|
||||
// A late success must not resurrect an entry for the closed workflow.
|
||||
fireExecutionSuccess('job-a')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops service-level errors without writing failed', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
|
||||
// Service-level error: empty node_id triggers the short-circuit branch.
|
||||
const handler = apiEventHandlers.get('execution_error')
|
||||
handler!(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '',
|
||||
node_type: '',
|
||||
exception_message: 'Job has stagnated',
|
||||
exception_type: 'StagnationError',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('running')
|
||||
})
|
||||
|
||||
it('drops pending failed when service-level error fires before storeJob', () => {
|
||||
apiEventHandlers.get('execution_error')!(
|
||||
new CustomEvent('execution_error', {
|
||||
detail: {
|
||||
prompt_id: 'job-1',
|
||||
node_id: '',
|
||||
node_type: '',
|
||||
exception_message: 'Job has stagnated',
|
||||
exception_type: 'StagnationError',
|
||||
traceback: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
callStoreJob('job-1', workflowA)
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears workflowStatus on unbindExecutionEvents', () => {
|
||||
callStoreJob('job-1', workflowA)
|
||||
fireExecutionStart('job-1')
|
||||
fireExecutionSuccess('job-1')
|
||||
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
|
||||
|
||||
store.unbindExecutionEvents()
|
||||
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - clearActiveJobIfStale', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -93,17 +93,6 @@ function buildExecutionNodeLookup(
|
||||
*/
|
||||
export const MAX_PROGRESS_JOBS = 1000
|
||||
|
||||
export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed'
|
||||
|
||||
export const WORKFLOW_STATUS_I18N_KEYS: Record<
|
||||
WorkflowExecutionStatus,
|
||||
string
|
||||
> = {
|
||||
running: 'g.running',
|
||||
completed: 'g.completed',
|
||||
failed: 'g.failed'
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -132,86 +121,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
const initializingJobIds = ref<Set<JobId>>(new Set())
|
||||
|
||||
const workflowStatus = shallowRef<
|
||||
Map<ComfyWorkflow, WorkflowExecutionStatus>
|
||||
>(new Map())
|
||||
|
||||
const jobIdToWorkflow = new Map<string, ComfyWorkflow>()
|
||||
|
||||
// Buffers statuses arriving before storeJob attaches the workflow.
|
||||
// FIFO-capped to bound growth if a matching storeJob never fires.
|
||||
const pendingWorkflowStatusByJobId = new Map<
|
||||
string,
|
||||
WorkflowExecutionStatus
|
||||
>()
|
||||
|
||||
function bufferPendingWorkflowStatus(
|
||||
jobId: string,
|
||||
status: WorkflowExecutionStatus
|
||||
) {
|
||||
pendingWorkflowStatusByJobId.delete(jobId)
|
||||
pendingWorkflowStatusByJobId.set(jobId, status)
|
||||
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
|
||||
const oldest = pendingWorkflowStatusByJobId.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
pendingWorkflowStatusByJobId.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function mutateStatus(
|
||||
mutator: (map: Map<ComfyWorkflow, WorkflowExecutionStatus>) => void
|
||||
) {
|
||||
const next = new Map(workflowStatus.value)
|
||||
mutator(next)
|
||||
workflowStatus.value = next
|
||||
}
|
||||
|
||||
function applyWorkflowStatus(
|
||||
workflow: ComfyWorkflow,
|
||||
status: WorkflowExecutionStatus
|
||||
) {
|
||||
// A late terminal event can arrive after the tab closed; don't resurrect
|
||||
// an entry (which also pins the workflow ref) for a closed workflow.
|
||||
if (!workflowStore.isOpen(workflow)) return
|
||||
mutateStatus((m) => m.set(workflow, status))
|
||||
}
|
||||
|
||||
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
|
||||
const workflow = jobIdToWorkflow.get(jobId)
|
||||
if (!workflow) {
|
||||
bufferPendingWorkflowStatus(jobId, status)
|
||||
return
|
||||
}
|
||||
applyWorkflowStatus(workflow, status)
|
||||
}
|
||||
|
||||
function clearWorkflowStatus(workflow: ComfyWorkflow) {
|
||||
if (!workflowStatus.value.has(workflow)) return
|
||||
mutateStatus((m) => m.delete(workflow))
|
||||
}
|
||||
|
||||
function getWorkflowStatus(
|
||||
workflow: ComfyWorkflow | undefined | null
|
||||
): WorkflowExecutionStatus | undefined {
|
||||
if (!workflow) return undefined
|
||||
return workflowStatus.value.get(workflow)
|
||||
}
|
||||
|
||||
// Prune statuses for workflows that have been closed.
|
||||
watch(
|
||||
() => workflowStore.openWorkflows,
|
||||
(openWorkflows) => {
|
||||
if (workflowStatus.value.size === 0) return
|
||||
const openSet = new Set(openWorkflows)
|
||||
const filtered = new Map(
|
||||
[...workflowStatus.value].filter(([w]) => openSet.has(w))
|
||||
)
|
||||
if (filtered.size !== workflowStatus.value.size) {
|
||||
workflowStatus.value = filtered
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Cache for executionIdToNodeLocatorId lookups.
|
||||
* Avoids redundant graph traversals during a single execution run.
|
||||
@@ -364,10 +273,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
|
||||
if (workflowStatus.value.size > 0) workflowStatus.value = new Map()
|
||||
pendingWorkflowStatusByJobId.clear()
|
||||
jobIdToWorkflow.clear()
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
@@ -383,7 +288,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
|
||||
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
|
||||
}
|
||||
setWorkflowStatus(activeJobId.value, 'running')
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
@@ -397,10 +301,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
e: CustomEvent<ExecutionInterruptedWsMessage>
|
||||
) {
|
||||
const jobId = e.detail.prompt_id
|
||||
// User-initiated stop is not a failure — drop the badge entirely.
|
||||
pendingWorkflowStatusByJobId.delete(jobId)
|
||||
const workflow = jobIdToWorkflow.get(jobId)
|
||||
if (workflow) clearWorkflowStatus(workflow)
|
||||
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
|
||||
resetExecutionState(jobId)
|
||||
}
|
||||
@@ -412,7 +312,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
|
||||
const jobId = e.detail.prompt_id
|
||||
setWorkflowStatus(jobId, 'completed')
|
||||
const queuedJob = queuedJobs.value[jobId]
|
||||
const telemetry = useTelemetry()
|
||||
if (queuedJob) {
|
||||
@@ -534,11 +433,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
if (isCloud) {
|
||||
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
|
||||
// Pre-flight validation isn't a runtime failure — no badge.
|
||||
if (handleCloudValidationError(e.detail)) {
|
||||
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
|
||||
return
|
||||
}
|
||||
if (handleCloudValidationError(e.detail)) return
|
||||
}
|
||||
|
||||
// Account preconditions (sign-in, subscription, credits) open their own
|
||||
@@ -546,12 +441,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (handleAccountPreconditionError(e.detail)) return
|
||||
|
||||
// Service-level errors (e.g. "Job has stagnated") have no associated node.
|
||||
if (handleServiceLevelError(e.detail)) {
|
||||
pendingWorkflowStatusByJobId.delete(e.detail.prompt_id)
|
||||
return
|
||||
}
|
||||
// Route them as job errors
|
||||
if (handleServiceLevelError(e.detail)) return
|
||||
|
||||
setWorkflowStatus(e.detail.prompt_id, 'failed')
|
||||
// OSS path / Cloud fallback (real runtime errors)
|
||||
executionErrorStore.lastExecutionError = e.detail
|
||||
clearInitializationByJobId(e.detail.prompt_id)
|
||||
resetExecutionState(e.detail.prompt_id)
|
||||
@@ -676,7 +569,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
delete map[jobId]
|
||||
nodeProgressStatesByJob.value = map
|
||||
useJobPreviewStore().clearPreview(jobId)
|
||||
jobIdToWorkflow.delete(jobId)
|
||||
}
|
||||
if (activeJobId.value) {
|
||||
delete queuedJobs.value[activeJobId.value]
|
||||
@@ -732,7 +624,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
|
||||
queuedJob.workflow = workflow
|
||||
if (workflow) jobIdToWorkflow.set(String(id), workflow)
|
||||
queuedJob.shareId = workflow?.shareId
|
||||
const queuedMode = getWorkflowMode(workflow)
|
||||
queuedJob.viewMode = queuedMode
|
||||
@@ -744,19 +635,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
if (workflow?.path) {
|
||||
ensureSessionWorkflowPath(id, workflow.path)
|
||||
}
|
||||
flushPendingWorkflowStatus(String(id), workflow)
|
||||
}
|
||||
|
||||
function flushPendingWorkflowStatus(
|
||||
jobId: string,
|
||||
workflow: ComfyWorkflow | undefined
|
||||
) {
|
||||
const pending = pendingWorkflowStatusByJobId.get(jobId)
|
||||
if (pending === undefined || !workflow) return
|
||||
pendingWorkflowStatusByJobId.delete(jobId)
|
||||
// Don't let a stale 'running' overwrite a terminal status already set.
|
||||
if (pending === 'running' && workflowStatus.value.has(workflow)) return
|
||||
applyWorkflowStatus(workflow, pending)
|
||||
}
|
||||
|
||||
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
|
||||
@@ -851,8 +729,6 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
nodeLocatorIdToExecutionId,
|
||||
jobIdToWorkflowId,
|
||||
jobIdToSessionWorkflowPath,
|
||||
ensureSessionWorkflowPath,
|
||||
getWorkflowStatus,
|
||||
clearWorkflowStatus
|
||||
ensureSessionWorkflowPath
|
||||
}
|
||||
})
|
||||
|
||||
@@ -88,9 +88,10 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
const bottomLeftRef = useTemplateRef<HTMLDivElement>('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef<HTMLDivElement>('bottomRightRef')
|
||||
const linearWorkflowRef =
|
||||
useTemplateRef<InstanceType<typeof LinearControls>>('linearWorkflowRef')
|
||||
|
||||
function dragDrop(e: DragEvent) {
|
||||
const { dataTransfer } = e
|
||||
|
||||
Reference in New Issue
Block a user