mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
21 Commits
jaewon/fe-
...
glary/subs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d537370533 | ||
|
|
ee0efca2a5 | ||
|
|
233a296203 | ||
|
|
5f390be100 | ||
|
|
5911a3cc88 | ||
|
|
afac440b25 | ||
|
|
e99a27c4c3 | ||
|
|
f7215fcae7 | ||
|
|
34ac5f1c36 | ||
|
|
7abe01de06 | ||
|
|
440d001a8a | ||
|
|
0b8c5593be | ||
|
|
ce3c078ab3 | ||
|
|
7c78d0e93a | ||
|
|
201b667b9e | ||
|
|
206539734a | ||
|
|
9fa69ac099 | ||
|
|
28d0c3cfe8 | ||
|
|
ec86f067a6 | ||
|
|
e3d74f1266 | ||
|
|
4dccfb1556 |
BIN
.github/fe-1247-activity-tab.png
vendored
BIN
.github/fe-1247-activity-tab.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 195 KiB |
@@ -93,16 +93,6 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/auth/useCurrentUser',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useCurrentUser.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
49
browser_tests/fixtures/data/subscriptionFixtures.ts
Normal file
49
browser_tests/fixtures/data/subscriptionFixtures.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { operations } from '@comfyorg/registry-types'
|
||||
|
||||
export type SubscriptionStatusResponse =
|
||||
operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
|
||||
|
||||
export type BalanceResponse =
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
|
||||
export function createSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse> = {}
|
||||
): SubscriptionStatusResponse {
|
||||
return {
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: null,
|
||||
has_fund: false,
|
||||
renewal_date: null,
|
||||
end_date: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function createBalance(
|
||||
overrides: Partial<BalanceResponse> = {}
|
||||
): BalanceResponse {
|
||||
return {
|
||||
amount_micros: 0,
|
||||
prepaid_balance_micros: 0,
|
||||
cloud_credit_balance_micros: 0,
|
||||
pending_charges_micros: 0,
|
||||
effective_balance_micros: 0,
|
||||
currency: 'USD',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export const UNSUBSCRIBED: SubscriptionStatusResponse =
|
||||
createSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_id: null,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
|
||||
export const ZERO_BALANCE: BalanceResponse = createBalance({
|
||||
amount_micros: 0,
|
||||
effective_balance_micros: 0
|
||||
})
|
||||
222
browser_tests/fixtures/helpers/SubscriptionHelper.ts
Normal file
222
browser_tests/fixtures/helpers/SubscriptionHelper.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import { PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
|
||||
import {
|
||||
createBalance,
|
||||
createSubscriptionStatus,
|
||||
UNSUBSCRIBED,
|
||||
ZERO_BALANCE
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
import type {
|
||||
BalanceResponse,
|
||||
SubscriptionStatusResponse
|
||||
} from '@e2e/fixtures/data/subscriptionFixtures'
|
||||
|
||||
export interface SubscriptionConfig {
|
||||
status: SubscriptionStatusResponse
|
||||
balance: BalanceResponse
|
||||
}
|
||||
|
||||
function emptyConfig(): SubscriptionConfig {
|
||||
return {
|
||||
status: createSubscriptionStatus(UNSUBSCRIBED),
|
||||
balance: createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export type SubscriptionOperator = (
|
||||
config: SubscriptionConfig
|
||||
) => SubscriptionConfig
|
||||
|
||||
function withSubscriptionStatus(
|
||||
overrides: Partial<SubscriptionStatusResponse>
|
||||
): SubscriptionOperator {
|
||||
return (config) => ({
|
||||
...config,
|
||||
status: { ...config.status, ...overrides }
|
||||
})
|
||||
}
|
||||
|
||||
export function withActiveSubscription(
|
||||
tier: NonNullable<SubscriptionStatusResponse['subscription_tier']> = 'CREATOR'
|
||||
): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: tier,
|
||||
renewal_date: '2099-12-31T00:00:00.000Z',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withFreeTier(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export function withUnsubscribed(): SubscriptionOperator {
|
||||
return withSubscriptionStatus({
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
end_date: null,
|
||||
renewal_date: null
|
||||
})
|
||||
}
|
||||
|
||||
export class SubscriptionHelper {
|
||||
private statusResponse: SubscriptionStatusResponse
|
||||
private balanceResponse: BalanceResponse
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: SubscriptionConfig = emptyConfig()
|
||||
) {
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.page.addInitScript(() => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
subscription_required: true
|
||||
}
|
||||
})
|
||||
|
||||
// The cloud build calls `/api/features` at boot via `refreshRemoteConfig`,
|
||||
// which overwrites `window.__CONFIG__` wholesale. Mock it to preserve
|
||||
// `subscription_required: true` after that fetch resolves.
|
||||
const featuresPattern = '**/api/features'
|
||||
const featuresHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: { subscription_required: true } })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: featuresPattern,
|
||||
handler: featuresHandler
|
||||
})
|
||||
await this.page.route(featuresPattern, featuresHandler)
|
||||
|
||||
const statusPattern = '**/customers/cloud-subscription-status'
|
||||
const statusHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.statusResponse })
|
||||
}
|
||||
this.routeHandlers.push({ pattern: statusPattern, handler: statusHandler })
|
||||
await this.page.route(statusPattern, statusHandler)
|
||||
|
||||
const balancePattern = '**/customers/balance'
|
||||
const balanceHandler = async (route: Route) => {
|
||||
await route.fulfill({ json: this.balanceResponse })
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: balancePattern,
|
||||
handler: balanceHandler
|
||||
})
|
||||
await this.page.route(balancePattern, balanceHandler)
|
||||
|
||||
const checkoutPattern = '**/customers/cloud-subscription-checkout**'
|
||||
const checkoutHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
json: { checkout_url: 'https://checkout.stripe.com/mock' }
|
||||
})
|
||||
}
|
||||
this.routeHandlers.push({
|
||||
pattern: checkoutPattern,
|
||||
handler: checkoutHandler
|
||||
})
|
||||
await this.page.route(checkoutPattern, checkoutHandler)
|
||||
}
|
||||
|
||||
configure(...operators: SubscriptionOperator[]): void {
|
||||
const base: SubscriptionConfig = {
|
||||
status: createSubscriptionStatus(this.statusResponse),
|
||||
balance: createBalance(this.balanceResponse)
|
||||
}
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
base
|
||||
)
|
||||
this.statusResponse = createSubscriptionStatus(config.status)
|
||||
this.balanceResponse = createBalance(config.balance)
|
||||
}
|
||||
|
||||
setStatus(overrides: Partial<SubscriptionStatusResponse>): void {
|
||||
this.statusResponse = {
|
||||
...this.statusResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
setBalance(overrides: Partial<BalanceResponse>): void {
|
||||
this.balanceResponse = {
|
||||
...this.balanceResponse,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed localStorage with a pending checkout attempt.
|
||||
* Required for `visibilitychange` to trigger a subscription re-fetch,
|
||||
* because `recoverPendingSubscriptionCheckout` checks
|
||||
* `hasPendingSubscriptionCheckoutAttempt()` before fetching.
|
||||
*
|
||||
* Call AFTER page navigation (localStorage needs a page context).
|
||||
*/
|
||||
async seedPendingCheckout(
|
||||
tier: string = 'standard',
|
||||
cycle: string = 'monthly'
|
||||
): Promise<void> {
|
||||
const storageKey = PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
|
||||
await this.page.evaluate(
|
||||
([key, t, c]) => {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
attempt_id: `test-${Date.now()}`,
|
||||
started_at_ms: Date.now(),
|
||||
tier: t,
|
||||
cycle: c,
|
||||
checkout_type: 'new'
|
||||
})
|
||||
)
|
||||
},
|
||||
[storageKey, tier, cycle] as const
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch `visibilitychange` to trigger pending-checkout recovery.
|
||||
* The app listens for this event and re-fetches subscription status
|
||||
* when a pending checkout attempt exists in localStorage.
|
||||
*/
|
||||
async triggerSubscriptionRefetch(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.statusResponse = createSubscriptionStatus(UNSUBSCRIBED)
|
||||
this.balanceResponse = createBalance(ZERO_BALANCE)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSubscriptionHelper(
|
||||
page: Page,
|
||||
...operators: SubscriptionOperator[]
|
||||
): SubscriptionHelper {
|
||||
const config = operators.reduce<SubscriptionConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new SubscriptionHelper(page, config)
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export const TestIds = {
|
||||
queueModeMenuTrigger: 'queue-mode-menu-trigger',
|
||||
saveButton: 'save-workflow-button',
|
||||
subscribeButton: 'topbar-subscribe-button',
|
||||
subscribeToRunButton: 'subscribe-to-run-button',
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
@@ -247,7 +248,9 @@ export const TestIds = {
|
||||
workflowCard: (id: string) => `template-workflow-${id}`
|
||||
},
|
||||
user: {
|
||||
currentUserIndicator: 'current-user-indicator'
|
||||
currentUserButton: 'current-user-button',
|
||||
currentUserIndicator: 'current-user-indicator',
|
||||
currentUserPopover: 'current-user-popover'
|
||||
},
|
||||
queue: {
|
||||
jobHistorySidebar: 'job-history-sidebar',
|
||||
|
||||
262
browser_tests/tests/subscription.spec.ts
Normal file
262
browser_tests/tests/subscription.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
createSubscriptionHelper,
|
||||
withActiveSubscription,
|
||||
withFreeTier,
|
||||
withUnsubscribed
|
||||
} from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { SubscriptionHelper } from '@e2e/fixtures/helpers/SubscriptionHelper'
|
||||
|
||||
async function openUserPopover(page: Page): Promise<Locator> {
|
||||
// Use dispatchEvent instead of click() to bypass Playwright's actionability
|
||||
// check — in the cloud environment a subscription dialog backdrop can be
|
||||
// present during initial page load and would block a standard click.
|
||||
await page.getByTestId(TestIds.user.currentUserButton).dispatchEvent('click')
|
||||
const popover = page.getByTestId(TestIds.user.currentUserPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
return popover
|
||||
}
|
||||
|
||||
async function clickPopoverSubscribe(page: Page): Promise<void> {
|
||||
const popover = await openUserPopover(page)
|
||||
// Use dispatchEvent instead of click() because the click opens the
|
||||
// subscription dialog whose backdrop appears mid-action; Playwright's
|
||||
// actionability re-check would otherwise see the mask intercepting and
|
||||
// retry until timeout. The button is already known-visible from
|
||||
// openUserPopover, so dispatching a synthetic click is safe here.
|
||||
await popover
|
||||
.getByRole('button', { name: /subscribe/i })
|
||||
.first()
|
||||
.dispatchEvent('click')
|
||||
}
|
||||
|
||||
// Closes the auto-opened subscription-required dialog if present.
|
||||
// Polls briefly because the dialog opens asynchronously after the
|
||||
// `isLoggedIn` watcher fires on app boot.
|
||||
async function dismissSubscriptionDialogIfOpen(page: Page): Promise<void> {
|
||||
// Target only the subscription-required dialog by its known aria-labelledby
|
||||
// key — avoids strict-mode violations when multiple GlobalDialog items are
|
||||
// on the stack simultaneously.
|
||||
const dialog = page.locator('[aria-labelledby="subscription-required"]')
|
||||
// Use expect with a short timeout: this is intentionally a "dismiss if open"
|
||||
// helper, so absence of the dialog (TimeoutError) is not a failure — we
|
||||
// discard only the timeout error, not any other unexpected exception.
|
||||
const appeared = await expect(dialog)
|
||||
.toBeVisible({ timeout: 2000 })
|
||||
.then(() => true)
|
||||
.catch((e: Error) => {
|
||||
if (e.message.includes('Timeout')) return false
|
||||
throw e
|
||||
})
|
||||
if (!appeared) return
|
||||
const closeButton = dialog.getByRole('button', { name: /close/i }).first()
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click()
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
// Installs subscription mocks AFTER comfyPage.setup() and reloads the page
|
||||
// so `addInitScript` (which sets `window.__CONFIG__.subscription_required`)
|
||||
// applies before module-level reads in `ComfyRunButton/index.ts` evaluate.
|
||||
// Depending on `comfyPage` here forces ordering: comfyPage's auth + setup
|
||||
// runs first, then mocks are installed, then the page reloads with the
|
||||
// mocked config + intercepted endpoints in place.
|
||||
function createSubscriptionTest(
|
||||
...defaultOps: Parameters<typeof createSubscriptionHelper>[1][]
|
||||
) {
|
||||
return comfyPageFixture.extend<{
|
||||
subscriptionHelper: SubscriptionHelper
|
||||
}>({
|
||||
subscriptionHelper: [
|
||||
async ({ comfyPage }, use) => {
|
||||
const helper = createSubscriptionHelper(comfyPage.page, ...defaultOps)
|
||||
await helper.mock()
|
||||
// Disable the cloud-subscription extension so its `requireActive
|
||||
// Subscription` watcher doesn't auto-open the subscription dialog
|
||||
// on app boot. The PrimeVue Dialog mask would otherwise intercept
|
||||
// pointer events on every topbar button these tests interact with.
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.Extension.Disabled': ['Comfy.Cloud.Subscription']
|
||||
})
|
||||
await comfyPage.page.reload()
|
||||
// Wait for Firebase auth to resolve: the user button is v-if="isLoggedIn"
|
||||
// and only renders after onAuthStateChanged fires with the mock user from
|
||||
// IndexedDB. waitForAppReady() does not wait for this — Firebase resolves
|
||||
// asynchronously after app boot. Waiting here ensures the button is
|
||||
// present before any test body tries to click it.
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.user.currentUserButton)
|
||||
).toBeVisible()
|
||||
// Defense-in-depth: if the dialog still surfaces (e.g. via a
|
||||
// different code path), dismiss it before the test runs.
|
||||
await dismissSubscriptionDialogIfOpen(comfyPage.page)
|
||||
await use(helper)
|
||||
await helper.clearMocks()
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribedTest = createSubscriptionTest(withUnsubscribed())
|
||||
const subscribedTest = createSubscriptionTest(withActiveSubscription('CREATOR'))
|
||||
const freeTierTest = createSubscriptionTest(withFreeTier())
|
||||
|
||||
unsubscribedTest.describe(
|
||||
'Subscription buttons — unsubscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun visible when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun click opens subscription dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
.click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'SubscribeToRun shows short label at narrow viewport',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.setViewportSize({ width: 393, height: 851 })
|
||||
const btn = comfyPage.page.getByTestId(
|
||||
TestIds.topbar.subscribeToRunButton
|
||||
)
|
||||
await expect(btn).toBeVisible()
|
||||
await expect(btn).not.toContainText(/to run/i)
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover shows subscribe when unsubscribed',
|
||||
async ({ comfyPage }) => {
|
||||
const popover = await openUserPopover(comfyPage.page)
|
||||
await expect(
|
||||
popover.getByRole('button', { name: /subscribe/i })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'User popover subscribe click opens dialog',
|
||||
async ({ comfyPage }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
await expect(
|
||||
comfyPage.page.locator('[aria-labelledby="subscription-required"]')
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Subscription state transition updates UI after re-fetch',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeVisible()
|
||||
|
||||
// Simulate returning from Stripe checkout: seed pending checkout,
|
||||
// mutate mock to return active subscription, trigger re-fetch.
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
unsubscribedTest(
|
||||
'Cleanup prevents stale subscription state after dialog close',
|
||||
async ({ comfyPage, subscriptionHelper }) => {
|
||||
await clickPopoverSubscribe(comfyPage.page)
|
||||
// Use the aria-labelledby key to target only the subscription dialog —
|
||||
// avoids strict-mode violations when a second GlobalDialog is stacked.
|
||||
const dialog = comfyPage.page.locator(
|
||||
'[aria-labelledby="subscription-required"]'
|
||||
)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await subscriptionHelper.seedPendingCheckout('standard', 'monthly')
|
||||
subscriptionHelper.setStatus({
|
||||
is_active: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
subscription_duration: 'MONTHLY'
|
||||
})
|
||||
await subscriptionHelper.triggerSubscriptionRefetch()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest.describe(
|
||||
'Subscription buttons — subscribed',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
subscribedTest(
|
||||
'SubscribeToRun hidden when subscribed',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeToRunButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
subscribedTest(
|
||||
'Topbar subscribe button hidden for paid tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.queueButton)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeHidden()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
freeTierTest.describe(
|
||||
'Subscription buttons — free tier',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
freeTierTest(
|
||||
'Topbar subscribe button visible for free tier',
|
||||
async ({ comfyPage }) => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.topbar.subscribeButton)
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -23,7 +23,6 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
const SLOT_ERROR_CLASS = /before:ring-error/
|
||||
const UNKNOWN_NODE_ID = '1'
|
||||
const INNER_EXECUTION_ID = '2:1'
|
||||
const KSAMPLER_MODEL_INPUT_NAME = 'model'
|
||||
@@ -70,25 +69,6 @@ async function selectLoadImageNodeForPaste(
|
||||
}, localLoadImageId)
|
||||
}
|
||||
|
||||
async function getInputSlotIndexByName(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string,
|
||||
inputName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
({ inputName, nodeId }) => {
|
||||
const graph = window.app!.canvas.graph ?? window.app!.graph
|
||||
const node = graph.getNodeById(nodeId)
|
||||
const index = node?.findInputSlot(inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ inputName, nodeId: toNodeId(nodeId) }
|
||||
)
|
||||
}
|
||||
|
||||
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
const loadImageNode = (
|
||||
@@ -159,10 +139,17 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
|
||||
const modelInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
ksamplerId,
|
||||
KSAMPLER_MODEL_INPUT_NAME
|
||||
const modelInputIndex = await comfyPage.page.evaluate(
|
||||
({ nodeId, inputName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const index =
|
||||
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
|
||||
if (index < 0) {
|
||||
throw new Error(`Input slot "${inputName}" not found`)
|
||||
}
|
||||
return index
|
||||
},
|
||||
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
|
||||
)
|
||||
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
|
||||
ksamplerId,
|
||||
@@ -188,7 +175,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(modelInputSlotRow).toBeVisible()
|
||||
await expect(modelInputSlotRow).toBeInViewport()
|
||||
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
|
||||
).toHaveClass(ERROR_CLASS)
|
||||
@@ -420,76 +407,5 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('boundary-linked validation error surfaces on the subgraph host', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const subgraphParentId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
||||
const innerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
|
||||
const hostInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
subgraphParentId,
|
||||
'positive'
|
||||
)
|
||||
const hostInputSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
subgraphParentId,
|
||||
hostInputIndex
|
||||
)
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host must mount before injecting validation errors'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
innerWrapper,
|
||||
'subgraph host should start without an error ring'
|
||||
).not.toHaveClass(ERROR_CLASS)
|
||||
|
||||
await test.step('surface the boundary-linked error on the host', async () => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[INNER_EXECUTION_ID]: buildKSamplerError(
|
||||
'required_input_missing',
|
||||
'positive',
|
||||
'Required input is missing: positive'
|
||||
)
|
||||
})
|
||||
await comfyPage.runButton.click()
|
||||
await dismissErrorOverlay(comfyPage)
|
||||
|
||||
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
||||
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
|
||||
})
|
||||
|
||||
await test.step('confirm the interior node does not show the surfaced ring', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
const interiorKSamplerId =
|
||||
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
|
||||
const interiorPositiveInputIndex = await getInputSlotIndexByName(
|
||||
comfyPage,
|
||||
interiorKSamplerId,
|
||||
'positive'
|
||||
)
|
||||
const interiorPositiveSlotHighlight =
|
||||
comfyPage.vueNodes.getInputSlotConnectionDot(
|
||||
interiorKSamplerId,
|
||||
interiorPositiveInputIndex
|
||||
)
|
||||
const interiorInnerWrapper =
|
||||
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
|
||||
|
||||
await expect(interiorInnerWrapper).toBeVisible()
|
||||
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
|
||||
await expect(interiorPositiveSlotHighlight).toBeVisible()
|
||||
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
|
||||
SLOT_ERROR_CLASS
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
|
||||
@@ -5,11 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
isGraphReady: true,
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({})),
|
||||
getNodeById: vi.fn()
|
||||
@@ -129,8 +127,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
getExecutionIdByNode,
|
||||
getNodeByExecutionId
|
||||
@@ -497,47 +493,6 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups lifted boundary errors under the host node card', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph({
|
||||
interiorType: 'InteriorClass'
|
||||
})
|
||||
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
|
||||
await vi.importActual<typeof GraphTraversalUtil>(
|
||||
'@/utils/graphTraversalUtil'
|
||||
)
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
|
||||
})
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError(
|
||||
[
|
||||
validationError(
|
||||
'required_input_missing',
|
||||
'seed_input',
|
||||
{},
|
||||
'Required input is missing'
|
||||
)
|
||||
],
|
||||
'InteriorClass'
|
||||
)
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.type).toBe('execution')
|
||||
if (execGroup?.type !== 'execution') return
|
||||
|
||||
const card = execGroup.cards[0]
|
||||
expect(card.nodeId).toBe('12')
|
||||
expect(card.title).toBe(host.title)
|
||||
expect(card.errors[0].displayDetails).toBe(
|
||||
`${host.title} is missing a required input: seed`
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (!executionErrorStore.surfacedNodeErrors) return
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.surfacedNodeErrors
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class="p-1 hover:bg-transparent"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.currentUser')"
|
||||
data-testid="current-user-button"
|
||||
@click="popover?.toggle($event)"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
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 -->
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const isOpen = ref(false)
|
||||
provide(hoverCardOpenKey, isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
|
||||
<slot />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
|
||||
import type { HoverCardContentProps } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
const {
|
||||
class: className,
|
||||
side = 'bottom',
|
||||
sideOffset = 8,
|
||||
...rest
|
||||
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
|
||||
// Body-portaled content sits at a static z-1700 unless a dialog that joined
|
||||
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
|
||||
const open = inject(hoverCardOpenKey, undefined)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open?.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
v-bind="forwarded"
|
||||
:side
|
||||
:side-offset
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardTrigger } from 'reka-ui'
|
||||
import type { HoverCardTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger v-bind="props">
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Shares the root open-state with the content so it can lift its z-index above
|
||||
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
|
||||
// body-portaled content renders behind the settings dialog).
|
||||
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('hoverCardOpen')
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
|
||||
}
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
nodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
|
||||
|
||||
const stop = watch(
|
||||
[
|
||||
nodeErrors,
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
app.rootGraph,
|
||||
nodeErrors.value,
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('liftNodeErrorsToBoundary', () => {
|
||||
it('lifts a boundary-linked slot error to the host', () => {
|
||||
const { host, rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual({
|
||||
'12': {
|
||||
class_type: host.title,
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
type: 'required_input_missing',
|
||||
extra_info: expect.objectContaining({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('lifts a promoted-widget value error to the host input', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode('CheckpointLoaderSimple')
|
||||
interior.id = toNodeId(5)
|
||||
const input = interior.addInput('ckpt_name', 'COMBO')
|
||||
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
|
||||
values: ['present.safetensors']
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
subgraph.add(interior)
|
||||
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('value_not_in_list', 'ckpt_name', {
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'ckpt_name',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'ckpt_name',
|
||||
received_value: 'missing.safetensors',
|
||||
input_config: ['COMBO', { values: ['present.safetensors'] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('recurses through nested boundary-linked hosts', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
outerHost.title = 'Outer Host'
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(Object.keys(result)).toEqual(['1'])
|
||||
expect(result['1'].class_type).toBe(outerHost.title)
|
||||
expect(result['1'].errors[0].extra_info).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '1:2:3',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps errors on ordinary interior data-flow links', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
|
||||
const source = new LGraphNode('SourceNode')
|
||||
source.id = toNodeId(4)
|
||||
source.addOutput('seed', '*')
|
||||
subgraph.add(source)
|
||||
|
||||
const target = new LGraphNode('TargetNode')
|
||||
target.id = toNodeId(5)
|
||||
target.addInput('seed_input', '*')
|
||||
subgraph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps errors without a liftable subject on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing'),
|
||||
validationError('exception_during_validation', 'seed_input'),
|
||||
validationError('dependency_cycle', 'seed_input'),
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{ received_value: 'image.png' },
|
||||
'Invalid image file'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('keeps unknown typed validation errors on the interior node', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12:5': nodeError([
|
||||
validationError('future_backend_validation_type', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('splits liftable and non-liftable errors from the same node entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input'),
|
||||
validationError('exception_during_validation', 'seed_input')
|
||||
])
|
||||
})
|
||||
|
||||
expect(result['12'].errors).toHaveLength(1)
|
||||
expect(result['12'].errors[0].type).toBe('required_input_missing')
|
||||
expect(result['12:5'].errors).toHaveLength(1)
|
||||
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
|
||||
})
|
||||
|
||||
it('merges a lifted error into an existing host entry', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
const errors = {
|
||||
'12': {
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output'],
|
||||
errors: [validationError('value_smaller_than_min', 'other')]
|
||||
},
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result['12']).toMatchObject({
|
||||
class_type: 'ExistingHostClass',
|
||||
dependent_outputs: ['existing-output']
|
||||
})
|
||||
expect(result['12'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps own errors before lifted errors for nested host keys', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
]),
|
||||
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
|
||||
})
|
||||
|
||||
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
|
||||
'value_smaller_than_min',
|
||||
'required_input_missing'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves empty error entries unchanged', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const errors = {
|
||||
'12': nodeError([], 'ExtraRootNode')
|
||||
}
|
||||
|
||||
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
|
||||
})
|
||||
|
||||
it('fails open without mutating the input record', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const host = createTestSubgraphNode(subgraph, { id: 12 })
|
||||
rootGraph.add(host)
|
||||
const interior = new LGraphNode('InteriorNode')
|
||||
interior.id = toNodeId(5)
|
||||
interior.addInput('unlinked', '*')
|
||||
subgraph.add(interior)
|
||||
|
||||
const errors = {
|
||||
'99:5': nodeError([validationError('required_input_missing', 'x')]),
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'missing'),
|
||||
validationError('value_not_in_list', 'unlinked')
|
||||
])
|
||||
}
|
||||
const original = structuredClone(errors)
|
||||
|
||||
const result = liftNodeErrorsToBoundary(rootGraph, errors)
|
||||
|
||||
expect(result).toEqual(original)
|
||||
expect(errors).toEqual(original)
|
||||
expect(result).not.toBe(errors)
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import { groupBy, partition } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
export interface LiftedErrorExtraInfo {
|
||||
input_name: string
|
||||
source_execution_id: string
|
||||
source_input_name: string
|
||||
}
|
||||
|
||||
export interface LiftedSurface {
|
||||
hostExecId: NodeExecutionId
|
||||
hostInputName: string
|
||||
}
|
||||
|
||||
interface ErrorPlacement {
|
||||
kind: 'own' | 'lifted'
|
||||
targetExecId: string
|
||||
error: NodeValidationError
|
||||
}
|
||||
|
||||
export function getLiftedErrorSource(
|
||||
error: NodeValidationError
|
||||
): LiftedErrorExtraInfo | null {
|
||||
const extraInfo = error.extra_info
|
||||
if (!extraInfo) return null
|
||||
|
||||
const { input_name, source_execution_id, source_input_name } = extraInfo
|
||||
if (
|
||||
typeof input_name !== 'string' ||
|
||||
typeof source_execution_id !== 'string' ||
|
||||
typeof source_input_name !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { input_name, source_execution_id, source_input_name }
|
||||
}
|
||||
|
||||
function getHostExecutionId(executionId: string): NodeExecutionId | null {
|
||||
const separatorIndex = executionId.lastIndexOf(':')
|
||||
if (separatorIndex <= 0) return null
|
||||
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
|
||||
* Walks one host per level and stops at the last resolvable surface, so an
|
||||
* unresolvable deeper host falls back to the shallower one (fail-open).
|
||||
*/
|
||||
export function resolveLiftChain(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
inputName: string
|
||||
): LiftedSurface[] {
|
||||
const chain: LiftedSurface[] = []
|
||||
let currentExecId = executionId
|
||||
let currentInputName = inputName
|
||||
|
||||
for (;;) {
|
||||
const node = getNodeByExecutionId(rootGraph, currentExecId)
|
||||
const graph = node?.graph
|
||||
if (!node || !graph || !isSubgraph(graph)) break
|
||||
|
||||
const slot = node.inputs?.find((input) => input.name === currentInputName)
|
||||
if (slot?.link == null) break
|
||||
|
||||
const subgraphInput = graph
|
||||
.getLink(slot.link)
|
||||
?.resolve(graph)?.subgraphInput
|
||||
if (!subgraphInput) break
|
||||
|
||||
const hostExecId = getHostExecutionId(currentExecId)
|
||||
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
|
||||
|
||||
chain.push({ hostExecId, hostInputName: subgraphInput.name })
|
||||
currentExecId = hostExecId
|
||||
currentInputName = subgraphInput.name
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
function createEmptyNodeError(nodeError: NodeError): NodeError {
|
||||
return {
|
||||
...nodeError,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
|
||||
function createLiftedHostEntry(
|
||||
rootGraph: LGraph,
|
||||
hostExecId: string
|
||||
): NodeError {
|
||||
return {
|
||||
class_type:
|
||||
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
|
||||
dependent_outputs: [],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function toErrorPlacement(
|
||||
rootGraph: LGraph,
|
||||
executionId: string,
|
||||
error: NodeValidationError
|
||||
): ErrorPlacement {
|
||||
const inputName = error.extra_info?.input_name
|
||||
const surface =
|
||||
inputName && !isNodeLevelValidationError(error)
|
||||
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
|
||||
: undefined
|
||||
|
||||
if (!inputName || !surface) {
|
||||
return {
|
||||
kind: 'own',
|
||||
targetExecId: executionId,
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
const liftedExtraInfo: LiftedErrorExtraInfo = {
|
||||
input_name: surface.hostInputName,
|
||||
source_execution_id: executionId,
|
||||
source_input_name: inputName
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'lifted',
|
||||
targetExecId: surface.hostExecId,
|
||||
error: {
|
||||
...error,
|
||||
extra_info: {
|
||||
...error.extra_info,
|
||||
...liftedExtraInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function liftNodeErrorsToBoundary(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError>
|
||||
): Record<string, NodeError> {
|
||||
const output: Record<string, NodeError> = {}
|
||||
const placements = Object.entries(nodeErrors).flatMap(
|
||||
([executionId, nodeError]) =>
|
||||
nodeError.errors.map((error) =>
|
||||
toErrorPlacement(rootGraph, executionId, error)
|
||||
)
|
||||
)
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
||||
if (nodeError.errors.length === 0) {
|
||||
output[executionId] = createEmptyNodeError(nodeError)
|
||||
}
|
||||
}
|
||||
|
||||
const placementsByTarget = groupBy(
|
||||
placements,
|
||||
(placement) => placement.targetExecId
|
||||
)
|
||||
|
||||
for (const [targetExecId, targetPlacements] of Object.entries(
|
||||
placementsByTarget
|
||||
)) {
|
||||
const baseEntry = nodeErrors[targetExecId]
|
||||
? createEmptyNodeError(nodeErrors[targetExecId])
|
||||
: createLiftedHostEntry(rootGraph, targetExecId)
|
||||
|
||||
const [ownErrors, liftedErrors] = partition(
|
||||
targetPlacements,
|
||||
(placement) => placement.kind === 'own'
|
||||
)
|
||||
|
||||
output[targetExecId] = {
|
||||
...baseEntry,
|
||||
errors: [...ownErrors, ...liftedErrors].map(
|
||||
(placement) => placement.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -3,42 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
|
||||
const {
|
||||
mockSetMode,
|
||||
mockAttach,
|
||||
mockDetach,
|
||||
mockGetHelper,
|
||||
mockDispose,
|
||||
transformControlsInstances,
|
||||
omitGetPointer
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn(),
|
||||
transformControlsInstances: [] as unknown[],
|
||||
omitGetPointer: { value: false }
|
||||
}))
|
||||
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
|
||||
vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/TransformControls', () => {
|
||||
class TransformControls {
|
||||
enabled = true
|
||||
dragging = false
|
||||
camera: THREE.Camera
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
private listeners = new Map<string, ((e: unknown) => void)[]>()
|
||||
|
||||
constructor(camera: THREE.Camera) {
|
||||
this.camera = camera
|
||||
if (!omitGetPointer.value) {
|
||||
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
|
||||
}
|
||||
transformControlsInstances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (e: unknown) => void) {
|
||||
@@ -83,8 +64,6 @@ describe('GizmoManager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
transformControlsInstances.length = 0
|
||||
omitGetPointer.value = false
|
||||
|
||||
scene = new THREE.Scene()
|
||||
interactionElement = document.createElement('div')
|
||||
@@ -110,120 +89,6 @@ describe('GizmoManager', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('setPointerNdcSource', () => {
|
||||
type PointerNdc = { x: number; y: number; button: number }
|
||||
function lastControls() {
|
||||
return transformControlsInstances.at(-1) as {
|
||||
dragging: boolean
|
||||
_getPointer?: (event: PointerEvent) => PointerNdc
|
||||
}
|
||||
}
|
||||
function getPointerOverride() {
|
||||
return lastControls()._getPointer
|
||||
}
|
||||
|
||||
it('routes TransformControls pointer NDC through the injected source', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource((clientX, clientY) => ({
|
||||
x: clientX / 100,
|
||||
y: clientY / 100,
|
||||
inside: true
|
||||
}))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 50,
|
||||
clientY: -25,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('maps unmappable points to an off-screen pointer', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => null)
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
lastControls().dragging = true
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: -1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
|
||||
})
|
||||
|
||||
it('applies a source registered before init once init runs', () => {
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
manager.init()
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
|
||||
})
|
||||
|
||||
it('delegates to the stock mapping until a source is registered', () => {
|
||||
manager.init()
|
||||
|
||||
const stock = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
|
||||
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
|
||||
|
||||
const mapped = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('warns and skips the override when _getPointer is missing at init', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
omitGetPointer.value = true
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
|
||||
manager.init()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
|
||||
expect(lastControls()._getPointer).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('adds helper to scene with correct name and render order', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -4,9 +4,6 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode, Model3DTransform } from './interfaces'
|
||||
import type { PointerNdcSource } from './load3dViewport'
|
||||
|
||||
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
@@ -21,7 +18,6 @@ export class GizmoManager {
|
||||
private interactionElement: HTMLElement
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
private getPointerNdc?: PointerNdcSource
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -50,45 +46,12 @@ export class GizmoManager {
|
||||
}
|
||||
})
|
||||
|
||||
this.installPointerNdcOverride()
|
||||
|
||||
const helper = this.transformControls.getHelper()
|
||||
helper.name = 'GizmoTransformControls'
|
||||
helper.renderOrder = 999
|
||||
this.scene.add(helper)
|
||||
}
|
||||
|
||||
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
|
||||
this.getPointerNdc = getPointerNdc
|
||||
}
|
||||
|
||||
private installPointerNdcOverride(): void {
|
||||
if (!this.transformControls) return
|
||||
const transformControls = this.transformControls
|
||||
const controls = transformControls as unknown as {
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
}
|
||||
const original = controls._getPointer
|
||||
if (typeof original !== 'function') {
|
||||
console.warn(
|
||||
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
|
||||
)
|
||||
return
|
||||
}
|
||||
controls._getPointer = (event: PointerEvent) => {
|
||||
if (!this.getPointerNdc) return original.call(transformControls, event)
|
||||
const ndc = this.getPointerNdc(event.clientX, event.clientY)
|
||||
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
|
||||
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
|
||||
}
|
||||
return { x: ndc.x, y: ndc.y, button: event.button }
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(model: THREE.Object3D): void {
|
||||
if (!this.transformControls) return
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type {
|
||||
CameraState,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
@@ -1262,102 +1260,4 @@ describe('Load3d', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor wiring', () => {
|
||||
function makeConstructorDeps() {
|
||||
const container = document.createElement('div')
|
||||
const canvas = document.createElement('canvas')
|
||||
container.appendChild(canvas)
|
||||
|
||||
const view = {
|
||||
canvas,
|
||||
renderer: {
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
|
||||
observeResize: vi.fn(),
|
||||
beginRender: vi.fn(),
|
||||
blit: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const gizmoManager = {
|
||||
setPointerNdcSource: vi.fn(),
|
||||
init: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const deps = {
|
||||
view,
|
||||
eventManager: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
},
|
||||
sceneManager: {
|
||||
init: vi.fn(),
|
||||
scene: new THREE.Scene(),
|
||||
renderBackground: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
cameraManager: {
|
||||
init: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
|
||||
lightingManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
viewHelperManager: {
|
||||
createViewHelper: vi.fn(),
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
render: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
hdriManager: { dispose: vi.fn() },
|
||||
loaderManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
modelManager: { dispose: vi.fn() },
|
||||
recordingManager: {
|
||||
getIsRecording: vi.fn(() => false),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
animationManager: {
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
isAnimationPlaying: false,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
gizmoManager,
|
||||
adapterRef: { current: null, capabilities: null }
|
||||
}
|
||||
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
|
||||
}
|
||||
|
||||
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
|
||||
const { container, deps, gizmoManager } = makeConstructorDeps()
|
||||
const load3d = new Load3d(container, deps)
|
||||
|
||||
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
|
||||
|
||||
const ndc = { x: 0.25, y: -0.5, inside: true }
|
||||
const clientPointToNdc = vi
|
||||
.spyOn(load3d, 'clientPointToNdc')
|
||||
.mockReturnValue(ndc)
|
||||
const source = gizmoManager.setPointerNdcSource.mock
|
||||
.calls[0][0] as PointerNdcSource
|
||||
|
||||
expect(source(12, 34)).toBe(ndc)
|
||||
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
|
||||
|
||||
load3d.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,9 +83,6 @@ class Load3d extends Viewport3d {
|
||||
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
|
||||
this.clientPointToNdc(clientX, clientY)
|
||||
)
|
||||
this.gizmoManager.init()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
|
||||
@@ -386,67 +386,6 @@ describe('Viewport3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToNdc', () => {
|
||||
function installCanvas(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
...rect,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
Object.assign(ctx.viewport, { view: { canvas } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 100,
|
||||
targetHeight: 100,
|
||||
targetAspectRatio: 1,
|
||||
isViewerMode: false
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
|
||||
x: expect.closeTo(0),
|
||||
y: expect.closeTo(0),
|
||||
inside: true
|
||||
})
|
||||
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
|
||||
x: expect.closeTo(-1.5),
|
||||
y: expect.closeTo(0),
|
||||
inside: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the canvas has no layout size', () => {
|
||||
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the full canvas when no aspect ratio is maintained', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
|
||||
x: expect.closeTo(-1),
|
||||
y: expect.closeTo(1),
|
||||
inside: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('start / remove lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
import { normalize } from '@/utils/mathUtil'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
@@ -18,12 +17,7 @@ import type {
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import type { LetterboxNdc } from './load3dViewport'
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
const VIEW_HELPER_SIZE = 128
|
||||
|
||||
@@ -282,17 +276,6 @@ export class Viewport3d {
|
||||
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
|
||||
}
|
||||
|
||||
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
|
||||
const rect = this.domElement.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
return clientPointToLetterboxNdc(
|
||||
normalize(clientX, rect.left, rect.right),
|
||||
normalize(clientY, rect.top, rect.bottom),
|
||||
{ width: rect.width, height: rect.height },
|
||||
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
|
||||
)
|
||||
}
|
||||
|
||||
protected startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import type { Load3dActivityFlags } from './load3dViewport'
|
||||
|
||||
describe('computeLetterboxedViewport', () => {
|
||||
@@ -110,59 +106,3 @@ describe('isLoad3dActive', () => {
|
||||
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToLetterboxNdc', () => {
|
||||
function ndc(x: number, y: number, inside = true) {
|
||||
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
|
||||
}
|
||||
|
||||
it('maps the full canvas when no target aspect is set', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(0, 0))
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(-1, -1))
|
||||
})
|
||||
|
||||
it('maps pillarboxed content edges to -1/1', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
|
||||
ndc(-1, 0)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
|
||||
})
|
||||
|
||||
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
|
||||
ndc(-1.6, 0, false)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
|
||||
ndc(1.6, 0, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('handles letterbox bars above/below wide content', () => {
|
||||
const container = { width: 200, height: 400 }
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
|
||||
ndc(0, 1)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
|
||||
ndc(0, 3.2, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null instead of NaN for zero-size containers', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { denormalize, normalize } from '@/utils/mathUtil'
|
||||
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
type LetterboxedViewport = {
|
||||
@@ -36,39 +34,6 @@ export function computeLetterboxedViewport(
|
||||
}
|
||||
}
|
||||
|
||||
export type LetterboxNdc = { x: number; y: number; inside: boolean }
|
||||
|
||||
export type PointerNdcSource = (
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => LetterboxNdc | null
|
||||
|
||||
export function clientPointToLetterboxNdc(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
container: Size,
|
||||
targetAspectRatio: number | null
|
||||
): LetterboxNdc | null {
|
||||
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
|
||||
x: denormalize(localX, -1, 1),
|
||||
y: -denormalize(localY, -1, 1),
|
||||
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
|
||||
})
|
||||
|
||||
if (targetAspectRatio === null) {
|
||||
return toNdc(normalizedX, normalizedY)
|
||||
}
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
container,
|
||||
targetAspectRatio
|
||||
)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
return toNdc(
|
||||
normalize(normalizedX * container.width, offsetX, offsetX + width),
|
||||
normalize(normalizedY * container.height, offsetY, offsetY + height)
|
||||
)
|
||||
}
|
||||
|
||||
export type Load3dActivityFlags = {
|
||||
mouseOnNode: boolean
|
||||
mouseOnScene: boolean
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
@@ -87,23 +86,6 @@ interface TestSubgraphNodeOptions {
|
||||
size?: [number, number]
|
||||
}
|
||||
|
||||
interface BoundaryLinkedSubgraphOptions {
|
||||
rootGraph?: LGraph
|
||||
hostId?: SerializedNodeId
|
||||
interiorId?: SerializedNodeId
|
||||
boundaryName?: string
|
||||
inputName?: string
|
||||
hostTitle?: string
|
||||
interiorType?: string
|
||||
}
|
||||
|
||||
export interface BoundaryLinkedSubgraphFixture {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
host: SubgraphNode
|
||||
interior: LGraphNode
|
||||
}
|
||||
|
||||
interface NestedSubgraphOptions {
|
||||
depth?: number
|
||||
nodesPerLevel?: number
|
||||
@@ -287,32 +269,6 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function createBoundaryLinkedSubgraph({
|
||||
rootGraph = createTestRootGraph(),
|
||||
hostId = 12,
|
||||
interiorId = 5,
|
||||
boundaryName = 'seed',
|
||||
inputName = 'seed_input',
|
||||
hostTitle = 'Host Subgraph',
|
||||
interiorType = 'InteriorNode'
|
||||
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
|
||||
const subgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: boundaryName, type: '*' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph, { id: hostId })
|
||||
host.title = hostTitle
|
||||
rootGraph.add(host)
|
||||
|
||||
const interior = new LGraphNode(interiorType)
|
||||
interior.id = toNodeId(interiorId)
|
||||
const input = interior.addInput(inputName, '*')
|
||||
subgraph.add(interior)
|
||||
subgraph.inputNode.slots[0].connect(input, interior)
|
||||
|
||||
return { rootGraph, subgraph, host, interior }
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
|
||||
@@ -1559,7 +1559,6 @@
|
||||
"3DViewer": "3DViewer",
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits",
|
||||
"Members": "Members",
|
||||
"Vue Nodes": "Nodes 2.0",
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0",
|
||||
@@ -2882,37 +2881,9 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"activity": {
|
||||
"columns": {
|
||||
"creditsUsed": "Credits",
|
||||
"date": "Date",
|
||||
"eventDetails": "Event details",
|
||||
"eventType": "Event type",
|
||||
"user": "User"
|
||||
},
|
||||
"empty": "No activity yet.",
|
||||
"eventType": {
|
||||
"cloudRun": "Cloud workflow run",
|
||||
"partnerNode": "Partner node usage"
|
||||
},
|
||||
"fullActivity": "Full activity",
|
||||
"hoverCard": {
|
||||
"lastActivity": "Last activity",
|
||||
"partnerNodeUsed": "Partner node used",
|
||||
"totalCreditsUsed": "Total credits used"
|
||||
},
|
||||
"perUserHint": "Looking for total usage per user?",
|
||||
"seeMembers": "See Members"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"activity": "Activity",
|
||||
"overview": "Credits"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2922,12 +2893,6 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
export type { NodeValidationError }
|
||||
export type NodeValidationError = NodeError['errors'][number]
|
||||
|
||||
export interface ResolvedErrorMessage {
|
||||
catalogId?: string
|
||||
|
||||
@@ -11,12 +11,6 @@ import {
|
||||
translateOptionalCatalogMessage
|
||||
} from './catalogI18n'
|
||||
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
|
||||
import {
|
||||
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
NODE_LEVEL_VALIDATION_ERROR_TYPES,
|
||||
getInputConfigBounds,
|
||||
isImageNotLoadedValidationError
|
||||
} from '@/utils/executionErrorUtil'
|
||||
|
||||
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
|
||||
|
||||
@@ -68,31 +62,51 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
|
||||
Object.fromEntries(
|
||||
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
|
||||
string,
|
||||
ValidationCatalogRule
|
||||
> = Object.fromEntries(
|
||||
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
|
||||
type,
|
||||
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
|
||||
])
|
||||
)
|
||||
|
||||
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
|
||||
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
|
||||
[REQUIRED_INPUT_MISSING_TYPE]: {
|
||||
catalogId: MISSING_CONNECTION_CATALOG_ID,
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
...NODE_LEVEL_VALIDATION_ERROR_RULES
|
||||
bad_linked_input: {
|
||||
catalogId: 'bad_linked_input',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
return_type_mismatch: {
|
||||
catalogId: 'return_type_mismatch',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
invalid_input_type: {
|
||||
catalogId: 'invalid_input_type',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_smaller_than_min: {
|
||||
catalogId: 'value_smaller_than_min',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_bigger_than_max: {
|
||||
catalogId: 'value_bigger_than_max',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
value_not_in_list: {
|
||||
catalogId: 'value_not_in_list',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
custom_validation_failed: {
|
||||
catalogId: 'custom_validation_failed',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_inner_validation: {
|
||||
catalogId: 'exception_during_inner_validation',
|
||||
itemLabel: 'nodeInput'
|
||||
},
|
||||
exception_during_validation: {
|
||||
catalogId: 'exception_during_validation',
|
||||
itemLabel: 'node'
|
||||
},
|
||||
dependency_cycle: {
|
||||
catalogId: 'dependency_cycle',
|
||||
itemLabel: 'node'
|
||||
}
|
||||
}
|
||||
|
||||
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
|
||||
@@ -117,6 +131,26 @@ function getInputName(error: NodeValidationError): string {
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorText(error: NodeValidationError) {
|
||||
return [
|
||||
'message' in error ? error.message : undefined,
|
||||
'details' in error ? error.details : undefined
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function isImageNotLoadedText(text: string): boolean {
|
||||
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
|
||||
}
|
||||
|
||||
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
isImageNotLoadedText(getErrorText(error))
|
||||
)
|
||||
}
|
||||
|
||||
function nodeInputItemLabel(nodeName: string, inputName: string): string {
|
||||
return `${nodeName} - ${inputName}`
|
||||
}
|
||||
@@ -145,7 +179,13 @@ function getInputConfigValue(
|
||||
error: NodeValidationError,
|
||||
key: 'min' | 'max'
|
||||
): string | undefined {
|
||||
return formatCatalogValue(getInputConfigBounds(error)[key])
|
||||
const inputConfig = error.extra_info?.input_config
|
||||
if (!Array.isArray(inputConfig)) return undefined
|
||||
|
||||
const config = inputConfig[1]
|
||||
if (!config || typeof config !== 'object') return undefined
|
||||
|
||||
return formatCatalogValue((config as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
function getInputConfigType(error: NodeValidationError): string | undefined {
|
||||
|
||||
@@ -95,7 +95,6 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
@@ -136,14 +135,6 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let a panel deep-link into a sibling panel (e.g. the Activity tab → Members).
|
||||
const { requestedPanelKey } = useSettingsNavigation()
|
||||
watch(requestedPanelKey, (key) => {
|
||||
if (!key) return
|
||||
activeCategoryKey.value = key
|
||||
requestedPanelKey.value = null
|
||||
})
|
||||
|
||||
const searchableNavItems = computed(() =>
|
||||
navGroups.value.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
|
||||
@@ -27,14 +27,13 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Members: 'icon-[lucide--users]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--receipt-text]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--receipt-text]'
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
@@ -176,30 +175,16 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panels: only available on cloud with team workspaces enabled.
|
||||
// The old single "Workspace" panel is split into two sidebar entries; the
|
||||
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
|
||||
const planCreditsPanel: SettingPanelItem = {
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'PlanCredits',
|
||||
label: 'Workspace',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,9 +245,7 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -312,13 +295,8 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
// The legacy per-account Credits panel is redundant once the workspace
|
||||
// Plan & Credits panel is present, which now owns the credit balance.
|
||||
...(!shouldShowWorkspacePanel.value &&
|
||||
isLoggedIn.value &&
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// A one-shot request to switch the open Settings dialog to another panel, so a
|
||||
// panel's content can deep-link into a sibling panel (e.g. Activity → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
data-testid="current-user-popover"
|
||||
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 -->
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import PlanCreditsPanelContent from './PlanCreditsPanelContent.vue'
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => reactive({ workspaceName: 'Acme Team' })
|
||||
}))
|
||||
|
||||
const stubs = {
|
||||
WorkspaceProfilePic: { template: '<div />' },
|
||||
SubscriptionPanelContentWorkspace: {
|
||||
template: '<div data-testid="credits-body" />'
|
||||
},
|
||||
WorkspaceActivityContent: {
|
||||
props: ['search'],
|
||||
template: '<div data-testid="activity-body">{{ search }}</div>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(PlanCreditsPanelContent, { global: { plugins: [i18n], stubs } })
|
||||
}
|
||||
|
||||
describe('PlanCreditsPanelContent', () => {
|
||||
it('shows Credits and Activity tabs with Credits active by default', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByRole('button', { name: 'Credits' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy()
|
||||
expect(screen.getByTestId('credits-body')).toBeTruthy()
|
||||
expect(screen.queryByTestId('activity-body')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the search box only on the Activity tab', async () => {
|
||||
renderPanel()
|
||||
expect(screen.queryByPlaceholderText('Search')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
expect(screen.getByTestId('activity-body')).toBeTruthy()
|
||||
expect(screen.queryByTestId('credits-body')).toBeNull()
|
||||
expect(screen.getByPlaceholderText('Search')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('passes the search query to the Activity tab and clears it on tab change', async () => {
|
||||
renderPanel()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'flux')
|
||||
expect(screen.getByTestId('activity-body').textContent).toContain('flux')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Credits' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
expect(screen.getByTestId('activity-body').textContent).not.toContain(
|
||||
'flux'
|
||||
)
|
||||
expect(
|
||||
(screen.getByPlaceholderText('Search') as HTMLInputElement).value
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div class="@container flex size-full min-h-0 flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="mb-4 flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
@click="setView(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</Button>
|
||||
</div>
|
||||
<SearchInput
|
||||
v-if="activeView === 'activity'"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-full @2xl:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscriptionPanelContentWorkspace v-if="activeView === 'overview'" />
|
||||
<WorkspaceActivityContent
|
||||
v-else
|
||||
:search="searchQuery"
|
||||
:events="activityEvents"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
|
||||
import { useWorkspaceActivitySource } from '@/platform/workspace/composables/useWorkspaceActivitySource'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
type View = 'overview' | 'activity'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(workspaceStore)
|
||||
|
||||
const { events: activityEvents } = useWorkspaceActivitySource()
|
||||
|
||||
// The Invoices tab (owner/admin only) is added by FE-1245, which owns the
|
||||
// next-invoice banner + Stripe portal link that fill it.
|
||||
const tabs = computed<{ key: View; label: string }[]>(() => [
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
|
||||
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
|
||||
])
|
||||
|
||||
const activeView = ref<View>('overview')
|
||||
const searchQuery = ref('')
|
||||
|
||||
function setView(view: View) {
|
||||
activeView.value = view
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
|
||||
|
||||
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
|
||||
|
||||
// The Activity tab renders inside a flex, height-constrained dialog panel and
|
||||
// auto-sizes its page to the available height; the decorator reproduces that
|
||||
// container (and the @container query context the footer/tabs rely on).
|
||||
const meta: Meta<typeof WorkspaceActivityContent> = {
|
||||
title: 'Platform/Workspace/WorkspaceActivityContent',
|
||||
component: WorkspaceActivityContent,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="@container flex h-[520px] flex-col p-6"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: { search: '' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Populated: Story = {
|
||||
args: { events: activityFixture }
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { events: [] }
|
||||
}
|
||||
|
||||
export const Searched: Story = {
|
||||
args: { events: activityFixture, search: 'partner' }
|
||||
}
|
||||
@@ -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 enMessages from '@/locales/en/main.json'
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
|
||||
|
||||
const {
|
||||
mockCanManageSubscription,
|
||||
mockNavigateToPanel,
|
||||
mockRequestMembersSort
|
||||
} = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
mockCanManageSubscription: ref(true),
|
||||
mockNavigateToPanel: vi.fn(),
|
||||
mockRequestMembersSort: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManageSubscription: mockCanManageSubscription.value }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userDisplayName: { value: 'Ada Lovelace' },
|
||||
userEmail: { value: 'ada@example.com' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateToPanel: mockNavigateToPanel })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
requestMembersSort: mockRequestMembersSort
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.test'
|
||||
}))
|
||||
|
||||
class NoopResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
|
||||
function renderContent(events: ActivityEvent[] = []) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(WorkspaceActivityContent, {
|
||||
props: { search: '', events },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const creditedRow: ActivityEvent = {
|
||||
id: 'inflow',
|
||||
date: new Date('2026-07-14T12:00:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Auto-reload',
|
||||
detail: '—',
|
||||
credits: 20000,
|
||||
credited: true
|
||||
}
|
||||
|
||||
describe('WorkspaceActivityContent', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = NoopResizeObserver
|
||||
mockCanManageSubscription.value = true
|
||||
mockNavigateToPanel.mockClear()
|
||||
mockRequestMembersSort.mockClear()
|
||||
})
|
||||
|
||||
it('renders the empty state when there is no activity', () => {
|
||||
renderContent([])
|
||||
expect(screen.getByText('No activity yet.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the per-user footer actions to a billing manager', () => {
|
||||
renderContent([])
|
||||
const link = screen.getByRole('link', { name: /full activity/i })
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://platform.test/profile/usage'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /see members/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the per-user footer from members', () => {
|
||||
mockCanManageSubscription.value = false
|
||||
renderContent([])
|
||||
expect(screen.queryByRole('link', { name: /full activity/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /see members/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('deep-links to the Members panel pre-sorted by spend', async () => {
|
||||
renderContent([])
|
||||
await userEvent.click(screen.getByRole('button', { name: /see members/i }))
|
||||
expect(mockRequestMembersSort).toHaveBeenCalledWith('credits')
|
||||
expect(mockNavigateToPanel).toHaveBeenCalledWith('workspace-members')
|
||||
})
|
||||
|
||||
it('marks a credit inflow with a leading plus', () => {
|
||||
renderContent([creditedRow])
|
||||
expect(screen.getByText(/^\+20,000$/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div
|
||||
ref="tableContainer"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
|
||||
>
|
||||
<Table class="min-h-0 flex-1 px-4">
|
||||
<TableHeader class="sticky top-0 z-10 bg-base-background">
|
||||
<TableRow
|
||||
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
|
||||
>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('date')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('date')">
|
||||
{{ $t('workspacePanel.activity.columns.date') }}
|
||||
<i :class="sortIcon('date')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-56" :aria-sort="ariaSort('user')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('user')">
|
||||
{{ $t('workspacePanel.activity.columns.user') }}
|
||||
<i :class="sortIcon('user')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead :aria-sort="ariaSort('eventType')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
|
||||
{{ $t('workspacePanel.activity.columns.eventType') }}
|
||||
<i :class="sortIcon('eventType')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('detail')">
|
||||
{{ $t('workspacePanel.activity.columns.eventDetails') }}
|
||||
<i :class="sortIcon('detail')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('credits')"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
|
||||
<i :class="sortIcon('credits')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="event in pagedItems"
|
||||
:key="event.id"
|
||||
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ formatDate(event.date) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HoverCard
|
||||
v-if="event.userName"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger
|
||||
as="div"
|
||||
class="flex w-fit cursor-default items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: userBadgeColor(event.userName) }"
|
||||
>
|
||||
<span class="text-2xs font-bold text-base-foreground">
|
||||
{{ event.userName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.userName }}
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-64" align="start">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.activity.hoverCard.totalCreditsUsed'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i
|
||||
class="icon-[lucide--coins] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground tabular-nums">
|
||||
{{
|
||||
summaryFor(
|
||||
event.userName
|
||||
).totalCredits.toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.lastActivity')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ lastActivityLabel(event.userName) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<span v-else class="text-sm text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
<HoverCard
|
||||
v-if="event.partnerNode"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger as="span" class="cursor-default">
|
||||
{{ event.eventType }}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-72" align="start">
|
||||
<div class="flex h-5 items-center justify-between gap-4">
|
||||
<span
|
||||
class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
|
||||
}}
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.partnerNode }}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<template v-else>{{ event.eventType }}</template>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ event.detail || '—' }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'text-right text-sm tabular-nums',
|
||||
event.credited ? 'text-credit' : 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ event.credited ? '+' : ''
|
||||
}}{{ event.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.empty') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
|
||||
<div
|
||||
v-if="canViewTeamUsage"
|
||||
class="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
>
|
||||
<a
|
||||
:href="fullActivityUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('workspacePanel.activity.fullActivity') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.activity.perUserHint') }}
|
||||
</p>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
|
||||
@click="goToMembers"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.seeMembers') }}
|
||||
<i class="icon-[lucide--arrow-right] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="@2xl:ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
|
||||
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
|
||||
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
|
||||
import Pagination from '@/components/ui/pagination/Pagination.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import type {
|
||||
ActivityEvent,
|
||||
ActivitySortField
|
||||
} from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// `events` is the data seam: the tab shell mounts this with no events (empty
|
||||
// state) until FE-1249 wires the per-workspace usage API and passes them in.
|
||||
const { search, events = [] } = defineProps<{
|
||||
search: string
|
||||
events?: ActivityEvent[]
|
||||
}>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize } = useAutoPageSize(tableContainer, 1)
|
||||
|
||||
// Owners/admins see team-wide activity + the per-user footer; members see only
|
||||
// their own usage, scoped to their name.
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { userDisplayName, userEmail } = useCurrentUser()
|
||||
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
|
||||
const selfName = computed(() =>
|
||||
canViewTeamUsage.value
|
||||
? null
|
||||
: userDisplayName.value || userEmail.value || t('g.you')
|
||||
)
|
||||
|
||||
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
|
||||
|
||||
const { navigateToPanel } = useSettingsNavigation()
|
||||
|
||||
function goToMembers() {
|
||||
requestMembersSort('credits')
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
|
||||
const {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
} = useWorkspaceActivity(
|
||||
() => search,
|
||||
pageSize,
|
||||
selfName,
|
||||
() => events
|
||||
)
|
||||
|
||||
function summaryFor(userName: string) {
|
||||
return (
|
||||
userSummaries.value.get(userName) ?? {
|
||||
totalCredits: 0,
|
||||
lastActivity: new Date()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function lastActivityLabel(userName: string): string {
|
||||
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
|
||||
justNow: t('workspacePanel.members.activity.justNow'),
|
||||
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
|
||||
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
|
||||
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
|
||||
})
|
||||
}
|
||||
|
||||
const sortHeaderClass =
|
||||
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
|
||||
|
||||
function sortIcon(field: ActivitySortField) {
|
||||
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
|
||||
return sortDirection.value === 'asc'
|
||||
? 'icon-[lucide--chevron-up] size-3'
|
||||
: 'icon-[lucide--chevron-down] size-3'
|
||||
}
|
||||
|
||||
function ariaSort(
|
||||
field: ActivitySortField
|
||||
): 'ascending' | 'descending' | 'none' {
|
||||
if (sortField.value !== field) return 'none'
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import WorkspaceMembersPanelContent from './WorkspaceMembersPanelContent.vue'
|
||||
|
||||
const { mockFetchMembers, mockFetchPendingInvites } = vi.hoisted(() => ({
|
||||
mockFetchMembers: vi.fn(),
|
||||
mockFetchPendingInvites: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () =>
|
||||
reactive({
|
||||
workspaceName: 'Acme Team',
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({ workspaceRole: ref('owner') })
|
||||
}))
|
||||
|
||||
const stubs = {
|
||||
WorkspaceProfilePic: { template: '<div />' },
|
||||
MembersPanelContent: { template: '<div data-testid="members-body" />' }
|
||||
}
|
||||
|
||||
describe('WorkspaceMembersPanelContent', () => {
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
render(WorkspaceMembersPanelContent, { global: { stubs } })
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
workspaceName: ref('Acme Team'),
|
||||
members: mockMembers,
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
|
||||
() => ({
|
||||
default: { name: 'MembersPanelContent', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createMember(id: string): WorkspaceMember {
|
||||
return {
|
||||
id,
|
||||
name: `Member ${id}`,
|
||||
email: `member${id}@example.com`,
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the counted label for team workspaces with multiple members', () => {
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the count when the owner is the only member', () => {
|
||||
mockMembers.value = [createMember('1')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plain Members label for personal workspaces', () => {
|
||||
mockWorkspaceType.value = 'personal'
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
renderComponent()
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
showMembersTabCount
|
||||
? $t('workspacePanel.tabs.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -1,151 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useAutoPageSize } from './useAutoPageSize'
|
||||
|
||||
const resizeObserverState = vi.hoisted(() => {
|
||||
const state = {
|
||||
callback: null as ResizeObserverCallback | null,
|
||||
observe: vi.fn<(element: Element) => void>(),
|
||||
disconnect: vi.fn<() => void>()
|
||||
}
|
||||
|
||||
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
|
||||
observe = state.observe
|
||||
unobserve = vi.fn()
|
||||
disconnect = state.disconnect
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
state.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
interface ContainerShape {
|
||||
clientHeight: number
|
||||
rowHeight: number | null
|
||||
headerHeight: number | null
|
||||
}
|
||||
|
||||
function fakeContainer({
|
||||
clientHeight,
|
||||
rowHeight,
|
||||
headerHeight
|
||||
}: ContainerShape): HTMLElement {
|
||||
return {
|
||||
clientHeight,
|
||||
querySelector(selector: string) {
|
||||
if (selector === 'tbody tr') {
|
||||
return rowHeight === null
|
||||
? null
|
||||
: { getBoundingClientRect: () => ({ height: rowHeight }) }
|
||||
}
|
||||
if (selector === 'thead') {
|
||||
return headerHeight === null
|
||||
? null
|
||||
: { getBoundingClientRect: () => ({ height: headerHeight }) }
|
||||
}
|
||||
if (selector === 'table') return {}
|
||||
return null
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function runInScope(container: Ref<HTMLElement | null>, min?: number) {
|
||||
const scope = effectScope()
|
||||
const result = scope.run(() => useAutoPageSize(container, min))!
|
||||
return { ...result, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useAutoPageSize', () => {
|
||||
beforeEach(() => {
|
||||
resizeObserverState.callback = null
|
||||
resizeObserverState.observe.mockClear()
|
||||
resizeObserverState.disconnect.mockClear()
|
||||
})
|
||||
|
||||
it('fits as many whole rows as the container height allows', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 56 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// (500 - 56) / 41 = 10.83 -> floored to 10 whole rows
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('floors a fractional fit so a not-quite-fitting row is left out', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 449, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// 449 / 41 = 10.95 -> 10, never 11 (which would overflow + show a scrollbar)
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('never returns fewer than the requested minimum', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 50, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 5)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// fit is 1, but min=5 wins
|
||||
expect(pageSize.value).toBe(5)
|
||||
})
|
||||
|
||||
it('falls back to a default row height before any rows are rendered', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// no tbody row yet -> FALLBACK_ROW_HEIGHT 41: floor(420 / 41) = 10
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('re-measures when the table height changes after rows load', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
expect(pageSize.value).toBe(10)
|
||||
|
||||
// real rows arrive taller than the fallback; the observer fires again
|
||||
container.value = fakeContainer({
|
||||
clientHeight: 420,
|
||||
rowHeight: 60,
|
||||
headerHeight: 0
|
||||
})
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// floor(420 / 60) = 7
|
||||
expect(pageSize.value).toBe(7)
|
||||
})
|
||||
|
||||
it('disconnects the observer when the scope stops', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { stop } = runInScope(container, 1)
|
||||
|
||||
stop()
|
||||
|
||||
expect(resizeObserverState.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
const FALLBACK_ROW_HEIGHT = 41
|
||||
const MIN_ROWS = 5
|
||||
|
||||
/**
|
||||
* Derive a table's rows-per-page from the live height of its scroll container so
|
||||
* a taller dialog shows more rows instead of leaving empty space. Row and header
|
||||
* heights are read from the rendered table, so it adapts if the design changes.
|
||||
*/
|
||||
export function useAutoPageSize(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
min: number = MIN_ROWS
|
||||
) {
|
||||
const pageSize = ref(min)
|
||||
|
||||
function measure() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
// Use fractional (getBoundingClientRect) heights, not the integer
|
||||
// offsetHeight: truncating each row's height makes the floor below think an
|
||||
// extra partial row fits, which overflows the container by a few pixels and
|
||||
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
|
||||
const rowHeight =
|
||||
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
|
||||
.height || FALLBACK_ROW_HEIGHT
|
||||
const headerHeight =
|
||||
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
|
||||
.height ?? 0
|
||||
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
|
||||
pageSize.value = Math.max(min, fit)
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null
|
||||
watch(
|
||||
containerRef,
|
||||
(el) => {
|
||||
observer?.disconnect()
|
||||
if (!el) return
|
||||
observer = new ResizeObserver(() => measure())
|
||||
observer.observe(el)
|
||||
// Also observe the table itself: async-loaded rows change the table's
|
||||
// height without resizing the container, so the initial measure (taken
|
||||
// against an empty state or fallback row height) would otherwise stick.
|
||||
// Re-measuring converges — pageSize stabilizes once real rows exist.
|
||||
const table = el.querySelector('table')
|
||||
if (table) observer.observe(table)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onScopeDispose(() => observer?.disconnect())
|
||||
|
||||
return { pageSize }
|
||||
}
|
||||
@@ -23,17 +23,6 @@ type ActiveView = 'active' | 'pending'
|
||||
type SortField = 'inviteDate' | 'expiryDate' | 'role'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// One-shot cross-panel request from the Activity tab to open the Members table
|
||||
// sorted by a per-member column. Those columns ship with member auditing on the
|
||||
// Members panel (DES-479); until then useMembersPanel clears the request without
|
||||
// a matching column to apply it to.
|
||||
type RequestedMembersSort = 'credits' | 'lastActivity'
|
||||
const requestedMembersSort = ref<RequestedMembersSort | null>(null)
|
||||
|
||||
export function requestMembersSort(field: RequestedMembersSort) {
|
||||
requestedMembersSort.value = field
|
||||
}
|
||||
|
||||
export function sortMembers(
|
||||
members: WorkspaceMember[],
|
||||
currentUserEmail: string | null,
|
||||
@@ -124,9 +113,6 @@ export function useMembersPanel() {
|
||||
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
// Consume the Activity tab's cross-panel sort request (see requestMembersSort).
|
||||
if (requestedMembersSort.value) requestedMembersSort.value = null
|
||||
|
||||
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
|
||||
// the subscription tier.
|
||||
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
|
||||
|
||||
import { useWorkspaceActivity } from './useWorkspaceActivity'
|
||||
import type { ActivityEvent } from './useWorkspaceActivity'
|
||||
|
||||
interface SetupOptions {
|
||||
search?: Ref<string>
|
||||
pageSize?: Ref<number>
|
||||
selfName?: Ref<string | null>
|
||||
source?: ActivityEvent[]
|
||||
}
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const search = options.search ?? ref('')
|
||||
const pageSize = options.pageSize ?? ref(100)
|
||||
const selfName = options.selfName ?? ref<string | null>(null)
|
||||
const scope = effectScope()
|
||||
const api = scope.run(() =>
|
||||
useWorkspaceActivity(
|
||||
search,
|
||||
pageSize,
|
||||
selfName,
|
||||
options.source ?? activityFixture
|
||||
)
|
||||
)!
|
||||
return { ...api, search, pageSize, selfName, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useWorkspaceActivity', () => {
|
||||
it('shows the whole ledger to an owner (no self scope)', () => {
|
||||
const { total } = setup()
|
||||
expect(total.value).toBe(activityFixture.length)
|
||||
})
|
||||
|
||||
it('scopes a member to their own usage rows plus workspace credit inflows', () => {
|
||||
const { total, pagedItems } = setup({
|
||||
selfName: ref('Ada Lovelace')
|
||||
})
|
||||
// Ada's 4 usage rows + the 2 credited inflows (userName '')
|
||||
expect(total.value).toBe(6)
|
||||
const names = new Set(pagedItems.value.map((e) => e.userName))
|
||||
expect(names).toEqual(new Set(['Ada Lovelace', '']))
|
||||
})
|
||||
|
||||
it('searches user name and event type only, not other columns', () => {
|
||||
const search = ref('')
|
||||
const { total } = setup({ search })
|
||||
|
||||
search.value = 'ada'
|
||||
expect(total.value).toBe(4) // the 4 "Ada Lovelace" rows
|
||||
|
||||
search.value = 'partner'
|
||||
expect(total.value).toBe(5) // the 5 "Partner node usage" rows
|
||||
|
||||
search.value = '2 runs' // a detail value — not searched
|
||||
expect(total.value).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts by date descending by default and dispatches other fields', () => {
|
||||
const { pagedItems, sortField, toggleSort } = setup()
|
||||
|
||||
expect(pagedItems.value[0].id).toBe('evt-01') // newest date
|
||||
|
||||
toggleSort('credits')
|
||||
expect(sortField.value).toBe('credits')
|
||||
expect(pagedItems.value[0].id).toBe('evt-12') // 50000, highest
|
||||
|
||||
toggleSort('credits') // flip to ascending
|
||||
expect(pagedItems.value[0].id).toBe('evt-09') // 760, lowest
|
||||
})
|
||||
|
||||
it('slices to the current page size', () => {
|
||||
const { pagedItems, total } = setup({ pageSize: ref(5) })
|
||||
expect(total.value).toBe(activityFixture.length)
|
||||
expect(pagedItems.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('clamps the page when the filtered result shrinks past it', async () => {
|
||||
const search = ref('')
|
||||
const { page, search: s } = setup({ search, pageSize: ref(5) })
|
||||
|
||||
page.value = 3 // valid: ceil(14 / 5) = 3 pages
|
||||
s.value = 'katherine' // narrows to 2 rows -> 1 page
|
||||
await nextTick()
|
||||
|
||||
expect(page.value).toBe(1)
|
||||
})
|
||||
|
||||
it('rolls up per-user totals excluding credit inflows', () => {
|
||||
const { userSummaries } = setup()
|
||||
const ada = userSummaries.value.get('Ada Lovelace')
|
||||
|
||||
expect(ada?.totalCredits).toBe(1840 + 5120 + 4100 + 2950)
|
||||
expect(ada?.lastActivity).toEqual(new Date('2026-07-14T09:32:00Z'))
|
||||
// credited inflows (userName '') never appear in the rollups
|
||||
expect(userSummaries.value.has('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string
|
||||
date: Date
|
||||
userName: string
|
||||
eventType: string
|
||||
detail: string
|
||||
credits: number
|
||||
/** The partner node used, for 'Partner node usage' events. */
|
||||
partnerNode?: string
|
||||
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
|
||||
credited?: boolean
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
totalCredits: number
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
export type ActivitySortField =
|
||||
| 'date'
|
||||
| 'user'
|
||||
| 'eventType'
|
||||
| 'detail'
|
||||
| 'credits'
|
||||
|
||||
/**
|
||||
* Headless state for the workspace Activity ledger: role-scoped filtering,
|
||||
* search, sort, auto-paginated slicing, and per-user rollups.
|
||||
*
|
||||
* `source` is the data seam. It defaults to an empty list, so the ledger renders
|
||||
* its empty state until a caller supplies events; the per-workspace usage API
|
||||
* that will feed it is tracked in FE-1249. Swapping in real data means passing a
|
||||
* populated ref/getter here — every downstream computed stays untouched.
|
||||
*/
|
||||
export function useWorkspaceActivity(
|
||||
search: MaybeRefOrGetter<string>,
|
||||
pageSize: MaybeRefOrGetter<number>,
|
||||
selfName: MaybeRefOrGetter<string | null> = null,
|
||||
source: MaybeRefOrGetter<ActivityEvent[]> = []
|
||||
) {
|
||||
const page = ref(1)
|
||||
const perPage = computed(() => Math.max(1, toValue(pageSize)))
|
||||
const sortField = ref<ActivitySortField>('date')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// Members only see their own usage; credit inflows stay workspace-level.
|
||||
const base = computed<ActivityEvent[]>(() => {
|
||||
const events = toValue(source)
|
||||
const self = toValue(selfName)
|
||||
if (!self) return events
|
||||
return events.filter((event) => event.credited || event.userName === self)
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = toValue(search).trim().toLowerCase()
|
||||
if (!q) return base.value
|
||||
return base.value.filter(
|
||||
(event) =>
|
||||
event.userName.toLowerCase().includes(q) ||
|
||||
event.eventType.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const sorted = computed(() => {
|
||||
const dir = sortDirection.value === 'asc' ? 1 : -1
|
||||
return [...filtered.value].sort((a, b) => {
|
||||
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
|
||||
if (sortField.value === 'user')
|
||||
return dir * a.userName.localeCompare(b.userName)
|
||||
if (sortField.value === 'eventType')
|
||||
return dir * a.eventType.localeCompare(b.eventType)
|
||||
if (sortField.value === 'detail')
|
||||
return dir * a.detail.localeCompare(b.detail)
|
||||
return dir * (a.date.getTime() - b.date.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
const total = computed(() => filtered.value.length)
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return sorted.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
function toggleSort(field: ActivitySortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
watch([total, perPage], ([count]) => {
|
||||
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
|
||||
if (page.value > lastPage) page.value = lastPage
|
||||
})
|
||||
|
||||
// Per-user rollups behind the User hover card: lifetime credits and the most
|
||||
// recent event, aggregated across the whole (unpaged) list.
|
||||
const userSummaries = computed(() => {
|
||||
const map = new Map<string, UserSummary>()
|
||||
for (const event of base.value) {
|
||||
if (event.credited) continue
|
||||
const existing = map.get(event.userName)
|
||||
if (!existing) {
|
||||
map.set(event.userName, {
|
||||
totalCredits: event.credits,
|
||||
lastActivity: event.date
|
||||
})
|
||||
} else {
|
||||
existing.totalCredits += event.credits
|
||||
if (event.date > existing.lastActivity)
|
||||
existing.lastActivity = event.date
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage: perPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
|
||||
import {
|
||||
billingEventToActivity,
|
||||
isUsageEvent
|
||||
} from '@/platform/workspace/utils/billingEventToActivity'
|
||||
|
||||
/**
|
||||
* Live data source for the workspace Activity ledger (FE-1249): fetches the
|
||||
* per-workspace usage feed (`GET /api/billing/events`), keeps only usage rows,
|
||||
* and maps them to the ledger's `ActivityEvent` shape, resolving member names
|
||||
* from the workspace store so the mapping updates once members load.
|
||||
*/
|
||||
export function useWorkspaceActivitySource() {
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(workspaceStore)
|
||||
|
||||
const rawEvents = ref<BillingEventInput[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<unknown>(null)
|
||||
|
||||
function resolveUserName(userId: string | undefined): string {
|
||||
if (!userId) return ''
|
||||
const member = members.value.find((m) => m.id === userId)
|
||||
return member?.name || member?.email || userId
|
||||
}
|
||||
|
||||
const events = computed<ActivityEvent[]>(() => {
|
||||
const labels = {
|
||||
cloudRun: t('workspacePanel.activity.eventType.cloudRun'),
|
||||
partnerNode: t('workspacePanel.activity.eventType.partnerNode')
|
||||
}
|
||||
return rawEvents.value
|
||||
.filter(isUsageEvent)
|
||||
.map((event) => billingEventToActivity(event, resolveUserName, labels))
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.getBillingEvents()
|
||||
rawEvents.value = response.events
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
rawEvents.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
})
|
||||
|
||||
return { events, isLoading, error, refresh }
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
* Builds the Plan & Credits overflow-menu model for the workspace subscription
|
||||
* panel. Visibility and the Delete enable/disable policy are derived from the
|
||||
* shared useWorkspaceUI state so this menu can't desync with the sibling
|
||||
* Plan & Credits panel menu.
|
||||
* WorkspacePanelContent menu.
|
||||
*/
|
||||
export function useWorkspaceMenuItems() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
/**
|
||||
* Non-production sample ledger for Storybook and unit tests only — never
|
||||
* imported by shipping code. It stands in for the per-workspace usage API
|
||||
* (FE-1249) so the Activity table can be exercised with a populated,
|
||||
* multi-page, multi-user data set (usage outflows, partner-node rows, and
|
||||
* workspace-level credit inflows) while the live source is still empty.
|
||||
*/
|
||||
export const activityFixture: ActivityEvent[] = [
|
||||
{
|
||||
id: 'evt-01',
|
||||
date: new Date('2026-07-14T09:32:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '2 runs',
|
||||
credits: 1840
|
||||
},
|
||||
{
|
||||
id: 'evt-02',
|
||||
date: new Date('2026-07-14T08:10:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '1 run',
|
||||
credits: 3200,
|
||||
partnerNode: 'Flux Pro 1.1 Ultra'
|
||||
},
|
||||
{
|
||||
id: 'evt-03',
|
||||
date: new Date('2026-07-13T22:47:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '4 runs',
|
||||
credits: 5120,
|
||||
partnerNode: 'Kling v2 Master'
|
||||
},
|
||||
{
|
||||
id: 'evt-04',
|
||||
date: new Date('2026-07-13T18:05:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '7 runs',
|
||||
credits: 6300
|
||||
},
|
||||
{
|
||||
id: 'evt-05',
|
||||
date: new Date('2026-07-13T12:00:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Auto-reload',
|
||||
detail: '—',
|
||||
credits: 20000,
|
||||
credited: true
|
||||
},
|
||||
{
|
||||
id: 'evt-06',
|
||||
date: new Date('2026-07-12T16:22:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '3 runs',
|
||||
credits: 2700
|
||||
},
|
||||
{
|
||||
id: 'evt-07',
|
||||
date: new Date('2026-07-12T11:41:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '2 runs',
|
||||
credits: 4480,
|
||||
partnerNode: 'Gemini 2.5 Flash Image'
|
||||
},
|
||||
{
|
||||
id: 'evt-08',
|
||||
date: new Date('2026-07-11T20:15:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '5 runs',
|
||||
credits: 4100
|
||||
},
|
||||
{
|
||||
id: 'evt-09',
|
||||
date: new Date('2026-07-11T09:03:00Z'),
|
||||
userName: 'Katherine Johnson',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '1 run',
|
||||
credits: 760
|
||||
},
|
||||
{
|
||||
id: 'evt-10',
|
||||
date: new Date('2026-07-10T14:38:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '6 runs',
|
||||
credits: 8900,
|
||||
partnerNode: 'Veo 3'
|
||||
},
|
||||
{
|
||||
id: 'evt-11',
|
||||
date: new Date('2026-07-10T07:26:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '2 runs',
|
||||
credits: 1560
|
||||
},
|
||||
{
|
||||
id: 'evt-12',
|
||||
date: new Date('2026-07-09T19:52:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Top-up',
|
||||
detail: '—',
|
||||
credits: 50000,
|
||||
credited: true
|
||||
},
|
||||
{
|
||||
id: 'evt-13',
|
||||
date: new Date('2026-07-09T13:14:00Z'),
|
||||
userName: 'Katherine Johnson',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '3 runs',
|
||||
credits: 3840,
|
||||
partnerNode: 'Recraft V3'
|
||||
},
|
||||
{
|
||||
id: 'evt-14',
|
||||
date: new Date('2026-07-08T10:48:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '4 runs',
|
||||
credits: 2950
|
||||
}
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { userBadgeColor } from './badgeColor'
|
||||
|
||||
const HEX = /^#[0-9a-f]{6}$/
|
||||
|
||||
describe('userBadgeColor', () => {
|
||||
it('is deterministic for the same seed', () => {
|
||||
expect(userBadgeColor('Ada Lovelace')).toBe(userBadgeColor('Ada Lovelace'))
|
||||
})
|
||||
|
||||
it('always returns a color from the palette', () => {
|
||||
for (const seed of ['', 'a', 'Grace Hopper', 'user@example.com', '你好']) {
|
||||
expect(userBadgeColor(seed)).toMatch(HEX)
|
||||
}
|
||||
})
|
||||
|
||||
it('spreads different seeds across more than one color', () => {
|
||||
const seeds = Array.from({ length: 30 }, (_, i) => `user-${i}`)
|
||||
const distinct = new Set(seeds.map(userBadgeColor))
|
||||
expect(distinct.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
|
||||
// Dark tones that read against the dark surface with a white monogram.
|
||||
const BADGE_COLORS = [
|
||||
'#956252', // terracotta
|
||||
'#3e465f', // slate indigo
|
||||
'#424f45', // olive green
|
||||
'#90646e', // mauve rose
|
||||
'#6d5a7a', // muted purple
|
||||
'#4f6b6b', // muted teal
|
||||
'#7a6a4a', // khaki
|
||||
'#5a6270' // steel
|
||||
]
|
||||
|
||||
/** Stable muted badge color for a user, keyed by name/email. */
|
||||
export function userBadgeColor(seed: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return BADGE_COLORS[hash % BADGE_COLORS.length]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
|
||||
import {
|
||||
billingEventToActivity,
|
||||
isUsageEvent
|
||||
} from '@/platform/workspace/utils/billingEventToActivity'
|
||||
|
||||
const labels = {
|
||||
cloudRun: 'Cloud workflow run',
|
||||
partnerNode: 'Partner node usage'
|
||||
}
|
||||
|
||||
const nameById: Record<string, string> = { 'user-1': 'Ada Lovelace' }
|
||||
|
||||
function resolveName(userId: string | undefined): string {
|
||||
if (!userId) return ''
|
||||
return nameById[userId] ?? userId
|
||||
}
|
||||
|
||||
function event(overrides: Partial<BillingEventInput>): BillingEventInput {
|
||||
return {
|
||||
event_type: 'gpu_usage',
|
||||
event_id: 'evt',
|
||||
createdAt: '2026-07-14T00:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isUsageEvent', () => {
|
||||
it('keeps gpu and api-node usage and drops non-usage events', () => {
|
||||
expect(isUsageEvent(event({ event_type: 'gpu_usage' }))).toBe(true)
|
||||
expect(isUsageEvent(event({ event_type: 'api_node_usage' }))).toBe(true)
|
||||
expect(isUsageEvent(event({ event_type: 'invoice_paid' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('billingEventToActivity', () => {
|
||||
it('maps a GPU usage row to a cloud-run entry attributed to its member', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({
|
||||
event_type: 'gpu_usage',
|
||||
event_id: 'evt-1',
|
||||
createdAt: '2026-07-14T09:32:00Z',
|
||||
params: { user_id: 'user-1', gpu_seconds: 12 }
|
||||
}),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row).toMatchObject({
|
||||
id: 'evt-1',
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
credits: 0,
|
||||
credited: false
|
||||
})
|
||||
expect(row.date.toISOString()).toBe('2026-07-14T09:32:00.000Z')
|
||||
expect(row.partnerNode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps a partner usage row with its partner node', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({
|
||||
event_type: 'api_node_usage',
|
||||
event_id: 'evt-2',
|
||||
params: { user_id: 'user-1', partner_node: 'Flux Pro 1.1 Ultra' }
|
||||
}),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row.eventType).toBe('Partner node usage')
|
||||
expect(row.partnerNode).toBe('Flux Pro 1.1 Ultra')
|
||||
})
|
||||
|
||||
it('leaves the user name empty when the event has no user_id', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({ event_id: 'evt-3', params: {} }),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row.userName).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
/**
|
||||
* Maps a `/api/billing/events` usage row to an Activity ledger row (FE-1249).
|
||||
*
|
||||
* Only the fields the backend exposes today are populated: the date, the member
|
||||
* the event is attributed to (`params.user_id`), and the event type. The Credits
|
||||
* column stays 0 until per-event cost lands (FE-1249 / P1), and `partnerNode` is
|
||||
* best-effort from `params` because the partner (comfy-api) property contract is
|
||||
* not yet fixed.
|
||||
*/
|
||||
|
||||
export interface BillingEventInput {
|
||||
event_type: string
|
||||
event_id: string
|
||||
params?: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ActivityEventTypeLabels {
|
||||
cloudRun: string
|
||||
partnerNode: string
|
||||
}
|
||||
|
||||
const USAGE_EVENT_TYPES = new Set(['gpu_usage', 'api_node_usage'])
|
||||
|
||||
export function isUsageEvent(event: BillingEventInput): boolean {
|
||||
return USAGE_EVENT_TYPES.has(event.event_type)
|
||||
}
|
||||
|
||||
function stringParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const value = params?.[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function billingEventToActivity(
|
||||
event: BillingEventInput,
|
||||
resolveUserName: (userId: string | undefined) => string,
|
||||
labels: ActivityEventTypeLabels
|
||||
): ActivityEvent {
|
||||
const isPartner = event.event_type === 'api_node_usage'
|
||||
return {
|
||||
id: event.event_id,
|
||||
date: new Date(event.createdAt),
|
||||
userName: resolveUserName(stringParam(event.params, 'user_id')),
|
||||
eventType: isPartner ? labels.partnerNode : labels.cloudRun,
|
||||
detail: '',
|
||||
credits: 0,
|
||||
partnerNode: isPartner
|
||||
? stringParam(event.params, 'partner_node')
|
||||
: undefined,
|
||||
credited: false
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatRelativeTime } from './relativeTime'
|
||||
|
||||
const labels = {
|
||||
justNow: 'just now',
|
||||
minutesAgo: (n: number) => `${n} min ago`,
|
||||
hoursAgo: (n: number) => `${n} hr ago`,
|
||||
daysAgo: (n: number) => `${n} days ago`
|
||||
}
|
||||
|
||||
const now = new Date('2026-07-14T12:00:00Z')
|
||||
|
||||
function ago(ms: number) {
|
||||
return new Date(now.getTime() - ms)
|
||||
}
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('reads "just now" under a minute', () => {
|
||||
expect(formatRelativeTime(ago(30 * SECOND), now, labels)).toBe('just now')
|
||||
})
|
||||
|
||||
it('reads whole minutes under an hour', () => {
|
||||
expect(formatRelativeTime(ago(6 * MINUTE), now, labels)).toBe('6 min ago')
|
||||
expect(formatRelativeTime(ago(59 * MINUTE), now, labels)).toBe('59 min ago')
|
||||
})
|
||||
|
||||
it('reads whole hours under a day', () => {
|
||||
expect(formatRelativeTime(ago(2 * HOUR), now, labels)).toBe('2 hr ago')
|
||||
})
|
||||
|
||||
it('reads whole days beyond a day', () => {
|
||||
expect(formatRelativeTime(ago(3 * DAY), now, labels)).toBe('3 days ago')
|
||||
})
|
||||
|
||||
it('never returns a negative bucket for a future timestamp', () => {
|
||||
expect(formatRelativeTime(ago(-5 * MINUTE), now, labels)).toBe('just now')
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const HOUR_MS = 60 * MINUTE_MS
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
interface RelativeTimeLabels {
|
||||
justNow: string
|
||||
minutesAgo: (n: number) => string
|
||||
hoursAgo: (n: number) => string
|
||||
daysAgo: (n: number) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbreviated "time ago" label (e.g. "6 min ago", "2 hr ago", "3 days ago"),
|
||||
* matching the member-list activity column. Copy is injected so callers can
|
||||
* supply localized, pluralized strings.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: Date,
|
||||
now: Date,
|
||||
labels: RelativeTimeLabels
|
||||
): string {
|
||||
const elapsed = Math.max(0, now.getTime() - date.getTime())
|
||||
|
||||
if (elapsed < MINUTE_MS) return labels.justNow
|
||||
if (elapsed < HOUR_MS)
|
||||
return labels.minutesAgo(Math.floor(elapsed / MINUTE_MS))
|
||||
if (elapsed < DAY_MS) return labels.hoursAgo(Math.floor(elapsed / HOUR_MS))
|
||||
return labels.daysAgo(Math.floor(elapsed / DAY_MS))
|
||||
}
|
||||
@@ -35,13 +35,13 @@ const inputNodeIds = computed(() => {
|
||||
})
|
||||
|
||||
const accessibleNodeErrors = computed(() =>
|
||||
Object.keys(executionErrorStore.surfacedNodeErrors ?? {}).filter((k) =>
|
||||
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
|
||||
inputNodeIds.value.has(k)
|
||||
)
|
||||
)
|
||||
const accessibleErrors = computed(() =>
|
||||
accessibleNodeErrors.value.flatMap((k) => {
|
||||
const nodeError = executionErrorStore.surfacedNodeErrors?.[k]
|
||||
const nodeError = executionErrorStore.lastNodeErrors?.[k]
|
||||
if (!nodeError) return []
|
||||
|
||||
return nodeError.errors.flatMap((error) => {
|
||||
|
||||
@@ -252,41 +252,6 @@ describe('hasWidgetError', () => {
|
||||
).toBe(true)
|
||||
expect(spy).toHaveBeenCalledWith('1', 'display_slot')
|
||||
})
|
||||
|
||||
it('matches raw interior errors by the source widget name for promoted widgets', () => {
|
||||
const sourceExecutionId = createNodeExecutionId([
|
||||
toNodeId(65),
|
||||
toNodeId(18)
|
||||
])
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
sourceExecutionId,
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
[sourceExecutionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
message: 'Invalid model',
|
||||
details: '',
|
||||
extra_info: { input_name: 'ckpt_name' }
|
||||
}
|
||||
],
|
||||
class_type: 'CheckpointLoaderSimple',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
expect(
|
||||
hasWidgetError(
|
||||
widget,
|
||||
createNodeExecutionId([toNodeId(1)]),
|
||||
undefined,
|
||||
executionErrorStore,
|
||||
missingModelStore
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
const noopUi = {
|
||||
@@ -702,36 +667,6 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clears raw interior errors through widget.sourceExecutionId, which boundary lift relies on', () => {
|
||||
const sourceExecutionId = createNodeExecutionId([65, 18])
|
||||
const widget = createMockWidget({
|
||||
name: 'display_slot',
|
||||
nodeId: NODE_ID,
|
||||
sourceExecutionId,
|
||||
sourceWidgetName: 'ckpt_name'
|
||||
})
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
executionErrorStore.lastNodeErrors = {
|
||||
[sourceExecutionId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_not_in_list',
|
||||
message: 'Invalid model',
|
||||
details: '',
|
||||
extra_info: { input_name: 'ckpt_name' }
|
||||
}
|
||||
],
|
||||
class_type: 'CheckpointLoaderSimple',
|
||||
dependent_outputs: []
|
||||
}
|
||||
}
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler('real_model.safetensors')
|
||||
|
||||
expect(executionErrorStore.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears execution errors on update', () => {
|
||||
const widget = createMockWidget({
|
||||
name: 'seed',
|
||||
|
||||
@@ -130,12 +130,8 @@ export function hasWidgetError(
|
||||
const errors = widget.sourceExecutionId
|
||||
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
||||
: nodeErrors?.errors
|
||||
// Raw interior errors name the source widget, not the boundary name
|
||||
const errorInputName = widget.sourceExecutionId
|
||||
? (widget.sourceWidgetName ?? widget.name)
|
||||
: widget.name
|
||||
return (
|
||||
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
|
||||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
|
||||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import {
|
||||
createBoundaryLinkedSubgraph,
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -51,20 +39,11 @@ import { useExecutionErrorStore } from './executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function mockGraphReady(rootGraph: typeof app.rootGraph) {
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
||||
}
|
||||
|
||||
describe('executionErrorStore — node error operations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('clearSimpleNodeErrors', () => {
|
||||
it('does nothing if lastNodeErrors is null', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
@@ -317,97 +296,6 @@ describe('executionErrorStore — node error operations', () => {
|
||||
// Error should remain
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a lifted host slot error from the raw interior record', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
expect(store.surfacedNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear lifted host slot errors when the raw error is not simple', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError(
|
||||
'custom_validation_failed',
|
||||
'seed_input',
|
||||
{},
|
||||
'Custom validation failed'
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a nested lifted error fixed at an intermediate host level', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
rootGraph.add(outerHost)
|
||||
|
||||
const middleSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const middleHost = createTestSubgraphNode(middleSubgraph, {
|
||||
id: 2,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(middleHost)
|
||||
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
|
||||
|
||||
const leaf = new LGraphNode('LeafNode')
|
||||
leaf.id = toNodeId(3)
|
||||
const leafInput = leaf.addInput('seed_input', '*')
|
||||
middleSubgraph.add(leaf)
|
||||
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'1:2:3': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('1')
|
||||
|
||||
store.clearSimpleNodeErrors(
|
||||
createNodeExecutionId([toNodeId(1), toNodeId(2)]),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors,
|
||||
'a fix at the intermediate host clears the raw interior error'
|
||||
).toBeNull()
|
||||
expect(store.surfacedNodeErrors).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors', () => {
|
||||
@@ -500,137 +388,6 @@ describe('executionErrorStore — node error operations', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('validates the base target against live widget bounds, not recorded ones', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'123': nodeError([
|
||||
validationError('value_bigger_than_max', 'testWidget', {
|
||||
input_config: ['INT', { max: 100 }]
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(123)]),
|
||||
'testWidget',
|
||||
'testWidget',
|
||||
150,
|
||||
{ max: 200 }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors,
|
||||
'a value within the refreshed widget bounds clears despite stale recorded bounds'
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear lifted range errors until the host value is in range', () => {
|
||||
const { rootGraph } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
200,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
50,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears fan-out lifted targets per their own recorded bounds', () => {
|
||||
const { rootGraph, subgraph } = createBoundaryLinkedSubgraph()
|
||||
const second = new LGraphNode('SecondInterior')
|
||||
second.id = toNodeId(7)
|
||||
const secondInput = second.addInput('other_input', '*')
|
||||
subgraph.add(second)
|
||||
subgraph.inputNode.slots[0].connect(secondInput, second)
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('value_bigger_than_max', 'seed_input', {
|
||||
input_config: ['INT', { max: 100 }]
|
||||
})
|
||||
]),
|
||||
'12:7': nodeError([
|
||||
validationError('value_bigger_than_max', 'other_input', {
|
||||
input_config: ['INT', { max: 50 }]
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
|
||||
|
||||
store.clearWidgetRelatedErrors(
|
||||
createNodeExecutionId([toNodeId(12)]),
|
||||
'seed',
|
||||
'seed',
|
||||
75,
|
||||
{ max: 100 }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.lastNodeErrors?.['12:5'],
|
||||
'the target whose max=100 is satisfied by 75 clears'
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
store.lastNodeErrors?.['12:7'].errors,
|
||||
'the target whose max=50 is still violated by 75 stays'
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfacedNodeErrors', () => {
|
||||
it('derives boundary-lifted errors while preserving the raw record', () => {
|
||||
const { rootGraph, host } = createBoundaryLinkedSubgraph()
|
||||
mockGraphReady(rootGraph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
store.lastNodeErrors = {
|
||||
'12:5': nodeError([
|
||||
validationError('required_input_missing', 'seed_input')
|
||||
])
|
||||
}
|
||||
|
||||
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
|
||||
|
||||
expect(store.lastNodeErrors).toHaveProperty('12:5')
|
||||
expect(store.surfacedNodeErrors).toHaveProperty('12')
|
||||
expect(
|
||||
store.surfacedNodeErrors?.['12'].errors[0].extra_info
|
||||
).toMatchObject({
|
||||
input_name: 'seed',
|
||||
source_execution_id: '12:5',
|
||||
source_input_name: 'seed_input'
|
||||
})
|
||||
expect(store.getNodeErrors(hostLocatorId)?.class_type).toBe(host.title)
|
||||
expect(store.allErrorExecutionIds).toEqual(['12'])
|
||||
expect(store.activeGraphErrorNodeIds).toEqual(new Set(['12']))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,11 +2,6 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
|
||||
import {
|
||||
getLiftedErrorSource,
|
||||
liftNodeErrorsToBoundary,
|
||||
resolveLiftChain
|
||||
} from '@/core/graph/subgraph/liftNodeErrorsToBoundary'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
@@ -21,10 +16,7 @@ import type {
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import {
|
||||
getAncestorExecutionIds,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
@@ -33,18 +25,10 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
SIMPLE_ERROR_TYPES,
|
||||
getInputConfigBounds,
|
||||
isValueStillOutOfRange
|
||||
} from '@/utils/executionErrorUtil'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
|
||||
interface SlotNodeErrorClearTarget {
|
||||
executionId: NodeExecutionId
|
||||
slotName: string
|
||||
/** Interior targets validate against the bounds recorded on their errors. */
|
||||
useRecordedBounds?: boolean
|
||||
}
|
||||
|
||||
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
|
||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -95,27 +79,31 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastPromptError.value = null
|
||||
}
|
||||
|
||||
function clearSimpleNodeErrorsFromRecord(
|
||||
nodeErrors: Record<string, NodeError>,
|
||||
/**
|
||||
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||
* types. When `slotName` is provided, only errors for that slot are checked.
|
||||
*/
|
||||
function clearSimpleNodeErrors(
|
||||
executionId: NodeExecutionId,
|
||||
slotName?: string
|
||||
): Record<string, NodeError> | null {
|
||||
const nodeError = nodeErrors[executionId]
|
||||
if (!nodeError) return null
|
||||
): void {
|
||||
if (!lastNodeErrors.value) return
|
||||
const nodeError = lastNodeErrors.value[executionId]
|
||||
if (!nodeError) return
|
||||
|
||||
const isSlotScoped = slotName !== undefined
|
||||
|
||||
const relevantErrors = isSlotScoped
|
||||
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
|
||||
: nodeError.errors
|
||||
|
||||
if (relevantErrors.length === 0) return null
|
||||
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) {
|
||||
return null
|
||||
}
|
||||
if (relevantErrors.length === 0) return
|
||||
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return
|
||||
|
||||
const updated = { ...nodeErrors }
|
||||
const updated = { ...lastNodeErrors.value }
|
||||
|
||||
if (isSlotScoped) {
|
||||
// Remove only the target slot's errors if they were all simple
|
||||
const remainingErrors = nodeError.errors.filter(
|
||||
(e) => e.extra_info?.input_name !== slotName
|
||||
)
|
||||
@@ -128,150 +116,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no slot specified and all errors were simple, clear the whole node
|
||||
delete updated[executionId]
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw interior sources of lifted errors whose boundary chain passes through
|
||||
* `(executionId, slotName)`, so a fix at any host level — final surface or
|
||||
* intermediate — clears the error at its raw key.
|
||||
*/
|
||||
function getLiftedErrorSourceTargets(
|
||||
executionId: NodeExecutionId,
|
||||
slotName: string
|
||||
): SlotNodeErrorClearTarget[] {
|
||||
const surfaced = surfacedNodeErrors.value
|
||||
if (!surfaced || !app.isGraphReady) return []
|
||||
|
||||
return Object.values(surfaced).flatMap((surface) =>
|
||||
surface.errors.flatMap((error): SlotNodeErrorClearTarget[] => {
|
||||
const source = getLiftedErrorSource(error)
|
||||
if (!source) return []
|
||||
|
||||
const sourceExecutionId = tryNormalizeNodeExecutionId(
|
||||
source.source_execution_id
|
||||
)
|
||||
if (!sourceExecutionId) return []
|
||||
|
||||
const clearsThisError = resolveLiftChain(
|
||||
app.rootGraph,
|
||||
sourceExecutionId,
|
||||
source.source_input_name
|
||||
).some(
|
||||
(level) =>
|
||||
level.hostExecId === executionId && level.hostInputName === slotName
|
||||
)
|
||||
return clearsThisError
|
||||
? [
|
||||
{
|
||||
executionId: sourceExecutionId,
|
||||
slotName: source.source_input_name,
|
||||
useRecordedBounds: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Raw targets are keys into lastNodeErrors, not surfacedNodeErrors. */
|
||||
function getRawClearTargets(
|
||||
executionId: NodeExecutionId,
|
||||
slotName: string
|
||||
): SlotNodeErrorClearTarget[] {
|
||||
return [
|
||||
{ executionId, slotName },
|
||||
...getLiftedErrorSourceTargets(executionId, slotName)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds recorded on the error win only for interior lifted targets, where
|
||||
* the caller's options describe the host widget rather than the interior
|
||||
* input. The base target keeps the caller's live widget bounds, which stay
|
||||
* authoritative when node definitions change after validation.
|
||||
*/
|
||||
function getTargetRangeOptions(
|
||||
errors: NodeError['errors'],
|
||||
fallback: { min?: number; max?: number }
|
||||
): { min?: number; max?: number } {
|
||||
for (const error of errors) {
|
||||
const { min, max } = getInputConfigBounds(error)
|
||||
if (min === undefined && max === undefined) continue
|
||||
return {
|
||||
min: typeof min === 'number' ? min : fallback.min,
|
||||
max: typeof max === 'number' ? max : fallback.max
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function isTargetStillOutOfRange(
|
||||
nodeErrors: Record<string, NodeError>,
|
||||
target: SlotNodeErrorClearTarget,
|
||||
value: number,
|
||||
callerOptions: { min?: number; max?: number }
|
||||
): boolean {
|
||||
const nodeError = nodeErrors[target.executionId]
|
||||
if (!nodeError) return false
|
||||
|
||||
const errors = nodeError.errors.filter(
|
||||
(error) => error.extra_info?.input_name === target.slotName
|
||||
)
|
||||
const options = target.useRecordedBounds
|
||||
? getTargetRangeOptions(errors, callerOptions)
|
||||
: callerOptions
|
||||
|
||||
return isValueStillOutOfRange(value, errors, options)
|
||||
}
|
||||
|
||||
function clearTargets(
|
||||
targets: { executionId: NodeExecutionId; slotName?: string }[]
|
||||
): void {
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
let updated = lastNodeErrors.value
|
||||
for (const target of targets) {
|
||||
updated =
|
||||
clearSimpleNodeErrorsFromRecord(
|
||||
updated,
|
||||
target.executionId,
|
||||
target.slotName
|
||||
) ?? updated
|
||||
}
|
||||
|
||||
if (updated === lastNodeErrors.value) return
|
||||
lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node's errors if they consist entirely of simple, auto-resolvable
|
||||
* types. When `slotName` is provided, only errors for that slot are checked
|
||||
* and boundary-lifted errors are also cleared through their raw interior
|
||||
* source. Node-scoped calls (no `slotName`) operate on the raw record key
|
||||
* only.
|
||||
*/
|
||||
function clearSimpleNodeErrors(
|
||||
executionId: NodeExecutionId,
|
||||
slotName?: string
|
||||
): void {
|
||||
clearTargets(
|
||||
slotName === undefined
|
||||
? [{ executionId }]
|
||||
: getRawClearTargets(executionId, slotName)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to clear an error for a given widget, but avoids clearing it if
|
||||
* the error is a range violation and the new value is still out of bounds.
|
||||
* The base target validates against the caller's live widget bounds; each
|
||||
* interior lifted target validates against its own recorded bounds, so a
|
||||
* boundary input fanning out to inputs with different constraints clears
|
||||
* only the targets the new value satisfies.
|
||||
*
|
||||
* Note: `value_not_in_list` errors are optimistically cleared without
|
||||
* list-membership validation because combo widgets constrain choices to
|
||||
@@ -284,24 +138,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
newValue: unknown,
|
||||
options?: { min?: number; max?: number }
|
||||
): void {
|
||||
const nodeErrors = lastNodeErrors.value
|
||||
if (!nodeErrors) return
|
||||
|
||||
const targets = getRawClearTargets(executionId, widgetName)
|
||||
const clearableTargets =
|
||||
typeof newValue === 'number'
|
||||
? targets.filter(
|
||||
(target) =>
|
||||
!isTargetStillOutOfRange(
|
||||
nodeErrors,
|
||||
target,
|
||||
newValue,
|
||||
options ?? {}
|
||||
)
|
||||
)
|
||||
: targets
|
||||
|
||||
clearTargets(clearableTargets)
|
||||
if (typeof newValue === 'number' && lastNodeErrors.value) {
|
||||
const nodeErrors = lastNodeErrors.value[executionId]
|
||||
if (nodeErrors) {
|
||||
const errs = nodeErrors.errors.filter(
|
||||
(e) => e.extra_info?.input_name === widgetName
|
||||
)
|
||||
if (isValueStillOutOfRange(newValue, errs, options || {})) return
|
||||
}
|
||||
}
|
||||
clearSimpleNodeErrors(executionId, widgetName)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,13 +224,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
|
||||
const surfacedNodeErrors = computed(() =>
|
||||
lastNodeErrors.value && app.isGraphReady
|
||||
? liftNodeErrorsToBoundary(app.rootGraph, lastNodeErrors.value)
|
||||
: lastNodeErrors.value
|
||||
)
|
||||
|
||||
const hasAnyError = computed(
|
||||
() =>
|
||||
hasExecutionError.value ||
|
||||
@@ -397,8 +236,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
const ids: string[] = []
|
||||
if (surfacedNodeErrors.value) {
|
||||
ids.push(...Object.keys(surfacedNodeErrors.value))
|
||||
if (lastNodeErrors.value) {
|
||||
ids.push(...Object.keys(lastNodeErrors.value))
|
||||
}
|
||||
if (lastExecutionError.value) {
|
||||
const nodeId = lastExecutionError.value.node_id
|
||||
@@ -440,8 +279,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
// Fall back to rootGraph when currentGraph hasn't been initialized yet
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
|
||||
if (surfacedNodeErrors.value) {
|
||||
for (const executionId of Object.keys(surfacedNodeErrors.value)) {
|
||||
if (lastNodeErrors.value) {
|
||||
for (const executionId of Object.keys(lastNodeErrors.value)) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
@@ -463,12 +302,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
if (!surfacedNodeErrors.value) return {}
|
||||
if (!lastNodeErrors.value) return {}
|
||||
|
||||
const map: Record<NodeLocatorId, NodeError> = {}
|
||||
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
surfacedNodeErrors.value
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (locatorId) {
|
||||
@@ -522,7 +361,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
@@ -541,7 +380,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
dismissErrorOverlay,
|
||||
|
||||
// Derived state
|
||||
surfacedNodeErrors,
|
||||
hasExecutionError,
|
||||
hasPromptError,
|
||||
hasNodeError,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Storybook mock for `useCurrentUser`.
|
||||
*
|
||||
* The real composable reads the Firebase-backed `authStore`, whose setup calls
|
||||
* `setPersistence` and crashes in the Storybook environment (no Firebase). This
|
||||
* static stub presents a signed-in user so components that only need the
|
||||
* display name / email (e.g. WorkspaceActivityContent's member self-scope)
|
||||
* render without any auth.
|
||||
*/
|
||||
export const useCurrentUser = () => ({
|
||||
userDisplayName: ref('Ada Lovelace'),
|
||||
userEmail: ref('ada@example.com'),
|
||||
isLoggedIn: computed(() => true)
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Storybook mock for `useWorkspaceUI`.
|
||||
*
|
||||
* The real composable derives its state from the auth/workspace stores that
|
||||
* pull in Firebase and crash in Storybook. This stub presents a billing
|
||||
* manager (owner) so role-gated surfaces — the Activity ledger's team-wide
|
||||
* scope and its per-user footer — render in their fully-populated form.
|
||||
*/
|
||||
export function useWorkspaceUI() {
|
||||
return {
|
||||
permissions: computed(() => ({ canManageSubscription: true })),
|
||||
workspaceRole: ref('owner'),
|
||||
workspaceType: ref('team')
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
|
||||
|
||||
type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
function createRequiredInputMissingNodeError(inputName: string): NodeError {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: inputName }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
export function seedRequiredInputMissingNodeError(
|
||||
store: ExecutionErrorStore,
|
||||
executionId: NodeExecutionId,
|
||||
inputName: string
|
||||
): void {
|
||||
store.lastNodeErrors = {
|
||||
[executionId]: nodeError(
|
||||
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
|
||||
'TestNode'
|
||||
)
|
||||
[executionId]: createRequiredInputMissingNodeError(inputName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import type { NodeValidationError } from '@/utils/executionErrorUtil'
|
||||
|
||||
export function validationError(
|
||||
type: string,
|
||||
inputName?: string,
|
||||
extraInfo: Record<string, unknown> = {},
|
||||
message = `${type} message`,
|
||||
details = `${type} details`
|
||||
): NodeValidationError {
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
details,
|
||||
...(inputName
|
||||
? { extra_info: { ...extraInfo, input_name: inputName } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeError(
|
||||
errors: NodeValidationError[],
|
||||
classType = 'InteriorNode'
|
||||
): NodeError {
|
||||
return {
|
||||
class_type: classType,
|
||||
dependent_outputs: [],
|
||||
errors
|
||||
}
|
||||
}
|
||||
@@ -210,15 +210,10 @@ describe('formatShortMonthDay', () => {
|
||||
})
|
||||
|
||||
describe('formatClockTime', () => {
|
||||
it('uses app locale with explicit 12-hour preference', () => {
|
||||
it('formats time with hours, minutes, and seconds', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
|
||||
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h12')).toBe('2:05:06 PM')
|
||||
})
|
||||
|
||||
it('uses app locale with explicit 24-hour preference', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
|
||||
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h23')).toBe('14:05:06')
|
||||
const result = formatClockTime(ts, 'en-GB')
|
||||
// en-GB uses 24-hour format
|
||||
expect(result).toBe('14:05:06')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,27 +84,17 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized clock time, e.g. "10:05:06" with the app locale for language and
|
||||
* the browser/system locale preference for 12/24-hour formatting.
|
||||
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
|
||||
*
|
||||
* @param ts Unix timestamp in milliseconds
|
||||
* @param locale BCP-47 locale string
|
||||
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
|
||||
* @returns Localized time string
|
||||
*/
|
||||
export const formatClockTime = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
clockPreferenceLocale?: string
|
||||
): string => {
|
||||
export const formatClockTime = (ts: number, locale: string): string => {
|
||||
const d = new Date(ts)
|
||||
const { hourCycle } = new Intl.DateTimeFormat(clockPreferenceLocale, {
|
||||
hour: 'numeric'
|
||||
}).resolvedOptions()
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle
|
||||
second: '2-digit'
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
@@ -105,61 +105,6 @@ export const SIMPLE_ERROR_TYPES = new Set([
|
||||
'required_input_missing'
|
||||
])
|
||||
|
||||
export type NodeValidationError = NodeError['errors'][number]
|
||||
|
||||
export const INPUT_LEVEL_VALIDATION_ERROR_TYPES = new Set([
|
||||
'required_input_missing',
|
||||
'bad_linked_input',
|
||||
'return_type_mismatch',
|
||||
'invalid_input_type',
|
||||
'value_smaller_than_min',
|
||||
'value_bigger_than_max',
|
||||
'value_not_in_list',
|
||||
'custom_validation_failed',
|
||||
'exception_during_inner_validation'
|
||||
])
|
||||
|
||||
export const NODE_LEVEL_VALIDATION_ERROR_TYPES = new Set([
|
||||
'exception_during_validation',
|
||||
'dependency_cycle'
|
||||
])
|
||||
|
||||
/** Decodes the `[type, { min, max, ... }]` tuple the backend attaches to range errors. */
|
||||
export function getInputConfigBounds(error: NodeValidationError): {
|
||||
min?: unknown
|
||||
max?: unknown
|
||||
} {
|
||||
const config = error.extra_info?.input_config
|
||||
if (!Array.isArray(config)) return {}
|
||||
|
||||
const bounds = config[1]
|
||||
if (!bounds || typeof bounds !== 'object') return {}
|
||||
|
||||
const { min, max } = bounds as { min?: unknown; max?: unknown }
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
export function isImageNotLoadedValidationError(
|
||||
error: NodeValidationError
|
||||
): boolean {
|
||||
return (
|
||||
error.type === 'custom_validation_failed' &&
|
||||
/invalid image file|\[errno 21\].*is a directory/i.test(
|
||||
[error.message, error.details].filter(Boolean).join('\n')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Anything not input-level (including unknown types) is node-level.
|
||||
export function isNodeLevelValidationError(
|
||||
error: NodeValidationError
|
||||
): boolean {
|
||||
return (
|
||||
!INPUT_LEVEL_VALIDATION_ERROR_TYPES.has(error.type) ||
|
||||
isImageNotLoadedValidationError(error)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `value` still violates a recorded range constraint.
|
||||
* Pass errors already filtered to the target widget (by `input_name`).
|
||||
|
||||
Reference in New Issue
Block a user