mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
4 Commits
synap5e/te
...
split/allo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22627df289 | ||
|
|
f2d632385b | ||
|
|
fa2e174d81 | ||
|
|
a898e39d20 |
@@ -254,18 +254,6 @@ export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder's own row (not the whole subtree). Required for nested folders:
|
||||
* an ancestor `.p-tree-node`'s text contains its descendants' labels, so
|
||||
* `getFolderByLabel` would match — and click — the ancestor instead.
|
||||
*/
|
||||
getFolderRowByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node:not(.p-tree-node-leaf) > .p-tree-node-content')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
type MediaFilterKind = 'image' | 'video' | 'audio' | '3d'
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Core-native asset shape: the ingest Asset plus the `loader_path` contract
|
||||
* field that `supports_model_type_tags` backends emit (see
|
||||
* `src/platform/assets/schemas/assetSchema.ts`).
|
||||
*/
|
||||
export type CoreModelAsset = Asset & Pick<AssetItem, 'loader_path'>
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
@@ -98,70 +89,6 @@ export const STABLE_LORA: Asset = createModelAsset({
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
function createCoreModelAsset(
|
||||
overrides: Partial<CoreModelAsset>
|
||||
): CoreModelAsset {
|
||||
return { ...createModelAsset(), ...overrides }
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_NESTED: CoreModelAsset =
|
||||
createCoreModelAsset({
|
||||
id: 'mt-checkpoint-001',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'SDXL/sd_xl_base_1.0.safetensors',
|
||||
created_at: '2025-01-15T10:30:00Z',
|
||||
updated_at: '2025-01-15T10:30:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_ROOT: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-checkpoint-002',
|
||||
name: 'v1-5-pruned-emaonly.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'v1-5-pruned-emaonly.safetensors',
|
||||
created_at: '2025-01-20T08:00:00Z',
|
||||
updated_at: '2025-01-20T08:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_GGUF: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-checkpoint-003',
|
||||
name: 'flux_quantized.gguf',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'flux_quantized.gguf',
|
||||
created_at: '2025-02-01T09:00:00Z',
|
||||
updated_at: '2025-02-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_CHECKPOINT_SCANNED: CoreModelAsset =
|
||||
createCoreModelAsset({
|
||||
id: 'mt-checkpoint-004',
|
||||
name: 'freshly_scanned.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
loader_path: 'freshly_scanned.safetensors',
|
||||
created_at: '2025-02-10T09:00:00Z',
|
||||
updated_at: '2025-02-10T09:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_LORA: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-lora-001',
|
||||
name: 'detail_enhancer_v1.2.safetensors',
|
||||
tags: ['models', 'model_type:loras'],
|
||||
loader_path: 'detail_enhancer_v1.2.safetensors',
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const MODEL_TYPE_LORA_README: CoreModelAsset = createCoreModelAsset({
|
||||
id: 'mt-lora-002',
|
||||
name: 'README.txt',
|
||||
mime_type: 'text/plain',
|
||||
size: 2_048,
|
||||
tags: ['models', 'model_type:loras'],
|
||||
loader_path: 'README.txt',
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
ListAssetsResponse,
|
||||
SeedAssetsResponse,
|
||||
UpdateAssetData
|
||||
} from '@comfyorg/ingest-types'
|
||||
import {
|
||||
@@ -36,13 +35,6 @@ function emptyConfig(): AssetConfig {
|
||||
|
||||
type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
/**
|
||||
* Scoped to the API path so the built frontend's own `/assets/*.js` chunks
|
||||
* are never intercepted (a page navigated after `mock()` would otherwise
|
||||
* fail to load).
|
||||
*/
|
||||
const ASSET_API_ROUTE_PATTERN = '**/api/assets**'
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
for (const asset of newAssets) {
|
||||
@@ -149,8 +141,6 @@ export class AssetHelper {
|
||||
return this.handleUpdateAsset(route, path, body)
|
||||
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleDeleteAsset(route, path)
|
||||
if (method === 'POST' && path.endsWith('/assets/seed'))
|
||||
return this.handleSeedScan(route)
|
||||
if (method === 'POST' && /\/assets\/?$/.test(path))
|
||||
return this.handleUploadAsset(route)
|
||||
if (method === 'POST' && path.endsWith('/assets/download'))
|
||||
@@ -159,7 +149,7 @@ export class AssetHelper {
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
const pattern = ASSET_API_ROUTE_PATTERN
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
@@ -175,7 +165,7 @@ export class AssetHelper {
|
||||
})
|
||||
}
|
||||
|
||||
const pattern = ASSET_API_ROUTE_PATTERN
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
@@ -286,11 +276,6 @@ export class AssetHelper {
|
||||
return route.fulfill({ status: 201, json: response })
|
||||
}
|
||||
|
||||
private handleSeedScan(route: Route) {
|
||||
const response: SeedAssetsResponse = { status: 'started' }
|
||||
return route.fulfill({ status: 200, json: response })
|
||||
}
|
||||
|
||||
private handleDownloadAsset(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
|
||||
@@ -51,22 +51,6 @@ export class FeatureFlagHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force server feature flags (the WS `feature_flags` handshake payload) on
|
||||
* the running app by merging into `api.serverFeatureFlags`. The `ff:`
|
||||
* localStorage override is dev-only (tree-shaken from production builds),
|
||||
* so this is the way to control `api.serverSupportsFeature()` in e2e.
|
||||
* Call after `comfyPage.setup()` so the real handshake cannot clobber it.
|
||||
*/
|
||||
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
...flagMap
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dispatches a wire-level custom event on the app's api singleton, simulating
|
||||
* a websocket broadcast (e.g. `assets.seed.fast_complete`). Uses the raw
|
||||
* EventTarget dispatch because `api.dispatchCustomEvent` is typed against the
|
||||
* ApiEventTypes map, which deliberately excludes events consumed via
|
||||
* `addCustomEventListener`.
|
||||
*/
|
||||
export async function dispatchApiCustomEvent(
|
||||
page: Page,
|
||||
type: string
|
||||
): Promise<void> {
|
||||
await page.evaluate((eventType) => {
|
||||
EventTarget.prototype.dispatchEvent.call(
|
||||
window.app!.api,
|
||||
new CustomEvent(eventType)
|
||||
)
|
||||
}, type)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
MODEL_TYPE_CHECKPOINT_GGUF,
|
||||
MODEL_TYPE_CHECKPOINT_NESTED,
|
||||
MODEL_TYPE_CHECKPOINT_ROOT,
|
||||
MODEL_TYPE_CHECKPOINT_SCANNED,
|
||||
MODEL_TYPE_LORA,
|
||||
MODEL_TYPE_LORA_README,
|
||||
STABLE_CHECKPOINT
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { withModels } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { dispatchApiCustomEvent } from '@e2e/fixtures/utils/dispatchApiEvent'
|
||||
import type { ModelFolderInfo } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
// Deliberately not alphabetical: the sidebar must show folders in backend
|
||||
// registration order, so 'loras' listed first must render first.
|
||||
const REGISTERED_FOLDERS: ModelFolderInfo[] = [
|
||||
{ name: 'loras', folders: ['/models/loras'], extensions: [] },
|
||||
{
|
||||
name: 'checkpoints',
|
||||
folders: ['/models/checkpoints'],
|
||||
extensions: ['.safetensors', '.gguf']
|
||||
}
|
||||
]
|
||||
|
||||
const WALK_ASSETS: Asset[] = [
|
||||
MODEL_TYPE_CHECKPOINT_NESTED,
|
||||
MODEL_TYPE_CHECKPOINT_ROOT,
|
||||
MODEL_TYPE_CHECKPOINT_GGUF,
|
||||
MODEL_TYPE_LORA,
|
||||
MODEL_TYPE_LORA_README
|
||||
]
|
||||
|
||||
test.use({
|
||||
initialSettings: {
|
||||
'Comfy.Assets.UseAssetAPI': true,
|
||||
'Comfy.ModelLibrary.UseAssetBrowser': false
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - asset mode', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(withModels(WALK_ASSETS))
|
||||
await assetApi.mock()
|
||||
await comfyPage.modelLibrary.mockModelFolders(REGISTERED_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.featureFlags.setServerFlags({
|
||||
supports_model_type_tags: true
|
||||
})
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Lists folders in backend registration order', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await expect(tab.folderNodes.nth(0)).toContainText('loras')
|
||||
await expect(tab.folderNodes.nth(1)).toContainText('checkpoints')
|
||||
})
|
||||
|
||||
test('Eager-loads models and drops the load-all button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeHidden()
|
||||
|
||||
// Models render from the eager walk on expansion, with loader_path
|
||||
// subdirectories as nested folders.
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
|
||||
await tab.getFolderRowByLabel('SDXL').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Applies registered extension allowlists verbatim and default-filters match-all folders', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
// checkpoints registers ['.safetensors', '.gguf'], so the .gguf model
|
||||
// shows even though the legacy fixed list would have hidden it.
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('flux_quantized.gguf')).toBeVisible()
|
||||
|
||||
// loras is registered match-all (empty allowlist); the FE substitutes
|
||||
// the default model-extension list, hiding non-model noise.
|
||||
await tab.getFolderRowByLabel('loras').click()
|
||||
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('README')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Refresh seeds a backend rescan', async ({ comfyPage, assetApi }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.refreshButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
assetApi
|
||||
.getMutations()
|
||||
.find(
|
||||
(mutation) =>
|
||||
mutation.method === 'POST' &&
|
||||
mutation.endpoint.endsWith('/assets/seed')
|
||||
)?.body
|
||||
)
|
||||
.toEqual({ roots: ['models'] })
|
||||
})
|
||||
|
||||
test('Live-updates the tree when the scan fast-phase completes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('v1-5-pruned-emaonly')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeHidden()
|
||||
|
||||
assetApi.configure(
|
||||
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
|
||||
)
|
||||
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
|
||||
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Active search results update when the scan fast-phase completes', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
// Search an existing model first: its result proves the eager load and
|
||||
// the debounced search pipeline have settled, so the later update can
|
||||
// only come from the scan event, not from a still-pending load.
|
||||
await tab.searchInput.fill('detail_enhancer')
|
||||
await expect(tab.getLeafByLabel('detail_enhancer_v1.2')).toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('freshly')
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
|
||||
assetApi.configure(
|
||||
withModels([...WALK_ASSETS, MODEL_TYPE_CHECKPOINT_SCANNED])
|
||||
)
|
||||
await dispatchApiCustomEvent(comfyPage.page, 'assets.seed.fast_complete')
|
||||
|
||||
await expect(tab.getLeafByLabel('freshly_scanned')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Placing a model fills the loader with the category-relative loader path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await tab.getFolderRowByLabel('SDXL').click()
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
// The visible ghost preview marks arming as complete, so a zero node
|
||||
// count here proves nothing is placed until the canvas is clicked.
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toBeVisible()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('SDXL/sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - asset mode on bare-tag backends', () => {
|
||||
test.beforeEach(async ({ comfyPage, assetApi }) => {
|
||||
assetApi.configure(withModels([STABLE_CHECKPOINT]))
|
||||
await assetApi.mock()
|
||||
await comfyPage.modelLibrary.mockModelFolders([
|
||||
{
|
||||
name: 'checkpoints',
|
||||
folders: ['/models/checkpoints'],
|
||||
extensions: ['.safetensors']
|
||||
}
|
||||
])
|
||||
await comfyPage.setup()
|
||||
// Force the capability off rather than omitting it: the real backend's
|
||||
// feature_flags handshake would otherwise decide which mode this tests.
|
||||
// Bare-tag backends bucket by bare tags and emit no loader_path, so
|
||||
// names fall back to the filename.
|
||||
await comfyPage.featureFlags.setServerFlags({
|
||||
supports_model_type_tags: false
|
||||
})
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Buckets by bare tags and names leaves from the filename', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
|
||||
await tab.getFolderRowByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, assetApiFixture)
|
||||
|
||||
const assetBrowserModal = '[data-component-id="AssetBrowserModal"]'
|
||||
|
||||
test.describe('Model library tab routing', () => {
|
||||
test('Opens the asset browser when both asset settings are enabled', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mock()
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.tabButton.click()
|
||||
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
|
||||
})
|
||||
|
||||
test('Keeps the sidebar tree when the asset API is disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// With the asset API off, the browser setting is inert.
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', false)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
true
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
|
||||
})
|
||||
|
||||
test('Keeps the sidebar tree when only the asset API is enabled', async ({
|
||||
comfyPage,
|
||||
assetApi
|
||||
}) => {
|
||||
await assetApi.mock()
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
false
|
||||
)
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.open()
|
||||
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeVisible()
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Model library tab routing on cloud', { tag: '@cloud' }, () => {
|
||||
test('Defaults to the asset browser', async ({ comfyPage, assetApi }) => {
|
||||
// Cloud defaults both asset settings on; no explicit settings here so the
|
||||
// test pins the defaults, not just the routing.
|
||||
await assetApi.mock()
|
||||
|
||||
await comfyPage.menu.modelLibraryTab.tabButton.click()
|
||||
|
||||
await expect(comfyPage.page.locator(assetBrowserModal)).toBeVisible()
|
||||
await expect(comfyPage.menu.modelLibraryTab.modelTree).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,12 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration: consumed by split/member-auditing (Activity pager);
|
||||
// models governance (DES-503) reuses it later
|
||||
'src/components/ui/pagination/Pagination.vue',
|
||||
// Pending integration: consumed by split/plan-credits-tabs (Overview);
|
||||
// models governance (DES-503) reuses it later
|
||||
'src/platform/workspace/composables/useAutoPageSize.ts',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
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>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -426,7 +426,7 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.for(['p-dialog', 'p-select-overlay', 'p-toast'])(
|
||||
it.for(['p-dialog', 'p-select-overlay'])(
|
||||
'focus-outside on a sibling %s portal does not dismiss the parent',
|
||||
(className) => {
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
|
||||
// PrimeVue Dialogs, Toasts) teleport to body. Reka treats clicks on
|
||||
// body-portaled elements as outside its dialog and would auto-dismiss on the
|
||||
// first interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside. Toasts matter for focus-outside: when a
|
||||
// button disables itself mid-action (e.g. a confirm entering its loading
|
||||
// state), the browser drops focus and recovery can land on the toast's close
|
||||
// button, which must not dismiss the dialog underneath.
|
||||
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
|
||||
// elements as outside its dialog and would auto-dismiss on the first
|
||||
// interaction, tearing the overlay down mid-interaction. Treat any
|
||||
// PrimeVue overlay click as inside.
|
||||
const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog, .p-toast'
|
||||
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
|
||||
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
@@ -15,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -19,10 +18,7 @@ const {
|
||||
mockGetNodeProvider,
|
||||
mockToggleNodeOnEvent,
|
||||
mockRefreshModelFolder,
|
||||
mockLoadModels,
|
||||
downloadStoreState,
|
||||
settingState,
|
||||
modelsState
|
||||
downloadStoreState
|
||||
} = vi.hoisted(() => {
|
||||
let capturedRoot: TreeExplorerNode<unknown> | null = null
|
||||
return {
|
||||
@@ -37,13 +33,7 @@ const {
|
||||
mockGetNodeProvider: vi.fn(),
|
||||
mockToggleNodeOnEvent: vi.fn(),
|
||||
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
|
||||
mockLoadModels: vi.fn().mockResolvedValue([]),
|
||||
downloadStoreState: { setLastCompleted: (_: unknown) => {} },
|
||||
settingState: { useAssetAPI: false, autoLoadAll: false },
|
||||
modelsState: {
|
||||
push: (_: unknown) => {},
|
||||
reset: () => {}
|
||||
}
|
||||
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,30 +54,20 @@ const mockModel = fromPartial<ComfyModelDef>({
|
||||
searchable: 'checkpoints/model.safetensors'
|
||||
})
|
||||
|
||||
vi.mock('@/stores/modelStore', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const models = reactive<ComfyModelDef[]>([])
|
||||
modelsState.push = (model: unknown) => {
|
||||
models.push(model as ComfyModelDef)
|
||||
}
|
||||
modelsState.reset = () => {
|
||||
models.splice(0, models.length, mockModel)
|
||||
}
|
||||
return {
|
||||
ResourceState: {
|
||||
Loading: 'loading',
|
||||
Loaded: 'loaded'
|
||||
},
|
||||
useModelStore: () => ({
|
||||
modelFolders: [],
|
||||
models,
|
||||
loadModels: mockLoadModels,
|
||||
loadModelFolders: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshModelFolder: mockRefreshModelFolder
|
||||
})
|
||||
}
|
||||
})
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
ResourceState: {
|
||||
Loading: 'loading',
|
||||
Loaded: 'loaded'
|
||||
},
|
||||
useModelStore: () => ({
|
||||
modelFolders: [],
|
||||
models: [mockModel],
|
||||
loadModels: vi.fn().mockResolvedValue([]),
|
||||
loadModelFolders: vi.fn().mockResolvedValue([]),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshModelFolder: mockRefreshModelFolder
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetDownloadStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
@@ -112,10 +92,6 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.ModelLibrary.NameFormat') return 'filename'
|
||||
if (key === 'Comfy.Assets.UseAssetAPI') return settingState.useAssetAPI
|
||||
if (key === 'Comfy.ModelLibrary.AutoLoadAll') {
|
||||
return settingState.autoLoadAll
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
@@ -128,45 +104,26 @@ vi.mock('@/composables/useTreeExpansion', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/TreeExplorer.vue', async () => {
|
||||
const { watchEffect } = await import('vue')
|
||||
return {
|
||||
default: {
|
||||
name: 'TreeExplorer',
|
||||
template: '<div data-testid="tree-explorer" />',
|
||||
props: ['root', 'expandedKeys'],
|
||||
setup(props: { root: TreeExplorerNode<unknown> }) {
|
||||
watchEffect(() => captureRoot(props.root))
|
||||
}
|
||||
vi.mock('@/components/common/TreeExplorer.vue', () => ({
|
||||
default: {
|
||||
name: 'TreeExplorer',
|
||||
template: '<div data-testid="tree-explorer" />',
|
||||
props: ['root', 'expandedKeys'],
|
||||
setup(props: { root: TreeExplorerNode<unknown> }) {
|
||||
captureRoot(props.root)
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchInput',
|
||||
template: '<input data-testid="search-input" @input="onInput" />',
|
||||
template: '<input data-testid="search-input" />',
|
||||
props: ['modelValue', 'placeholder'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
setup(
|
||||
_props: unknown,
|
||||
{
|
||||
emit,
|
||||
expose
|
||||
}: {
|
||||
emit: (event: 'update:modelValue' | 'search', value: string) => void
|
||||
expose: (exposed: Record<string, unknown>) => void
|
||||
}
|
||||
) {
|
||||
expose({ focus: vi.fn() })
|
||||
return {
|
||||
onInput: (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit('update:modelValue', value)
|
||||
emit('search', value)
|
||||
}
|
||||
}
|
||||
}
|
||||
setup() {
|
||||
return { focus: vi.fn() }
|
||||
},
|
||||
expose: ['focus']
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -177,8 +134,7 @@ vi.mock('./SidebarTopArea.vue', () => ({
|
||||
vi.mock('./SidebarTabTemplate.vue', () => ({
|
||||
default: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template:
|
||||
'<div><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
|
||||
template: '<div><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -201,17 +157,13 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
vi.clearAllMocks()
|
||||
resetRoot()
|
||||
downloadStoreState.setLastCompleted(null)
|
||||
settingState.useAssetAPI = false
|
||||
settingState.autoLoadAll = false
|
||||
modelsState.reset()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
return render(ModelLibrarySidebarTab, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false }), i18n],
|
||||
stubs: { teleport: true },
|
||||
directives: { tooltip: {} }
|
||||
stubs: { teleport: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -284,67 +236,4 @@ describe('ModelLibrarySidebarTab', () => {
|
||||
|
||||
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('search', () => {
|
||||
it('updates active search results when a reload adds a matching model', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
await user.type(screen.getByTestId('search-input'), 'model')
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoadModels).toHaveBeenCalled()
|
||||
const leafLabels = () => {
|
||||
const { children: folders = [] } = getRoot()
|
||||
return folders.flatMap(({ children: leaves = [] }) =>
|
||||
leaves.map((leaf) => leaf.label)
|
||||
)
|
||||
}
|
||||
expect(leafLabels()).toEqual(['model'])
|
||||
|
||||
// A completed scan reloads the store while the search is still active.
|
||||
modelsState.push(
|
||||
fromPartial<ComfyModelDef>({
|
||||
key: 'checkpoints/model-new.safetensors',
|
||||
file_name: 'model-new.safetensors',
|
||||
simplified_file_name: 'model-new',
|
||||
title: 'Model New',
|
||||
directory: 'checkpoints',
|
||||
searchable: 'checkpoints/model-new.safetensors'
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(leafLabels()).toEqual(['model', 'model-new'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('asset mode', () => {
|
||||
it('hides the load-all button and eager-loads models on mount', async () => {
|
||||
settingState.useAssetAPI = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByLabelText('g.loadAllFolders')).toBeNull()
|
||||
expect(screen.getByLabelText('g.refresh')).toBeInTheDocument()
|
||||
expect(mockLoadModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('legacy mode keeps the load-all button and stays lazy by default', async () => {
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByLabelText('g.loadAllFolders')).toBeInTheDocument()
|
||||
expect(mockLoadModels).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('legacy mode still honors AutoLoadAll', async () => {
|
||||
settingState.autoLoadAll = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoadModels).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<i class="icon-[lucide--refresh-cw] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!usesAssetAPI"
|
||||
v-tooltip.bottom="$t('g.loadAllFolders')"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
@@ -78,28 +77,28 @@ import { buildTree } from '@/utils/treeUtil'
|
||||
const modelStore = useModelStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const usesAssetAPI = computed(() =>
|
||||
settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
)
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const searchBoxRef = ref()
|
||||
const searchQuery = ref<string>('')
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
const filteredModels = computed<ComfyModelDef[]>(() => {
|
||||
const search = searchQuery.value.toLocaleLowerCase()
|
||||
if (!search) return []
|
||||
return modelStore.models.filter((model) => model.searchable.includes(search))
|
||||
})
|
||||
|
||||
const filteredModels = ref<ComfyModelDef[]>([])
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query) {
|
||||
filteredModels.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
}
|
||||
// Load all models to ensure results cover folders not yet opened
|
||||
// Load all models to ensure we have the latest data
|
||||
await modelStore.loadModels()
|
||||
const search = query.toLocaleLowerCase()
|
||||
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
|
||||
return model.searchable.includes(search)
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expandNode(root.value)
|
||||
}
|
||||
|
||||
type ModelOrFolder = ComfyModelDef | ModelFolder
|
||||
@@ -113,12 +112,6 @@ const root = computed<TreeNode>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
watch(root, async (newRoot) => {
|
||||
if (!searchQuery.value) return
|
||||
await nextTick()
|
||||
expandNode(newRoot)
|
||||
})
|
||||
|
||||
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
|
||||
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
|
||||
@@ -200,13 +193,7 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
// In asset mode the whole library resolves from one cached walk, so eager
|
||||
// loading is cheap and keeps search and folder badges complete from the
|
||||
// start; AutoLoadAll remains the opt-in for the request-per-folder legacy path.
|
||||
if (
|
||||
usesAssetAPI.value ||
|
||||
settingStore.get('Comfy.ModelLibrary.AutoLoadAll')
|
||||
) {
|
||||
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
|
||||
await modelStore.loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyModelDef } from '@/stores/modelStore'
|
||||
import { getModelPreviewUrl } from '@/stores/modelStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import ModelPreview from './ModelPreview.vue'
|
||||
@@ -38,7 +37,17 @@ const props = defineProps<{
|
||||
// Note: The leaf node should always have a model definition on node.data.
|
||||
const modelDef = computed<ComfyModelDef>(() => props.node.data!)
|
||||
|
||||
const modelPreviewUrl = computed(() => getModelPreviewUrl(modelDef.value))
|
||||
const modelPreviewUrl = computed(() => {
|
||||
if (modelDef.value.image) {
|
||||
return modelDef.value.image
|
||||
}
|
||||
const folder = modelDef.value.directory
|
||||
const path_index = modelDef.value.path_index
|
||||
const extension = modelDef.value.file_name.split('.').pop()
|
||||
const filename = modelDef.value.file_name.replace(`.${extension}`, '.webp')
|
||||
const encodedFilename = encodeURIComponent(filename).replace(/%2F/g, '/')
|
||||
return `/api/experiment/models/preview/${folder}/${path_index}/${encodedFilename}`
|
||||
})
|
||||
|
||||
const previewRef = ref<InstanceType<typeof ModelPreview> | null>(null)
|
||||
const modelPreviewStyle = ref<CSSProperties>({
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
31
src/components/ui/checkbox/Checkbox.vue
Normal file
31
src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-model="checked"
|
||||
:class="
|
||||
cn(
|
||||
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-white',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="flex items-center justify-center">
|
||||
<i
|
||||
:class="
|
||||
checked === 'indeterminate'
|
||||
? 'icon-[lucide--minus] size-3'
|
||||
: 'icon-[lucide--check] size-3'
|
||||
"
|
||||
/>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
const checked = defineModel<boolean | 'indeterminate'>({ default: false })
|
||||
</script>
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<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>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<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>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -33,8 +33,7 @@ export enum ServerFeatureFlag {
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile',
|
||||
SUPPORTS_MODEL_TYPE_TAGS = 'supports_model_type_tags'
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,12 +208,6 @@ export function useFeatureFlags() {
|
||||
remoteConfig.value.signup_turnstile,
|
||||
'off'
|
||||
)
|
||||
},
|
||||
get supportsModelTypeTags() {
|
||||
return api.getServerFeature(
|
||||
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2593,7 +2593,7 @@
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"usedAfterMonthly": "Used after plan credits run out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
@@ -2831,7 +2831,10 @@
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"enterprisePlanName": "Enterprise",
|
||||
"percentUsed": "{percent}% used",
|
||||
"usageProgress": "{used} of {total} credits used"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2846,7 +2849,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"inviteLimitReached": "Your workspace is at the member limit",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2861,12 +2864,17 @@
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
"pendingCount": "Pending ({count})",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"creditsUsed": "Credits used this month",
|
||||
"email": "Email",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2882,14 +2890,22 @@
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
|
||||
"renameWorkspace": "Rename Workspace"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -2978,6 +2994,97 @@
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"memberLimitDialog": {
|
||||
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
|
||||
"title": "Workspace is at the member limit"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"workflowQueuedDialog": {
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
|
||||
"title": "Your workflow is queued"
|
||||
},
|
||||
"billingStatus": {
|
||||
"ending": {
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan",
|
||||
"title": "Your team plan ends on {date}"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"addCredits": "Add credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Out of credits"
|
||||
},
|
||||
"paused": {
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
|
||||
"title": "Subscription paused"
|
||||
},
|
||||
"updatePayment": "Update payment",
|
||||
"warning": {
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"title": "Payment declined"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"changePlan": "Change plan",
|
||||
"inactive": {
|
||||
"reactivate": "Reactivate plan",
|
||||
"subtitle": "Reactivate your team plan to add more members and run workflows",
|
||||
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
|
||||
"title": "Inactive team subscription",
|
||||
"titleEnterprise": "Inactive enterprise subscription"
|
||||
},
|
||||
"learnMore": "Learn more",
|
||||
"managePayment": "Manage payment",
|
||||
"messageSupport": "Message support",
|
||||
"paused": "Paused",
|
||||
"perMonth": "mo",
|
||||
"pricingTable": "Partner Nodes pricing table",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"seeMore": "See more",
|
||||
"snapshot": {
|
||||
"creditsUsed": "Credits used",
|
||||
"empty": {
|
||||
"recentActivity": "No activity yet",
|
||||
"topSpenders": "No credits used yet this month"
|
||||
},
|
||||
"lastActivity": "Last activity",
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"allowlist": {
|
||||
"disableAll": "Disable all",
|
||||
"enableAll": "Enable all",
|
||||
"tabs": {
|
||||
"partnerNodes": "Partner nodes"
|
||||
}
|
||||
},
|
||||
"partnerNodes": {
|
||||
"autoEnableSubject": "newly added partner nodes",
|
||||
"autoEnableVerb": "auto-enable",
|
||||
"clearSelection": "Clear selection",
|
||||
"columns": {
|
||||
"lastModified": "Last modified",
|
||||
"name": "Partner Node",
|
||||
"nodes": "Nodes"
|
||||
},
|
||||
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
|
||||
"empty": "No partner nodes match your search.",
|
||||
"groupCount": "{enabled}/{total} enabled",
|
||||
"loadError": "Failed to load partner nodes",
|
||||
"neverModified": "—",
|
||||
"searchPlaceholder": "Search partner nodes",
|
||||
"selectAll": "Select all partner nodes",
|
||||
"selectedCount": "{count} node selected | {count} nodes selected",
|
||||
"updateError": "Failed to update partner nodes"
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
@@ -2990,7 +3097,7 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
@@ -3000,7 +3107,8 @@
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
"failedToSwitch": "Failed to switch workspace",
|
||||
"roleAdmin": "Admin"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
|
||||
@@ -9,20 +9,6 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
const mockAssetsByKey = vi.hoisted(() => new Map<string, AssetItem[]>())
|
||||
const mockLoadingByKey = vi.hoisted(() => new Map<string, boolean>())
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
@@ -228,7 +214,6 @@ describe('AssetBrowserModal', () => {
|
||||
vi.resetAllMocks()
|
||||
mockAssetsByKey.clear()
|
||||
mockLoadingByKey.clear()
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
describe('Integration with useAssetBrowser', () => {
|
||||
@@ -435,20 +420,5 @@ describe('AssetBrowserModal', () => {
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the title when the flag is on', async () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Model A', 'model_type:checkpoints')
|
||||
]
|
||||
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
|
||||
|
||||
renderModal({ nodeType: 'CheckpointLoaderSimple' })
|
||||
await flushPromises()
|
||||
|
||||
expect(screen.getByTestId('modal-title').textContent).toBe(
|
||||
'assetBrowser.allCategory:{"category":"Checkpoints"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +100,6 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||
@@ -110,14 +109,12 @@ import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getPrimaryCategoryTag } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const assetStore = useAssetsStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
@@ -194,21 +191,9 @@ const focusedAsset = ref<AssetDisplayItem | null>(null)
|
||||
const isRightPanelOpen = ref(false)
|
||||
|
||||
const primaryCategoryTag = computed(() => {
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
// A node-typed picker is FOR a category; title off that category rather
|
||||
// than guessing from the first asset, whose first model_type value may be
|
||||
// a different category it shares a root with.
|
||||
if (modelTypeMode && props.nodeType) {
|
||||
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
|
||||
if (mapped) return mapped
|
||||
}
|
||||
|
||||
const assets = fetchedAssets.value ?? []
|
||||
// Covered assets title off the model_type value they group under (so title
|
||||
// and grouping cannot diverge); uncovered assets keep the legacy verbatim
|
||||
// first tag.
|
||||
const tagFromAssets = assets
|
||||
.map((asset) => getPrimaryCategoryTag(asset, modelTypeMode))
|
||||
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
|
||||
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
|
||||
if (tagFromAssets) return tagFromAssets
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
<SelectionBar
|
||||
data-testid="assets-selection-bar"
|
||||
:label="$t('mediaAsset.selection.selectedCount', { count })"
|
||||
:deselect-label="$t('mediaAsset.selection.deselectAll')"
|
||||
@deselect="emit('deselect')"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
@@ -14,14 +14,6 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockDistribution = vi.hoisted(() => ({ isCloud: false }))
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
get isCloud() {
|
||||
return mockDistribution.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -49,10 +41,6 @@ describe('ModelInfoPanel', () => {
|
||||
...overrides
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockDistribution.isCloud = false
|
||||
})
|
||||
|
||||
function renderPanel(asset: AssetDisplayItem) {
|
||||
return render(ModelInfoPanel, {
|
||||
props: { asset },
|
||||
@@ -150,18 +138,6 @@ describe('ModelInfoPanel', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an editable model type dropdown for a mutable asset on cloud', () => {
|
||||
mockDistribution.isCloud = true
|
||||
renderPanel(createMockAsset({ is_immutable: false }))
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the model type read-only on core even for a mutable asset', () => {
|
||||
mockDistribution.isCloud = false
|
||||
renderPanel(createMockAsset({ is_immutable: false }))
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders base models field', () => {
|
||||
const asset = createMockAsset({
|
||||
user_metadata: { base_model: ['SDXL'] }
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
</template>
|
||||
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
|
||||
<Select v-if="isModelTypeEditable" v-model="selectedModelType">
|
||||
<Select v-if="!isImmutable" v-model="selectedModelType">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue
|
||||
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
|
||||
@@ -215,7 +215,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
@@ -230,19 +229,17 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
|
||||
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
buildModelTypeTagUpdate,
|
||||
getAssetAdditionalTags,
|
||||
getAssetBaseModels,
|
||||
getAssetDescription,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetModelType,
|
||||
getAssetSourceUrl,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetUserDescription,
|
||||
getEditableModelType,
|
||||
getSourceName
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -268,7 +265,6 @@ const { asset, cacheKey, selectContentStyle } = defineProps<{
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { modelTypes } = useModelTypes()
|
||||
|
||||
const pendingUpdates = ref<AssetUserMetadata>({})
|
||||
@@ -276,9 +272,6 @@ const pendingModelType = ref<string | undefined>(undefined)
|
||||
const isEditingDisplayName = ref(false)
|
||||
|
||||
const isImmutable = computed(() => asset.is_immutable ?? true)
|
||||
// Retagging a model rewrites its asset tags; core is filesystem-backed and does
|
||||
// not yet move the file to match, so the model type is read-only off-cloud.
|
||||
const isModelTypeEditable = computed(() => !isImmutable.value && isCloud)
|
||||
const displayName = computed(
|
||||
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
|
||||
)
|
||||
@@ -325,17 +318,12 @@ function handleDisplayNameEdit(newName: string) {
|
||||
}
|
||||
|
||||
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
|
||||
if (!isModelTypeEditable.value) return
|
||||
const currentModelType = getEditableModelType(
|
||||
asset,
|
||||
flags.supportsModelTypeTags
|
||||
)
|
||||
if (isImmutable.value) return
|
||||
const currentModelType = getAssetModelType(asset)
|
||||
if (currentModelType === newModelType) return
|
||||
const newTags = buildModelTypeTagUpdate(
|
||||
asset,
|
||||
newModelType,
|
||||
flags.supportsModelTypeTags
|
||||
)
|
||||
const newTags = asset.tags
|
||||
.filter((tag) => tag !== currentModelType)
|
||||
.concat(newModelType)
|
||||
assetsStore.updateAssetTags(asset, newTags, cacheKey)
|
||||
}, 500)
|
||||
|
||||
@@ -357,10 +345,7 @@ const userDescription = computed({
|
||||
})
|
||||
|
||||
const selectedModelType = computed({
|
||||
get: () =>
|
||||
pendingModelType.value ??
|
||||
getEditableModelType(asset, flags.supportsModelTypeTags) ??
|
||||
undefined,
|
||||
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
|
||||
set: (value: string | undefined) => {
|
||||
if (!value) return
|
||||
pendingModelType.value = value
|
||||
|
||||
@@ -25,22 +25,10 @@ vi.mock('@/i18n', () => ({
|
||||
d: (date: Date) => date.toLocaleDateString()
|
||||
}))
|
||||
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetBrowser', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
// Test fixtures - minimal data focused on functionality being tested
|
||||
@@ -150,25 +138,6 @@ describe('useAssetBrowser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the badge when the flag is on', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'model_type:checkpoints', 'sdxl']
|
||||
})
|
||||
|
||||
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
|
||||
const result = filteredAssets.value[0]
|
||||
|
||||
expect(result.badges).toContainEqual({
|
||||
label: 'checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
expect(result.badges).not.toContainEqual({
|
||||
label: 'model_type:checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles tags with multiple slashes in badges', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'checkpoint/subfolder/model-name']
|
||||
@@ -699,34 +668,6 @@ describe('useAssetBrowser', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('groups by model_type:* value and ignores other tags when the flag is on', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models', 'model_type:checkpoints', 'sdxl'] }),
|
||||
createApiAsset({ tags: ['models', 'model_type:LLM'] })
|
||||
]
|
||||
|
||||
const { navItems } = useAssetBrowser(ref(assets))
|
||||
|
||||
const typeGroup = navItems.value[2] as { items: { id: string }[] }
|
||||
expect(typeGroup.items.map((i) => i.id)).toEqual(['LLM', 'checkpoints'])
|
||||
})
|
||||
|
||||
it('ignores model_type: and groups by bare tags when the flag is off', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models', 'model_type:checkpoints'] }),
|
||||
createApiAsset({ tags: ['models', 'model_type:LLM'] })
|
||||
]
|
||||
|
||||
const { navItems } = useAssetBrowser(ref(assets))
|
||||
|
||||
const typeGroup = navItems.value[2] as { items: { id: string }[] }
|
||||
expect(typeGroup.items.map((i) => i.id)).toEqual([
|
||||
'model_type:LLM',
|
||||
'model_type:checkpoints'
|
||||
])
|
||||
})
|
||||
|
||||
it('handles assets with no category tag', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['models'] }), // No second tag
|
||||
|
||||
@@ -19,13 +19,10 @@ import {
|
||||
} from '@/platform/assets/utils/assetFilterUtils'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetCategories,
|
||||
getAssetFilename,
|
||||
getAssetTypeBadges
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { MODELS_TAG } from '@/platform/assets/services/assetService'
|
||||
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
@@ -46,19 +43,18 @@ export interface AssetDisplayItem extends AssetItem {
|
||||
}
|
||||
}
|
||||
|
||||
const displayItemCache = new WeakMap<
|
||||
AssetItem,
|
||||
{ modelTypeMode: boolean; item: AssetDisplayItem }
|
||||
>()
|
||||
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
|
||||
|
||||
function buildDisplayItem(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): AssetDisplayItem {
|
||||
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
|
||||
const badges: AssetBadge[] = []
|
||||
|
||||
for (const typeBadge of getAssetTypeBadges(asset, modelTypeMode)) {
|
||||
badges.push({ label: typeBadge, type: 'type' })
|
||||
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||
if (typeTag) {
|
||||
const badgeLabel = typeTag.includes('/')
|
||||
? typeTag.substring(typeTag.indexOf('/') + 1)
|
||||
: typeTag
|
||||
|
||||
badges.push({ label: badgeLabel, type: 'type' })
|
||||
}
|
||||
|
||||
for (const model of getAssetBaseModels(asset)) {
|
||||
@@ -79,15 +75,12 @@ function buildDisplayItem(
|
||||
}
|
||||
}
|
||||
|
||||
function transformAssetForDisplay(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): AssetDisplayItem {
|
||||
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
|
||||
const cached = displayItemCache.get(asset)
|
||||
if (cached && cached.modelTypeMode === modelTypeMode) return cached.item
|
||||
const item = buildDisplayItem(asset, modelTypeMode)
|
||||
displayItemCache.set(asset, { modelTypeMode, item })
|
||||
return item
|
||||
if (cached) return cached
|
||||
const built = buildDisplayItem(asset)
|
||||
displayItemCache.set(asset, built)
|
||||
return built
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +93,6 @@ export function useAssetBrowser(
|
||||
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
// State
|
||||
const searchQuery = ref('')
|
||||
@@ -130,10 +122,12 @@ export function useAssetBrowser(
|
||||
})
|
||||
|
||||
const typeCategories = computed<NavItemData[]>(() => {
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
const categories = assets.value
|
||||
.filter((asset) => asset.tags.includes(MODELS_TAG))
|
||||
.flatMap((asset) => getAssetCategories(asset, modelTypeMode))
|
||||
.flatMap((asset) =>
|
||||
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
||||
)
|
||||
.map((tag) => tag.split('/')[0])
|
||||
|
||||
return Array.from(new Set(categories))
|
||||
.sort()
|
||||
@@ -197,9 +191,7 @@ export function useAssetBrowser(
|
||||
|
||||
// Category-filtered assets for filter options (before search/format/base model filters)
|
||||
const categoryFilteredAssets = computed(() => {
|
||||
return assets.value.filter(
|
||||
filterByCategory(selectedCategory.value, flags.supportsModelTypeTags)
|
||||
)
|
||||
return assets.value.filter(filterByCategory(selectedCategory.value))
|
||||
})
|
||||
|
||||
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
|
||||
@@ -256,10 +248,7 @@ export function useAssetBrowser(
|
||||
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
|
||||
|
||||
// Transform to display format
|
||||
const modelTypeMode = flags.supportsModelTypeTags
|
||||
return sortedAssets.map((asset) =>
|
||||
transformAssetForDisplay(asset, modelTypeMode)
|
||||
)
|
||||
return sortedAssets.map(transformAssetForDisplay)
|
||||
})
|
||||
|
||||
function updateFilters(newFilters: AssetFilterState) {
|
||||
|
||||
@@ -38,10 +38,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
apiURL: vi.fn((path: string) => path),
|
||||
getServerFeature: vi.fn(
|
||||
(_name: string, defaultValue?: unknown) => defaultValue
|
||||
)
|
||||
apiURL: vi.fn((path: string) => path)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -282,43 +279,6 @@ describe('useUploadModelWizard', () => {
|
||||
expect(result?.modelType).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('namespaces the tag but keeps user_metadata.model_type bare when the backend supports it', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
const { api } = await import('@/scripts/api')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}
|
||||
})
|
||||
vi.mocked(api.getServerFeature).mockImplementation((name, defaultValue) =>
|
||||
name === 'supports_model_type_tags' ? true : defaultValue
|
||||
)
|
||||
|
||||
try {
|
||||
const wizard = setupUploadModelWizard(modelTypes, {
|
||||
requiredModelType: 'checkpoints'
|
||||
})
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
await wizard.uploadModel()
|
||||
|
||||
const uploadArg = vi.mocked(assetService.uploadAssetAsync).mock
|
||||
.calls[0][0]
|
||||
expect(uploadArg.tags).toEqual(['models', 'model_type:checkpoints'])
|
||||
expect(uploadArg.user_metadata?.model_type).toBe('checkpoints')
|
||||
// The namespaced returned tag must not trip the required-type guard.
|
||||
expect(wizard.uploadTypeMismatch.value).toBeNull()
|
||||
} finally {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_name, defaultValue) => defaultValue
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the synced asset filename for sync imports', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
@@ -387,65 +347,6 @@ describe('useUploadModelWizard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a namespaced model_type: tag as satisfying the required type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(wizard.uploadTypeMismatch.value).toBeNull()
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix from the imported-type label on a real mismatch', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
|
||||
type: 'sync',
|
||||
asset: {
|
||||
id: 'asset-lora',
|
||||
name: 'model.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
}
|
||||
})
|
||||
|
||||
const wizard = setupUploadModelWizard(
|
||||
ref([
|
||||
{ name: 'Checkpoint', value: 'checkpoints' },
|
||||
{ name: 'LoRA', value: 'loras' }
|
||||
]),
|
||||
{ requiredModelType: 'checkpoints' }
|
||||
)
|
||||
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
|
||||
|
||||
const result = await wizard.uploadModel()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(wizard.uploadTypeMismatch.value).toEqual({
|
||||
importedModelType: 'loras',
|
||||
importedModelTypeLabel: 'LoRA',
|
||||
requiredModelType: 'checkpoints',
|
||||
requiredModelTypeLabel: 'Checkpoint'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not block sync imports as mismatches without a required model type', async () => {
|
||||
const { assetService } =
|
||||
await import('@/platform/assets/services/assetService')
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
|
||||
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
|
||||
@@ -12,11 +11,7 @@ import type {
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { ImportSource } from '@/platform/assets/types/importSource'
|
||||
import {
|
||||
getAssetFilename,
|
||||
stripModelTypePrefix,
|
||||
toModelTypeTag
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -73,7 +68,6 @@ export function useUploadModelWizard(
|
||||
options: UploadModelWizardOptions = {}
|
||||
) {
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const assetsStore = useAssetsStore()
|
||||
const assetDownloadStore = useAssetDownloadStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
@@ -277,22 +271,19 @@ export function useUploadModelWizard(
|
||||
}
|
||||
|
||||
function getImportedModelType(asset: AssetItem): string | undefined {
|
||||
const subtypeTags = asset.tags
|
||||
.filter((tag) => tag !== MODEL_ROOT_TAG)
|
||||
.map(stripModelTypePrefix)
|
||||
return (
|
||||
subtypeTags.find((tag) =>
|
||||
const knownType = asset.tags.find(
|
||||
(tag) =>
|
||||
tag !== MODEL_ROOT_TAG &&
|
||||
modelTypes.value.some((type) => type.value === tag)
|
||||
) ?? subtypeTags[0]
|
||||
)
|
||||
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
|
||||
}
|
||||
|
||||
function blockMismatchedImportedModel(
|
||||
asset: AssetItem,
|
||||
requiredType: string
|
||||
): boolean {
|
||||
if (asset.tags.map(stripModelTypePrefix).includes(requiredType))
|
||||
return false
|
||||
if (asset.tags.includes(requiredType)) return false
|
||||
|
||||
const importedType = getImportedModelType(asset)
|
||||
uploadStatus.value = 'error'
|
||||
@@ -326,11 +317,7 @@ export function useUploadModelWizard(
|
||||
|
||||
try {
|
||||
const modelType = resolvedModelType.value
|
||||
const subtypeTag =
|
||||
modelType && flags.supportsModelTypeTags
|
||||
? toModelTypeTag(modelType)
|
||||
: modelType
|
||||
const tags = subtypeTag ? [MODEL_ROOT_TAG, subtypeTag] : [MODEL_ROOT_TAG]
|
||||
const tags = modelType ? ['models', modelType] : ['models']
|
||||
const filename =
|
||||
wizardData.value.metadata?.filename ||
|
||||
wizardData.value.metadata?.name ||
|
||||
|
||||
@@ -11,8 +11,6 @@ const zAsset = z.object({
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
display_name: z.string().optional(),
|
||||
/** Path within the model's category folder, i.e. the value a loader widget expects. */
|
||||
loader_path: z.string().nullish(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
@@ -29,6 +27,11 @@ const zAssetResponse = zListAssetsResponse
|
||||
assets: z.array(zAsset)
|
||||
})
|
||||
|
||||
const zModelFolder = z.object({
|
||||
name: z.string(),
|
||||
folders: z.array(z.string())
|
||||
})
|
||||
|
||||
// Zod schema for ModelFile to align with interface
|
||||
const zModelFile = z.object({
|
||||
name: z.string(),
|
||||
@@ -97,6 +100,7 @@ export type AssetItem = z.infer<typeof zAsset>
|
||||
export type AssetResponse = z.infer<typeof zAssetResponse>
|
||||
export type AssetMetadata = z.infer<typeof zAssetMetadata>
|
||||
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
|
||||
export type ModelFolder = z.infer<typeof zModelFolder>
|
||||
export type ModelFile = z.infer<typeof zModelFile>
|
||||
|
||||
/** Payload for updating an asset via PUT /assets/:id */
|
||||
@@ -128,10 +132,4 @@ export type TagsOperationResult = z.infer<typeof tagsOperationResultSchema>
|
||||
export interface ModelFolderInfo {
|
||||
name: string
|
||||
folders: string[]
|
||||
/**
|
||||
* The folder's raw registered extension allowlist from
|
||||
* `/experiment/models`. An empty array means match-all; absent on older
|
||||
* backends.
|
||||
*/
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { api } from '@/scripts/api'
|
||||
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: true }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
@@ -20,16 +19,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: mockSettingStoreGet
|
||||
@@ -51,9 +40,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
addCustomEventListener: vi.fn(),
|
||||
removeCustomEventListener: vi.fn()
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -100,7 +87,6 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'model.safetensors',
|
||||
loader_path: overrides.name ?? 'model.safetensors',
|
||||
tags: ['models'],
|
||||
...overrides
|
||||
}
|
||||
@@ -430,330 +416,33 @@ describe(assetService.deleteAsset, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.getAssetModels, () => {
|
||||
describe(assetService.getAssetModelFolders, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
assetService.invalidateModelBuckets()
|
||||
mockSupportsModelTypeTags.value = true
|
||||
})
|
||||
|
||||
it('walks the models tag once, excluding missing assets', async () => {
|
||||
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({ id: 'a', tags: ['models', 'model_type:checkpoints'] })
|
||||
validAsset({ id: 'a', tags: ['models', 'loras'] }),
|
||||
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
|
||||
validAsset({ id: 'c', tags: ['models', 'configs'] }),
|
||||
validAsset({ id: 'e', tags: ['models', 'loras'] })
|
||||
])
|
||||
)
|
||||
|
||||
await assetService.getAssetModels('checkpoints')
|
||||
const folders = await assetService.getAssetModelFolders()
|
||||
|
||||
expect(folders).toEqual([
|
||||
{ name: 'checkpoints', folders: [] },
|
||||
{ name: 'loras', folders: [] }
|
||||
])
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1)
|
||||
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
|
||||
const params = new URL(requestedUrl, 'http://localhost').searchParams
|
||||
expect(params.get('include_tags')).toBe('models')
|
||||
expect(params.has('include_public')).toBe(false)
|
||||
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
})
|
||||
|
||||
it('buckets by bare tags when model_type tags are unsupported', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'a',
|
||||
name: 'a.safetensors',
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'a.safetensors', pathIndex: 0 }])
|
||||
})
|
||||
|
||||
it('drops uncategorized model assets with a warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'ok',
|
||||
name: 'ok.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'uncat',
|
||||
name: 'orphan.safetensors',
|
||||
tags: ['models']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const loras = await assetService.getAssetModels('loras')
|
||||
|
||||
expect(loras).toEqual([{ name: 'ok.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('orphan.safetensors')
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('maps loader_path and drops unloadable assets without one', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'nested',
|
||||
name: 'model.safetensors',
|
||||
loader_path: 'sdxl/model.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'orphan',
|
||||
name: 'orphan.safetensors',
|
||||
loader_path: null,
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'other-folder',
|
||||
name: 'lora.safetensors',
|
||||
tags: ['models', 'model_type:loras']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'sdxl/model.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('orphan.safetensors')
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('drops assets whose loader path is traversal-shaped', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'evil',
|
||||
name: 'evil.safetensors',
|
||||
loader_path: '../../secrets/evil.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'ok',
|
||||
name: 'fine.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([{ name: 'fine.safetensors', pathIndex: 0 }])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsafe'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('groups slashed bare tags by their top-level segment', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'slashed',
|
||||
name: 'model1.safetensors',
|
||||
tags: ['models', 'Chatterbox/subfolder1/model1']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('Chatterbox')
|
||||
|
||||
expect(models).toEqual([{ name: 'model1.safetensors', pathIndex: 0 }])
|
||||
})
|
||||
|
||||
it('falls back to filename metadata then name on bare-tag backends', async () => {
|
||||
mockSupportsModelTypeTags.value = false
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'cloud-hash',
|
||||
name: 'blake3-content-hash',
|
||||
loader_path: null,
|
||||
user_metadata: { filename: 'sdxl/cloud-model.safetensors' },
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
validAsset({
|
||||
id: 'bare',
|
||||
name: 'plain.safetensors',
|
||||
loader_path: null,
|
||||
tags: ['models', 'checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models).toEqual([
|
||||
{ name: 'sdxl/cloud-model.safetensors', pathIndex: 0 },
|
||||
{ name: 'plain.safetensors', pathIndex: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it('orders each folder subdirectories-first then files, alphabetically', async () => {
|
||||
const checkpointAsset = (id: string, loaderPath: string) =>
|
||||
validAsset({
|
||||
id,
|
||||
name: loaderPath.split('/').pop()!,
|
||||
loader_path: loaderPath,
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
checkpointAsset('1', 'sdxl/base.safetensors'),
|
||||
checkpointAsset('2', 'v1-5.safetensors'),
|
||||
checkpointAsset('3', 'sdxl/refiner.safetensors'),
|
||||
checkpointAsset('4', 'anything.safetensors'),
|
||||
checkpointAsset('5', 'dynamicrafter/model.safetensors')
|
||||
])
|
||||
)
|
||||
|
||||
const models = await assetService.getAssetModels('checkpoints')
|
||||
|
||||
expect(models.map((m) => m.name)).toEqual([
|
||||
'dynamicrafter/model.safetensors',
|
||||
'sdxl/base.safetensors',
|
||||
'sdxl/refiner.safetensors',
|
||||
'anything.safetensors',
|
||||
'v1-5.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a stale in-flight walk overwrite an invalidated cache', async () => {
|
||||
let resolveStaleWalk!: (response: Response) => void
|
||||
fetchApiMock.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveStaleWalk = resolve
|
||||
})
|
||||
)
|
||||
const staleRead = assetService.getAssetModels('checkpoints')
|
||||
|
||||
assetService.invalidateModelBuckets()
|
||||
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'fresh',
|
||||
name: 'fresh.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
const freshModels = await assetService.getAssetModels('checkpoints')
|
||||
expect(freshModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
|
||||
|
||||
resolveStaleWalk(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'stale',
|
||||
name: 'stale.safetensors',
|
||||
tags: ['models', 'model_type:checkpoints']
|
||||
})
|
||||
])
|
||||
)
|
||||
await staleRead
|
||||
|
||||
const cachedModels = await assetService.getAssetModels('checkpoints')
|
||||
expect(cachedModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('places multi-category assets in every folder from a single walk', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([
|
||||
validAsset({
|
||||
id: 'shared',
|
||||
name: 'dual_use.safetensors',
|
||||
loader_path: 'dual_use.safetensors',
|
||||
tags: [
|
||||
'models',
|
||||
'model_type:checkpoints',
|
||||
'model_type:diffusion_models'
|
||||
]
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const checkpoints = await assetService.getAssetModels('checkpoints')
|
||||
const diffusion = await assetService.getAssetModels('diffusion_models')
|
||||
|
||||
expect(checkpoints).toEqual([
|
||||
{ name: 'dual_use.safetensors', pathIndex: 0 }
|
||||
])
|
||||
expect(diffusion).toEqual([{ name: 'dual_use.safetensors', pathIndex: 0 }])
|
||||
// Both folder reads resolve from a single memoized models walk.
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.onModelsScanned, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('invokes the callback when the scan event fires and unsubscribes cleanly', () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const unsubscribe = assetService.onModelsScanned(callback)
|
||||
|
||||
const [eventType, handler] = vi.mocked(api.addCustomEventListener).mock
|
||||
.calls[0]!
|
||||
expect(eventType).toBe('assets.seed.fast_complete')
|
||||
|
||||
handler!(new CustomEvent(eventType))
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
|
||||
unsubscribe()
|
||||
expect(api.removeCustomEventListener).toHaveBeenCalledWith(
|
||||
eventType,
|
||||
handler
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.seedModelAssets, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('POSTs the models root to the seed endpoint', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({ status: 'started' }, { status: 202 })
|
||||
)
|
||||
|
||||
await assetService.seedModelAssets()
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/assets/seed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roots: ['models'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an already-running scan (409) as success', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({ status: 'already_running' }, { ok: false, status: 409 })
|
||||
)
|
||||
|
||||
await expect(assetService.seedModelAssets()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws on other error statuses', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildResponse({}, { ok: false, status: 500 })
|
||||
)
|
||||
|
||||
await expect(assetService.seedModelAssets()).rejects.toThrow('500')
|
||||
})
|
||||
})
|
||||
|
||||
describe(assetService.updateAsset, () => {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
import {
|
||||
assetFilenameSchema,
|
||||
assetItemSchema,
|
||||
assetResponseSchema,
|
||||
asyncUploadResponseSchema,
|
||||
@@ -19,9 +17,9 @@ import type {
|
||||
AssetUpdatePayload,
|
||||
AsyncUploadResponse,
|
||||
ModelFile,
|
||||
ModelFolder,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -182,7 +180,6 @@ function getLocalizedErrorMessage(errorCode: string): string {
|
||||
}
|
||||
|
||||
const ASSETS_ENDPOINT = '/assets'
|
||||
const ASSETS_SEED_ENDPOINT = '/assets/seed'
|
||||
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
|
||||
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
|
||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||
@@ -190,8 +187,6 @@ const DEFAULT_LIMIT = 500
|
||||
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
|
||||
|
||||
export const MODELS_TAG = 'models'
|
||||
/** Prefix for the namespaced tag that carries a model's folder category, e.g. `model_type:checkpoints`. */
|
||||
const MODEL_TYPE_TAG_PREFIX = 'model_type:'
|
||||
export const INPUT_TAG = 'input'
|
||||
export const OUTPUT_TAG = 'output'
|
||||
/** Asset tag used by the backend for placeholder records that are not installed. */
|
||||
@@ -214,48 +209,6 @@ function normalizeAssetTags(tags: string[]): string[] {
|
||||
return tags.map((tag) => tag.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the model folder a tag represents, or undefined when the tag is not
|
||||
* a folder category. `supports_model_type_tags` backends carry the category as
|
||||
* a namespaced `model_type:<folder>` tag; older backends mint bare tags, which
|
||||
* may carry subfolder paths (e.g. `Chatterbox/sub/model`) and group by their
|
||||
* top-level segment, matching the asset browser's legacy grouping.
|
||||
*/
|
||||
function modelFolderFromTag(
|
||||
tag: string,
|
||||
modelTypeMode: boolean
|
||||
): string | undefined {
|
||||
if (modelTypeMode) {
|
||||
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
|
||||
: undefined
|
||||
}
|
||||
if (tag === MODELS_TAG || tag.length === 0) return undefined
|
||||
return tag.split('/')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders loader paths as subdirectories before files at every level,
|
||||
* alphabetical within each group. The asset API returns models in storage
|
||||
* order, which would otherwise interleave root-level files with folder
|
||||
* contents in the sidebar tree.
|
||||
*/
|
||||
function compareLoaderPaths(a: string, b: string): number {
|
||||
const aSegments = a.split('/')
|
||||
const bSegments = b.split('/')
|
||||
const sharedDepth = Math.min(aSegments.length, bSegments.length)
|
||||
for (let i = 0; i < sharedDepth; i++) {
|
||||
const aIsFile = i === aSegments.length - 1
|
||||
const bIsFile = i === bSegments.length - 1
|
||||
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1
|
||||
const order = aSegments[i].localeCompare(bSegments[i], undefined, {
|
||||
numeric: true
|
||||
})
|
||||
if (order !== 0) return order
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
async function withCallerAbort<T>(
|
||||
promise: Promise<T>,
|
||||
signal?: AbortSignal
|
||||
@@ -316,26 +269,6 @@ function createAssetService() {
|
||||
let inputAssetsIncludingPublicRequestId = 0
|
||||
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
|
||||
|
||||
/**
|
||||
* Model assets bucketed by folder category, built from a single walk of the
|
||||
* `models` tag rather than a fetch per category. Shared by the folder list
|
||||
* and per-folder listings so the sidebar loads every model in one pass.
|
||||
*/
|
||||
let modelBuckets: Map<string, AssetItem[]> | null = null
|
||||
let modelBucketsRequestId = 0
|
||||
let pendingModelBuckets: Promise<Map<string, AssetItem[]>> | null = null
|
||||
|
||||
/**
|
||||
* Discards the cached model buckets so the next read re-walks the models
|
||||
* tag. Bumping the request id keeps a walk that was already in flight from
|
||||
* repopulating the cache with pre-invalidation data.
|
||||
*/
|
||||
function invalidateModelBuckets(): void {
|
||||
modelBucketsRequestId++
|
||||
modelBuckets = null
|
||||
pendingModelBuckets = null
|
||||
}
|
||||
|
||||
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
|
||||
function invalidateInputAssetsIncludingPublic(): void {
|
||||
inputAssetsIncludingPublicRequestId++
|
||||
@@ -397,156 +330,51 @@ function createAssetService() {
|
||||
return validateAssetResponse(data)
|
||||
}
|
||||
/**
|
||||
* Walks every `models`-tagged asset once and buckets each into the folder
|
||||
* categories carried by its `model_type:` tags. A single asset lands in every
|
||||
* category it is tagged with (e.g. a shared-root model in both `checkpoints`
|
||||
* and `diffusion_models`). Which folders are actually shown is decided by
|
||||
* `/experiment/models`; models with no category tag are dropped with a warning
|
||||
* rather than hidden silently.
|
||||
* Gets a list of model folder keys from the asset API
|
||||
*
|
||||
* Logic:
|
||||
* 1. Extract directory names directly from asset tags
|
||||
* 2. Filter out blacklisted directories
|
||||
* 3. Return alphabetically sorted directories with assets
|
||||
*
|
||||
* @returns The list of model folder keys
|
||||
*/
|
||||
async function buildModelBuckets(): Promise<Map<string, AssetItem[]>> {
|
||||
const assets = await getAllAssetsByTag(MODELS_TAG, true)
|
||||
const modelTypeMode = useFeatureFlags().flags.supportsModelTypeTags
|
||||
const buckets = new Map<string, AssetItem[]>()
|
||||
async function getAssetModelFolders(): Promise<ModelFolder[]> {
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [MODELS_TAG] },
|
||||
'model folders'
|
||||
)
|
||||
|
||||
for (const asset of assets) {
|
||||
const folders = asset.tags
|
||||
.map((tag) => modelFolderFromTag(tag, modelTypeMode))
|
||||
.filter((folder): folder is string => folder !== undefined)
|
||||
// Blacklist directories we don't want to show
|
||||
const blacklistedDirectories = new Set(['configs'])
|
||||
|
||||
if (folders.length === 0) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) is tagged '${MODELS_TAG}' but has no model category; skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
const folderTags = data.assets
|
||||
.flatMap((asset) => asset.tags)
|
||||
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
|
||||
const discoveredFolders = new Set<string>(folderTags)
|
||||
|
||||
// On loader_path-contract backends a null loader_path marks an
|
||||
// unloadable asset (e.g. an orphan): it must not mint a widget value,
|
||||
// and `name` is deprecated for path semantics.
|
||||
if (modelTypeMode && !asset.loader_path) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) has no loader_path; skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// The loader value flows into viewMetadata URLs and widget values, so a
|
||||
// traversal-shaped path must not pass through even if the backend's own
|
||||
// validation ever regresses.
|
||||
const loaderValue = asset.loader_path ?? getAssetFilename(asset)
|
||||
if (!assetFilenameSchema.safeParse(loaderValue).success) {
|
||||
console.warn(
|
||||
`Asset ${asset.id} (${asset.name}) has an unsafe loader path ('${loaderValue}'); skipping.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const folder of folders) {
|
||||
const bucket = buckets.get(folder)
|
||||
if (bucket) bucket.push(asset)
|
||||
else buckets.set(folder, [asset])
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
bucket.sort((a, b) =>
|
||||
compareLoaderPaths(
|
||||
a.loader_path ?? getAssetFilename(a),
|
||||
b.loader_path ?? getAssetFilename(b)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
/** Returns the memoized model buckets, walking the models tag on first read. */
|
||||
async function loadModelBuckets(): Promise<Map<string, AssetItem[]>> {
|
||||
if (modelBuckets) return modelBuckets
|
||||
if (pendingModelBuckets) return pendingModelBuckets
|
||||
|
||||
const requestId = ++modelBucketsRequestId
|
||||
const walk = async () => {
|
||||
try {
|
||||
const buckets = await buildModelBuckets()
|
||||
if (requestId === modelBucketsRequestId) {
|
||||
modelBuckets = buckets
|
||||
}
|
||||
return buckets
|
||||
} finally {
|
||||
if (requestId === modelBucketsRequestId) {
|
||||
pendingModelBuckets = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingModelBuckets = walk()
|
||||
return pendingModelBuckets
|
||||
// Return only discovered folders in alphabetical order
|
||||
const sortedFolders = Array.from(discoveredFolders).toSorted()
|
||||
return sortedFolders.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the models in the specified folder from the single models walk.
|
||||
* Gets a list of models in the specified folder from the asset API
|
||||
* @param folder The folder to list models from, such as 'checkpoints'
|
||||
* @returns The list of model filenames within the specified folder
|
||||
*/
|
||||
async function getAssetModels(folder: string): Promise<ModelFile[]> {
|
||||
const buckets = await loadModelBuckets()
|
||||
return (buckets.get(folder) ?? []).map((asset) => ({
|
||||
// `loader_path` is the category-relative path the loader widget expects
|
||||
// and the source for the sidebar tree. Backends that predate it (bare-tag
|
||||
// mode; today's cloud) fall back to the filename metadata — the same
|
||||
// value the asset browser serializes — rather than `name`, which is a
|
||||
// content hash on cloud.
|
||||
name: asset.loader_path ?? getAssetFilename(asset),
|
||||
// Asset records carry no root identity, so every model reports root 0.
|
||||
// Known limitation on multi-root categories (extra_model_paths.yaml):
|
||||
// preview reads target root 0 (wrong file or 404 for secondary-root
|
||||
// files), and same-relative-path files in different roots collapse
|
||||
// onto one sidebar row. Metadata is unaffected unless relative paths
|
||||
// collide (/view_metadata searches roots in order without an index),
|
||||
// as are loader widget values; lifting this needs the backend to carry
|
||||
// root identity on assets.
|
||||
const data = await handleAssetRequest(
|
||||
{ includeTags: [MODELS_TAG, folder] },
|
||||
`models for ${folder}`
|
||||
)
|
||||
|
||||
return data.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
pathIndex: 0
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend to rescan the model roots on disk so newly added files
|
||||
* become assets. Fire-and-forget: the scan's fast (insert) phase already
|
||||
* writes the category tags and filenames the sidebar needs and is announced
|
||||
* by an `assets.seed.fast_complete` websocket event. A 409 means a scan is
|
||||
* already running, which will emit the same event, so it is not an error.
|
||||
*/
|
||||
async function seedModelAssets(): Promise<void> {
|
||||
const res = await api.fetchApi(ASSETS_SEED_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ roots: ['models'] })
|
||||
})
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(
|
||||
`Unable to start asset scan: Server returned ${res.status}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the backend's scan fast-phase completion broadcast — the
|
||||
* moment newly scanned files' tags and loader paths become queryable. The
|
||||
* wire-level event (`assets.seed.fast_complete`) is owned here; consumers
|
||||
* receive a callback and an unsubscribe function.
|
||||
*/
|
||||
function onModelsScanned(callback: () => void | Promise<void>): () => void {
|
||||
const handler = () => {
|
||||
void callback()
|
||||
}
|
||||
api.addCustomEventListener('assets.seed.fast_complete', handler)
|
||||
return () => {
|
||||
api.removeCustomEventListener('assets.seed.fast_complete', handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
||||
*
|
||||
@@ -1143,10 +971,8 @@ function createAssetService() {
|
||||
}
|
||||
|
||||
return {
|
||||
getAssetModelFolders,
|
||||
getAssetModels,
|
||||
invalidateModelBuckets,
|
||||
onModelsScanned,
|
||||
seedModelAssets,
|
||||
isAssetAPIEnabled,
|
||||
isAssetBrowserEligible,
|
||||
shouldUseAssetBrowser,
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('assetFilterUtils properties', () => {
|
||||
it('filterByCategory("all") accepts every asset', () => {
|
||||
fc.assert(
|
||||
fc.property(arbAssetItem, (asset) => {
|
||||
expect(filterByCategory('all', false)(asset)).toBe(true)
|
||||
expect(filterByCategory('all')(asset)).toBe(true)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -56,7 +56,7 @@ describe('assetFilterUtils properties', () => {
|
||||
fc.array(arbAssetItem, { maxLength: 30 }),
|
||||
fc.stringMatching(/^[a-z]{1,8}$/),
|
||||
(assets, category) => {
|
||||
const filter = filterByCategory(category, false)
|
||||
const filter = filterByCategory(category)
|
||||
const result = assets.filter(filter)
|
||||
expect(result.length).toBeLessThanOrEqual(assets.length)
|
||||
for (const item of result) {
|
||||
|
||||
@@ -26,54 +26,19 @@ function createAsset(
|
||||
|
||||
describe('filterByCategory', () => {
|
||||
it.for([
|
||||
{ category: 'all', tags: ['checkpoint'], mode: false, expected: true },
|
||||
{
|
||||
category: 'checkpoint',
|
||||
tags: ['checkpoint'],
|
||||
mode: false,
|
||||
expected: true
|
||||
},
|
||||
{ category: 'lora', tags: ['checkpoint'], mode: false, expected: false },
|
||||
{ category: 'all', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
|
||||
{ category: 'lora', tags: ['checkpoint'], expected: false },
|
||||
{
|
||||
category: 'checkpoint',
|
||||
tags: ['models', 'checkpoint/xl'],
|
||||
mode: false,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
category: 'xl',
|
||||
tags: ['models', 'checkpoint/xl'],
|
||||
mode: false,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
category: 'checkpoints',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
mode: true,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
category: 'LLM',
|
||||
tags: ['models', 'model_type:LLM'],
|
||||
mode: true,
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
category: 'sdxl',
|
||||
tags: ['models', 'model_type:checkpoints', 'sdxl'],
|
||||
mode: true,
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
category: 'checkpoints',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
mode: false,
|
||||
expected: false
|
||||
}
|
||||
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
|
||||
])(
|
||||
'category=$category tags=$tags mode=$mode returns $expected',
|
||||
({ category, tags, mode, expected }) => {
|
||||
const filter = filterByCategory(category, mode)
|
||||
'category=$category with tags=$tags returns $expected',
|
||||
({ category, tags, expected }) => {
|
||||
const filter = filterByCategory(category)
|
||||
const asset = createAsset('model.safetensors', { tags })
|
||||
expect(filter(asset)).toBe(expected)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
|
||||
import {
|
||||
getAssetBaseModels,
|
||||
getAssetCategories
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
export function filterByCategory(category: string, modelTypeMode: boolean) {
|
||||
export function filterByCategory(category: string) {
|
||||
return (asset: AssetItem) => {
|
||||
if (category === 'all') return true
|
||||
return getAssetCategories(asset, modelTypeMode).includes(category)
|
||||
|
||||
// Check if any tag matches the category (for exact matches)
|
||||
if (asset.tags.includes(category)) return true
|
||||
|
||||
// Check if any tag's top-level folder matches the category
|
||||
return asset.tags.some((tag) => {
|
||||
if (typeof tag === 'string' && tag.includes('/')) {
|
||||
return tag.split('/')[0] === category
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,34 +2,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
buildModelTypeTagUpdate,
|
||||
getAssetAdditionalTags,
|
||||
getAssetBaseModel,
|
||||
getAssetBaseModels,
|
||||
getAssetCardTitle,
|
||||
getAssetCategories,
|
||||
getAssetDescription,
|
||||
getAssetDisplayFilename,
|
||||
getAssetDisplayName,
|
||||
getAssetFilename,
|
||||
getAssetMetadataDimensions,
|
||||
getAssetModelType,
|
||||
getAssetNodeCategoryCandidates,
|
||||
getAssetSourceUrl,
|
||||
getPrimaryCategoryTag,
|
||||
getAssetStoredFilename,
|
||||
getAssetTriggerPhrases,
|
||||
getAssetTypeBadges,
|
||||
getAssetUserDescription,
|
||||
getEditableModelType,
|
||||
getSourceName,
|
||||
resolveDisplayImageDimensions,
|
||||
stripModelTypePrefix,
|
||||
toModelTypeTag
|
||||
resolveDisplayImageDimensions
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
const { isCloudRef } = vi.hoisted(() => ({
|
||||
@@ -286,17 +274,7 @@ describe('assetMetadataUtils', () => {
|
||||
tags: ['models'],
|
||||
expected: null
|
||||
},
|
||||
{ name: 'returns null when tags empty', tags: [], expected: null },
|
||||
{
|
||||
name: 'never returns a raw model_type: literal (no round-trip into edit widgets)',
|
||||
tags: ['models', 'model_type:checkpoints'],
|
||||
expected: null
|
||||
},
|
||||
{
|
||||
name: 'skips model_type: tags in favour of the bare twin',
|
||||
tags: ['models', 'model_type:checkpoints', 'checkpoints'],
|
||||
expected: 'checkpoints'
|
||||
}
|
||||
{ name: 'returns null when tags empty', tags: [], expected: null }
|
||||
])('$name', ({ tags, expected }) => {
|
||||
const asset = { ...mockAsset, tags }
|
||||
expect(getAssetModelType(asset)).toBe(expected)
|
||||
@@ -562,353 +540,3 @@ describe('assetMetadataUtils', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetCategories', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('uses model_type:* values as the group and disregards other tags in model_type mode', () => {
|
||||
expect(
|
||||
getAssetCategories(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
|
||||
it('preserves the model_type value casing', () => {
|
||||
expect(
|
||||
getAssetCategories(asset(['models', 'model_type:LLM']), true)
|
||||
).toEqual(['LLM'])
|
||||
})
|
||||
|
||||
it('routes an uncovered asset by its bare tags in model_type mode', () => {
|
||||
expect(getAssetCategories(asset(['models', 'checkpoints']), true)).toEqual([
|
||||
'checkpoints'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores model_type: and uses bare-tag grouping when mode is off', () => {
|
||||
expect(
|
||||
getAssetCategories(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
false
|
||||
)
|
||||
).toEqual(['model_type:checkpoints', 'sdxl'])
|
||||
})
|
||||
|
||||
it('never surfaces namespace residue as a category for uncovered assets in mode', () => {
|
||||
expect(
|
||||
getAssetCategories(asset(['models', 'model_type:', 'sdxl']), true)
|
||||
).toEqual(['sdxl'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPrimaryCategoryTag', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('uses the model_type value a covered asset groups under', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'sdxl', 'model_type:vae']), true)
|
||||
).toBe('vae')
|
||||
})
|
||||
|
||||
it('keeps the legacy verbatim tag for an uncovered hierarchical asset', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'Chatterbox/sub/model']), true)
|
||||
).toBe('Chatterbox/sub/model')
|
||||
})
|
||||
|
||||
it('skips namespace residue instead of titling off a raw model_type: tag', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'model_type:']), true)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the legacy first non-models tag when mode is off', () => {
|
||||
expect(
|
||||
getPrimaryCategoryTag(asset(['models', 'model_type:vae']), false)
|
||||
).toBe('model_type:vae')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetNodeCategoryCandidates', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('orders the most specific (deepest) tag ahead of a flat model_type value', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']),
|
||||
true
|
||||
)
|
||||
).toEqual(['LLM/Qwen-VL/Qwen3-0.6B', 'LLM'])
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix when it is the only candidate', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), true)
|
||||
).toEqual(['vae'])
|
||||
})
|
||||
|
||||
it('keeps a model_type value ahead of an equally-deep bare tag', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints', 'sdxl'])
|
||||
})
|
||||
|
||||
it('demotes an unrelated deeper bare tag below the model_type value', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:vae', 'foo/bar']),
|
||||
true
|
||||
)
|
||||
).toEqual(['vae', 'foo/bar'])
|
||||
})
|
||||
|
||||
it('keeps unrelated bare tags as trailing fallbacks rather than dropping them', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL', 'foo/bar/baz']),
|
||||
true
|
||||
)
|
||||
).toEqual(['LLM/Qwen-VL', 'LLM', 'foo/bar/baz'])
|
||||
})
|
||||
|
||||
it('keeps a hierarchical tag intact', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset(['models', 'chatterbox/chatterbox_vc']),
|
||||
true
|
||||
)
|
||||
).toEqual(['chatterbox/chatterbox_vc'])
|
||||
})
|
||||
|
||||
it('returns no candidates when only reserved tags are present', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'missing']), true)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the first non-reserved tag verbatim when mode is off', () => {
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), false)
|
||||
).toEqual(['model_type:vae'])
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(asset(['models', 'checkpoints']), false)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetTypeBadges', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix in model_type mode (no raw leak)', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(
|
||||
asset(['models', 'model_type:checkpoints', 'sdxl']),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints'])
|
||||
})
|
||||
|
||||
it('badges the model_type value even when a bare tag comes first, matching the grouping', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'foo', 'model_type:bar']), true)
|
||||
).toEqual(['bar'])
|
||||
})
|
||||
|
||||
it('badges every category a shared multi-type asset groups under', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(
|
||||
asset([
|
||||
'models',
|
||||
'model_type:checkpoints',
|
||||
'model_type:diffusion_models'
|
||||
]),
|
||||
true
|
||||
)
|
||||
).toEqual(['checkpoints', 'diffusion_models'])
|
||||
})
|
||||
|
||||
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
|
||||
expect(getAssetTypeBadges(asset(['models', 'sdxl']), true)).toEqual([
|
||||
'sdxl'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no badge rather than a blank one for a malformed empty model_type: tag', () => {
|
||||
expect(getAssetTypeBadges(asset(['models', 'model_type:']), true)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('leaks the literal model_type: tag when mode is off', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'model_type:checkpoints']), false)
|
||||
).toEqual(['model_type:checkpoints'])
|
||||
})
|
||||
|
||||
it('shows the segment after the first slash for a bare hierarchical tag', () => {
|
||||
expect(
|
||||
getAssetTypeBadges(asset(['models', 'checkpoint/xl']), false)
|
||||
).toEqual(['xl'])
|
||||
})
|
||||
|
||||
it('returns no badges when only the models tag is present', () => {
|
||||
expect(getAssetTypeBadges(asset(['models']), true)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripModelTypePrefix', () => {
|
||||
it('removes the model_type: prefix when present', () => {
|
||||
expect(stripModelTypePrefix('model_type:checkpoints')).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('leaves a tag without the prefix unchanged', () => {
|
||||
expect(stripModelTypePrefix('checkpoints')).toBe('checkpoints')
|
||||
expect(stripModelTypePrefix('checkpoint/xl')).toBe('checkpoint/xl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toModelTypeTag', () => {
|
||||
it('prefixes a folder_name with the model_type namespace', () => {
|
||||
expect(toModelTypeTag('checkpoints')).toBe('model_type:checkpoints')
|
||||
expect(toModelTypeTag('ultralytics_bbox')).toBe(
|
||||
'model_type:ultralytics_bbox'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEditableModelType', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('returns the stripped model_type value in model_type mode', () => {
|
||||
expect(
|
||||
getEditableModelType(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints']),
|
||||
true
|
||||
)
|
||||
).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
|
||||
expect(getEditableModelType(asset(['models', 'sam2']), true)).toBe('sam2')
|
||||
})
|
||||
|
||||
it('uses the legacy first-non-models tag when mode is off', () => {
|
||||
expect(
|
||||
getEditableModelType(asset(['models', 'checkpoints', 'sdxl']), false)
|
||||
).toBe('checkpoints')
|
||||
})
|
||||
|
||||
it('returns null when only the models tag is present', () => {
|
||||
expect(getEditableModelType(asset(['models']), true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildModelTypeTagUpdate', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it('swaps the bare subtype tag when mode is off', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(asset(['models', 'checkpoints']), 'loras', false)
|
||||
).toEqual(['models', 'loras'])
|
||||
})
|
||||
|
||||
it('preserves user labels and swaps only the subtype tag when mode is off', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'sdxl']),
|
||||
'loras',
|
||||
false
|
||||
)
|
||||
).toEqual(['models', 'sdxl', 'loras'])
|
||||
})
|
||||
|
||||
it('writes only the model_type form for a covered asset, leaving the bare twin for the backend', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints']),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'checkpoints', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('replaces every existing model_type form for a shared-path dual-tagged asset', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset([
|
||||
'models',
|
||||
'diffusion_models',
|
||||
'model_type:diffusion_models',
|
||||
'model_type:unet_gguf'
|
||||
]),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'diffusion_models', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('drops the bare current type for an uncovered asset in model_type mode', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(asset(['models', 'sam2']), 'loras', true)
|
||||
).toEqual(['models', 'model_type:loras'])
|
||||
})
|
||||
|
||||
it('keeps user labels untouched in model_type mode', () => {
|
||||
expect(
|
||||
buildModelTypeTagUpdate(
|
||||
asset(['models', 'checkpoints', 'model_type:checkpoints', 'sdxl']),
|
||||
'loras',
|
||||
true
|
||||
)
|
||||
).toEqual(['models', 'checkpoints', 'sdxl', 'model_type:loras'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserved tag mirrors', () => {
|
||||
const asset = (tags: string[]): AssetItem => ({
|
||||
id: 'a',
|
||||
name: 'model.safetensors',
|
||||
tags
|
||||
})
|
||||
|
||||
it("treats assetService's canonical reserved tags as reserved (locals must not drift)", () => {
|
||||
expect(getAssetCategories(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
|
||||
expect(
|
||||
getAssetNodeCategoryCandidates(
|
||||
asset([MODELS_TAG, MISSING_TAG, 'x']),
|
||||
true
|
||||
)
|
||||
).toEqual(['x'])
|
||||
expect(getAssetTypeBadges(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
|
||||
expect(getAssetModelType(asset([MODELS_TAG]))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,11 +2,6 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isCivitaiUrl } from '@/utils/formatUtil'
|
||||
|
||||
// Reserved tag literals (mirror assetService's MODELS_TAG/MISSING_TAG). Kept
|
||||
// local so this leaf util doesn't pull the heavier assetService -> i18n chain.
|
||||
const MODELS_TAG = 'models'
|
||||
const MISSING_TAG = 'missing'
|
||||
|
||||
/**
|
||||
* Type-safe utilities for extracting metadata from assets.
|
||||
* These utilities check user_metadata first, then metadata, then fallback.
|
||||
@@ -145,236 +140,16 @@ export function getSourceName(url: string): string {
|
||||
return 'Source'
|
||||
}
|
||||
|
||||
export const MODEL_TYPE_TAG_PREFIX = 'model_type:'
|
||||
|
||||
/**
|
||||
* Extracts the model type from asset tags as a bare (non-namespaced) value.
|
||||
* Never returns a raw `model_type:*` literal: this value feeds edit widgets
|
||||
* whose save path writes tags back verbatim, so a namespaced tag leaking
|
||||
* through here would round-trip the prefixed literal into the tag set.
|
||||
* Extracts the model type from asset tags
|
||||
* @param asset - The asset to extract model type from
|
||||
* @returns The model type string or null if not present
|
||||
*/
|
||||
export function getAssetModelType(asset: AssetItem): string | null {
|
||||
const typeTag = asset.tags?.find(
|
||||
(tag) => tag && tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
|
||||
return typeTag ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the namespaced subtype tag the backend stores in `model_type:` mode.
|
||||
* The argument is a discovery folder_name (e.g. `checkpoints`,
|
||||
* `ultralytics_bbox`); the backend keeps the bare directory-path twin in sync.
|
||||
*/
|
||||
export function toModelTypeTag(folderName: string): string {
|
||||
return `${MODEL_TYPE_TAG_PREFIX}${folderName}`
|
||||
}
|
||||
|
||||
/** Strips the `model_type:` prefix off each namespaced tag, dropping non-`model_type:` tags. */
|
||||
function getModelTypeTagValues(asset: AssetItem): string[] {
|
||||
return asset.tags
|
||||
.filter((tag) => tag.startsWith(MODEL_TYPE_TAG_PREFIX))
|
||||
.map((tag) => tag.slice(MODEL_TYPE_TAG_PREFIX.length))
|
||||
.filter((tag) => tag.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the folder_name shown as the asset's current model type in the edit
|
||||
* dropdown. In `modelTypeMode` the stripped `model_type:` value is authoritative
|
||||
* (covered assets); an uncovered asset with no `model_type:` tag falls back to
|
||||
* its bare subtype tag, mirroring the read-side grouping. Outside the mode this
|
||||
* is the legacy first-non-`models` tag.
|
||||
*/
|
||||
export function getEditableModelType(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string | null {
|
||||
if (modelTypeMode) {
|
||||
const [modelType] = getModelTypeTagValues(asset)
|
||||
if (modelType) return modelType
|
||||
}
|
||||
return getAssetModelType(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the tag set for re-typing a model asset to `newFolderName`. In
|
||||
* `modelTypeMode` only the `model_type:` form is written — the backend keeps the
|
||||
* bare directory-path twin in sync, so existing `model_type:` tags are dropped
|
||||
* (covered assets) or the bare current type is dropped (uncovered assets) and
|
||||
* the new `model_type:<folder_name>` is added. Outside the mode it swaps the
|
||||
* legacy bare subtype tag, preserving the pre-namespace behavior.
|
||||
*/
|
||||
export function buildModelTypeTagUpdate(
|
||||
asset: AssetItem,
|
||||
newFolderName: string,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (!modelTypeMode) {
|
||||
const currentType = getAssetModelType(asset)
|
||||
return asset.tags.filter((tag) => tag !== currentType).concat(newFolderName)
|
||||
}
|
||||
|
||||
const modelTypeTags = asset.tags.filter((tag) =>
|
||||
tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
const currentBareType = getAssetModelType(asset)
|
||||
const tagsToRemove =
|
||||
modelTypeTags.length > 0
|
||||
? new Set(modelTypeTags)
|
||||
: new Set(currentBareType ? [currentBareType] : [])
|
||||
|
||||
return asset.tags
|
||||
.filter((tag) => !tagsToRemove.has(tag))
|
||||
.concat(toModelTypeTag(newFolderName))
|
||||
}
|
||||
|
||||
/** Legacy grouping: each non-`models` tag's top-level path segment. */
|
||||
function getBareTagCategories(asset: AssetItem): string[] {
|
||||
return asset.tags
|
||||
.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
||||
.map((tag) => tag.split('/')[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the category keys a model asset is grouped under.
|
||||
*
|
||||
* `modelTypeMode` reflects whether the backend declares the `model_type:` tag
|
||||
* scheme (the `supports_model_type_tags` capability). When true, an asset's
|
||||
* `model_type:*` values are authoritative; an asset with no `model_type:` tag
|
||||
* still routes by its bare tags. When false (the default) categories come from
|
||||
* the legacy bare-tag top-level grouping and `model_type:` is ignored.
|
||||
*/
|
||||
export function getAssetCategories(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (modelTypeMode) {
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length > 0) return modelTypes
|
||||
// Uncovered assets route by bare tags, but namespace residue (e.g. a
|
||||
// malformed empty `model_type:`) must not surface as a raw category.
|
||||
return getBareTagCategories(asset).filter(
|
||||
(category) => !category.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
}
|
||||
|
||||
return getBareTagCategories(asset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the primary tag a browser surface titles itself after. In
|
||||
* `modelTypeMode` a covered asset uses its first `model_type:*` value — the
|
||||
* key it groups under — while an uncovered asset keeps the legacy selection
|
||||
* (first non-`models` tag, verbatim, hierarchical paths intact). Outside the
|
||||
* mode this is exactly the legacy selection.
|
||||
*/
|
||||
export function getPrimaryCategoryTag(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string | undefined {
|
||||
if (modelTypeMode) {
|
||||
const [modelType] = getModelTypeTagValues(asset)
|
||||
if (modelType) return modelType
|
||||
return asset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
}
|
||||
return asset.tags.find((tag) => tag !== MODELS_TAG)
|
||||
}
|
||||
|
||||
/** Number of `parent/child` segments in a tag, used to pick the most specific. */
|
||||
function pathDepth(tag: string): number {
|
||||
return tag.split('/').length
|
||||
}
|
||||
|
||||
/** Removes the `model_type:` namespace prefix from a tag when present. */
|
||||
export function stripModelTypePrefix(tag: string): string {
|
||||
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
|
||||
: tag
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the labels shown as an asset card's type badges.
|
||||
*
|
||||
* In `modelTypeMode` a covered asset badges every `model_type:*` value — the
|
||||
* same keys it groups under (`getAssetCategories`) — so a shared-root asset
|
||||
* tagged with several categories carries each of them; whichever category
|
||||
* view the card appears in is represented on the card. Uncovered assets (and
|
||||
* legacy mode) keep the original single selection: first non-`models` tag,
|
||||
* with bare hierarchical tags showing the segment after the first `/`.
|
||||
*/
|
||||
export function getAssetTypeBadges(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (modelTypeMode) {
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length > 0) return modelTypes
|
||||
}
|
||||
const typeTag = asset.tags.find(
|
||||
(tag) =>
|
||||
tag !== MODELS_TAG &&
|
||||
!(modelTypeMode && tag.startsWith(MODEL_TYPE_TAG_PREFIX))
|
||||
)
|
||||
if (!typeTag) return []
|
||||
return [
|
||||
typeTag.includes('/') ? typeTag.slice(typeTag.indexOf('/') + 1) : typeTag
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered node-category candidates for an asset, most specific first.
|
||||
*
|
||||
* Callers resolve a node provider by trying each candidate in order and taking
|
||||
* the first that maps to a provider. The full (possibly hierarchical) value is
|
||||
* kept so `modelToNodeStore`'s `parent/child` fallback still works.
|
||||
*
|
||||
* In `modelTypeMode` (backend declares `supports_model_type_tags`) candidates
|
||||
* come in two tiers. Tier 1: the stripped `model_type:*` values plus bare tags
|
||||
* *related* to one of them (equal to it, or extending it as a `parent/child`
|
||||
* path), ordered by descending depth — so a resolvable `LLM/Qwen-VL/...` twin
|
||||
* wins over a flat `model_type:LLM`, while ties keep `model_type:*` values
|
||||
* ahead of bare tags. Tier 2: unrelated bare tags (e.g. a user-added
|
||||
* `foo/bar`), tried only when nothing authoritative resolves — they can no
|
||||
* longer pre-empt a resolvable `model_type:*` value however deep they are.
|
||||
* An uncovered asset (no `model_type:` tag) routes by all its bare tags,
|
||||
* deepest first. Outside `modelTypeMode` the legacy first-non-reserved tag is
|
||||
* used verbatim.
|
||||
*/
|
||||
export function getAssetNodeCategoryCandidates(
|
||||
asset: AssetItem,
|
||||
modelTypeMode: boolean
|
||||
): string[] {
|
||||
if (!modelTypeMode) {
|
||||
const legacy = asset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
)
|
||||
return legacy ? [legacy] : []
|
||||
}
|
||||
|
||||
const bareTags = asset.tags.filter(
|
||||
(tag) =>
|
||||
tag !== MODELS_TAG &&
|
||||
tag !== MISSING_TAG &&
|
||||
!tag.startsWith(MODEL_TYPE_TAG_PREFIX)
|
||||
)
|
||||
|
||||
const byDepthDesc = (a: string, b: string) => pathDepth(b) - pathDepth(a)
|
||||
|
||||
const modelTypes = getModelTypeTagValues(asset)
|
||||
if (modelTypes.length === 0) return bareTags.toSorted(byDepthDesc)
|
||||
|
||||
const isRelated = (tag: string) =>
|
||||
modelTypes.some((type) => tag === type || tag.startsWith(`${type}/`))
|
||||
|
||||
return [
|
||||
...[...modelTypes, ...bareTags.filter(isRelated)].sort(byDepthDesc),
|
||||
...bareTags.filter((tag) => !isRelated(tag)).sort(byDepthDesc)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts user description from asset user_metadata
|
||||
* @param asset - The asset to extract user description from
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
|
||||
|
||||
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
|
||||
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get supportsModelTypeTags() {
|
||||
return mockSupportsModelTypeTags.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'asset-123',
|
||||
@@ -60,11 +49,6 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSupportsModelTypeTags.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('valid assets', () => {
|
||||
@@ -84,48 +68,6 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('strips the model_type: prefix when resolving the provider in model_type mode', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({ tags: ['models', 'model_type:vae'] })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
|
||||
})
|
||||
|
||||
it('skips an unresolvable incidental tag and resolves via the model_type value', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockGetNodeProvider.mockImplementation((category: string) =>
|
||||
category === 'vae' ? createMockNodeProvider() : undefined
|
||||
)
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({ tags: ['models', 'model_type:vae', 'foo/bar'] })
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('foo/bar')
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
|
||||
})
|
||||
|
||||
it('prefers the deepest resolvable path over a flat model_type value', () => {
|
||||
mockSupportsModelTypeTags.value = true
|
||||
mockGetNodeProvider.mockImplementation((category: string) =>
|
||||
category === 'LLM/Qwen-VL/Qwen3-0.6B'
|
||||
? createMockNodeProvider()
|
||||
: undefined
|
||||
)
|
||||
const result = resolveModelNodeFromAsset(
|
||||
createMockAsset({
|
||||
tags: ['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGetNodeProvider).toHaveBeenCalledWith('LLM/Qwen-VL/Qwen3-0.6B')
|
||||
})
|
||||
|
||||
it('falls back to metadata.filename when user_metadata.filename missing', () => {
|
||||
mockProvider(createMockNodeProvider())
|
||||
const result = resolveModelNodeFromAsset(
|
||||
@@ -259,7 +201,7 @@ describe('resolveModelNodeFromAsset', () => {
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe('NO_PROVIDER')
|
||||
expect(result.error.message).toContain('checkpoints')
|
||||
expect(result.error.details?.candidates).toEqual(['checkpoints'])
|
||||
expect(result.error.details?.category).toBe('checkpoints')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
MISSING_TAG,
|
||||
MODELS_TAG
|
||||
} from '@/platform/assets/services/assetService'
|
||||
import {
|
||||
getAssetFilename,
|
||||
getAssetNodeCategoryCandidates
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -85,12 +81,10 @@ export function resolveModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const candidates = getAssetNodeCategoryCandidates(
|
||||
validAsset,
|
||||
flags.supportsModelTypeTags
|
||||
const category = validAsset.tags.find(
|
||||
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
if (!category) {
|
||||
console.error(
|
||||
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
|
||||
)
|
||||
@@ -105,31 +99,19 @@ export function resolveModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const resolved = candidates
|
||||
.map((category) => ({
|
||||
category,
|
||||
provider: modelToNodeStore.getNodeProvider(category)
|
||||
}))
|
||||
.find((candidate) => candidate.provider !== undefined)
|
||||
|
||||
if (!resolved?.provider) {
|
||||
// Known gap (out of scope for FE-1076): flat `model_type:LLM`-style tags
|
||||
// whose loaders are only registered hierarchically land here until the
|
||||
// backend emits a subtype-carrying tag.
|
||||
console.error(
|
||||
`No node provider registered for category: ${candidates.join(', ')}`
|
||||
)
|
||||
const provider = useModelToNodeStore().getNodeProvider(category)
|
||||
if (!provider) {
|
||||
console.error(`No node provider registered for category: ${category}`)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NO_PROVIDER',
|
||||
message: `No node provider registered for category: ${candidates.join(', ')}`,
|
||||
message: `No node provider registered for category: ${category}`,
|
||||
assetId: validAsset.id,
|
||||
details: { candidates }
|
||||
details: { category }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, value: { provider: resolved.provider, filename } }
|
||||
return { success: true, value: { provider, filename } }
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isPaused: computed(() => false),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
@@ -97,24 +98,14 @@ const i18n = createI18n({
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
refillsNextCycle: 'Refills next cycle',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
monthlyUsageProgress: '{used} of {total} monthly credits used',
|
||||
yearly: 'Yearly',
|
||||
percentUsed: '{percent}% used',
|
||||
usageProgress: '{used} of {total} credits used',
|
||||
additionalCreditsInfo: 'About additional credits',
|
||||
additionalCreditsTooltip: 'Credits you add on top of your plan.',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
usedAfterMonthly: 'Used after plan credits run out',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('98% used')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
expect(container.textContent).toContain('Used after plan credits run out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
it('uses the team credit stop grant for a monthly allowance', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
renderTile()
|
||||
// Allowance is the stop's grant, not the tier fallback.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'527500'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the per-month nominal grant for an annual personal tier', () => {
|
||||
it('grants the full year upfront for an annual plan', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Annual billing still grants the monthly nominal (21,100), not 12x.
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).not.toContain('253,200')
|
||||
renderTile()
|
||||
// Annual plans grant the whole year at once: 21,100 x 12.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'253200'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a dateless refills label when renewal date is missing', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Refills next cycle')
|
||||
expect(container.textContent).not.toContain('Refills Feb')
|
||||
})
|
||||
|
||||
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
|
||||
activeProSubscription()
|
||||
it('labels the allowance by billing duration (yearly for annual)', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: 'not-a-date'
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain("You're out of credits")
|
||||
expect(container.textContent).not.toContain('Credits refill')
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
renderTile()
|
||||
expect(screen.getByText('Yearly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
it('shows no in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
it('shows no in-use badge until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
:class="
|
||||
cn(
|
||||
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
// Paused subscriptions can't spend credits, so dim the whole tile to
|
||||
// read as frozen and defer to the Update-payment banner. A lapsed plan
|
||||
// (frozen) reads the same way.
|
||||
(isPaused || frozen) && 'opacity-50',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -19,8 +28,10 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold tabular-nums">{{
|
||||
displayTotal
|
||||
}}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
@@ -28,37 +39,22 @@
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">{{ cycleLabel }}</span>
|
||||
<span class="text-muted">
|
||||
{{ refillsLabel }}
|
||||
{{ cycleStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="usage.used"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="monthlyTotalCredits ?? 0"
|
||||
:aria-valuetext="monthlyUsageLabel"
|
||||
:aria-valuemax="allowanceTotalCredits ?? 0"
|
||||
:aria-valuetext="cycleUsageLabel"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
@@ -66,40 +62,6 @@
|
||||
:style="{ width: usedBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
@@ -118,7 +80,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="text-muted"
|
||||
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
@@ -132,9 +94,9 @@
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -156,15 +118,10 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
class="w-full font-normal"
|
||||
:disabled="isPaused || frozen"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
@@ -178,6 +135,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -186,40 +144,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
const {
|
||||
zeroState = false,
|
||||
frozen = false,
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
/**
|
||||
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
|
||||
* subscription that still has a shape to show. Mirrors the paused treatment.
|
||||
*/
|
||||
frozen?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
isPaused,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -227,40 +190,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
const cycleLabel = computed(() =>
|
||||
subscription.value?.duration === 'ANNUAL'
|
||||
? t('subscription.yearly')
|
||||
: t('subscription.monthly')
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? ''
|
||||
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
const cycleUsedPercent = computed(() =>
|
||||
Math.round(usage.value.usedFraction * 100)
|
||||
)
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
const cycleStatusLabel = computed(() =>
|
||||
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
|
||||
)
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
@@ -270,82 +211,58 @@ const formatCreditCount = (value: number) =>
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
const allowanceTotalDisplay = computed(() => {
|
||||
const total = allowanceTotalCredits.value
|
||||
return total === null ? '—' : formatCreditCount(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
const monthlyUsageLabel = computed(() =>
|
||||
t('subscription.monthlyUsageProgress', {
|
||||
const cycleUsageLabel = computed(() =>
|
||||
t('subscription.usageProgress', {
|
||||
used: usedDisplay.value,
|
||||
total: monthlyTotalDisplay.value
|
||||
total: allowanceTotalDisplay.value
|
||||
})
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBreakdown = computed(
|
||||
() => (isActiveSubscription.value || frozen) && !zeroState
|
||||
)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
allowanceTotalCredits.value !== null &&
|
||||
allowanceTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
() =>
|
||||
(isActiveSubscription.value || frozen) &&
|
||||
!zeroState &&
|
||||
permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
const isAllowanceDepleted = computed(
|
||||
() =>
|
||||
!isPaused.value &&
|
||||
!frozen &&
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
// Fully out (monthly depleted and no additional credits left): emphasize the
|
||||
// add-credits button. Spending-additional keeps the quieter tertiary.
|
||||
const isOutOfCredits = computed(
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
|
||||
: t('subscription.outOfCreditsTitleNoDate'),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
})
|
||||
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription credit calculations and formatting.
|
||||
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
// Total credits granted for the current billing cycle. Team plans read the
|
||||
// credit stop; personal tiers read the tier grant. Annual plans front-load the
|
||||
// whole year, so multiply the monthly nominal by the cycle length.
|
||||
const cycleMonths = computed(() =>
|
||||
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
|
||||
)
|
||||
const allowanceTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = toValue(billingContext.currentTeamCreditStop)
|
||||
const tier = toValue(billingContext.subscription)?.tier
|
||||
const tierKey = tier
|
||||
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
|
||||
: DEFAULT_TIER_KEY
|
||||
const monthly = teamStop
|
||||
? teamStop.credits_monthly
|
||||
: getTierCredits(tierKey)
|
||||
return monthly === null ? null : monthly * cycleMonths.value
|
||||
})
|
||||
|
||||
// Usage of that allowance drives the credits bar. Paused plans read as unused
|
||||
// (credits are frozen), so force it to zero.
|
||||
const usage = computed(() => {
|
||||
const base = computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
allowanceTotalCredits.value ?? 0
|
||||
)
|
||||
return toValue(billingContext.isPaused)
|
||||
? { ...base, used: 0, usedFraction: 0 }
|
||||
: base
|
||||
})
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout
|
||||
content-title=""
|
||||
data-testid="settings-dialog"
|
||||
size="full"
|
||||
header-height-class="h-22"
|
||||
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -48,6 +54,7 @@
|
||||
id="keybinding-panel-header"
|
||||
class="flex-1"
|
||||
/>
|
||||
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -55,6 +62,7 @@
|
||||
v-if="activeCategoryKey === 'keybinding'"
|
||||
id="keybinding-panel-actions"
|
||||
/>
|
||||
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.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 {
|
||||
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let panels deep-link into a sibling panel (e.g. Overview → 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) => ({
|
||||
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const WORKSPACE_PANEL_KEYS = [
|
||||
'workspace',
|
||||
'workspace-members',
|
||||
'workspace-partner-nodes'
|
||||
]
|
||||
const isWorkspacePanel = computed(
|
||||
() =>
|
||||
!!activeCategoryKey.value &&
|
||||
WORKSPACE_PANEL_KEYS.includes(activeCategoryKey.value)
|
||||
)
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
billingType: 'legacy' as 'legacy' | 'workspace',
|
||||
canManagePartnerNodes: false
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
getSettingInfo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockSettingParams {
|
||||
id: string
|
||||
name: string
|
||||
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
billingType: 'legacy',
|
||||
canManagePartnerNodes: false
|
||||
})
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
@@ -233,5 +245,17 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the partner nodes entry only to owners and admins', () => {
|
||||
env.state.canManagePartnerNodes = false
|
||||
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
|
||||
env.state.canManagePartnerNodes = true
|
||||
expect(navKeys(useSettingUI().navGroups.value)).toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type { NavGroupData } from '@/types/navTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PartnerNodes: 'icon-[lucide--shield-check]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
@@ -54,6 +56,7 @@ export function useSettingUI(
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription, type: billingType } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -188,10 +191,40 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const partnerNodesPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-partner-nodes',
|
||||
label: 'PartnerNodes',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/AllowlistPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowWorkspacePanel = computed(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
// Partner-node governance is Owner/Admin-only; Members never see the tab.
|
||||
const shouldShowPartnerNodesPanel = computed(
|
||||
() =>
|
||||
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
|
||||
)
|
||||
|
||||
const secretsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'secrets',
|
||||
@@ -245,7 +278,8 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,7 +329,10 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [workspacePanel.node, membersPanel.node]
|
||||
: []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
|
||||
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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. Overview → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -1215,15 +1215,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: isCloud ? true : false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ModelLibrary.UseAssetBrowser',
|
||||
name: 'Use the asset browser for the model library',
|
||||
type: 'hidden',
|
||||
tooltip:
|
||||
'When enabled alongside the asset API, the model library opens the asset browser. Otherwise it opens the sidebar tree.',
|
||||
defaultValue: isCloud ? true : false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VersionCompatibility.DisableWarnings',
|
||||
name: 'Disable version compatibility warnings',
|
||||
|
||||
@@ -87,3 +87,5 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-members'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
89
src/platform/workspace/api/partnerNodesApi.ts
Normal file
89
src/platform/workspace/api/partnerNodesApi.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/** A partner (paid-API) node the workspace can allow or block. */
|
||||
export interface PartnerNode {
|
||||
id: string
|
||||
name: string
|
||||
partner: string
|
||||
/** ISO date of the last governance change, or null if never modified. */
|
||||
last_modified: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PartnerNodesResponse {
|
||||
partner_nodes: PartnerNode[]
|
||||
/** Workspace default applied to newly added partner nodes. */
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
interface SetEnabledPayload {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface BulkSetEnabledPayload {
|
||||
node_ids: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SetAutoEnablePayload {
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
const partnerNodesApiClient = axios.create({
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
attachUnifiedRemintInterceptor(partnerNodesApiClient)
|
||||
|
||||
async function authHeader() {
|
||||
return useAuthStore().getAuthHeaderOrThrow()
|
||||
}
|
||||
|
||||
export const partnerNodesApi = {
|
||||
/** GET /api/workspace/partner-nodes */
|
||||
async list(): Promise<PartnerNodesResponse> {
|
||||
const headers = await authHeader()
|
||||
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** PATCH /api/workspace/partner-nodes/:id */
|
||||
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: SetEnabledPayload = { enabled }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL(`/workspace/partner-nodes/${nodeId}`),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
/** PATCH /api/workspace/partner-nodes (bulk) */
|
||||
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
/** PUT /api/workspace/partner-nodes/settings */
|
||||
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
|
||||
await partnerNodesApiClient.put(
|
||||
api.apiURL('/workspace/partner-nodes/settings'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ export interface Member {
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
// Last time the member ran or interacted with the workspace, and the credits
|
||||
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
|
||||
// does not carry these fields yet.
|
||||
last_active_at?: string | null
|
||||
credits_used_this_month?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
| 'paused'
|
||||
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
|
||||
<div
|
||||
v-else-if="isLoading && !subscription"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
|
||||
<div
|
||||
v-else-if="error && !subscription"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-text-secondary">
|
||||
<i class="pi pi-exclamation-circle text-danger" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div class="rounded-2xl border border-interface-stroke/60 p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planDisplayName = computed(() =>
|
||||
isInPersonalWorkspace.value
|
||||
? subscriptionTierName.value
|
||||
const planDisplayName = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return subscriptionTierName.value
|
||||
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
|
||||
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
|
||||
? t('subscription.enterprisePlanName')
|
||||
: t('subscription.teamPlanName')
|
||||
)
|
||||
})
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
|
||||
$attrs.class as string
|
||||
)
|
||||
"
|
||||
:style="imageUrl ? undefined : { background: gradient, textShadow }"
|
||||
>
|
||||
{{ letter }}
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="workspaceName"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<template v-else>{{ letter }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { workspaceName, imageUrl } = defineProps<{
|
||||
workspaceName: string
|
||||
imageUrl?: string
|
||||
}>()
|
||||
|
||||
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
|
||||
mockChangeMemberRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('shows promote copy and confirms with Make owner', async () => {
|
||||
it('shows promote copy and confirms with Make admin', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
expect(
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -80,7 +81,11 @@ const workspaceName = ref('')
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
@@ -35,7 +35,7 @@
|
||||
:value="email"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
'rounded-full bg-tertiary-background-hover',
|
||||
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="close"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 p-4">
|
||||
<Button variant="muted-textonly" @click="close">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="requestMore">
|
||||
{{ $t('workspacePanel.requestMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { dialogKey, onRequestMore } = defineProps<{
|
||||
dialogKey: string
|
||||
title: string
|
||||
message: string
|
||||
onRequestMore: () => void
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function close() {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function requestMore() {
|
||||
onRequestMore()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
|
||||
expect(findCreateButton(container)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables create button for name exceeding 50 characters', async () => {
|
||||
it('disables create button for name exceeding the character limit', async () => {
|
||||
const { container, user } = mountComponent()
|
||||
const input = container.querySelector(
|
||||
'#workspace-name-input'
|
||||
|
||||
@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -178,7 +179,11 @@ const tierLabels = computed(
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
SAFE_NAME_REGEX.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="@container flex min-h-0 flex-1 flex-col gap-4">
|
||||
<!-- TODO(DES-503): models governance adds a Models sub-tab strip here;
|
||||
prototype in PR #13487 -->
|
||||
<div
|
||||
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
|
||||
>
|
||||
<span class="min-w-0 flex-1 text-sm font-medium">
|
||||
{{ $t('workspacePanel.allowlist.tabs.partnerNodes') }}
|
||||
</span>
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-full @2xl:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartnerNodesPanelContent :search="searchQuery" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import PartnerNodesPanelContent from '@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue'
|
||||
|
||||
const searchQuery = ref('')
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="banner"
|
||||
role="status"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
// Muted circle for the calm plan-ending notice; amber triangle for
|
||||
// every action-needed problem (paused, payment failed, out of credits).
|
||||
banner.kind === 'ending'
|
||||
? 'icon-[lucide--circle-alert] text-muted-foreground'
|
||||
: 'icon-[lucide--triangle-alert] text-warning-background'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="banner.showAction"
|
||||
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
<template v-if="banner.kind === 'outOfCredits'">
|
||||
<Button variant="textonly" size="lg" @click="dismiss">
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="handleAddCredits">
|
||||
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-else-if="banner.kind === 'ending'"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
|
||||
</Button>
|
||||
<Button v-else variant="inverted" size="lg">
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const {
|
||||
billingStatus,
|
||||
isPaused,
|
||||
isActiveSubscription,
|
||||
subscription,
|
||||
renewalDate
|
||||
} = useBillingContext()
|
||||
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
|
||||
const dialogService = useDialogService()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
|
||||
const cycleResetDate = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
})
|
||||
|
||||
const planEndDate = computed(() => {
|
||||
const raw = subscription.value?.endDate
|
||||
return raw
|
||||
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: ''
|
||||
})
|
||||
|
||||
// Out of credits: an active, non-paused team that has exhausted its balance.
|
||||
// Paused takes over this slot (see priority below). Dismissible for the session.
|
||||
const dismissed = ref(false)
|
||||
const isOutOfCredits = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
!isPaused.value &&
|
||||
subscription.value?.hasFunds === false
|
||||
)
|
||||
|
||||
// A cancelled-but-still-active plan is winding down to its end date. Unlike the
|
||||
// states above it's a calm, owner-initiated notice (not a problem), so it sits
|
||||
// last and reads with the muted circle icon and a low-key secondary action.
|
||||
const isEnding = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
!isPaused.value &&
|
||||
(subscription.value?.isCancelled ?? false) &&
|
||||
planEndDate.value !== ''
|
||||
)
|
||||
|
||||
// One status banner slot across every workspace tab, in priority order: paused →
|
||||
// payment-failure warning → out of credits → plan ending. All owner/admin-only
|
||||
// (members can't act on any of them).
|
||||
const banner = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return null
|
||||
|
||||
if (isPaused.value) {
|
||||
return {
|
||||
kind: 'paused' as const,
|
||||
title: t('workspacePanel.billingStatus.paused.title'),
|
||||
body: canManage.value
|
||||
? t('workspacePanel.billingStatus.paused.body')
|
||||
: t('workspacePanel.billingStatus.paused.memberBody'),
|
||||
showAction: canManage.value
|
||||
}
|
||||
}
|
||||
|
||||
if (billingStatus.value === 'payment_failed' && canManage.value) {
|
||||
return {
|
||||
kind: 'warning' as const,
|
||||
title: t('workspacePanel.billingStatus.warning.title'),
|
||||
body: t('workspacePanel.billingStatus.warning.body', {
|
||||
date: cycleResetDate.value
|
||||
}),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isOutOfCredits.value && canManage.value && !dismissed.value) {
|
||||
return {
|
||||
kind: 'outOfCredits' as const,
|
||||
title: t('workspacePanel.billingStatus.outOfCredits.title'),
|
||||
body: cycleResetDate.value
|
||||
? t('workspacePanel.billingStatus.outOfCredits.body', {
|
||||
date: cycleResetDate.value
|
||||
})
|
||||
: t('workspacePanel.billingStatus.outOfCredits.bodyNoDate'),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnding.value && canManage.value) {
|
||||
return {
|
||||
kind: 'ending' as const,
|
||||
title: t('workspacePanel.billingStatus.ending.title', {
|
||||
date: planEndDate.value
|
||||
}),
|
||||
body: t('workspacePanel.billingStatus.ending.body'),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
function handleAddCredits() {
|
||||
void dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
|
||||
striped && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="isCurrentUser ? photoUrl : undefined"
|
||||
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="showRoleColumn && !isSingleSeatPlan"
|
||||
class="text-right text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
member.role === 'owner'
|
||||
? $t('workspaceSwitcher.roleOwner')
|
||||
: $t('workspaceSwitcher.roleMember')
|
||||
}}
|
||||
</span>
|
||||
<div
|
||||
v-if="canManageMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="!isCurrentUser && !isOriginalOwner"
|
||||
:entries="menuItems"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
showRoleColumn = false,
|
||||
canManageMembers = false,
|
||||
isSingleSeatPlan = false,
|
||||
isOriginalOwner = false,
|
||||
striped = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
photoUrl?: string
|
||||
gridCols: string
|
||||
showRoleColumn?: boolean
|
||||
canManageMembers?: boolean
|
||||
isSingleSeatPlan?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
striped?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<TableRow
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
class="group hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{
|
||||
backgroundColor: userBadgeColor(member.name || member.email)
|
||||
}"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ initial }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
|
||||
{{ lastActivityLabel }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="canManageMembers"
|
||||
class="text-right text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ creditsLabel }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
|
||||
<DropdownMenu
|
||||
v-if="showMenu"
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
content-class="min-w-44"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
|
||||
const {
|
||||
member,
|
||||
isCurrentUser,
|
||||
canManageMembers = false,
|
||||
isOriginalOwner = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
canManageMembers?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const initial = computed(() =>
|
||||
(member.name || member.email).charAt(0).toUpperCase()
|
||||
)
|
||||
|
||||
// The creator and the current user can't be managed from their own row.
|
||||
const showMenu = computed(
|
||||
() => canManageMembers && !isCurrentUser && !isOriginalOwner
|
||||
)
|
||||
|
||||
const lastActivityLabel = computed(() => {
|
||||
if (!member.lastActivity) return '—'
|
||||
return formatRelativeTime(member.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 creditsLabel = computed(() =>
|
||||
(member.creditsUsedThisMonth ?? 0).toLocaleString()
|
||||
)
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Slots } from 'vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MembersPanelContent from './MembersPanelContent.vue'
|
||||
@@ -18,6 +17,7 @@ const mockMemberMenuItems = vi.fn(() => [])
|
||||
const mockShowTeamPlans = vi.fn()
|
||||
const mockToggleSort = vi.fn()
|
||||
const mockHandleInviteMember = vi.fn()
|
||||
const mockFetchBalance = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
@@ -27,13 +27,14 @@ const {
|
||||
mockFilteredPendingInvites,
|
||||
mockIsPersonalWorkspace,
|
||||
mockIsOnTeamPlan,
|
||||
mockHasMultipleMembers,
|
||||
mockShowSearch,
|
||||
mockShowViewTabs,
|
||||
mockShowInviteButton,
|
||||
mockIsInviteDisabled,
|
||||
mockActiveView,
|
||||
mockSearchQuery,
|
||||
mockSortField,
|
||||
mockSortDirection,
|
||||
mockPermissions,
|
||||
mockUiConfig
|
||||
} = vi.hoisted(() => {
|
||||
@@ -44,7 +45,6 @@ const {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockPendingInvites: ref<PendingInvite[]>([]),
|
||||
mockOriginalOwnerId: ref<string | null>(null),
|
||||
mockHasMultipleMembers: ref(true),
|
||||
mockShowSearch: ref(true),
|
||||
mockShowViewTabs: ref(true),
|
||||
mockShowInviteButton: ref(true),
|
||||
@@ -55,39 +55,36 @@ const {
|
||||
mockIsOnTeamPlan: ref(true),
|
||||
mockActiveView: ref<'active' | 'pending'>('active'),
|
||||
mockSearchQuery: ref(''),
|
||||
mockSortField: ref('role'),
|
||||
mockSortDirection: ref('desc'),
|
||||
mockPermissions: ref({
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canManageMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canTopUp: true
|
||||
canManageMembers: true
|
||||
}),
|
||||
mockUiConfig: ref({
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
|
||||
workspaceMenuDisabledTooltip: null as string | null
|
||||
showSearch: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isPaused: computed(() => false) })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
useMembersPanel: () => ({
|
||||
searchQuery: mockSearchQuery,
|
||||
activeView: mockActiveView,
|
||||
maxSeats: computed(() => 20),
|
||||
sortField: mockSortField,
|
||||
sortDirection: mockSortDirection,
|
||||
maxSeats: computed(() => 50),
|
||||
memberCount: computed(() => mockMembers.value.length),
|
||||
isOnTeamPlan: mockIsOnTeamPlan,
|
||||
hasMultipleMembers: mockHasMultipleMembers,
|
||||
hasLapsedTeamPlan: computed(() => false),
|
||||
showSearch: mockShowSearch,
|
||||
showViewTabs: mockShowViewTabs,
|
||||
showInviteButton: mockShowInviteButton,
|
||||
@@ -104,7 +101,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
memberMenuItems: mockMemberMenuItems,
|
||||
memberMenus: computed(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -112,28 +108,21 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
)
|
||||
),
|
||||
isPersonalWorkspace: mockIsPersonalWorkspace,
|
||||
members: mockMembers,
|
||||
pendingInvites: mockPendingInvites,
|
||||
permissions: mockPermissions,
|
||||
uiConfig: mockUiConfig,
|
||||
userPhotoUrl: ref(null),
|
||||
fetchBalance: mockFetchBalance,
|
||||
isCurrentUser: (m: WorkspaceMember) =>
|
||||
m.email.toLowerCase() === 'owner@example.com',
|
||||
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
|
||||
toggleSort: mockToggleSort,
|
||||
showTeamPlans: mockShowTeamPlans,
|
||||
handleResendInvite: mockHandleResendInvite,
|
||||
handleRevokeInvite: mockHandleRevokeInvite,
|
||||
handleRemoveMember: vi.fn(),
|
||||
handleChangeRole: vi.fn()
|
||||
handleRevokeInvite: mockHandleRevokeInvite
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: () => {} }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -157,6 +146,15 @@ const SearchInputStub = {
|
||||
emits: ['update:modelValue']
|
||||
}
|
||||
|
||||
// Render the trigger slot (carries the g.moreOptions button) plus each entry as
|
||||
// a flat button, so menu items are assertable without opening a real overlay.
|
||||
const DropdownMenuStub = {
|
||||
name: 'DropdownMenu',
|
||||
props: ['entries', 'modal', 'contentClass'],
|
||||
template:
|
||||
'<div><slot name="button" /><button v-for="e in (entries || [])" :key="e.label" :aria-label="e.label" @click="e.command && e.command()">{{ e.label }}</button></div>'
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(MembersPanelContent, {
|
||||
global: {
|
||||
@@ -164,8 +162,9 @@ function renderComponent() {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
SearchInput: SearchInputStub,
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
UserAvatar: true,
|
||||
WorkspaceMenuButton: true
|
||||
BillingStatusBanner: true
|
||||
},
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
@@ -207,7 +206,6 @@ describe('MembersPanelContent', () => {
|
||||
mockFilteredPendingInvites.value = []
|
||||
mockIsPersonalWorkspace.value = false
|
||||
mockIsOnTeamPlan.value = true
|
||||
mockHasMultipleMembers.value = true
|
||||
mockShowSearch.value = true
|
||||
mockShowViewTabs.value = true
|
||||
mockShowInviteButton.value = true
|
||||
@@ -215,27 +213,15 @@ describe('MembersPanelContent', () => {
|
||||
mockActiveView.value = 'active'
|
||||
mockSearchQuery.value = ''
|
||||
mockPermissions.value = {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canManageMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canTopUp: true
|
||||
canManageMembers: true
|
||||
}
|
||||
mockUiConfig.value = {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
showSearch: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,13 +229,9 @@ describe('MembersPanelContent', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPersonalWorkspace.value = true
|
||||
mockIsOnTeamPlan.value = false
|
||||
mockHasMultipleMembers.value = false
|
||||
mockShowSearch.value = false
|
||||
mockShowViewTabs.value = false
|
||||
mockIsInviteDisabled.value = true
|
||||
mockUiConfig.value.showMembersList = false
|
||||
mockUiConfig.value.showSearch = false
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
})
|
||||
|
||||
it('shows the upsell banner below the members card', () => {
|
||||
@@ -275,7 +257,7 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('team workspace - member list', () => {
|
||||
describe('team workspace - member table', () => {
|
||||
it('shows the Role column header and member roles', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ role: 'owner', email: 'boss@test.com' }),
|
||||
@@ -285,18 +267,47 @@ describe('MembersPanelContent', () => {
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.role')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the Last activity and Credits columns', () => {
|
||||
mockFilteredMembers.value = [createMember()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.lastActivity')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.creditsUsed')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the monthly credits for a member', () => {
|
||||
mockFilteredMembers.value = [createMember({ creditsUsedThisMonth: 6532 })]
|
||||
renderComponent()
|
||||
expect(screen.getByText('6,532')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('labels the original owner as Owner and other owners as Admin', () => {
|
||||
mockOriginalOwnerId.value = 'creator-1'
|
||||
mockFilteredMembers.value = [
|
||||
createMember({
|
||||
id: 'creator-1',
|
||||
email: 'creator@test.com',
|
||||
role: 'owner',
|
||||
isOriginalOwner: true
|
||||
}),
|
||||
createMember({ id: '2', email: 'admin@test.com', role: 'owner' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders filtered members', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ name: 'Alice', email: 'alice@test.com' }),
|
||||
createMember({
|
||||
id: '2',
|
||||
name: 'Bob',
|
||||
email: 'bob@test.com'
|
||||
})
|
||||
createMember({ id: '2', name: 'Bob', email: 'bob@test.com' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(screen.getByText('Alice')).toBeTruthy()
|
||||
@@ -388,34 +399,20 @@ describe('MembersPanelContent', () => {
|
||||
describe('member role', () => {
|
||||
beforeEach(() => {
|
||||
mockPermissions.value = {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canManageMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canTopUp: false
|
||||
canManageMembers: false
|
||||
}
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
mockUiConfig.value.showPendingTab = true
|
||||
})
|
||||
|
||||
it('hides the pending tab button', () => {
|
||||
it('shows the pending tab button (view-only)', () => {
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show the pending invites header', () => {
|
||||
mockActiveView.value = 'pending'
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
|
||||
).toBeNull()
|
||||
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no action menus on member rows', () => {
|
||||
@@ -451,15 +448,6 @@ describe('MembersPanelContent', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('opens subscription dialog on upgrade click', async () => {
|
||||
renderComponent()
|
||||
const upgradeBtn = screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.upgradeToTeam/
|
||||
})
|
||||
await userEvent.click(upgradeBtn)
|
||||
expect(mockShowTeamPlans).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides search input', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
@@ -472,17 +460,15 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
|
||||
describe('contact us footer', () => {
|
||||
it('opens discord in a new tab for team workspaces on a team plan', async () => {
|
||||
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.needMoreMembers')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
|
||||
await userEvent.click(
|
||||
screen.getByText('workspacePanel.members.contactUs')
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://www.comfy.org/discord',
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
@@ -496,16 +482,12 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('member count display', () => {
|
||||
it('shows member count header for team workspace', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ id: '1' }),
|
||||
createMember({ id: '2' })
|
||||
]
|
||||
mockMembers.value = mockFilteredMembers.value
|
||||
describe('member count tab', () => {
|
||||
it('shows the members count tab for team workspace', () => {
|
||||
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText(/workspacePanel\.members\.membersCount/)
|
||||
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -540,10 +522,7 @@ describe('MembersPanelContent', () => {
|
||||
mockShowViewTabs.value = false
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.tabs.active')
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.columns.role')
|
||||
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,209 +1,217 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div class="@container flex min-h-0 flex-1 flex-col gap-4 pb-6">
|
||||
<!-- Header: tabs (left) + search / invite (right), above the card -->
|
||||
<div
|
||||
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<template v-if="activeView === 'active'">
|
||||
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('workspacePanel.members.header') }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.pendingInvitesCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<template v-if="showViewTabs">
|
||||
<Button
|
||||
v-if="showInviteButton"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
:disabled="isInviteDisabled"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
@click="activeView = 'active'"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus text-sm" />
|
||||
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
|
||||
</Button>
|
||||
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
|
||||
</div>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
pendingInvites.length > 0
|
||||
? $t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
: $t('workspacePanel.members.tabs.pending')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
<span v-else class="text-base font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Members Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList && showViewTabs"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
<div class="flex w-full items-center gap-2 @2xl:w-auto">
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="min-w-0 flex-1 @2xl:w-64 @2xl:flex-none"
|
||||
/>
|
||||
<Button
|
||||
v-if="showInviteButton"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="shrink-0"
|
||||
:disabled="isInviteDisabled"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'active'"
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner />
|
||||
|
||||
<!-- Card: fills height, table scrolls inside -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
|
||||
>
|
||||
<Table v-if="activeView === 'active'" 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 :aria-sort="ariaSort('email')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('email')">
|
||||
{{ $t('workspacePanel.members.columns.email') }}
|
||||
<i :class="sortIcon('email')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
:class="permissions.canManageMembers ? 'w-40' : undefined"
|
||||
:aria-sort="ariaSort('role')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.tabs.active') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="
|
||||
activeView === 'pending' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'pending'"
|
||||
<button :class="sortHeaderClass" @click="toggleSort('role')">
|
||||
{{ $t('workspacePanel.members.columns.role') }}
|
||||
<i :class="sortIcon('role')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="permissions.canManageMembers"
|
||||
class="w-40"
|
||||
:aria-sort="ariaSort('lastActivity')"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Date column headers -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('inviteDate')"
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('lastActivity')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.lastActivity') }}
|
||||
<i :class="sortIcon('lastActivity')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
v-if="permissions.canManageMembers"
|
||||
class="w-64"
|
||||
:aria-sort="ariaSort('credits')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<div />
|
||||
</template>
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('credits')"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.members.columns.creditsUsed') }}
|
||||
<i :class="sortIcon('credits')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageMembers" class="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<MemberTableRow
|
||||
v-if="isPersonalWorkspace"
|
||||
:member="personalWorkspaceMember"
|
||||
:is-current-user="true"
|
||||
/>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('role')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.role') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canManageMembers" />
|
||||
<MemberTableRow
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
:is-current-user="isCurrentUser(member)"
|
||||
:can-manage-members="permissions.canManageMembers"
|
||||
:is-original-owner="isOriginalOwner(member)"
|
||||
:menu-items="memberMenus.get(member.id)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Active Members -->
|
||||
<template v-if="activeView === 'active'">
|
||||
<!-- Personal Workspace: show only current user -->
|
||||
<template v-if="isPersonalWorkspace">
|
||||
<MemberListItem
|
||||
:member="personalWorkspaceMember"
|
||||
:is-current-user="true"
|
||||
:photo-url="userPhotoUrl ?? undefined"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Team Workspace: sorted list -->
|
||||
<template v-else>
|
||||
<MemberListItem
|
||||
v-for="(member, index) in filteredMembers"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
:is-current-user="isCurrentUser(member)"
|
||||
:photo-url="
|
||||
isCurrentUser(member)
|
||||
? (userPhotoUrl ?? undefined)
|
||||
: undefined
|
||||
"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
:show-role-column="
|
||||
uiConfig.showRoleColumn && hasMultipleMembers
|
||||
"
|
||||
:can-manage-members="permissions.canManageMembers"
|
||||
:is-single-seat-plan="!isOnTeamPlan"
|
||||
:is-original-owner="isOriginalOwner(member)"
|
||||
:striped="index % 2 === 1"
|
||||
:menu-items="memberMenus.get(member.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<PendingInvitesList
|
||||
v-if="activeView === 'pending'"
|
||||
:invites="filteredPendingInvites"
|
||||
:grid-cols="uiConfig.pendingGridCols"
|
||||
<Table v-else 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>
|
||||
<span :class="sortHeaderClass">
|
||||
{{ $t('workspacePanel.members.columns.email') }}
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('inviteDate')">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i :class="sortIcon('inviteDate')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('expiryDate')">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i :class="sortIcon('expiryDate')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageInvites" class="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<PendingInviteRow
|
||||
v-for="invite in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
:invite="invite"
|
||||
:can-manage="permissions.canManageInvites"
|
||||
@resend="handleResendInvite"
|
||||
@revoke="handleRevokeInvite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow
|
||||
v-if="filteredPendingInvites.length === 0"
|
||||
class="hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
:colspan="permissions.canManageInvites ? 4 : 3"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<!-- Upsell Banner -->
|
||||
|
||||
<MemberUpsellBanner
|
||||
v-if="!isOnTeamPlan"
|
||||
:reactivate="hasLapsedTeamPlan"
|
||||
@show-plans="showTeamPlans()"
|
||||
/>
|
||||
<!-- Need More Members Footer -->
|
||||
<div
|
||||
v-if="isOnTeamPlan && !isPersonalWorkspace"
|
||||
class="flex items-center pt-2"
|
||||
class="flex h-8 items-center"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.needMoreMembers') }}
|
||||
{{ membersUsageLabel }}
|
||||
<template v-if="permissions.canInviteMembers">
|
||||
{{ $t('workspacePanel.members.needMoreMembers') }}
|
||||
</template>
|
||||
</p>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
size="md"
|
||||
class="text-sm text-base-foreground"
|
||||
@click="handleContactUs"
|
||||
>
|
||||
{{ $t('workspacePanel.members.contactUs') }}
|
||||
@@ -215,21 +223,31 @@
|
||||
<script setup lang="ts">
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.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 { useExternalLink } from '@/composables/useExternalLink'
|
||||
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
|
||||
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
|
||||
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
|
||||
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
|
||||
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
activeView,
|
||||
sortField,
|
||||
sortDirection,
|
||||
maxSeats,
|
||||
memberCount,
|
||||
isOnTeamPlan,
|
||||
hasLapsedTeamPlan,
|
||||
hasMultipleMembers,
|
||||
showSearch,
|
||||
showViewTabs,
|
||||
showInviteButton,
|
||||
@@ -241,11 +259,10 @@ const {
|
||||
filteredPendingInvites,
|
||||
memberMenus,
|
||||
isPersonalWorkspace,
|
||||
members,
|
||||
pendingInvites,
|
||||
permissions,
|
||||
uiConfig,
|
||||
userPhotoUrl,
|
||||
fetchBalance,
|
||||
isCurrentUser,
|
||||
isOriginalOwner,
|
||||
toggleSort,
|
||||
@@ -255,8 +272,38 @@ const {
|
||||
} = useMembersPanel()
|
||||
|
||||
const { staticUrls } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Owners get "Need more members?" after the count, where the period reads as a
|
||||
// separator; members see just the count, so drop the trailing period.
|
||||
const membersUsageLabel = computed(() => {
|
||||
const label = t('workspacePanel.members.membersUsage', {
|
||||
count: memberCount.value,
|
||||
max: maxSeats.value
|
||||
})
|
||||
return permissions.value.canInviteMembers ? label : label.replace(/\.$/, '')
|
||||
})
|
||||
|
||||
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: string) {
|
||||
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: string): 'ascending' | 'descending' | 'none' {
|
||||
if (sortField.value !== field) return 'none'
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
function handleContactUs() {
|
||||
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
|
||||
window.open(staticUrls.teamPlanRequests, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(getProviderIcon(partner), 'size-3')" :style="iconStyle" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { partner } = defineProps<{ partner: string }>()
|
||||
|
||||
// Monotone provider glyphs (Anthropic, BFL, …) render in currentColor, so tint
|
||||
// them with the brand color. Multi-color brands (ByteDance, Kling, Gemini, …)
|
||||
// ship full-color icons — identified by a gradient brand color — and must be
|
||||
// left untouched or the tint replaces their artwork.
|
||||
const iconStyle = computed(() => {
|
||||
const style = getProviderBorderStyle(partner)
|
||||
return style.includes('gradient') ? undefined : { color: style }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="@container relative flex min-h-0 flex-1 flex-col gap-4 pb-6">
|
||||
<div class="flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-6">
|
||||
<span class="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.description') }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="setAllFilteredEnabled(true)"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.enableAll') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="lg"
|
||||
@click="setAllFilteredEnabled(false)"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.disableAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner />
|
||||
|
||||
<div
|
||||
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 scrollbar-gutter-stable 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-6">
|
||||
<Checkbox
|
||||
:model-value="allFilteredSelected"
|
||||
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead :aria-sort="ariaSort('name')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('name')">
|
||||
{{ $t('workspacePanel.partnerNodes.columns.name') }}
|
||||
<i :class="sortIcon('name')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
{{ $t('workspacePanel.partnerNodes.columns.nodes') }}
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('lastModified')">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('lastModified')"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.columns.lastModified') }}
|
||||
<i :class="sortIcon('lastModified')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-14" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-for="group in groups" :key="group.partner">
|
||||
<!-- Provider row: click to expand/collapse its nodes -->
|
||||
<TableRow
|
||||
class="cursor-pointer hover:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors"
|
||||
:aria-expanded="group.expanded"
|
||||
@click="togglePartnerCollapsed(group.partner)"
|
||||
>
|
||||
<TableCell @click.stop>
|
||||
<Checkbox
|
||||
:model-value="groupSelectionState(group)"
|
||||
:aria-label="group.partner"
|
||||
@update:model-value="toggleGroupSelection(group)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
group.expanded && 'rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2',
|
||||
group.enabledCount === 0 && 'opacity-30'
|
||||
)
|
||||
"
|
||||
>
|
||||
<PartnerBadge :partner="group.partner" />
|
||||
<span class="font-medium text-base-foreground">
|
||||
{{ group.partner }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground tabular-nums">
|
||||
{{
|
||||
$t('workspacePanel.partnerNodes.groupCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.totalCount
|
||||
})
|
||||
}}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatLastModified(group.lastModified) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<!-- On when any node in the group is enabled; the count column
|
||||
carries the partial state. Off disables the whole group. -->
|
||||
<Switch
|
||||
:model-value="group.enabledCount > 0"
|
||||
@update:model-value="
|
||||
(v: boolean) => setGroupEnabled(group, v)
|
||||
"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<template v-if="group.expanded">
|
||||
<TableRow
|
||||
v-for="node in group.nodes"
|
||||
:key="node.id"
|
||||
:data-state="selectedIds.has(node.id) ? 'selected' : undefined"
|
||||
class="group cursor-pointer hover:bg-transparent data-[state=selected]:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors [&[data-state=selected]>td]:bg-secondary-background/50"
|
||||
@click="toggleSelection(node.id)"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
:model-value="selectedIds.has(node.id)"
|
||||
:aria-label="node.name"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none',
|
||||
!hasSelection &&
|
||||
'opacity-0 transition-opacity group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<div :class="cn('pl-7', !node.enabled && 'opacity-30')">
|
||||
{{ node.name }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatLastModified(node.last_modified) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<Switch
|
||||
:model-value="node.enabled"
|
||||
@update:model-value="(v: boolean) => setEnabled(node, v)"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
<TableRow v-if="groups.length === 0" class="hover:bg-transparent">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.empty') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Auto-enable default: toggle-first sentence, bottom-left. -->
|
||||
<div class="flex h-8 items-center gap-3 text-sm text-muted-foreground">
|
||||
<Switch
|
||||
:model-value="autoEnableNew"
|
||||
@update:model-value="setAutoEnableNew"
|
||||
/>
|
||||
<!-- The sentence lights up with the toggle: foreground when the default
|
||||
is on, muted when off. -->
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'transition-colors',
|
||||
autoEnableNew ? 'text-base-foreground' : 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.autoEnableVerb') }}
|
||||
{{ $t('workspacePanel.partnerNodes.autoEnableSubject') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bulk selection toolbar: overlaid so toggling it doesn't reflow the panel -->
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<SelectionBar
|
||||
v-if="selectedCount > 0"
|
||||
:label="
|
||||
$t('workspacePanel.partnerNodes.selectedCount', selectedCount)
|
||||
"
|
||||
:deselect-label="$t('workspacePanel.partnerNodes.clearSelection')"
|
||||
@deselect="clearSelection"
|
||||
>
|
||||
<Switch :model-value="bulkEnabled" @update:model-value="applyBulk" />
|
||||
</SelectionBar>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.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 BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import PartnerBadge from '@/platform/workspace/components/dialogs/settings/PartnerBadge.vue'
|
||||
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { search } = defineProps<{ search: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
autoEnableNew,
|
||||
searchQuery,
|
||||
sortField,
|
||||
sortDirection,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allFilteredSelected,
|
||||
filteredNodes,
|
||||
groups,
|
||||
togglePartnerCollapsed,
|
||||
groupSelectionState,
|
||||
toggleGroupSelection,
|
||||
fetch,
|
||||
toggleSort,
|
||||
setEnabled,
|
||||
setSelectedEnabled,
|
||||
setAllFilteredEnabled,
|
||||
setGroupEnabled,
|
||||
setAutoEnableNew,
|
||||
toggleSelection,
|
||||
toggleSelectAll,
|
||||
clearSelection
|
||||
} = usePartnerNodes()
|
||||
|
||||
// Search lives in the Allowlist tab row (shared with the Models tab).
|
||||
watch(
|
||||
() => search,
|
||||
(value) => {
|
||||
searchQuery.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const hasSelection = computed(() => selectedCount.value > 0)
|
||||
|
||||
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: 'name' | 'lastModified') {
|
||||
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: 'name' | 'lastModified'
|
||||
): 'ascending' | 'descending' | 'none' {
|
||||
if (sortField.value !== field) return 'none'
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
// When every selected node is enabled the bulk switch reads "on", so a toggle
|
||||
// disables the whole selection; otherwise it enables them.
|
||||
const bulkEnabled = computed(() =>
|
||||
filteredNodes.value
|
||||
.filter((n) => selectedIds.value.has(n.id))
|
||||
.every((n) => n.enabled)
|
||||
)
|
||||
|
||||
function applyBulk(value: boolean) {
|
||||
void setSelectedEnabled(value)
|
||||
}
|
||||
|
||||
function formatLastModified(iso: string | null): string {
|
||||
if (!iso) return t('workspacePanel.partnerNodes.neverModified')
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<TableRow
|
||||
:data-testid="`invite-row-${invite.id}`"
|
||||
class="group hover:bg-transparent"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: userBadgeColor(invite.email) }"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ inviteInitial }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManage" class="text-right" @click.stop>
|
||||
<DropdownMenu
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
content-class="min-w-44"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
|
||||
const { invite, canManage } = defineProps<{
|
||||
invite: PendingInvite
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resend: [invite: PendingInvite]
|
||||
revoke: [invite: PendingInvite]
|
||||
}>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const inviteInitial = computed(() => invite.email.charAt(0).toUpperCase())
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('workspacePanel.members.actions.resendInvite'),
|
||||
command: () => emit('resend', invite)
|
||||
},
|
||||
{
|
||||
label: t('workspacePanel.members.actions.cancelInvite'),
|
||||
command: () => emit('revoke', invite)
|
||||
}
|
||||
])
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
</script>
|
||||
@@ -1,85 +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 type { Slots } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PendingInvitesList from './PendingInvitesList.vue'
|
||||
|
||||
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
|
||||
|
||||
const mockMenuClose = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: mockMenuClose }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
return {
|
||||
id: 'invite-1',
|
||||
email: 'invitee@example.com',
|
||||
inviteDate: new Date('2025-03-01'),
|
||||
expiryDate: new Date('2025-04-01'),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(invites: PendingInvite[]) {
|
||||
return render(PendingInvitesList, {
|
||||
props: {
|
||||
invites,
|
||||
gridCols: 'grid-cols-[50%_20%_20%_10%]'
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PendingInvitesList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the empty state without action buttons when there are no invites', () => {
|
||||
renderComponent([])
|
||||
|
||||
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits resend with the invite and closes the menu', async () => {
|
||||
const invite = createInvite({ id: 'inv-7' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.resendInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('resend')).toEqual([[invite]])
|
||||
expect(mockMenuClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits revoke with the invite from the cancel item', async () => {
|
||||
const invite = createInvite({ id: 'inv-8' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.cancelInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('revoke')).toEqual([[invite]])
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(invite, index) in invites"
|
||||
:key="invite.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
gridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ getInviteInitial(invite.email) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ getInviteDisplayName(invite.email) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<div class="flex items-center justify-end">
|
||||
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('resend', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-plus] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('revoke', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
|
||||
</Button>
|
||||
</MoreButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="invites.length === 0"
|
||||
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
|
||||
|
||||
defineProps<{
|
||||
invites: PendingInvite[]
|
||||
gridCols: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resend: [invite: PendingInvite]
|
||||
revoke: [invite: PendingInvite]
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
function getInviteDisplayName(email: string): string {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function getInviteInitial(email: string): string {
|
||||
return email.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from '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 { workspaceRole } = useWorkspaceUI()
|
||||
const { fetchMembers, fetchPendingInvites } = useTeamWorkspaceStore()
|
||||
|
||||
onMounted(() => {
|
||||
void fetchMembers()
|
||||
void fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@ import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
|
||||
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
|
||||
|
||||
@@ -75,6 +76,25 @@ describe('WorkspaceMenuButton', () => {
|
||||
mockUiConfig.value = ownerConfig
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
useWorkspaceRename().stopRenaming()
|
||||
})
|
||||
|
||||
it('offers Rename to an editor and starts inline renaming', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { isRenaming } = useWorkspaceRename()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rename Workspace' }))
|
||||
expect(isRenaming.value).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Rename from a member', () => {
|
||||
mockUiConfig.value = memberConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Rename Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets a member leave and offers no destructive workspace actions', () => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<DropdownMenu :entries="menuItems">
|
||||
<DropdownMenu
|
||||
v-if="menuItems.length > 0"
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
@close-auto-focus="onMenuCloseAutoFocus"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
@@ -21,20 +27,35 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
|
||||
useDialogService()
|
||||
const { isWorkspaceSubscribed, isCurrentUserOriginalOwner } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const { uiConfig } = useWorkspaceUI()
|
||||
const { startRenaming } = useWorkspaceRename()
|
||||
|
||||
// Reka returns focus to the trigger when the menu closes, which would blur (and
|
||||
// so tear down) the rename input we're about to focus. Suppress that focus
|
||||
// restoration for the one close that kicks off a rename.
|
||||
let renameStarting = false
|
||||
|
||||
function beginRename() {
|
||||
renameStarting = true
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
function onMenuCloseAutoFocus(event: Event) {
|
||||
if (!renameStarting) return
|
||||
renameStarting = false
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Disable delete when the workspace has an active subscription (prevents
|
||||
// accidental deletion); uses the workspace's own status, not the global one.
|
||||
@@ -51,22 +72,21 @@ const deleteTooltip = computed(() => {
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => showEditWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
|
||||
? [
|
||||
{
|
||||
label: t('workspacePanel.menu.renameWorkspace'),
|
||||
command: beginRename
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const destructiveItems: MenuItem[] = []
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
destructiveItems.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
|
||||
class: isDeleteDisabled.value ? undefined : 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
tooltip: deleteTooltip.value,
|
||||
command: isDeleteDisabled.value
|
||||
@@ -77,23 +97,23 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
|
||||
// Members and non-creator owners can leave; the creator sees it disabled.
|
||||
if (action === 'leave' || action === 'delete') {
|
||||
items.push(
|
||||
destructiveItems.push(
|
||||
isCurrentUserOriginalOwner.value
|
||||
? {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
class: 'opacity-50',
|
||||
disabled: true,
|
||||
tooltip: t('workspacePanel.menu.creatorCannotLeave')
|
||||
}
|
||||
: {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: () => showLeaveWorkspaceDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
const divider: MenuItem[] =
|
||||
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
|
||||
|
||||
return [...renameItems, ...divider, ...destructiveItems]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="group relative size-12 shrink-0">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 rounded-lg text-2xl"
|
||||
:workspace-name="workspaceName"
|
||||
:image-url="imageUrl ?? undefined"
|
||||
/>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg border-none bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
:aria-label="$t('workspacePanel.editWorkspaceImage')"
|
||||
@click="pickImage"
|
||||
>
|
||||
<i class="icon-[lucide--pencil] size-4 text-white" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="isRenaming"
|
||||
ref="inputRef"
|
||||
v-model="draftName"
|
||||
:maxlength="MAX_NAME_LENGTH"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 font-[inherit] text-2xl font-semibold text-base-foreground outline-none"
|
||||
@keydown.enter="commit"
|
||||
@keydown.esc="cancel"
|
||||
@blur="commit"
|
||||
/>
|
||||
<h1
|
||||
v-else
|
||||
v-tooltip="
|
||||
canEdit
|
||||
? { value: $t('workspacePanel.doubleClickToRename'), showDelay: 300 }
|
||||
: undefined
|
||||
"
|
||||
class="truncate text-2xl font-semibold text-base-foreground"
|
||||
@dblclick="beginRename"
|
||||
>
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isRenaming && remaining <= 10"
|
||||
class="shrink-0 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ $t('workspacePanel.charactersLeft', remaining) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(store)
|
||||
const { uiConfig } = useWorkspaceUI()
|
||||
|
||||
const MAX_NAME_LENGTH = WORKSPACE_NAME_MAX_LENGTH
|
||||
|
||||
// Renaming is gated to Owner + Admins (and the sole owner of a personal
|
||||
// workspace); Members never see the affordance.
|
||||
const canEdit = computed(() => uiConfig.value.showEditWorkspaceMenuItem)
|
||||
|
||||
const { isRenaming, startRenaming, stopRenaming } = useWorkspaceRename()
|
||||
const draftName = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// A single entry point (double-click here or the "Rename" menu item) flips
|
||||
// `isRenaming`; seed the draft and focus the field once the input mounts.
|
||||
watch(isRenaming, (renaming) => {
|
||||
if (!renaming) return
|
||||
draftName.value = workspaceName.value
|
||||
void nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
})
|
||||
|
||||
// Surface the limit only as the user approaches it, to keep the header quiet.
|
||||
const remaining = computed(() => MAX_NAME_LENGTH - draftName.value.length)
|
||||
|
||||
// Client-side only preview (prototype): the picked image is held locally, not
|
||||
// uploaded or persisted. Resets on reload.
|
||||
const imageUrl = ref<string | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickImage() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
imageUrl.value = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function beginRename() {
|
||||
if (!canEdit.value) return
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!isRenaming.value) return
|
||||
stopRenaming()
|
||||
const name = draftName.value.trim()
|
||||
if (!name || name === workspaceName.value) return
|
||||
try {
|
||||
await store.updateWorkspaceName(name)
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
stopRenaming()
|
||||
}
|
||||
</script>
|
||||
55
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
55
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 }
|
||||
}
|
||||
@@ -254,6 +254,7 @@ const mockShowChangeMemberRoleDialog = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockShowInviteMemberDialog = vi.fn()
|
||||
const mockShowInviteMemberUpsellDialog = vi.fn()
|
||||
const mockShowMemberLimitDialog = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
@@ -366,6 +367,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
subscription: mockSubscription,
|
||||
balance: { value: null },
|
||||
renewalDate: { value: null },
|
||||
fetchBalance: vi.fn(),
|
||||
getMaxSeats: (tierKey: string) => {
|
||||
const seats: Record<string, number> = {
|
||||
free: 1,
|
||||
@@ -391,7 +395,8 @@ vi.mock('@/services/dialogService', () => ({
|
||||
showRevokeInviteDialog: mockShowRevokeInviteDialog,
|
||||
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
|
||||
showInviteMemberDialog: mockShowInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
|
||||
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog,
|
||||
showMemberLimitDialog: mockShowMemberLimitDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -593,13 +598,13 @@ describe('useMembersPanel', () => {
|
||||
|
||||
const roleItems = items[0].items ?? []
|
||||
expect(roleItems.map((i) => i.label)).toEqual([
|
||||
'workspaceSwitcher.roleOwner',
|
||||
'workspaceSwitcher.roleAdmin',
|
||||
'workspaceSwitcher.roleMember'
|
||||
])
|
||||
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('checks Owner for owner rows', async () => {
|
||||
it('checks Admin for owner-role rows', async () => {
|
||||
const panel = await setup()
|
||||
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
|
||||
const roleItems = items[0].items ?? []
|
||||
@@ -706,14 +711,15 @@ describe('useMembersPanel', () => {
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the invite button at the member cap (30)', async () => {
|
||||
it('opens the member-limit dialog at the member cap (30)', async () => {
|
||||
mockTotalMemberSlots.value = 30
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
expect(panel.isInviteDisabled.value).toBe(false)
|
||||
expect(panel.inviteTooltip.value).toBe(
|
||||
'workspacePanel.inviteLimitReached'
|
||||
)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -724,10 +730,12 @@ describe('useMembersPanel', () => {
|
||||
expect(panel.inviteTooltip.value).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the invite button at the flat backend member cap', async () => {
|
||||
it('opens the member-limit dialog at the flat backend member cap', async () => {
|
||||
mockIsInviteLimitReached.value = true
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
expect(panel.isInviteDisabled.value).toBe(false)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the invite button when not on a team plan', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
|
||||
@@ -20,15 +21,37 @@ import {
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type ActiveView = 'active' | 'pending'
|
||||
type SortField = 'inviteDate' | 'expiryDate' | 'role'
|
||||
type SortField =
|
||||
| 'email'
|
||||
| 'role'
|
||||
| 'lastActivity'
|
||||
| 'credits'
|
||||
| 'inviteDate'
|
||||
| 'expiryDate'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export function sortMembers(
|
||||
members: WorkspaceMember[],
|
||||
currentUserEmail: string | null,
|
||||
sortDirection: SortDirection,
|
||||
originalOwnerId: string | null = null
|
||||
originalOwnerId: string | null = null,
|
||||
sortField: SortField = 'role'
|
||||
): WorkspaceMember[] {
|
||||
const dir = sortDirection === 'asc' ? 1 : -1
|
||||
|
||||
if (sortField === 'email') {
|
||||
return [...members].sort((a, b) => dir * a.name.localeCompare(b.name))
|
||||
}
|
||||
if (sortField === 'lastActivity') {
|
||||
const at = (m: WorkspaceMember) => m.lastActivity?.getTime() ?? 0
|
||||
return [...members].sort((a, b) => dir * (at(a) - at(b)))
|
||||
}
|
||||
if (sortField === 'credits') {
|
||||
const used = (m: WorkspaceMember) => m.creditsUsedThisMonth ?? 0
|
||||
return [...members].sort((a, b) => dir * (used(a) - used(b)))
|
||||
}
|
||||
|
||||
// Default (role) ordering pins the creator, then groups by role, then recency.
|
||||
return [...members].sort((a, b) => {
|
||||
const aIsOriginalOwner = a.id === originalOwnerId
|
||||
const bIsOriginalOwner = b.id === originalOwnerId
|
||||
@@ -97,7 +120,8 @@ export function useMembersPanel() {
|
||||
showRevokeInviteDialog,
|
||||
showChangeMemberRoleDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog
|
||||
showInviteMemberUpsellDialog,
|
||||
showMemberLimitDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
@@ -112,11 +136,14 @@ export function useMembersPanel() {
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
|
||||
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
|
||||
// the subscription tier.
|
||||
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
|
||||
|
||||
const memberCount = computed(() => members.value.length)
|
||||
|
||||
const hasMultipleMembers = computed(() => members.value.length > 1)
|
||||
|
||||
const showSearch = computed(
|
||||
@@ -138,16 +165,16 @@ export function useMembersPanel() {
|
||||
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
|
||||
)
|
||||
|
||||
// Invite is allowed only on an active (non-cancelled) team plan that is under
|
||||
// the member cap.
|
||||
// Invite stays enabled at the seat cap so the button can surface the
|
||||
// "at the member limit" dialog; only an inactive/cancelled plan disables it.
|
||||
const isInviteDisabled = computed(
|
||||
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
|
||||
() => !isOnTeamPlan.value || isCancelled.value
|
||||
)
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (!isOnTeamPlan.value) return null
|
||||
if (!isMemberLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached', { count: maxSeats.value })
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
@@ -155,7 +182,11 @@ export function useMembersPanel() {
|
||||
void showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isCancelled.value || isMemberLimitReached.value) return
|
||||
if (isCancelled.value) return
|
||||
if (isMemberLimitReached.value) {
|
||||
void showMemberLimitDialog()
|
||||
return
|
||||
}
|
||||
void showInviteMemberDialog()
|
||||
}
|
||||
|
||||
@@ -190,7 +221,7 @@ export function useMembersPanel() {
|
||||
{
|
||||
label: t('workspacePanel.members.actions.changeRole'),
|
||||
items: [
|
||||
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
|
||||
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
|
||||
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
|
||||
]
|
||||
},
|
||||
@@ -215,13 +246,14 @@ export function useMembersPanel() {
|
||||
searched,
|
||||
userEmail.value ?? null,
|
||||
sortDirection.value,
|
||||
originalOwnerId.value
|
||||
originalOwnerId.value,
|
||||
sortField.value
|
||||
)
|
||||
})
|
||||
|
||||
// Built once per member list rather than per row on every render, so an
|
||||
// unrelated re-render (e.g. typing in the search box) doesn't rebuild every
|
||||
// row's menu and churn MemberListItem's props.
|
||||
// row's menu and churn MemberTableRow's props.
|
||||
const memberMenus = computed(
|
||||
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
|
||||
)
|
||||
@@ -286,9 +318,11 @@ export function useMembersPanel() {
|
||||
sortField,
|
||||
sortDirection,
|
||||
maxSeats,
|
||||
memberCount,
|
||||
isOnTeamPlan,
|
||||
hasLapsedTeamPlan,
|
||||
hasMultipleMembers,
|
||||
fetchBalance,
|
||||
showSearch,
|
||||
showViewTabs,
|
||||
showInviteButton,
|
||||
|
||||
134
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal file
134
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/partnerNodesApi', () => ({
|
||||
partnerNodesApi: {
|
||||
list: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
setEnabledBulk: vi.fn(),
|
||||
setAutoEnableNew: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function node(overrides: Partial<PartnerNode> = {}): PartnerNode {
|
||||
return {
|
||||
id: 'pn-1',
|
||||
name: 'Anthropic Claude',
|
||||
partner: 'Anthropic',
|
||||
last_modified: null,
|
||||
enabled: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const sampleNodes: PartnerNode[] = [
|
||||
node({ id: 'a', name: 'Zeta Node', partner: 'BFL', enabled: true }),
|
||||
node({ id: 'b', name: 'Alpha Node', partner: 'Anthropic', enabled: false }),
|
||||
node({ id: 'c', name: 'Beta Node', partner: 'BFL', enabled: true })
|
||||
]
|
||||
|
||||
async function setupLoaded() {
|
||||
vi.mocked(partnerNodesApi.list).mockResolvedValue({
|
||||
partner_nodes: sampleNodes.map((n) => ({ ...n })),
|
||||
auto_enable_new: true
|
||||
})
|
||||
const pn = usePartnerNodes()
|
||||
await pn.fetch()
|
||||
return pn
|
||||
}
|
||||
|
||||
describe('usePartnerNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads nodes and the auto-enable default', async () => {
|
||||
const pn = await setupLoaded()
|
||||
expect(pn.nodes.value).toHaveLength(3)
|
||||
expect(pn.autoEnableNew.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts by name ascending by default and toggles direction', async () => {
|
||||
const pn = await setupLoaded()
|
||||
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
|
||||
'Alpha Node',
|
||||
'Beta Node',
|
||||
'Zeta Node'
|
||||
])
|
||||
pn.toggleSort('name')
|
||||
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
|
||||
'Zeta Node',
|
||||
'Beta Node',
|
||||
'Alpha Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by search across name and partner', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.searchQuery.value = 'anthropic'
|
||||
expect(pn.filteredNodes.value.map((n) => n.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('optimistically toggles a node and calls the api', async () => {
|
||||
const pn = await setupLoaded()
|
||||
const target = pn.nodes.value.find((n) => n.id === 'b')!
|
||||
await pn.setEnabled(target, true)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
|
||||
expect(partnerNodesApi.setEnabled).toHaveBeenCalledWith('b', true)
|
||||
})
|
||||
|
||||
it('reverts and toasts when a toggle fails', async () => {
|
||||
const pn = await setupLoaded()
|
||||
vi.mocked(partnerNodesApi.setEnabled).mockRejectedValueOnce(new Error('x'))
|
||||
const target = pn.nodes.value.find((n) => n.id === 'a')!
|
||||
await pn.setEnabled(target, false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
|
||||
expect(mockToastAdd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bulk-toggles the current selection and clears it on success', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.toggleSelection('a')
|
||||
pn.toggleSelection('c')
|
||||
await pn.setSelectedEnabled(false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
|
||||
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(
|
||||
['a', 'c'],
|
||||
false
|
||||
)
|
||||
expect(pn.selectedCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('group toggle bulk-toggles every node in the group', async () => {
|
||||
const pn = await setupLoaded()
|
||||
const bfl = pn.groups.value.find((g) => g.partner === 'BFL')!
|
||||
await pn.setGroupEnabled(bfl, false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
|
||||
const [ids, enabled] = vi.mocked(partnerNodesApi.setEnabledBulk).mock
|
||||
.calls[0]
|
||||
expect([...ids].sort()).toEqual(['a', 'c'])
|
||||
expect(enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('select-all reflects the filtered set', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.searchQuery.value = 'BFL'
|
||||
pn.toggleSelectAll()
|
||||
expect(pn.selectedCount.value).toBe(2)
|
||||
expect(pn.allFilteredSelected.value).toBe(true)
|
||||
})
|
||||
})
|
||||
285
src/platform/workspace/composables/usePartnerNodes.ts
Normal file
285
src/platform/workspace/composables/usePartnerNodes.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
|
||||
|
||||
export interface PartnerGroup {
|
||||
partner: string
|
||||
nodes: PartnerNode[]
|
||||
enabledCount: number
|
||||
totalCount: number
|
||||
lastModified: string | null
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'partner' | 'lastModified'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
function compareNodes(
|
||||
a: PartnerNode,
|
||||
b: PartnerNode,
|
||||
field: SortField,
|
||||
direction: SortDirection
|
||||
): number {
|
||||
const dir = direction === 'asc' ? 1 : -1
|
||||
if (field === 'lastModified') {
|
||||
const av = a.last_modified ?? ''
|
||||
const bv = b.last_modified ?? ''
|
||||
return av.localeCompare(bv) * dir
|
||||
}
|
||||
const key = field === 'partner' ? 'partner' : 'name'
|
||||
return a[key].localeCompare(b[key]) * dir
|
||||
}
|
||||
|
||||
export function usePartnerNodes() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const nodes = ref<PartnerNode[]>([])
|
||||
const autoEnableNew = ref(true)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const sortField = ref<SortField>('name')
|
||||
const sortDirection = ref<SortDirection>('asc')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const filtered = nodes.value.filter(
|
||||
(n) =>
|
||||
!q ||
|
||||
n.name.toLowerCase().includes(q) ||
|
||||
n.partner.toLowerCase().includes(q)
|
||||
)
|
||||
return filtered.sort((a, b) =>
|
||||
compareNodes(a, b, sortField.value, sortDirection.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Nodes grouped by provider; groups sort alphabetically, children follow the
|
||||
// active column sort. Groups start collapsed; searching overrides collapse
|
||||
// so matches are never hidden.
|
||||
const expandedPartners = ref<Set<string>>(new Set())
|
||||
const isSearching = computed(() => searchQuery.value.trim().length > 0)
|
||||
|
||||
const groups = computed<PartnerGroup[]>(() => {
|
||||
const byPartner = new Map<string, PartnerNode[]>()
|
||||
for (const node of filteredNodes.value) {
|
||||
const list = byPartner.get(node.partner)
|
||||
if (list) list.push(node)
|
||||
else byPartner.set(node.partner, [node])
|
||||
}
|
||||
return [...byPartner.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([partner, nodes]) => ({
|
||||
partner,
|
||||
nodes,
|
||||
enabledCount: nodes.filter((n) => n.enabled).length,
|
||||
totalCount: nodes.length,
|
||||
lastModified: nodes.reduce<string | null>(
|
||||
(latest, n) =>
|
||||
n.last_modified && (!latest || n.last_modified > latest)
|
||||
? n.last_modified
|
||||
: latest,
|
||||
null
|
||||
),
|
||||
expanded: isSearching.value || expandedPartners.value.has(partner)
|
||||
}))
|
||||
})
|
||||
|
||||
function togglePartnerCollapsed(partner: string) {
|
||||
const next = new Set(expandedPartners.value)
|
||||
if (next.has(partner)) next.delete(partner)
|
||||
else next.add(partner)
|
||||
expandedPartners.value = next
|
||||
}
|
||||
|
||||
// Tri-state group selection: unchecked/indeterminate -> select the whole
|
||||
// group, checked -> clear it. Selecting never expands — the group checkbox
|
||||
// and the selection bar carry the feedback.
|
||||
function groupSelectionState(group: PartnerGroup): boolean | 'indeterminate' {
|
||||
const selected = group.nodes.filter((n) => selectedIds.value.has(n.id))
|
||||
if (selected.length === 0) return false
|
||||
if (selected.length === group.nodes.length) return true
|
||||
return 'indeterminate'
|
||||
}
|
||||
|
||||
function toggleGroupSelection(group: PartnerGroup) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (groupSelectionState(group) === true) {
|
||||
for (const n of group.nodes) next.delete(n.id)
|
||||
} else {
|
||||
for (const n of group.nodes) next.add(n.id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size)
|
||||
const allFilteredSelected = computed(
|
||||
() =>
|
||||
filteredNodes.value.length > 0 &&
|
||||
filteredNodes.value.every((n) => selectedIds.value.has(n.id))
|
||||
)
|
||||
async function fetch() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await partnerNodesApi.list()
|
||||
nodes.value = data.partner_nodes
|
||||
autoEnableNew.value = data.auto_enable_new
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.loadError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
function applyEnabled(ids: string[], enabled: boolean) {
|
||||
const idSet = new Set(ids)
|
||||
const now = new Date().toISOString()
|
||||
nodes.value = nodes.value.map((n) =>
|
||||
idSet.has(n.id) ? { ...n, enabled, last_modified: now } : n
|
||||
)
|
||||
}
|
||||
|
||||
async function setEnabled(node: PartnerNode, enabled: boolean) {
|
||||
const { enabled: prevEnabled, last_modified: prevModified } = node
|
||||
applyEnabled([node.id], enabled)
|
||||
try {
|
||||
await partnerNodesApi.setEnabled(node.id, enabled)
|
||||
} catch {
|
||||
nodes.value = nodes.value.map((n) =>
|
||||
n.id === node.id
|
||||
? { ...n, enabled: prevEnabled, last_modified: prevModified }
|
||||
: n
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setNodesEnabled(
|
||||
ids: string[],
|
||||
enabled: boolean
|
||||
): Promise<boolean> {
|
||||
if (ids.length === 0) return false
|
||||
const previous = new Map(
|
||||
nodes.value.map((n) => [
|
||||
n.id,
|
||||
{ enabled: n.enabled, last_modified: n.last_modified }
|
||||
])
|
||||
)
|
||||
applyEnabled(ids, enabled)
|
||||
try {
|
||||
await partnerNodesApi.setEnabledBulk(ids, enabled)
|
||||
return true
|
||||
} catch {
|
||||
nodes.value = nodes.value.map((n) =>
|
||||
previous.has(n.id) ? { ...n, ...previous.get(n.id)! } : n
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelectedEnabled(enabled: boolean) {
|
||||
const ok = await setNodesEnabled([...selectedIds.value], enabled)
|
||||
// Clear on success: a kept selection can hide inside collapsed groups
|
||||
// and silently ride along with the next bulk toggle. On failure the
|
||||
// selection survives for a retry.
|
||||
if (ok) clearSelection()
|
||||
}
|
||||
|
||||
async function setAllFilteredEnabled(enabled: boolean) {
|
||||
await setNodesEnabled(
|
||||
filteredNodes.value.map((n) => n.id),
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
async function setGroupEnabled(group: PartnerGroup, enabled: boolean) {
|
||||
await setNodesEnabled(
|
||||
group.nodes.map((n) => n.id),
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
async function setAutoEnableNew(value: boolean) {
|
||||
const previous = autoEnableNew.value
|
||||
autoEnableNew.value = value
|
||||
try {
|
||||
await partnerNodesApi.setAutoEnableNew(value)
|
||||
} catch {
|
||||
autoEnableNew.value = previous
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allFilteredSelected.value) {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
selectedIds.value = new Set(filteredNodes.value.map((n) => n.id))
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
autoEnableNew,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
sortField,
|
||||
sortDirection,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allFilteredSelected,
|
||||
filteredNodes,
|
||||
groups,
|
||||
togglePartnerCollapsed,
|
||||
groupSelectionState,
|
||||
toggleGroupSelection,
|
||||
fetch,
|
||||
toggleSort,
|
||||
setEnabled,
|
||||
setSelectedEnabled,
|
||||
setAllFilteredEnabled,
|
||||
setGroupEnabled,
|
||||
setAutoEnableNew,
|
||||
toggleSelection,
|
||||
toggleSelectAll,
|
||||
clearSelection
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* WorkspacePanelContent menu.
|
||||
* Plan & Credits panel menu.
|
||||
*/
|
||||
export function useWorkspaceMenuItems() {
|
||||
const { t } = useI18n()
|
||||
|
||||
17
src/platform/workspace/composables/useWorkspaceRename.ts
Normal file
17
src/platform/workspace/composables/useWorkspaceRename.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Shared inline-rename state so both the header (double-click) and the
|
||||
// workspace menu ("Rename") drive the same editing affordance.
|
||||
const isRenaming = ref(false)
|
||||
|
||||
export function useWorkspaceRename() {
|
||||
function startRenaming() {
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
function stopRenaming() {
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
return { isRenaming, startRenaming, stopRenaming }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user