Compare commits

...

5 Commits

Author SHA1 Message Date
Dante
a0f4feb111 feat(billing): downgrade-to-personal member-removal confirm flow (FE-977) (#12789)
## Summary

Owner-initiated team→personal downgrade flow: a confirm dialog ("Change
to {plan} plan?", "All other members of this workspace will be
immediately removed", type-"I understand" gate) that removes non-owner
members and then initiates the tier change. No dialog when there are no
other members.

## Changes

- **What**: `DowngradeRemoveMembersDialogContent.vue` (confirm + type-"I
understand" gate, destructive Change plan); `useDowngradeToPersonal.ts`
(validates via `previewSubscribe`, removes every non-creator member via
`workspaceApi.removeMember`, then `useBillingContext().subscribe` with
`needs_payment_method`/`pending_payment` handling à la
`useSubscriptionCheckout`);
`dialogService.showDowngradeToPersonalDialog` (refreshes members,
skip-dialog when no other members; non-dismissable while open).
`subscription.downgrade.*` i18n.
- **Breaking**: none.

## Review Focus

- **Creator protection**: the cloud model is single-owner with no
distinct creator field — a member is protected if `role === 'owner'` OR
they are the current user. To be reconciled with FE-770's
earliest-`joinDate` creator inference when both land (single source: the
`isCreator` predicate). BE-1337 will expose an explicit original-owner
determination.
- **Integration seam**: there is no on-main team→personal trigger
(FE-934 pricing-table plan change is unmerged); the flow is exposed via
`dialogService.showDowngradeToPersonalDialog` for FE-934 to call on
`transition_type: 'downgrade'`.
- **Failure-path hardening** (follow-up commits): `previewSubscribe`
gate runs before any member is removed (a BE-disallowed transition
removes nobody and surfaces the BE reason); `subscribe` outcomes
`needs_payment_method` (payment tab + billing-op polling; a
popup-blocked tab throws so the dialog stays open and a retry
re-subscribes) and `pending_payment` (polling) are handled instead of
discarded; the member list is refetched before the no-members fast path
(a stale empty store can no longer skip the confirm gate);
fast-path/refresh failures toast instead of escaping as unhandled
rejections; the dialog is not dismissable via ESC/overlay-click
(`closable: false` — `dialogStore.updateCloseOnEscapeStates` derives
`closeOnEscape` from `closable`, so `closeOnEscape: false` alone is
overridden).
- **Accepted non-atomicity (until BE-1337)**: removals and the tier
change are separate FE-orchestrated calls — if the user confirms, then
abandons/fails the payment-method step, members are already removed
while the plan stays team (poller surfaces an error toast after 120s).
The dedicated BE downgrade endpoint makes removal+transition atomic.
- Tests: type-gate exact-match; no-dialog when no other members;
preview→remove→subscribe ordering; disallowed preview removes nobody;
payment-method/pending/popup-blocked outcomes; member-refresh gate
(composable + dialogService level); non-dismissable dialog props;
fast-path toast; creator excluded; cancel no-ops; error keeps dialog
open (23 green). typecheck / oxlint / eslint / stylelint / oxfmt / knip
clean.

## Screenshots

Captured live on the PR branch against mocked workspace/billing APIs
(4-member team workspace; `DELETE /api/workspace/members/:id` and `POST
/api/billing/subscribe` intercepted). Flow verified end-to-end: all 3
non-owner members removed, then `subscribe('standard-monthly')` issued;
owner retained.

| Confirm gate (initial) | Phrase typed → enabled | Removing members +
subscribing |
| --- | --- | --- |
| <img width="400" alt="initial"
src="https://github.com/user-attachments/assets/bd5c2d41-edc5-48c2-a44e-b95f9b6bcbd7"
/> | <img width="400" alt="typed enabled"
src="https://github.com/user-attachments/assets/69d5ef0f-56f4-44ce-94a3-f4c05594f403"
/> | <img width="400" alt="loading"
src="https://github.com/user-attachments/assets/073ff7b3-49ca-4fda-9ab8-0fca869f7347"
/> |

In context over the pricing table (the FE-934 trigger seam):

<img width="800" alt="dialog over pricing table"
src="https://github.com/user-attachments/assets/6589f823-f511-42eb-ae2e-5d7d523bb2ee"
/>

Disallowed transition (`previewSubscribe` gate): BE reason surfaced,
dialog stays open, no member removed:

<img width="800" alt="preview disallowed error toast"
src="https://github.com/user-attachments/assets/58759c77-d824-4bfe-80d4-3847e2145456"
/>

Fixes FE-977
2026-06-23 02:24:17 +00:00
Dante
d4be483c03 fix(billing): widen user popover so the credits row keeps both buttons inside (#13052)
## Issue

In the top-right user popover, a **cancelled-but-still-active** personal
subscription renders **both** "Add credits" and "Resubscribe" in the
credits row (the user can still top up *and* re-subscribe during the
grace period). With a wide (7-digit) credit balance, balance + help icon
+ both buttons exceeded the fixed `w-80` (320px) popover and the
trailing "Resubscribe" button spilled past the right edge.

Surfaced during FE-991 (Billing Rework V1) testing. Pre-existing on
`main` — reproducible for any personal owner whose subscription is
cancelled but not yet expired.

## Fix

Make the popover width **fluid** instead of fixed: `w-fit` clamped to
`min-w-80 max-w-96`. It stays **320px** in the common single-action case
(unchanged) and only grows — to **~370px** — when the credits row
actually needs the room for a second button.

## Before / After

**Before (`w-80`)** — "Resubscribe" clipped past the popover edge:

<img width="320" alt="before"
src="https://github.com/user-attachments/assets/439baae8-9e04-4cdf-b43f-098fb5e3853f"
/>

**After — single action (stays 320px):**

<img width="320" alt="after-single"
src="https://github.com/user-attachments/assets/e96f784e-6afd-4286-80c3-1cf0ecec7aa8"
/>

**After — both actions (grows to ~370px, fits):**

<img width="370" alt="after-both"
src="https://github.com/user-attachments/assets/578c1528-24ad-4717-a2b5-33e1af78f048"
/>

## Test

Adds a `@cloud` e2e that opens the popover in the cancelled-but-active
state (mocked `/customers/cloud-subscription-status` with `end_date` + a
7-digit balance) and asserts the "Resubscribe" button's right edge stays
within the popover bounds — same bounding-box pattern as
`workspaceSwitcher.spec.ts`. Validated red→green locally (fails on fixed
`w-80`, passes with the fluid width); single-action width measured at
320px, both-action at ~370px.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 01:55:46 +00:00
Dante
8d0b21e9e8 fix(billing): keep successful team subscribe when post-write refresh fails (#12951)
## What

Mirrors the personal/legacy adapter fix (`useLegacyBilling.subscribe` in
#12945, FE-967) on the **team** workspace adapter.

`useWorkspaceBilling.subscribe` performed the write
(`workspaceApi.subscribe`) and then refreshed status + balance with
`Promise.all([fetchStatus(), fetchBalance()])`. A post-write **refresh**
failure rejected the whole call, so the caller saw "subscribe failed"
and could prompt a retry of an **already-active** subscription.

## Fix

The refresh is now non-fatal: `Promise.allSettled` runs the refresh and,
on a rejected refresh, surfaces a soft signal via the existing `error`
ref (`Subscription succeeded, but billing state refresh failed`) and
returns the successful `SubscribeResponse`. Write semantics and the
success / needs-payment / pending branches are unchanged; a failed
**write** still rejects as before.

## Test

Added a regression test: `subscribe` resolves but the post-write refresh
rejects -> still returns the response (no false failure) and records the
soft error.

## Verification

- `pnpm typecheck` clean
- `pnpm test:unit
src/platform/workspace/composables/useWorkspaceBilling.test.ts` -> 41
passed
- eslint / oxlint / oxfmt clean on changed files
2026-06-23 01:04:49 +00:00
Matt Miller
69858538d0 Adopt jobs-namespace cancel endpoints in the jobs panel (#12863)
## ELI-5

When you cancel a job in the jobs panel, the app used to pick a
different cancel button under the hood depending on which backend it was
talking to and whether the job was already running or just waiting in
line. That meant three code paths for one user action. This swaps all of
that for a single "cancel this job" request that works the same way no
matter the job's state — plus a single "cancel all running jobs" request
for the bulk case.

## What

- Single cancel now calls `POST /api/jobs/{job_id}/cancel`.
- Batch / "cancel all running" now calls `POST /api/jobs/cancel` with
body `{ "job_ids": [...] }`.
- Removed the runtime + job-state branching that previously routed
cancellation through `/api/queue { delete }` or `/api/interrupt`.
- Added two thin client methods (`api.cancelJob`, `api.cancelJobs`) that
target these endpoints and throw on failure so existing error handling
fires.

The "clear queue" (clear-all-pending) action is intentionally
**unchanged** and still uses the existing `/api/queue` path — there is
no jobs-namespace replacement for it, and it is out of scope here.

## Why

The cancel flow had three branches (running vs pending, and one backend
vs another) for a single user intent. The jobs-namespace endpoints are
state-agnostic and idempotent (already-terminal jobs are a successful
no-op), so one call covers every case. Collapsing the branches removes
runtime-specific conditionals from the panel and makes the cancel
behavior identical everywhere.

## ⚠️ Dependency — do not merge before runtime parity

This change relies on the runtime that serves the API exposing **both**
of these endpoints:

- `POST /api/jobs/{job_id}/cancel`
- `POST /api/jobs/cancel`

Exposing these on every runtime this UI runs against is **in flight and
not yet complete**. Until that parity lands, some runtimes will not have
these endpoints, and cancellation would fail there.

**This PR should sit ready and only be merged once that runtime parity
exists.** Do not enable auto-merge. A code comment next to each cancel
site (and on the new client methods) restates this dependency.

## Testing

- `npx vue-tsc --noEmit` — clean (0 errors).
- `npx vitest run src/scripts/api.cancel.test.ts
src/composables/queue/useJobMenu.test.ts
src/components/queue/QueueProgressOverlay.test.ts` — 48 passed.
- `npx eslint` on the touched non-ignored files — clean.

New/updated unit tests cover the single cancel call, the batch cancel
call (including the empty-list no-op), and the error path (request
failure propagates and skips the queue refresh).

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-22 20:21:24 +00:00
Matt Miller
001b132b0c feat(assets): wire infinite scroll to the flat-output provider in the widget select dropdown (#12780)
## Summary

Wires the previously-dormant `loadMore` path of the flat-output assets
provider into the widget select dropdown, so cloud users with more than
one page of outputs can actually reach them. Stacked on #12774 (FE-985);
retargets to `main` when that merges.

## Changes

- **What**: `VirtualGrid`'s existing `approach-end` event now forwards
through `FormDropdownMenu` → `FormDropdown` → `WidgetSelectDropdown`,
which calls `outputMediaAssets.loadMore()` guarded by
`hasMore`/`isLoadingMore` and debounced 300ms — the same idiom as
`AssetsSidebarTab`. A spinner row (`loadingMore` prop) renders below the
grid while a page is in flight. On cloud this drives the FE-985 cursor
walk; on OSS it drives the FE-962 jobs-history cursor walk via
`useAssetsApi`.

## Review Focus

- Verified end-to-end against the dev server with a mocked 100-asset
backend: scroll → `after=cur-40` → `after=cur-80` → stops at
`has_more:false` (100/100, no duplicate fetches).
- **Known platform limitation, not introduced here**: VueUse ≥14's
`throttleFilter` with `leading=false` drops events spaced wider than the
throttle window, so `useScroll`'s `throttle: 64` in `VirtualGrid` never
reports discrete mouse-wheel scrolls — only high-frequency
(trackpad-style) scrolling triggers `approach-end`. This equally affects
the assets sidebar and manager dialog today; bug filed separately with
root cause. Fixing it makes this wiring work for wheel users with no
further changes.
- Underfill edge: `approach-end` cannot fire when loaded items don't
overflow the viewport (shared VirtualGrid trait with the sidebar); with
the 200-item page size this only matters for heavily-filtered media
types.

- Fixes FE-988

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-22 20:20:55 +00:00
22 changed files with 1503 additions and 110 deletions

View File

@@ -0,0 +1,138 @@
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)
})
})

View File

@@ -66,7 +66,6 @@ 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'
@@ -195,20 +194,15 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const jobId = item.taskRef?.jobId
if (!jobId) return
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)
}
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)
executionStore.clearInitializationByJobId(jobId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
await api.deleteItem('queue', jobId)
await queueStore.update()
}
})
@@ -292,17 +286,8 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
if (!jobIds.length) return
// 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)))
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
await api.cancelJobs(jobIds)
executionStore.clearInitializationByJobIds(jobIds)
await queueStore.update()
})

View File

@@ -68,12 +68,10 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: class {}
}))
const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
const cancelJobMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (runningJobId: string | null) => interruptMock(runningJobId),
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
cancelJob: (jobId: string) => cancelJobMock(jobId)
}
}))
@@ -197,6 +195,7 @@ describe('useJobMenu', () => {
}))
queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined)
cancelJobMock.mockResolvedValue(undefined)
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task,
@@ -281,29 +280,18 @@ describe('useJobMenu', () => {
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
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'] }))
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'] }))
await cancelJob()
await cancelJob()
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()
})
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
}
)
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
@@ -311,11 +299,22 @@ describe('useJobMenu', () => {
await cancelJob()
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
expect(cancelJobMock).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(
@@ -860,7 +859,7 @@ describe('useJobMenu', () => {
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
await cancelEntry?.onClick?.()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(cancelJobMock).toHaveBeenCalledWith('job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})

View File

@@ -6,7 +6,6 @@ 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'
@@ -83,14 +82,13 @@ export function useJobMenu(
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
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)
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)
}
executionStore.clearInitializationByJobId(target.id)
await queueStore.update()

View File

@@ -2474,6 +2474,19 @@
"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",

View File

@@ -1,7 +1,7 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
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)]"
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)]"
>
<!-- User Info Section -->
<div class="mb-4 flex flex-col items-center px-0 py-3">

View File

@@ -0,0 +1,122 @@
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()
})
})

View File

@@ -0,0 +1,105 @@
<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>

View File

@@ -0,0 +1,348 @@
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)
})
})
})

View File

@@ -0,0 +1,101 @@
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
}
}

View File

@@ -274,6 +274,27 @@ 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'))

View File

@@ -146,8 +146,18 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
cancelUrl
)
// Refresh status and balance after subscription
await Promise.all([fetchStatus(), fetchBalance()])
// 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'
}
return response
} catch (err) {

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -150,6 +151,17 @@ 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
@@ -179,10 +191,12 @@ 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>

View File

@@ -37,6 +37,7 @@ interface Props {
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
loadingMore?: boolean
isSelected?: (
selected: Set<string>,
item: FormDropdownItem,
@@ -64,11 +65,16 @@ 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')
)
@@ -316,9 +322,11 @@ 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>

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
@@ -7,8 +8,9 @@ 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" />'
'<div data-testid="virtual-grid" :data-items="JSON.stringify(items)" :data-max-columns="maxColumns" @click="$emit(\'approach-end\')" />'
}
function createItem(id: string, name: string): FormDropdownItem {
@@ -93,6 +95,31 @@ 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,

View File

@@ -27,6 +27,7 @@ interface Props {
baseModelOptions?: FilterOption[]
candidateIndex?: number
candidateLabel?: string
loadingMore?: boolean
}
const {
@@ -39,11 +40,13 @@ const {
showBaseModelFilter,
baseModelOptions,
candidateIndex = -1,
candidateLabel
candidateLabel,
loadingMore = false
} = 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')
@@ -158,6 +161,7 @@ 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
@@ -172,5 +176,15 @@ 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>

View File

@@ -0,0 +1,94 @@
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'
)
})
})
})

View File

@@ -1110,6 +1110,52 @@ 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
*/

View File

@@ -0,0 +1,124 @@
/**
* 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()
})
})

View File

@@ -10,6 +10,7 @@ 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,
@@ -607,6 +608,53 @@ export const useDialogService = () => {
})
}
/**
* 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
}
})
}
/** Shows one-time cloud notification modal for macOS desktop users. */
async function showCloudNotification(): Promise<void> {
const { default: component } = await lazyCloudNotificationContent()
@@ -668,6 +716,7 @@ export const useDialogService = () => {
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog
showCancelSubscriptionDialog,
showDowngradeToPersonalDialog
}
}

View File

@@ -5,7 +5,10 @@ import { nextTick, watch } from 'vue'
import { useAssetsStore } from '@/stores/assetsStore'
import { api } from '@/scripts/api'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetResponse
} from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
@@ -25,6 +28,7 @@ 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(),
@@ -1517,32 +1521,64 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
tags: ['output']
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
const makePage = (
assets: AssetItem[],
{
hasMore = false,
nextCursor
}: { hasMore?: boolean; nextCursor?: string } = {}
): AssetResponse => ({
assets,
total: assets.length,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
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')
])
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
})
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')
])
)
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
expect(assetService.getAssetsPageByTag).toHaveBeenCalledWith(
'output',
true,
expect.objectContaining({ limit: FLAT_OUTPUT_PAGE_SIZE, offset: 0 })
{
limit: FLAT_OUTPUT_PAGE_SIZE,
offset: 0
}
)
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['a1', 'a2'])
})
it('marks hasMore=false when the page is short', async () => {
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
makeAsset('a1', 'one.png')
])
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 })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
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()
@@ -1550,16 +1586,78 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
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`)
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 })
)
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(fullPage)
const store = useAssetsStore()
await store.updateFlatOutputs()
expect(store.flatOutputHasMore).toBe(true)
expect(store.flatOutputHasMore).toBe(false)
})
it('appends and dedupes on loadMoreFlatOutputs', async () => {
@@ -1570,9 +1668,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
makeAsset('a0', 'duplicate.png'),
makeAsset('newId', 'new.png')
]
vi.mocked(assetService.getAssetsByTag)
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce(secondPage)
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(makePage(firstPage, { hasMore: true }))
.mockResolvedValueOnce(makePage(secondPage))
const store = useAssetsStore()
await store.updateFlatOutputs()
@@ -1582,9 +1680,9 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.at(-1)?.id).toBe('newId')
})
it('records error and clears media on initial-fetch failure', async () => {
it('records error and resolves to an empty list on initial-fetch failure', async () => {
const err = new Error('network down')
vi.mocked(assetService.getAssetsByTag).mockRejectedValueOnce(err)
vi.mocked(assetService.getAssetsPageByTag).mockRejectedValueOnce(err)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
@@ -1599,37 +1697,105 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
}
})
it('refresh resets pagination', async () => {
vi.mocked(assetService.getAssetsByTag)
it('preserves the cursor for retry when loadMore fails', async () => {
const err = new Error('network down')
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
)
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
)
.mockResolvedValueOnce([makeAsset('fresh', 'fresh.png')])
.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)
.mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], {
hasMore: true,
nextCursor: 'cursor-1'
})
)
.mockResolvedValueOnce(makePage([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!: (assets: AssetItem[]) => void
const pagePromise = new Promise<AssetItem[]>((res) => {
let resolvePage!: (page: AssetResponse) => void
const pagePromise = new Promise<AssetResponse>((res) => {
resolvePage = res
})
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(pagePromise)
const store = useAssetsStore()
const p1 = store.updateFlatOutputs()
const p2 = store.updateFlatOutputs()
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)
expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes(1)
resolvePage([makeAsset('shared-1', 'shared.png', 'h.png')])
resolvePage(makePage([makeAsset('shared-1', 'shared.png', 'h.png')]))
await Promise.all([p1, p2])
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])

View File

@@ -267,6 +267,7 @@ 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[]> {
@@ -278,26 +279,36 @@ 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.getAssetsByTag(OUTPUT_TAG, true, {
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
limit: FLAT_OUTPUT_PAGE_SIZE,
offset: flatOutputOffset.value
...(requestedAfter
? { after: requestedAfter }
: { offset: flatOutputOffset.value })
})
const batch = page.assets
const fresh = loadMore
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
: page
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
: batch
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
flatOutputAssets.value = loadMore
? [...flatOutputAssets.value, ...fresh]
: page
flatOutputOffset.value += page.length
flatOutputHasMore.value = page.length === FLAT_OUTPUT_PAGE_SIZE
: 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
return flatOutputAssets.value
} catch (err) {
flatOutputError.value = err