Compare commits
70 Commits
feature/qu
...
fix/node-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9715d3ba9e | ||
|
|
980f280b3c | ||
|
|
4856fb0802 | ||
|
|
82ace36982 | ||
|
|
3d88d0a6ab | ||
|
|
21cfd44a2d | ||
|
|
d8d0dcbf71 | ||
|
|
066a1f1f11 | ||
|
|
2b896a722b | ||
|
|
96b9e886ea | ||
|
|
58182ddda7 | ||
|
|
0f0029ca29 | ||
|
|
ba7f622fbd | ||
|
|
fcb4341c98 | ||
|
|
27da781029 | ||
|
|
36d59f26cd | ||
|
|
5f7a6e7aba | ||
|
|
2c07bedbb1 | ||
|
|
78635294ce | ||
|
|
2f09c6321e | ||
|
|
38edba7024 | ||
|
|
f851c3189f | ||
|
|
71d26eb4d9 | ||
|
|
d04dd32235 | ||
|
|
c52f48af45 | ||
|
|
01cf3244b8 | ||
|
|
0f33444eef | ||
|
|
44ce9379eb | ||
|
|
138fa6a2ce | ||
|
|
ce9d0ca670 | ||
|
|
6cf0357b3e | ||
|
|
c0c81dba49 | ||
|
|
553ea63357 | ||
|
|
995ebc4ba4 | ||
|
|
d282353370 | ||
|
|
85ae0a57c3 | ||
|
|
0d64d503ec | ||
|
|
30ef6f2b8c | ||
|
|
6012341fd1 | ||
|
|
bebccd9018 | ||
|
|
45259f1169 | ||
|
|
a80f6d7922 | ||
|
|
0f5aca6726 | ||
|
|
4fc1d2ef5b | ||
|
|
92b7437d86 | ||
|
|
dd1fefe843 | ||
|
|
adcb663b3e | ||
|
|
28b171168a | ||
|
|
69062c6da1 | ||
|
|
a7c2115166 | ||
|
|
d044bed9b2 | ||
|
|
d873c8048f | ||
|
|
475d7035f7 | ||
|
|
eb6bf91e20 | ||
|
|
422227d2fc | ||
|
|
10e9bc2f8d | ||
|
|
f7b835e6a5 | ||
|
|
7f30d6b6a5 | ||
|
|
da56c9e554 | ||
|
|
79063edf54 | ||
|
|
536275cabe | ||
|
|
d4c40f5255 | ||
|
|
1e1d5c8308 | ||
|
|
e411a104f4 | ||
|
|
19a724710c | ||
|
|
9ecbb3af27 | ||
|
|
581452d312 | ||
|
|
9dde4e7bc7 | ||
|
|
0288ea5b39 | ||
|
|
061e96e488 |
@@ -96,6 +96,7 @@
|
|||||||
"typescript/restrict-template-expressions": "off",
|
"typescript/restrict-template-expressions": "off",
|
||||||
"typescript/unbound-method": "off",
|
"typescript/unbound-method": "off",
|
||||||
"typescript/no-floating-promises": "error",
|
"typescript/no-floating-promises": "error",
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
"vue/no-import-compiler-macros": "error",
|
"vue/no-import-compiler-macros": "error",
|
||||||
"vue/no-dupe-keys": "error"
|
"vue/no-dupe-keys": "error"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,12 +98,10 @@ const config: StorybookConfig = {
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
experimental: {
|
|
||||||
strictExecutionOrder: true
|
|
||||||
},
|
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
output: {
|
output: {
|
||||||
keepNames: true
|
keepNames: true,
|
||||||
|
strictExecutionOrder: true
|
||||||
},
|
},
|
||||||
onwarn: (warning, warn) => {
|
onwarn: (warning, warn) => {
|
||||||
// Suppress specific warnings
|
// Suppress specific warnings
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ export class SettingDialog extends BaseDialog {
|
|||||||
* @param value - The value to set
|
* @param value - The value to set
|
||||||
*/
|
*/
|
||||||
async setStringSetting(id: string, value: string) {
|
async setStringSetting(id: string, value: string) {
|
||||||
const settingInputDiv = this.page.locator(
|
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||||
`div.settings-container div[id="${id}"]`
|
|
||||||
)
|
|
||||||
await settingInputDiv.locator('input').fill(value)
|
await settingInputDiv.locator('input').fill(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,16 +32,31 @@ export class SettingDialog extends BaseDialog {
|
|||||||
* @param id - The id of the setting
|
* @param id - The id of the setting
|
||||||
*/
|
*/
|
||||||
async toggleBooleanSetting(id: string) {
|
async toggleBooleanSetting(id: string) {
|
||||||
const settingInputDiv = this.page.locator(
|
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
|
||||||
`div.settings-container div[id="${id}"]`
|
|
||||||
)
|
|
||||||
await settingInputDiv.locator('input').click()
|
await settingInputDiv.locator('input').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get searchBox() {
|
||||||
|
return this.root.getByPlaceholder(/Search/)
|
||||||
|
}
|
||||||
|
|
||||||
|
get categories() {
|
||||||
|
return this.root.locator('nav').getByRole('button')
|
||||||
|
}
|
||||||
|
|
||||||
|
category(name: string) {
|
||||||
|
return this.root.locator('nav').getByRole('button', { name })
|
||||||
|
}
|
||||||
|
|
||||||
|
get contentArea() {
|
||||||
|
return this.root.getByRole('main')
|
||||||
|
}
|
||||||
|
|
||||||
async goToAboutPanel() {
|
async goToAboutPanel() {
|
||||||
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
|
const aboutButton = this.root.locator('nav').getByRole('button', {
|
||||||
await this.page
|
name: 'About'
|
||||||
.getByTestId(TestIds.dialogs.about)
|
})
|
||||||
.waitFor({ state: 'visible' })
|
await aboutButton.click()
|
||||||
|
await this.page.waitForSelector('.about-container')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,9 +226,7 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
|||||||
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
|
||||||
await bottomPanel.shortcuts.manageButton.click()
|
await bottomPanel.shortcuts.manageButton.click()
|
||||||
|
|
||||||
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
|
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||||
await expect(
|
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||||
comfyPage.page.getByRole('option', { name: 'Keybinding' })
|
|
||||||
).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -244,9 +244,13 @@ test.describe('Missing models warning', () => {
|
|||||||
test.describe('Settings', () => {
|
test.describe('Settings', () => {
|
||||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
const settingsContent = comfyPage.page.locator('.settings-content')
|
const settingsDialog = comfyPage.page.locator(
|
||||||
await expect(settingsContent).toBeVisible()
|
'[data-testid="settings-dialog"]'
|
||||||
const isUsableHeight = await settingsContent.evaluate(
|
)
|
||||||
|
await expect(settingsDialog).toBeVisible()
|
||||||
|
const contentArea = settingsDialog.locator('main')
|
||||||
|
await expect(contentArea).toBeVisible()
|
||||||
|
const isUsableHeight = await contentArea.evaluate(
|
||||||
(el) => el.clientHeight > 30
|
(el) => el.clientHeight > 30
|
||||||
)
|
)
|
||||||
expect(isUsableHeight).toBeTruthy()
|
expect(isUsableHeight).toBeTruthy()
|
||||||
@@ -256,7 +260,9 @@ test.describe('Settings', () => {
|
|||||||
await comfyPage.page.keyboard.down('ControlOrMeta')
|
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||||
await comfyPage.page.keyboard.press(',')
|
await comfyPage.page.keyboard.press(',')
|
||||||
await comfyPage.page.keyboard.up('ControlOrMeta')
|
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||||
const settingsLocator = comfyPage.page.locator('.settings-container')
|
const settingsLocator = comfyPage.page.locator(
|
||||||
|
'[data-testid="settings-dialog"]'
|
||||||
|
)
|
||||||
await expect(settingsLocator).toBeVisible()
|
await expect(settingsLocator).toBeVisible()
|
||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
await expect(settingsLocator).not.toBeVisible()
|
await expect(settingsLocator).not.toBeVisible()
|
||||||
@@ -275,10 +281,15 @@ test.describe('Settings', () => {
|
|||||||
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||||
// Open the settings dialog
|
// Open the settings dialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
await comfyPage.page.waitForSelector('.settings-container')
|
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
|
||||||
|
|
||||||
// Open the keybinding tab
|
// Open the keybinding tab
|
||||||
await comfyPage.page.getByLabel('Keybinding').click()
|
const settingsDialog = comfyPage.page.locator(
|
||||||
|
'[data-testid="settings-dialog"]'
|
||||||
|
)
|
||||||
|
await settingsDialog
|
||||||
|
.locator('nav [role="button"]', { hasText: 'Keybinding' })
|
||||||
|
.click()
|
||||||
await comfyPage.page.waitForSelector(
|
await comfyPage.page.waitForSelector(
|
||||||
'[placeholder="Search Keybindings..."]'
|
'[placeholder="Search Keybindings..."]'
|
||||||
)
|
)
|
||||||
@@ -298,7 +309,10 @@ test.describe('Settings', () => {
|
|||||||
await input.press('Alt+n')
|
await input.press('Alt+n')
|
||||||
|
|
||||||
const requestPromise = comfyPage.page.waitForRequest(
|
const requestPromise = comfyPage.page.waitForRequest(
|
||||||
'**/api/settings/Comfy.Keybinding.NewBindings'
|
(req) =>
|
||||||
|
req.url().includes('/api/settings') &&
|
||||||
|
!req.url().includes('/api/settings/') &&
|
||||||
|
req.method() === 'POST'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save keybinding
|
// Save keybinding
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 29 KiB |
@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
|||||||
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
|
||||||
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||||
|
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
|
||||||
await comfyPage.settings.setSetting(
|
|
||||||
'Comfy.Graph.DeduplicateSubgraphNodeIds',
|
|
||||||
true
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('All node IDs are globally unique after loading', async ({
|
test('All node IDs are globally unique after loading', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
await subgraphNode.navigateIntoSubgraph()
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||||
|
'VAEEncode',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
await subgraphNode.navigateIntoSubgraph()
|
await subgraphNode.navigateIntoSubgraph()
|
||||||
|
|
||||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||||
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
|
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||||
|
'VAEEncode',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
@@ -820,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
|
|
||||||
// Open settings dialog using hotkey
|
// Open settings dialog using hotkey
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
await comfyPage.page.waitForSelector('.settings-container', {
|
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
|
||||||
state: 'visible'
|
state: 'visible'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -830,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
|||||||
|
|
||||||
// Dialog should be closed
|
// Dialog should be closed
|
||||||
await expect(
|
await expect(
|
||||||
comfyPage.page.locator('.settings-container')
|
comfyPage.page.locator('[data-testid="settings-dialog"]')
|
||||||
).not.toBeVisible()
|
).not.toBeVisible()
|
||||||
|
|
||||||
// Should still be in subgraph
|
// Should still be in subgraph
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 115 KiB |
@@ -22,7 +22,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
|||||||
name: 'TestSettingsExtension',
|
name: 'TestSettingsExtension',
|
||||||
settings: [
|
settings: [
|
||||||
{
|
{
|
||||||
// Extensions can register arbitrary setting IDs
|
|
||||||
id: 'TestHiddenSetting' as TestSettingId,
|
id: 'TestHiddenSetting' as TestSettingId,
|
||||||
name: 'Test Hidden Setting',
|
name: 'Test Hidden Setting',
|
||||||
type: 'hidden',
|
type: 'hidden',
|
||||||
@@ -30,7 +29,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
|||||||
category: ['Test', 'Hidden']
|
category: ['Test', 'Hidden']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Extensions can register arbitrary setting IDs
|
|
||||||
id: 'TestDeprecatedSetting' as TestSettingId,
|
id: 'TestDeprecatedSetting' as TestSettingId,
|
||||||
name: 'Test Deprecated Setting',
|
name: 'Test Deprecated Setting',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -39,7 +37,6 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
|||||||
category: ['Test', 'Deprecated']
|
category: ['Test', 'Deprecated']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Extensions can register arbitrary setting IDs
|
|
||||||
id: 'TestVisibleSetting' as TestSettingId,
|
id: 'TestVisibleSetting' as TestSettingId,
|
||||||
name: 'Test Visible Setting',
|
name: 'Test Visible Setting',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -52,238 +49,143 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Find the search box
|
await expect(dialog.searchBox).toHaveAttribute(
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
|
||||||
await expect(searchBox).toBeVisible()
|
|
||||||
|
|
||||||
// Verify search box has the correct placeholder
|
|
||||||
await expect(searchBox).toHaveAttribute(
|
|
||||||
'placeholder',
|
'placeholder',
|
||||||
expect.stringContaining('Search')
|
expect.stringContaining('Search')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Find and interact with the search box
|
await dialog.searchBox.fill('Comfy')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
await expect(dialog.searchBox).toHaveValue('Comfy')
|
||||||
await searchBox.fill('Comfy')
|
|
||||||
|
|
||||||
// Verify the input was accepted
|
|
||||||
await expect(searchBox).toHaveValue('Comfy')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search box clears properly', async ({ comfyPage }) => {
|
test('search box clears properly', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Find and interact with the search box
|
await dialog.searchBox.fill('test')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
await expect(dialog.searchBox).toHaveValue('test')
|
||||||
await searchBox.fill('test')
|
|
||||||
await expect(searchBox).toHaveValue('test')
|
|
||||||
|
|
||||||
// Clear the search box
|
await dialog.searchBox.clear()
|
||||||
await searchBox.clear()
|
await expect(dialog.searchBox).toHaveValue('')
|
||||||
await expect(searchBox).toHaveValue('')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Check that the sidebar has categories
|
expect(await dialog.categories.count()).toBeGreaterThan(0)
|
||||||
const categories = comfyPage.page.locator(
|
|
||||||
'.settings-sidebar .p-listbox-option'
|
|
||||||
)
|
|
||||||
expect(await categories.count()).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
// Check that at least one category is visible
|
|
||||||
await expect(categories.first()).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Click on a specific category (Appearance) to verify category switching
|
const categoryCount = await dialog.categories.count()
|
||||||
const appearanceCategory = comfyPage.page.getByRole('option', {
|
|
||||||
name: 'Appearance'
|
|
||||||
})
|
|
||||||
await appearanceCategory.click()
|
|
||||||
|
|
||||||
// Verify the category is selected
|
if (categoryCount > 1) {
|
||||||
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
|
await dialog.categories.nth(1).click()
|
||||||
})
|
|
||||||
|
|
||||||
test('settings content area is visible', async ({ comfyPage }) => {
|
await expect(dialog.categories.nth(1)).toHaveClass(
|
||||||
// Open settings dialog
|
/bg-interface-menu-component-surface-selected/
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
)
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
}
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Check that the content area is visible
|
|
||||||
const contentArea = comfyPage.page.locator('.settings-content')
|
|
||||||
await expect(contentArea).toBeVisible()
|
|
||||||
|
|
||||||
// Check that tab panels are visible
|
|
||||||
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
|
|
||||||
await expect(tabPanels).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Find the search box
|
await dialog.searchBox.fill('graph')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
await expect(dialog.searchBox).toHaveValue('graph')
|
||||||
|
|
||||||
// Type in search box
|
|
||||||
await searchBox.fill('graph')
|
|
||||||
|
|
||||||
// Verify that the search input is handled
|
|
||||||
await expect(searchBox).toHaveValue('graph')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Close with escape key
|
|
||||||
await comfyPage.page.keyboard.press('Escape')
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
|
await expect(dialog.root).not.toBeVisible()
|
||||||
// Verify dialog is closed
|
|
||||||
await expect(settingsDialog).not.toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Type rapidly in search box
|
await dialog.searchBox.fill('a')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
await dialog.searchBox.fill('ab')
|
||||||
await searchBox.fill('a')
|
await dialog.searchBox.fill('abc')
|
||||||
await searchBox.fill('ab')
|
await dialog.searchBox.fill('abcd')
|
||||||
await searchBox.fill('abc')
|
|
||||||
await searchBox.fill('abcd')
|
|
||||||
|
|
||||||
// Verify final value
|
await expect(dialog.searchBox).toHaveValue('abcd')
|
||||||
await expect(searchBox).toHaveValue('abcd')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search excludes hidden settings from results', async ({
|
test('search excludes hidden settings from results', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Search for our test settings
|
await dialog.searchBox.fill('Test')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
|
||||||
await searchBox.fill('Test')
|
|
||||||
|
|
||||||
// Get all settings content
|
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||||
|
|
||||||
// Should show visible setting but not hidden setting
|
|
||||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
|
||||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search excludes deprecated settings from results', async ({
|
test('search excludes deprecated settings from results', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Search for our test settings
|
await dialog.searchBox.fill('Test')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
|
||||||
await searchBox.fill('Test')
|
|
||||||
|
|
||||||
// Get all settings content
|
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
await expect(dialog.contentArea).not.toContainText(
|
||||||
|
'Test Deprecated Setting'
|
||||||
// Should show visible setting but not deprecated setting
|
)
|
||||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
|
||||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
// Search for our test settings
|
await dialog.searchBox.fill('Test')
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
|
||||||
await searchBox.fill('Test')
|
|
||||||
|
|
||||||
// Get all settings content
|
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||||
|
await expect(dialog.contentArea).not.toContainText(
|
||||||
// Should only show the visible setting
|
'Test Deprecated Setting'
|
||||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
)
|
||||||
|
|
||||||
// Should not show hidden or deprecated settings
|
|
||||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
|
||||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('search by setting name excludes hidden and deprecated', async ({
|
test('search by setting name excludes hidden and deprecated', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
// Open settings dialog
|
const dialog = comfyPage.settingDialog
|
||||||
await comfyPage.page.keyboard.press('Control+,')
|
await dialog.open()
|
||||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
await dialog.searchBox.clear()
|
||||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
await dialog.searchBox.fill('Hidden')
|
||||||
|
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
|
||||||
|
|
||||||
// Search specifically for hidden setting by name
|
await dialog.searchBox.clear()
|
||||||
await searchBox.clear()
|
await dialog.searchBox.fill('Deprecated')
|
||||||
await searchBox.fill('Hidden')
|
await expect(dialog.contentArea).not.toContainText(
|
||||||
|
'Test Deprecated Setting'
|
||||||
|
)
|
||||||
|
|
||||||
// Should not show the hidden setting even when searching by name
|
await dialog.searchBox.clear()
|
||||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
await dialog.searchBox.fill('Visible')
|
||||||
|
await expect(dialog.contentArea).toContainText('Test Visible Setting')
|
||||||
// Search specifically for deprecated setting by name
|
|
||||||
await searchBox.clear()
|
|
||||||
await searchBox.fill('Deprecated')
|
|
||||||
|
|
||||||
// Should not show the deprecated setting even when searching by name
|
|
||||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
|
||||||
|
|
||||||
// Search for visible setting by name - should work
|
|
||||||
await searchBox.clear()
|
|
||||||
await searchBox.fill('Visible')
|
|
||||||
|
|
||||||
// Should show the visible setting
|
|
||||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
7
global.d.ts
vendored
@@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
|
|||||||
declare const __ALGOLIA_API_KEY__: string
|
declare const __ALGOLIA_API_KEY__: string
|
||||||
declare const __USE_PROD_CONFIG__: boolean
|
declare const __USE_PROD_CONFIG__: boolean
|
||||||
|
|
||||||
|
interface ImpactQueueFunction {
|
||||||
|
(...args: unknown[]): void
|
||||||
|
a?: unknown[][]
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
__CONFIG__: {
|
__CONFIG__: {
|
||||||
gtm_container_id?: string
|
gtm_container_id?: string
|
||||||
@@ -37,6 +42,8 @@ interface Window {
|
|||||||
session_number?: string
|
session_number?: string
|
||||||
}
|
}
|
||||||
dataLayer?: Array<Record<string, unknown>>
|
dataLayer?: Array<Record<string, unknown>>
|
||||||
|
ire_o?: string
|
||||||
|
ire?: ImpactQueueFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Navigator {
|
interface Navigator {
|
||||||
|
|||||||
42
index.html
@@ -35,18 +35,6 @@
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
#vue-app:has(#loading-logo) {
|
|
||||||
display: contents;
|
|
||||||
color: var(--fg-color);
|
|
||||||
& #loading-logo {
|
|
||||||
place-self: center;
|
|
||||||
font-size: clamp(2px, 1vw, 6px);
|
|
||||||
line-height: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 100vw;
|
|
||||||
border-radius: 20ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -65,36 +53,6 @@
|
|||||||
<body class="litegraph grid">
|
<body class="litegraph grid">
|
||||||
<div id="vue-app">
|
<div id="vue-app">
|
||||||
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
|
||||||
<svg
|
|
||||||
width="520"
|
|
||||||
height="520"
|
|
||||||
viewBox="0 0 520 520"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
id="loading-logo"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="mask0_227_285"
|
|
||||||
style="mask-type: alpha"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="0"
|
|
||||||
y="0"
|
|
||||||
width="520"
|
|
||||||
height="520"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
|
||||||
fill="#EEFF30"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#mask0_227_285)">
|
|
||||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
|
||||||
<path
|
|
||||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
|
||||||
fill="#F0FF41"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="src/main.ts"></script>
|
<script type="module" src="src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"version": "1.39.10",
|
"version": "1.40.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Official front-end implementation of ComfyUI",
|
"description": "Official front-end implementation of ComfyUI",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "^8.0.0-beta.8"
|
"vite": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
packages/design-system/src/icons/comfy-c.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
|
||||||
|
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
556
pnpm-lock.yaml
generated
@@ -92,7 +92,7 @@ catalog:
|
|||||||
unplugin-icons: ^22.5.0
|
unplugin-icons: ^22.5.0
|
||||||
unplugin-typegpu: 0.8.0
|
unplugin-typegpu: 0.8.0
|
||||||
unplugin-vue-components: ^30.0.0
|
unplugin-vue-components: ^30.0.0
|
||||||
vite: ^8.0.0-beta.8
|
vite: 8.0.0-beta.13
|
||||||
vite-plugin-dts: ^4.5.4
|
vite-plugin-dts: ^4.5.4
|
||||||
vite-plugin-html: ^3.2.2
|
vite-plugin-html: ^3.2.2
|
||||||
vite-plugin-vue-devtools: ^8.0.0
|
vite-plugin-vue-devtools: ^8.0.0
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { downloadFile } from '@/base/common/downloadUtil'
|
import {
|
||||||
|
downloadFile,
|
||||||
|
extractFilenameFromContentDisposition
|
||||||
|
} from '@/base/common/downloadUtil'
|
||||||
|
|
||||||
let mockIsCloud = false
|
let mockIsCloud = false
|
||||||
|
|
||||||
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
|
|||||||
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
|
||||||
const blob = new Blob(['test'])
|
const blob = new Blob(['test'])
|
||||||
const blobFn = vi.fn().mockResolvedValue(blob)
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
|
const headersMock = {
|
||||||
|
get: vi.fn().mockReturnValue(null)
|
||||||
|
}
|
||||||
fetchMock.mockResolvedValue({
|
fetchMock.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
blob: blobFn
|
blob: blobFn,
|
||||||
|
headers: headersMock
|
||||||
} as unknown as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
downloadFile(testUrl)
|
downloadFile(testUrl)
|
||||||
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
|
|||||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||||
consoleSpy.mockRestore()
|
consoleSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses filename from Content-Disposition header in cloud mode', async () => {
|
||||||
|
mockIsCloud = true
|
||||||
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
|
const blob = new Blob(['test'])
|
||||||
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
|
const headersMock = {
|
||||||
|
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
blob: blobFn,
|
||||||
|
headers: headersMock
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
downloadFile(testUrl)
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(testUrl)
|
||||||
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
|
await fetchPromise
|
||||||
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
|
await blobPromise
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
|
||||||
|
expect(mockLink.download).toBe('user-friendly.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses RFC 5987 filename from Content-Disposition header', async () => {
|
||||||
|
mockIsCloud = true
|
||||||
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
|
const blob = new Blob(['test'])
|
||||||
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
|
const headersMock = {
|
||||||
|
get: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(
|
||||||
|
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
blob: blobFn,
|
||||||
|
headers: headersMock
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
downloadFile(testUrl)
|
||||||
|
|
||||||
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
|
await fetchPromise
|
||||||
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
|
await blobPromise
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(mockLink.download).toBe('中文.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to provided filename when Content-Disposition is missing', async () => {
|
||||||
|
mockIsCloud = true
|
||||||
|
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
|
||||||
|
const blob = new Blob(['test'])
|
||||||
|
const blobFn = vi.fn().mockResolvedValue(blob)
|
||||||
|
const headersMock = {
|
||||||
|
get: vi.fn().mockReturnValue(null)
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
blob: blobFn,
|
||||||
|
headers: headersMock
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
downloadFile(testUrl, 'my-fallback.png')
|
||||||
|
|
||||||
|
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
|
||||||
|
await fetchPromise
|
||||||
|
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
|
||||||
|
await blobPromise
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(mockLink.download).toBe('my-fallback.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractFilenameFromContentDisposition', () => {
|
||||||
|
it('returns null for null header', () => {
|
||||||
|
expect(extractFilenameFromContentDisposition(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for empty header', () => {
|
||||||
|
expect(extractFilenameFromContentDisposition('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts filename from simple quoted format', () => {
|
||||||
|
const header = 'attachment; filename="test-file.png"'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||||
|
'test-file.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts filename from unquoted format', () => {
|
||||||
|
const header = 'attachment; filename=test-file.png'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||||
|
'test-file.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts filename from RFC 5987 format', () => {
|
||||||
|
const header = "attachment; filename*=UTF-8''test%20file.png"
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||||
|
'test file.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers RFC 5987 format over simple format', () => {
|
||||||
|
const header =
|
||||||
|
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe(
|
||||||
|
'preferred.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles unicode characters in RFC 5987 format', () => {
|
||||||
|
const header =
|
||||||
|
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to simple format when RFC 5987 decoding fails', () => {
|
||||||
|
const header =
|
||||||
|
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles header with only attachment disposition', () => {
|
||||||
|
const header = 'attachment'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case-insensitive filename parameter', () => {
|
||||||
|
const header = 'attachment; FILENAME="test.png"'
|
||||||
|
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract filename from Content-Disposition header
|
||||||
|
* Handles both simple format: attachment; filename="name.png"
|
||||||
|
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
||||||
|
* @param header - The Content-Disposition header value
|
||||||
|
* @returns The extracted filename or null if not found
|
||||||
|
*/
|
||||||
|
export function extractFilenameFromContentDisposition(
|
||||||
|
header: string | null
|
||||||
|
): string | null {
|
||||||
|
if (!header) return null
|
||||||
|
|
||||||
|
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
||||||
|
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
||||||
|
if (extendedMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(extendedMatch[1])
|
||||||
|
} catch {
|
||||||
|
// Fall through to simple format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try simple quoted format: filename="..."
|
||||||
|
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
||||||
|
if (quotedMatch?.[1]) {
|
||||||
|
return quotedMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try unquoted format: filename=...
|
||||||
|
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
||||||
|
if (unquotedMatch?.[1]) {
|
||||||
|
return unquotedMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const downloadViaBlobFetch = async (
|
const downloadViaBlobFetch = async (
|
||||||
href: string,
|
href: string,
|
||||||
filename: string
|
fallbackFilename: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const response = await fetch(href)
|
const response = await fetch(href)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
throw new Error(`Failed to fetch ${href}: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get filename from Content-Disposition header (set by backend)
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
|
const headerFilename =
|
||||||
|
extractFilenameFromContentDisposition(contentDisposition)
|
||||||
|
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
downloadBlob(filename, blob)
|
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
|
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||||
|
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||||
import type {
|
import type {
|
||||||
@@ -19,7 +20,11 @@ import { useExecutionStore } from '@/stores/executionStore'
|
|||||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
|
|
||||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
|
const mockData = vi.hoisted(() => ({
|
||||||
|
isLoggedIn: false,
|
||||||
|
isDesktop: false,
|
||||||
|
setShowConflictRedDot: (_value: boolean) => {}
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||||
useCurrentUser: () => {
|
useCurrentUser: () => {
|
||||||
@@ -36,6 +41,36 @@ vi.mock('@/platform/distribution/types', () => ({
|
|||||||
return mockData.isDesktop
|
return mockData.isDesktop
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||||
|
useReleaseStore: () => ({
|
||||||
|
shouldShowRedDot: computed(() => true)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||||
|
() => {
|
||||||
|
const shouldShowConflictRedDot = ref(false)
|
||||||
|
mockData.setShowConflictRedDot = (value: boolean) => {
|
||||||
|
shouldShowConflictRedDot.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
useConflictAcknowledgment: () => ({
|
||||||
|
shouldShowRedDot: shouldShowConflictRedDot
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||||
|
useManagerState: () => ({
|
||||||
|
shouldShowManagerButtons: computed(() => true),
|
||||||
|
openManager: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||||
useFirebaseAuthStore: vi.fn(() => ({
|
useFirebaseAuthStore: vi.fn(() => ({
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
@@ -79,6 +114,7 @@ function createWrapper({
|
|||||||
SubgraphBreadcrumb: true,
|
SubgraphBreadcrumb: true,
|
||||||
QueueProgressOverlay: true,
|
QueueProgressOverlay: true,
|
||||||
QueueInlineProgressSummary: true,
|
QueueInlineProgressSummary: true,
|
||||||
|
QueueNotificationBannerHost: true,
|
||||||
CurrentUserButton: true,
|
CurrentUserButton: true,
|
||||||
LoginButton: true,
|
LoginButton: true,
|
||||||
ContextMenu: {
|
ContextMenu: {
|
||||||
@@ -108,12 +144,25 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
|||||||
return new TaskItemImpl(createJob(id, status))
|
return new TaskItemImpl(createJob(id, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'ComfyActionbar',
|
||||||
|
setup(_, { emit }) {
|
||||||
|
onMounted(() => {
|
||||||
|
emit('update:progressTarget', actionbarTarget)
|
||||||
|
})
|
||||||
|
return () => h('div')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('TopMenuSection', () => {
|
describe('TopMenuSection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
mockData.isDesktop = false
|
mockData.isDesktop = false
|
||||||
mockData.isLoggedIn = false
|
mockData.isLoggedIn = false
|
||||||
|
mockData.setShowConflictRedDot(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('authentication state', () => {
|
describe('authentication state', () => {
|
||||||
@@ -281,15 +330,7 @@ describe('TopMenuSection', () => {
|
|||||||
const executionStore = useExecutionStore(pinia)
|
const executionStore = useExecutionStore(pinia)
|
||||||
executionStore.activePromptId = 'prompt-1'
|
executionStore.activePromptId = 'prompt-1'
|
||||||
|
|
||||||
const ComfyActionbarStub = defineComponent({
|
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||||
name: 'ComfyActionbar',
|
|
||||||
setup(_, { emit }) {
|
|
||||||
onMounted(() => {
|
|
||||||
emit('update:progressTarget', actionbarTarget)
|
|
||||||
})
|
|
||||||
return () => h('div')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapper = createWrapper({
|
const wrapper = createWrapper({
|
||||||
pinia,
|
pinia,
|
||||||
@@ -311,6 +352,103 @@ describe('TopMenuSection', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe(QueueNotificationBannerHost, () => {
|
||||||
|
const configureSettings = (
|
||||||
|
pinia: ReturnType<typeof createTestingPinia>,
|
||||||
|
qpoV2Enabled: boolean
|
||||||
|
) => {
|
||||||
|
const settingStore = useSettingStore(pinia)
|
||||||
|
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||||
|
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
|
||||||
|
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders queue notification banners when QPO V2 is enabled', async () => {
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, true)
|
||||||
|
|
||||||
|
const wrapper = createWrapper({ pinia })
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders queue notification banners when QPO V2 is disabled', async () => {
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, false)
|
||||||
|
|
||||||
|
const wrapper = createWrapper({ pinia })
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders inline summary above banners when both are visible', async () => {
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, true)
|
||||||
|
const wrapper = createWrapper({ pinia })
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const html = wrapper.html()
|
||||||
|
const inlineSummaryIndex = html.indexOf(
|
||||||
|
'queue-inline-progress-summary-stub'
|
||||||
|
)
|
||||||
|
const queueBannerIndex = html.indexOf(
|
||||||
|
'queue-notification-banner-host-stub'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(inlineSummaryIndex).toBeGreaterThan(-1)
|
||||||
|
expect(queueBannerIndex).toBeGreaterThan(-1)
|
||||||
|
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not teleport queue notification banners when actionbar is floating', async () => {
|
||||||
|
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||||
|
const actionbarTarget = document.createElement('div')
|
||||||
|
document.body.appendChild(actionbarTarget)
|
||||||
|
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||||
|
configureSettings(pinia, true)
|
||||||
|
const executionStore = useExecutionStore(pinia)
|
||||||
|
executionStore.activePromptId = 'prompt-1'
|
||||||
|
|
||||||
|
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||||
|
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
pinia,
|
||||||
|
attachTo: document.body,
|
||||||
|
stubs: {
|
||||||
|
ComfyActionbar: ComfyActionbarStub,
|
||||||
|
QueueNotificationBannerHost: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
actionbarTarget.querySelector('queue-notification-banner-host-stub')
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.findComponent({ name: 'QueueNotificationBannerHost' })
|
||||||
|
.exists()
|
||||||
|
).toBe(true)
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
actionbarTarget.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||||
const wrapper = createWrapper()
|
const wrapper = createWrapper()
|
||||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||||
@@ -330,4 +468,16 @@ describe('TopMenuSection', () => {
|
|||||||
const model = menu.props('model') as MenuItem[]
|
const model = menu.props('model') as MenuItem[]
|
||||||
expect(model[0]?.disabled).toBe(false)
|
expect(model[0]?.disabled).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows manager red dot only for manager conflicts', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
|
||||||
|
// Release red dot is mocked as true globally for this test file.
|
||||||
|
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
|
||||||
|
|
||||||
|
mockData.setShowConflictRedDot(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="flex flex-col items-end gap-1">
|
||||||
<Teleport
|
<Teleport
|
||||||
v-if="inlineProgressSummaryTarget"
|
v-if="inlineProgressSummaryTarget"
|
||||||
:to="inlineProgressSummaryTarget"
|
:to="inlineProgressSummaryTarget"
|
||||||
@@ -121,6 +121,10 @@
|
|||||||
class="pr-1"
|
class="pr-1"
|
||||||
:hidden="isQueueOverlayExpanded"
|
:hidden="isQueueOverlayExpanded"
|
||||||
/>
|
/>
|
||||||
|
<QueueNotificationBannerHost
|
||||||
|
v-if="shouldShowQueueNotificationBanners"
|
||||||
|
class="pr-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -136,6 +140,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||||
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
|
||||||
|
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||||
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
||||||
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
||||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||||
@@ -145,7 +150,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
|||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
@@ -173,8 +177,6 @@ const sidebarTabStore = useSidebarTabStore()
|
|||||||
const { activeJobsCount } = storeToRefs(queueStore)
|
const { activeJobsCount } = storeToRefs(queueStore)
|
||||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||||
const releaseStore = useReleaseStore()
|
|
||||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
|
||||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||||
useConflictAcknowledgment()
|
useConflictAcknowledgment()
|
||||||
const isTopMenuHovered = ref(false)
|
const isTopMenuHovered = ref(false)
|
||||||
@@ -207,6 +209,9 @@ const isQueueProgressOverlayEnabled = computed(
|
|||||||
const shouldShowInlineProgressSummary = computed(
|
const shouldShowInlineProgressSummary = computed(
|
||||||
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
|
||||||
)
|
)
|
||||||
|
const shouldShowQueueNotificationBanners = computed(
|
||||||
|
() => isActionbarEnabled.value
|
||||||
|
)
|
||||||
const progressTarget = ref<HTMLElement | null>(null)
|
const progressTarget = ref<HTMLElement | null>(null)
|
||||||
function updateProgressTarget(target: HTMLElement | null) {
|
function updateProgressTarget(target: HTMLElement | null) {
|
||||||
progressTarget.value = target
|
progressTarget.value = target
|
||||||
@@ -236,10 +241,8 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// Use either release red dot or conflict red dot
|
|
||||||
const shouldShowRedDot = computed((): boolean => {
|
const shouldShowRedDot = computed((): boolean => {
|
||||||
const releaseRedDot = showReleaseRedDot.value
|
return shouldShowConflictRedDot.value
|
||||||
return releaseRedDot || shouldShowConflictRedDot.value
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Right side panel toggle
|
// Right side panel toggle
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||||
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||||
|
|
||||||
const bottomPanelStore = useBottomPanelStore()
|
const bottomPanelStore = useBottomPanelStore()
|
||||||
const dialogService = useDialogService()
|
const settingsDialog = useSettingsDialog()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isShortcutsTabActive = computed(() => {
|
const isShortcutsTabActive = computed(() => {
|
||||||
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openKeybindingSettings = async () => {
|
const openKeybindingSettings = async () => {
|
||||||
dialogService.showSettingsDialog('keybinding')
|
settingsDialog.show('keybinding')
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeBottomPanel = () => {
|
const closeBottomPanel = () => {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
nodeContent: ({ context }) => ({
|
nodeContent: ({ context }) => ({
|
||||||
class: 'group/tree-node',
|
class: 'group/tree-node',
|
||||||
onClick: (e: MouseEvent) =>
|
onClick: (e: MouseEvent) =>
|
||||||
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
|
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
|
||||||
onContextmenu: (e: MouseEvent) =>
|
onContextmenu: (e: MouseEvent) =>
|
||||||
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
|
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
|
||||||
}),
|
}),
|
||||||
nodeToggleButton: () => ({
|
nodeToggleButton: () => ({
|
||||||
onClick: (e: MouseEvent) => {
|
onClick: (e: MouseEvent) => {
|
||||||
@@ -36,15 +36,11 @@
|
|||||||
</Tree>
|
</Tree>
|
||||||
<ContextMenu ref="menu" :model="menuItems" />
|
<ContextMenu ref="menu" :model="menuItems" />
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="T">
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false
|
|
||||||
})
|
|
||||||
|
|
||||||
import ContextMenu from 'primevue/contextmenu'
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||||
import Tree from 'primevue/tree'
|
import Tree from 'primevue/tree'
|
||||||
import { computed, provide, ref } from 'vue'
|
import { computed, provide, ref, shallowRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||||
@@ -60,6 +56,10 @@ import type {
|
|||||||
} from '@/types/treeExplorerTypes'
|
} from '@/types/treeExplorerTypes'
|
||||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false
|
||||||
|
})
|
||||||
|
|
||||||
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
|
||||||
required: true
|
required: true
|
||||||
})
|
})
|
||||||
@@ -69,13 +69,13 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
|
|||||||
const storeSelectionKeys = selectionKeys.value !== undefined
|
const storeSelectionKeys = selectionKeys.value !== undefined
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
root: TreeExplorerNode
|
root: TreeExplorerNode<T>
|
||||||
class?: string
|
class?: string
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||||
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
|
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
|
||||||
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
|
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -83,19 +83,19 @@ const {
|
|||||||
getAddFolderMenuItem,
|
getAddFolderMenuItem,
|
||||||
handleFolderCreation,
|
handleFolderCreation,
|
||||||
addFolderCommand
|
addFolderCommand
|
||||||
} = useTreeFolderOperations(
|
} = useTreeFolderOperations<T>(
|
||||||
/* expandNode */ (node: TreeExplorerNode) => {
|
/* expandNode */ (node: TreeExplorerNode<T>) => {
|
||||||
expandedKeys.value[node.key] = true
|
expandedKeys.value[node.key] = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
|
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
|
||||||
const renderedRoot = fillNodeInfo(props.root)
|
const renderedRoot = fillNodeInfo(props.root)
|
||||||
return newFolderNode.value
|
return newFolderNode.value
|
||||||
? combineTrees(renderedRoot, newFolderNode.value)
|
? combineTrees(renderedRoot, newFolderNode.value)
|
||||||
: renderedRoot
|
: renderedRoot
|
||||||
})
|
})
|
||||||
const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
|
||||||
if (node.getIcon) {
|
if (node.getIcon) {
|
||||||
const icon = node.getIcon()
|
const icon = node.getIcon()
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@@ -111,7 +111,9 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
|
|||||||
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
const isExpanded = expandedKeys.value?.[node.key] ?? false
|
||||||
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
|
||||||
}
|
}
|
||||||
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
const fillNodeInfo = (
|
||||||
|
node: TreeExplorerNode<T>
|
||||||
|
): RenderedTreeExplorerNode<T> => {
|
||||||
const children = node.children?.map(fillNodeInfo) ?? []
|
const children = node.children?.map(fillNodeInfo) ?? []
|
||||||
const totalLeaves = node.leaf
|
const totalLeaves = node.leaf
|
||||||
? 1
|
? 1
|
||||||
@@ -128,7 +130,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
|
|||||||
}
|
}
|
||||||
const onNodeContentClick = async (
|
const onNodeContentClick = async (
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
node: RenderedTreeExplorerNode
|
node: RenderedTreeExplorerNode<T>
|
||||||
) => {
|
) => {
|
||||||
if (!storeSelectionKeys) {
|
if (!storeSelectionKeys) {
|
||||||
selectionKeys.value = {}
|
selectionKeys.value = {}
|
||||||
@@ -139,20 +141,22 @@ const onNodeContentClick = async (
|
|||||||
emit('nodeClick', node, e)
|
emit('nodeClick', node, e)
|
||||||
}
|
}
|
||||||
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||||
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
|
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||||
const extraMenuItems = computed(() => {
|
const extraMenuItems = computed(() => {
|
||||||
return menuTargetNode.value?.contextMenuItems
|
const node = menuTargetNode.value
|
||||||
? typeof menuTargetNode.value.contextMenuItems === 'function'
|
return node?.contextMenuItems
|
||||||
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
|
? typeof node.contextMenuItems === 'function'
|
||||||
: menuTargetNode.value.contextMenuItems
|
? node.contextMenuItems(node)
|
||||||
|
: node.contextMenuItems
|
||||||
: []
|
: []
|
||||||
})
|
})
|
||||||
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
|
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
|
||||||
const errorHandling = useErrorHandling()
|
const errorHandling = useErrorHandling()
|
||||||
const handleNodeLabelEdit = async (
|
const handleNodeLabelEdit = async (
|
||||||
node: RenderedTreeExplorerNode,
|
n: RenderedTreeExplorerNode,
|
||||||
newName: string
|
newName: string
|
||||||
) => {
|
) => {
|
||||||
|
const node = n as RenderedTreeExplorerNode<T>
|
||||||
await errorHandling.wrapWithErrorHandlingAsync(
|
await errorHandling.wrapWithErrorHandlingAsync(
|
||||||
async () => {
|
async () => {
|
||||||
if (node.key === newFolderNode.value?.key) {
|
if (node.key === newFolderNode.value?.key) {
|
||||||
@@ -170,35 +174,36 @@ const handleNodeLabelEdit = async (
|
|||||||
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const renameCommand = (node: RenderedTreeExplorerNode) => {
|
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
|
||||||
renameEditingNode.value = node
|
renameEditingNode.value = node
|
||||||
}
|
}
|
||||||
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
|
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
|
||||||
await node.handleDelete?.()
|
await node.handleDelete?.()
|
||||||
emit('nodeDelete', node)
|
emit('nodeDelete', node)
|
||||||
}
|
}
|
||||||
const menuItems = computed<MenuItem[]>(() =>
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
[
|
const node = menuTargetNode.value
|
||||||
getAddFolderMenuItem(menuTargetNode.value),
|
return [
|
||||||
|
getAddFolderMenuItem(node),
|
||||||
{
|
{
|
||||||
label: t('g.rename'),
|
label: t('g.rename'),
|
||||||
icon: 'pi pi-file-edit',
|
icon: 'pi pi-file-edit',
|
||||||
command: () => {
|
command: () => {
|
||||||
if (menuTargetNode.value) {
|
if (node) {
|
||||||
renameCommand(menuTargetNode.value)
|
renameCommand(node)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
visible: menuTargetNode.value?.handleRename !== undefined
|
visible: node?.handleRename !== undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('g.delete'),
|
label: t('g.delete'),
|
||||||
icon: 'pi pi-trash',
|
icon: 'pi pi-trash',
|
||||||
command: async () => {
|
command: async () => {
|
||||||
if (menuTargetNode.value) {
|
if (node) {
|
||||||
await deleteCommand(menuTargetNode.value)
|
await deleteCommand(node)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
visible: node?.handleDelete !== undefined,
|
||||||
isAsync: true // The delete command can be async
|
isAsync: true // The delete command can be async
|
||||||
},
|
},
|
||||||
...extraMenuItems.value
|
...extraMenuItems.value
|
||||||
@@ -210,9 +215,12 @@ const menuItems = computed<MenuItem[]>(() =>
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}))
|
}))
|
||||||
)
|
})
|
||||||
|
|
||||||
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
|
const handleContextMenu = (
|
||||||
|
e: MouseEvent,
|
||||||
|
node: RenderedTreeExplorerNode<T>
|
||||||
|
) => {
|
||||||
menuTargetNode.value = node
|
menuTargetNode.value = node
|
||||||
emit('contextMenu', node, e)
|
emit('contextMenu', node, e)
|
||||||
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
if (menuItems.value.filter((item) => item.visible).length > 0) {
|
||||||
@@ -224,15 +232,13 @@ const wrapCommandWithErrorHandler = (
|
|||||||
command: (event: MenuItemCommandEvent) => void,
|
command: (event: MenuItemCommandEvent) => void,
|
||||||
{ isAsync = false }: { isAsync: boolean }
|
{ isAsync = false }: { isAsync: boolean }
|
||||||
) => {
|
) => {
|
||||||
|
const node = menuTargetNode.value
|
||||||
return isAsync
|
return isAsync
|
||||||
? errorHandling.wrapWithErrorHandlingAsync(
|
? errorHandling.wrapWithErrorHandlingAsync(
|
||||||
command as (event: MenuItemCommandEvent) => Promise<void>,
|
command as (event: MenuItemCommandEvent) => Promise<void>,
|
||||||
menuTargetNode.value?.handleError
|
node?.handleError
|
||||||
)
|
|
||||||
: errorHandling.wrapWithErrorHandling(
|
|
||||||
command,
|
|
||||||
menuTargetNode.value?.handleError
|
|
||||||
)
|
)
|
||||||
|
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="T">
|
||||||
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
|
||||||
import Badge from 'primevue/badge'
|
import Badge from 'primevue/badge'
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
@@ -53,17 +53,17 @@ import type {
|
|||||||
} from '@/types/treeExplorerTypes'
|
} from '@/types/treeExplorerTypes'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: RenderedTreeExplorerNode
|
node: RenderedTreeExplorerNode<T>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(
|
(
|
||||||
e: 'itemDropped',
|
e: 'itemDropped',
|
||||||
node: RenderedTreeExplorerNode,
|
node: RenderedTreeExplorerNode<T>,
|
||||||
data: RenderedTreeExplorerNode
|
data: RenderedTreeExplorerNode<T>
|
||||||
): void
|
): void
|
||||||
(e: 'dragStart', node: RenderedTreeExplorerNode): void
|
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
|
||||||
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
|
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const nodeBadgeText = computed<string>(() => {
|
const nodeBadgeText = computed<string>(() => {
|
||||||
@@ -80,7 +80,7 @@ const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
|
|||||||
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
|
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
|
||||||
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
|
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
|
||||||
const handleRename = (newName: string) => {
|
const handleRename = (newName: string) => {
|
||||||
handleEditLabel?.(props.node, newName)
|
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = ref<HTMLElement | null>(null)
|
const container = ref<HTMLElement | null>(null)
|
||||||
@@ -117,9 +117,13 @@ if (props.node.droppable) {
|
|||||||
onDrop: async (event) => {
|
onDrop: async (event) => {
|
||||||
const dndData = event.source.data as TreeExplorerDragAndDropData
|
const dndData = event.source.data as TreeExplorerDragAndDropData
|
||||||
if (dndData.type === 'tree-explorer-node') {
|
if (dndData.type === 'tree-explorer-node') {
|
||||||
await props.node.handleDrop?.(dndData)
|
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
|
||||||
canDrop.value = false
|
canDrop.value = false
|
||||||
emit('itemDropped', props.node, dndData.data)
|
emit(
|
||||||
|
'itemDropped',
|
||||||
|
props.node,
|
||||||
|
dndData.data as RenderedTreeExplorerNode<T>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnter: (event) => {
|
onDragEnter: (event) => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="container" class="h-full scrollbar-custom">
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||||
|
>
|
||||||
<div :style="topSpacerStyle" />
|
<div :style="topSpacerStyle" />
|
||||||
<div :style="mergedGridStyle">
|
<div :style="mergedGridStyle">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModalLayout
|
<BaseModalLayout
|
||||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||||
class="workflow-template-selector-dialog"
|
size="md"
|
||||||
>
|
>
|
||||||
<template #leftPanelHeaderTitle>
|
<template #leftPanelHeaderTitle>
|
||||||
<i class="icon-[comfy--template]" />
|
<i class="icon-[comfy--template]" />
|
||||||
@@ -854,19 +854,3 @@ onBeforeUnmount(() => {
|
|||||||
cardRefs.value = [] // Release DOM refs
|
cardRefs.value = [] // Release DOM refs
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Ensure the workflow template selector dialog fits within provided dialog */
|
|
||||||
.workflow-template-selector-dialog.base-widget-layout {
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 1400px;
|
|
||||||
height: 100% !important;
|
|
||||||
aspect-ratio: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1600px) {
|
|
||||||
.workflow-template-selector-dialog.base-widget-layout {
|
|
||||||
max-width: 1600px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,12 +4,7 @@
|
|||||||
v-for="item in dialogStore.dialogStack"
|
v-for="item in dialogStore.dialogStack"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
v-model:visible="item.visible"
|
v-model:visible="item.visible"
|
||||||
:class="[
|
class="global-dialog"
|
||||||
'global-dialog',
|
|
||||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
|
||||||
? 'settings-dialog-workspace'
|
|
||||||
: ''
|
|
||||||
]"
|
|
||||||
v-bind="item.dialogComponentProps"
|
v-bind="item.dialogComponentProps"
|
||||||
:pt="getDialogPt(item)"
|
:pt="getDialogPt(item)"
|
||||||
:aria-labelledby="item.key"
|
:aria-labelledby="item.key"
|
||||||
|
|||||||
@@ -18,17 +18,35 @@
|
|||||||
<div class="flex justify-end gap-4">
|
<div class="flex justify-end gap-4">
|
||||||
<div
|
<div
|
||||||
v-if="type === 'overwriteBlueprint'"
|
v-if="type === 'overwriteBlueprint'"
|
||||||
class="flex justify-start gap-4"
|
class="flex flex-col justify-start gap-1"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<div class="flex gap-4">
|
||||||
v-model="doNotAskAgain"
|
<input
|
||||||
class="flex justify-start gap-4"
|
id="doNotAskAgain"
|
||||||
input-id="doNotAskAgain"
|
v-model="doNotAskAgain"
|
||||||
binary
|
type="checkbox"
|
||||||
/>
|
class="h-4 w-4 cursor-pointer"
|
||||||
<label for="doNotAskAgain" severity="secondary">{{
|
/>
|
||||||
t('missingModelsDialog.doNotAskAgain')
|
<label for="doNotAskAgain">{{
|
||||||
}}</label>
|
t('missingModelsDialog.doNotAskAgain')
|
||||||
|
}}</label>
|
||||||
|
</div>
|
||||||
|
<i18n-t
|
||||||
|
v-if="doNotAskAgain"
|
||||||
|
keypath="missingModelsDialog.reEnableInSettings"
|
||||||
|
tag="span"
|
||||||
|
class="text-sm text-muted-foreground ml-8"
|
||||||
|
>
|
||||||
|
<template #link>
|
||||||
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||||
|
@click="openBlueprintOverwriteSetting"
|
||||||
|
>
|
||||||
|
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -92,13 +110,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Checkbox from 'primevue/checkbox'
|
|
||||||
import Message from 'primevue/message'
|
import Message from 'primevue/message'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import type { ConfirmationDialogType } from '@/services/dialogService'
|
import type { ConfirmationDialogType } from '@/services/dialogService'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
@@ -114,6 +132,11 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const onCancel = () => useDialogStore().closeDialog()
|
const onCancel = () => useDialogStore().closeDialog()
|
||||||
|
|
||||||
|
function openBlueprintOverwriteSetting() {
|
||||||
|
useDialogStore().closeDialog()
|
||||||
|
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
|
||||||
|
}
|
||||||
|
|
||||||
const doNotAskAgain = ref(false)
|
const doNotAskAgain = ref(false)
|
||||||
|
|
||||||
const onDeny = () => {
|
const onDeny = () => {
|
||||||
|
|||||||
@@ -5,11 +5,34 @@
|
|||||||
:title="t('missingModelsDialog.missingModels')"
|
:title="t('missingModelsDialog.missingModels')"
|
||||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||||
/>
|
/>
|
||||||
<div class="mb-4 flex gap-1">
|
<div class="mb-4 flex flex-col gap-1">
|
||||||
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
|
<div class="flex gap-1">
|
||||||
<label for="doNotAskAgain">{{
|
<input
|
||||||
t('missingModelsDialog.doNotAskAgain')
|
id="doNotAskAgain"
|
||||||
}}</label>
|
v-model="doNotAskAgain"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label for="doNotAskAgain">{{
|
||||||
|
t('missingModelsDialog.doNotAskAgain')
|
||||||
|
}}</label>
|
||||||
|
</div>
|
||||||
|
<i18n-t
|
||||||
|
v-if="doNotAskAgain"
|
||||||
|
keypath="missingModelsDialog.reEnableInSettings"
|
||||||
|
tag="span"
|
||||||
|
class="text-sm text-muted-foreground ml-6"
|
||||||
|
>
|
||||||
|
<template #link>
|
||||||
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||||
|
@click="openShowMissingModelsSetting"
|
||||||
|
>
|
||||||
|
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</div>
|
</div>
|
||||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
@@ -31,16 +54,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Checkbox from 'primevue/checkbox'
|
|
||||||
import ListBox from 'primevue/listbox'
|
import ListBox from 'primevue/listbox'
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
|
||||||
import FileDownload from '@/components/common/FileDownload.vue'
|
import FileDownload from '@/components/common/FileDownload.vue'
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
||||||
import { isDesktop } from '@/platform/distribution/types'
|
import { isDesktop } from '@/platform/distribution/types'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
// TODO: Read this from server internal API rather than hardcoding here
|
// TODO: Read this from server internal API rather than hardcoding here
|
||||||
// as some installations may wish to use custom sources
|
// as some installations may wish to use custom sources
|
||||||
@@ -78,6 +103,11 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const doNotAskAgain = ref(false)
|
const doNotAskAgain = ref(false)
|
||||||
|
|
||||||
|
function openShowMissingModelsSetting() {
|
||||||
|
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
|
||||||
|
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
|
||||||
|
}
|
||||||
|
|
||||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||||
const missingModels = computed(() => {
|
const missingModels = computed(() => {
|
||||||
return props.missingModels.map((model) => {
|
return props.missingModels.map((model) => {
|
||||||
|
|||||||
@@ -1,55 +1,86 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Cloud mode: Learn More + Got It buttons -->
|
<div class="flex w-full flex-col gap-2 py-2 px-4">
|
||||||
<div
|
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||||
v-if="isCloud"
|
<div class="flex items-center gap-1">
|
||||||
class="flex w-full items-center justify-between gap-2 py-2 px-4"
|
<input
|
||||||
>
|
id="doNotAskAgainNodes"
|
||||||
<Button
|
v-model="doNotAskAgain"
|
||||||
variant="textonly"
|
type="checkbox"
|
||||||
size="sm"
|
class="h-4 w-4 cursor-pointer"
|
||||||
as="a"
|
/>
|
||||||
href="https://www.comfy.org/cloud"
|
<label for="doNotAskAgainNodes">{{
|
||||||
target="_blank"
|
$t('missingModelsDialog.doNotAskAgain')
|
||||||
rel="noopener noreferrer"
|
}}</label>
|
||||||
>
|
</div>
|
||||||
<i class="icon-[lucide--info]"></i>
|
<i18n-t
|
||||||
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
v-if="doNotAskAgain"
|
||||||
</Button>
|
keypath="missingModelsDialog.reEnableInSettings"
|
||||||
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
tag="span"
|
||||||
$t('missingNodes.cloud.gotIt')
|
class="text-sm text-muted-foreground ml-6"
|
||||||
}}</Button>
|
>
|
||||||
</div>
|
<template #link>
|
||||||
|
<Button
|
||||||
|
variant="textonly"
|
||||||
|
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
|
||||||
|
@click="openShowMissingNodesSetting"
|
||||||
|
>
|
||||||
|
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- OSS mode: Open Manager + Install All buttons -->
|
<!-- Cloud mode: Learn More + Got It buttons -->
|
||||||
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
|
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
|
||||||
<Button variant="textonly" @click="openManager">{{
|
<Button
|
||||||
$t('g.openManager')
|
variant="textonly"
|
||||||
}}</Button>
|
size="sm"
|
||||||
<PackInstallButton
|
as="a"
|
||||||
v-if="showInstallAllButton"
|
href="https://www.comfy.org/cloud"
|
||||||
type="secondary"
|
target="_blank"
|
||||||
size="md"
|
rel="noopener noreferrer"
|
||||||
:disabled="
|
>
|
||||||
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
<i class="icon-[lucide--info]"></i>
|
||||||
"
|
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
|
||||||
:is-loading="isLoading"
|
</Button>
|
||||||
:node-packs="missingNodePacks"
|
<Button variant="secondary" size="md" @click="handleGotItClick">{{
|
||||||
:label="
|
$t('missingNodes.cloud.gotIt')
|
||||||
isLoading
|
}}</Button>
|
||||||
? $t('manager.gettingInfo')
|
</div>
|
||||||
: $t('manager.installAllMissingNodes')
|
|
||||||
"
|
<!-- OSS mode: Open Manager + Install All buttons -->
|
||||||
/>
|
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
|
||||||
|
<Button variant="textonly" @click="openManager">{{
|
||||||
|
$t('g.openManager')
|
||||||
|
}}</Button>
|
||||||
|
<PackInstallButton
|
||||||
|
v-if="showInstallAllButton"
|
||||||
|
type="secondary"
|
||||||
|
size="md"
|
||||||
|
:disabled="
|
||||||
|
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
|
||||||
|
"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:node-packs="missingNodePacks"
|
||||||
|
:label="
|
||||||
|
isLoading
|
||||||
|
? $t('manager.gettingInfo')
|
||||||
|
: $t('manager.installAllMissingNodes')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
|
||||||
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
||||||
@@ -60,10 +91,21 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
|
|||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const doNotAskAgain = ref(false)
|
||||||
|
|
||||||
|
watch(doNotAskAgain, (value) => {
|
||||||
|
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
|
||||||
|
})
|
||||||
|
|
||||||
const handleGotItClick = () => {
|
const handleGotItClick = () => {
|
||||||
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openShowMissingNodesSetting() {
|
||||||
|
dialogStore.closeDialog({ key: 'global-missing-nodes' })
|
||||||
|
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
|
||||||
|
}
|
||||||
|
|
||||||
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
const { missingNodePacks, isLoading, error } = useMissingNodes()
|
||||||
const comfyManagerStore = useComfyManagerStore()
|
const comfyManagerStore = useComfyManagerStore()
|
||||||
const managerState = useManagerState()
|
const managerState = useManagerState()
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const dialogService = useDialogService()
|
const settingsDialog = useSettingsDialog()
|
||||||
const telemetry = useTelemetry()
|
const telemetry = useTelemetry()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
|||||||
: isSubscriptionEnabled()
|
: isSubscriptionEnabled()
|
||||||
? 'subscription'
|
? 'subscription'
|
||||||
: 'credits'
|
: 'credits'
|
||||||
dialogService.showSettingsDialog(settingsPanel)
|
settingsDialog.show(settingsPanel)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Purchase failed:', error)
|
console.error('Purchase failed:', error)
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
|
|||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
import { useBillingOperationStore } from '@/stores/billingOperationStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const dialogService = useDialogService()
|
const settingsDialog = useSettingsDialog()
|
||||||
const telemetry = useTelemetry()
|
const telemetry = useTelemetry()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||||
@@ -266,7 +266,7 @@ async function handleBuy() {
|
|||||||
})
|
})
|
||||||
await fetchBalance()
|
await fetchBalance()
|
||||||
handleClose(false)
|
handleClose(false)
|
||||||
dialogService.showSettingsDialog('workspace')
|
settingsDialog.show('workspace')
|
||||||
} else if (response.status === 'pending') {
|
} else if (response.status === 'pending') {
|
||||||
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
billingOperationStore.startOperation(response.billing_op_id, 'topup')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<PanelTemplate
|
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
|
||||||
value="About"
|
|
||||||
class="about-container"
|
|
||||||
data-testid="about-panel"
|
|
||||||
>
|
|
||||||
<h2 class="mb-2 text-2xl font-bold">
|
<h2 class="mb-2 text-2xl font-bold">
|
||||||
{{ $t('g.about') }}
|
{{ $t('g.about') }}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -32,7 +28,7 @@
|
|||||||
v-if="systemStatsStore.systemStats"
|
v-if="systemStatsStore.systemStats"
|
||||||
:stats="systemStatsStore.systemStats"
|
:stats="systemStatsStore.systemStats"
|
||||||
/>
|
/>
|
||||||
</PanelTemplate>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -43,8 +39,6 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
|
|||||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||||
|
|
||||||
import PanelTemplate from './PanelTemplate.vue'
|
|
||||||
|
|
||||||
const systemStatsStore = useSystemStatsStore()
|
const systemStatsStore = useSystemStatsStore()
|
||||||
const aboutPanelStore = useAboutPanelStore()
|
const aboutPanelStore = useAboutPanelStore()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<PanelTemplate value="Keybinding" class="keybinding-panel">
|
<div class="keybinding-panel flex flex-col gap-2">
|
||||||
<template #header>
|
<SearchBox
|
||||||
<SearchBox
|
v-model="filters['global'].value"
|
||||||
v-model="filters['global'].value"
|
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
|
||||||
:placeholder="
|
/>
|
||||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
v-model:selection="selectedCommandData"
|
v-model:selection="selectedCommandData"
|
||||||
@@ -135,7 +131,7 @@
|
|||||||
<i class="pi pi-replay" />
|
<i class="pi pi-replay" />
|
||||||
{{ $t('g.resetAll') }}
|
{{ $t('g.resetAll') }}
|
||||||
</Button>
|
</Button>
|
||||||
</PanelTemplate>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -159,7 +155,6 @@ import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
|||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
|
|
||||||
import PanelTemplate from './PanelTemplate.vue'
|
|
||||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<TabPanel value="Credits" class="credits-container h-full">
|
<div class="credits-container h-full">
|
||||||
<!-- Legacy Design -->
|
<!-- Legacy Design -->
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<h2 class="mb-2 text-2xl font-bold">
|
<h2 class="mb-2 text-2xl font-bold">
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -110,7 +110,6 @@ import Column from 'primevue/column'
|
|||||||
import DataTable from 'primevue/datatable'
|
import DataTable from 'primevue/datatable'
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import Skeleton from 'primevue/skeleton'
|
import Skeleton from 'primevue/skeleton'
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import UserCredit from '@/components/common/UserCredit.vue'
|
import UserCredit from '@/components/common/UserCredit.vue'
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
<!-- Section Header -->
|
<!-- Section Header -->
|
||||||
<div class="flex w-full items-center gap-9">
|
<div class="flex w-full items-center gap-9">
|
||||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||||
<span
|
<span class="text-base font-semibold text-base-foreground">
|
||||||
v-if="uiConfig.showMembersList"
|
|
||||||
class="text-base font-semibold text-base-foreground"
|
|
||||||
>
|
|
||||||
<template v-if="activeView === 'active'">
|
<template v-if="activeView === 'active'">
|
||||||
{{
|
{{
|
||||||
$t('workspacePanel.members.membersCount', {
|
$t('workspacePanel.members.membersCount', {
|
||||||
count: members.length
|
count:
|
||||||
|
isSingleSeatPlan || isPersonalWorkspace
|
||||||
|
? 1
|
||||||
|
: members.length,
|
||||||
|
maxSeats: maxSeats
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
@@ -27,7 +28,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
<div
|
||||||
|
v-if="uiConfig.showSearch && !isSingleSeatPlan"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
:placeholder="$t('g.search')"
|
:placeholder="$t('g.search')"
|
||||||
@@ -45,14 +49,16 @@
|
|||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'grid w-full items-center py-2',
|
'grid w-full items-center py-2',
|
||||||
activeView === 'pending'
|
isSingleSeatPlan
|
||||||
? uiConfig.pendingGridCols
|
? 'grid-cols-1 py-0'
|
||||||
: uiConfig.headerGridCols
|
: activeView === 'pending'
|
||||||
|
? uiConfig.pendingGridCols
|
||||||
|
: uiConfig.headerGridCols
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Tab buttons in first column -->
|
<!-- Tab buttons in first column -->
|
||||||
<div class="flex items-center gap-2">
|
<div v-if="!isSingleSeatPlan" class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
:variant="
|
:variant="
|
||||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||||
@@ -101,17 +107,19 @@
|
|||||||
<div />
|
<div />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button
|
<template v-if="!isSingleSeatPlan">
|
||||||
variant="muted-textonly"
|
<Button
|
||||||
size="sm"
|
variant="muted-textonly"
|
||||||
class="justify-end"
|
size="sm"
|
||||||
@click="toggleSort('joinDate')"
|
class="justify-end"
|
||||||
>
|
@click="toggleSort('joinDate')"
|
||||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
>
|
||||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||||
</Button>
|
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||||
<!-- Empty cell for action column header (OWNER only) -->
|
</Button>
|
||||||
<div v-if="permissions.canRemoveMembers" />
|
<!-- Empty cell for action column header (OWNER only) -->
|
||||||
|
<div v-if="permissions.canRemoveMembers" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,7 +174,7 @@
|
|||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'grid w-full items-center rounded-lg p-2',
|
'grid w-full items-center rounded-lg p-2',
|
||||||
uiConfig.membersGridCols,
|
isSingleSeatPlan ? 'grid-cols-1' : uiConfig.membersGridCols,
|
||||||
index % 2 === 1 && 'bg-secondary-background/50'
|
index % 2 === 1 && 'bg-secondary-background/50'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@@ -206,14 +214,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Join date -->
|
<!-- Join date -->
|
||||||
<span
|
<span
|
||||||
v-if="uiConfig.showDateColumn"
|
v-if="uiConfig.showDateColumn && !isSingleSeatPlan"
|
||||||
class="text-sm text-muted-foreground text-right"
|
class="text-sm text-muted-foreground text-right"
|
||||||
>
|
>
|
||||||
{{ formatDate(member.joinDate) }}
|
{{ formatDate(member.joinDate) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||||
<div
|
<div
|
||||||
v-if="permissions.canRemoveMembers"
|
v-if="permissions.canRemoveMembers && !isSingleSeatPlan"
|
||||||
class="flex items-center justify-end"
|
class="flex items-center justify-end"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -237,8 +245,29 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Upsell Banner -->
|
||||||
|
<div
|
||||||
|
v-if="isSingleSeatPlan"
|
||||||
|
class="flex items-center gap-2 rounded-xl border bg-secondary-background border-border-default px-4 py-3 mt-4 justify-center"
|
||||||
|
>
|
||||||
|
<p class="m-0 text-sm text-foreground">
|
||||||
|
{{
|
||||||
|
isActiveSubscription
|
||||||
|
? $t('workspacePanel.members.upsellBannerUpgrade')
|
||||||
|
: $t('workspacePanel.members.upsellBannerSubscribe')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="muted-textonly"
|
||||||
|
class="cursor-pointer underline text-sm"
|
||||||
|
@click="showSubscriptionDialog()"
|
||||||
|
>
|
||||||
|
{{ $t('workspacePanel.members.viewPlans') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pending Invites -->
|
<!-- Pending Invites -->
|
||||||
<template v-else>
|
<template v-if="activeView === 'pending'">
|
||||||
<div
|
<div
|
||||||
v-for="(invite, index) in filteredPendingInvites"
|
v-for="(invite, index) in filteredPendingInvites"
|
||||||
:key="invite.id"
|
:key="invite.id"
|
||||||
@@ -342,6 +371,8 @@ import SearchBox from '@/components/common/SearchBox.vue'
|
|||||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||||
|
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||||
import type {
|
import type {
|
||||||
PendingInvite,
|
PendingInvite,
|
||||||
@@ -367,6 +398,27 @@ const {
|
|||||||
} = storeToRefs(workspaceStore)
|
} = storeToRefs(workspaceStore)
|
||||||
const { copyInviteLink } = workspaceStore
|
const { copyInviteLink } = workspaceStore
|
||||||
const { permissions, uiConfig } = useWorkspaceUI()
|
const { permissions, uiConfig } = useWorkspaceUI()
|
||||||
|
const {
|
||||||
|
isActiveSubscription,
|
||||||
|
subscription,
|
||||||
|
showSubscriptionDialog,
|
||||||
|
getMaxSeats
|
||||||
|
} = useBillingContext()
|
||||||
|
|
||||||
|
const maxSeats = computed(() => {
|
||||||
|
if (isPersonalWorkspace.value) return 1
|
||||||
|
const tier = subscription.value?.tier
|
||||||
|
if (!tier) return 1
|
||||||
|
const tierKey = TIER_TO_KEY[tier]
|
||||||
|
if (!tierKey) return 1
|
||||||
|
return getMaxSeats(tierKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSingleSeatPlan = computed(() => {
|
||||||
|
if (isPersonalWorkspace.value) return false
|
||||||
|
if (!isActiveSubscription.value) return true
|
||||||
|
return maxSeats.value <= 1
|
||||||
|
})
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const activeView = ref<'active' | 'pending'>('active')
|
const activeView = ref<'active' | 'pending'>('active')
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
|
|
||||||
<div class="flex h-full w-full flex-col gap-2">
|
|
||||||
<slot name="header" />
|
|
||||||
<ScrollPanel class="h-0 grow pr-2">
|
|
||||||
<slot />
|
|
||||||
</ScrollPanel>
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import ScrollPanel from 'primevue/scrollpanel'
|
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
value: string
|
|
||||||
class?: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<TabPanel value="User" class="user-settings-container h-full">
|
<div class="user-settings-container h-full">
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
||||||
<Divider class="mb-3" />
|
<Divider class="mb-3" />
|
||||||
@@ -95,13 +95,12 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
|
|
||||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<template>
|
|
||||||
<TabPanel value="Workspace" class="h-full">
|
|
||||||
<WorkspacePanelContent />
|
|
||||||
</TabPanel>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
|
|
||||||
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full w-full flex-col">
|
<div class="flex h-full w-full flex-col">
|
||||||
<div class="pb-8 flex items-center gap-4">
|
<header class="mb-8 flex items-center gap-4">
|
||||||
<WorkspaceProfilePic
|
<WorkspaceProfilePic
|
||||||
class="size-12 !text-3xl"
|
class="size-12 !text-3xl"
|
||||||
:workspace-name="workspaceName"
|
:workspace-name="workspaceName"
|
||||||
@@ -8,44 +8,38 @@
|
|||||||
<h1 class="text-3xl text-base-foreground">
|
<h1 class="text-3xl text-base-foreground">
|
||||||
{{ workspaceName }}
|
{{ workspaceName }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</header>
|
||||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
<TabsRoot v-model="activeTab">
|
||||||
<div class="flex w-full items-center">
|
<div class="flex w-full items-center">
|
||||||
<TabList unstyled class="flex w-full gap-2">
|
<TabsList class="flex items-center gap-2 pb-1">
|
||||||
<Tab
|
<TabsTrigger
|
||||||
value="plan"
|
value="plan"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
buttonVariants({
|
tabTriggerBase,
|
||||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||||
size: 'md'
|
|
||||||
}),
|
|
||||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||||
</Tab>
|
</TabsTrigger>
|
||||||
<Tab
|
<TabsTrigger
|
||||||
value="members"
|
value="members"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
buttonVariants({
|
tabTriggerBase,
|
||||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||||
size: 'md'
|
|
||||||
}),
|
|
||||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
|
||||||
'ml-2'
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$t('workspacePanel.tabs.membersCount', {
|
$t('workspacePanel.tabs.membersCount', {
|
||||||
count: isInPersonalWorkspace ? 1 : members.length
|
count: members.length
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</Tab>
|
</TabsTrigger>
|
||||||
</TabList>
|
</TabsList>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
v-if="permissions.canInviteMembers"
|
v-if="permissions.canInviteMembers"
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
@@ -55,20 +49,22 @@
|
|||||||
"
|
"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
:disabled="isInviteLimitReached"
|
:disabled="!isSingleSeatPlan && isInviteLimitReached"
|
||||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
:class="
|
||||||
|
!isSingleSeatPlan &&
|
||||||
|
isInviteLimitReached &&
|
||||||
|
'opacity-50 cursor-not-allowed'
|
||||||
|
"
|
||||||
:aria-label="$t('workspacePanel.inviteMember')"
|
:aria-label="$t('workspacePanel.inviteMember')"
|
||||||
@click="handleInviteMember"
|
@click="handleInviteMember"
|
||||||
>
|
>
|
||||||
{{ $t('workspacePanel.invite') }}
|
<i class="pi pi-plus text-sm" />
|
||||||
<i class="pi pi-plus ml-1 text-sm" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||||
<Button
|
<Button
|
||||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||||
class="ml-2"
|
variant="muted-textonly"
|
||||||
variant="secondary"
|
size="icon"
|
||||||
size="lg"
|
|
||||||
:aria-label="$t('g.moreOptions')"
|
:aria-label="$t('g.moreOptions')"
|
||||||
@click="menu?.toggle($event)"
|
@click="menu?.toggle($event)"
|
||||||
>
|
>
|
||||||
@@ -76,17 +72,21 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<div
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
item.disabled && deleteTooltip
|
item.disabled && deleteTooltip
|
||||||
? { value: deleteTooltip, showDelay: 0 }
|
? { value: deleteTooltip, showDelay: 0 }
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:class="[
|
type="button"
|
||||||
'flex items-center gap-2 px-3 py-2',
|
:disabled="!!item.disabled"
|
||||||
item.class,
|
:class="
|
||||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
cn(
|
||||||
]"
|
'flex w-full items-center gap-2 px-3 py-2 bg-transparent border-none cursor-pointer',
|
||||||
|
item.class,
|
||||||
|
item.disabled && 'pointer-events-auto cursor-not-allowed'
|
||||||
|
)
|
||||||
|
"
|
||||||
@click="
|
@click="
|
||||||
item.command?.({
|
item.command?.({
|
||||||
originalEvent: $event,
|
originalEvent: $event,
|
||||||
@@ -96,44 +96,47 @@
|
|||||||
>
|
>
|
||||||
<i :class="item.icon" />
|
<i :class="item.icon" />
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</div>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Menu>
|
</Menu>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabPanels unstyled>
|
<TabsContent value="plan" class="mt-4">
|
||||||
<TabPanel value="plan">
|
<SubscriptionPanelContentWorkspace />
|
||||||
<SubscriptionPanelContentWorkspace />
|
</TabsContent>
|
||||||
</TabPanel>
|
<TabsContent value="members" class="mt-4">
|
||||||
<TabPanel value="members">
|
<MembersPanelContent :key="workspaceRole" />
|
||||||
<MembersPanelContent :key="workspaceRole" />
|
</TabsContent>
|
||||||
</TabPanel>
|
</TabsRoot>
|
||||||
</TabPanels>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import Menu from 'primevue/menu'
|
import Menu from 'primevue/menu'
|
||||||
import Tab from 'primevue/tab'
|
|
||||||
import TabList from 'primevue/tablist'
|
|
||||||
import TabPanel from 'primevue/tabpanel'
|
|
||||||
import TabPanels from 'primevue/tabpanels'
|
|
||||||
import Tabs from 'primevue/tabs'
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||||
|
|
||||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||||
|
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
|
||||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const tabTriggerBase =
|
||||||
|
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||||
|
const tabTriggerActive =
|
||||||
|
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||||
|
const tabTriggerInactive =
|
||||||
|
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||||
|
|
||||||
const { defaultTab = 'plan' } = defineProps<{
|
const { defaultTab = 'plan' } = defineProps<{
|
||||||
defaultTab?: string
|
defaultTab?: string
|
||||||
@@ -144,19 +147,26 @@ const {
|
|||||||
showLeaveWorkspaceDialog,
|
showLeaveWorkspaceDialog,
|
||||||
showDeleteWorkspaceDialog,
|
showDeleteWorkspaceDialog,
|
||||||
showInviteMemberDialog,
|
showInviteMemberDialog,
|
||||||
|
showInviteMemberUpsellDialog,
|
||||||
showEditWorkspaceDialog
|
showEditWorkspaceDialog
|
||||||
} = useDialogService()
|
} = useDialogService()
|
||||||
|
const { isActiveSubscription, subscription, getMaxSeats } = useBillingContext()
|
||||||
|
|
||||||
|
const isSingleSeatPlan = computed(() => {
|
||||||
|
if (!isActiveSubscription.value) return true
|
||||||
|
const tier = subscription.value?.tier
|
||||||
|
if (!tier) return true
|
||||||
|
const tierKey = TIER_TO_KEY[tier]
|
||||||
|
if (!tierKey) return true
|
||||||
|
return getMaxSeats(tierKey) <= 1
|
||||||
|
})
|
||||||
const workspaceStore = useTeamWorkspaceStore()
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
const {
|
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||||
workspaceName,
|
storeToRefs(workspaceStore)
|
||||||
members,
|
|
||||||
isInviteLimitReached,
|
|
||||||
isWorkspaceSubscribed,
|
|
||||||
isInPersonalWorkspace
|
|
||||||
} = storeToRefs(workspaceStore)
|
|
||||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
|
||||||
useWorkspaceUI()
|
const { workspaceRole, permissions, uiConfig } = useWorkspaceUI()
|
||||||
|
const activeTab = ref(defaultTab)
|
||||||
|
|
||||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||||
|
|
||||||
@@ -187,11 +197,16 @@ const deleteTooltip = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inviteTooltip = computed(() => {
|
const inviteTooltip = computed(() => {
|
||||||
|
if (isSingleSeatPlan.value) return null
|
||||||
if (!isInviteLimitReached.value) return null
|
if (!isInviteLimitReached.value) return null
|
||||||
return t('workspacePanel.inviteLimitReached')
|
return t('workspacePanel.inviteLimitReached')
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleInviteMember() {
|
function handleInviteMember() {
|
||||||
|
if (isSingleSeatPlan.value) {
|
||||||
|
showInviteMemberUpsellDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isInviteLimitReached.value) return
|
if (isInviteLimitReached.value) return
|
||||||
showInviteMemberDialog()
|
showInviteMemberDialog()
|
||||||
}
|
}
|
||||||
@@ -231,7 +246,6 @@ const menuItems = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setActiveTab(defaultTab)
|
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
fetchPendingInvites()
|
fetchPendingInvites()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<WorkspaceProfilePic
|
|
||||||
class="size-6 text-xs"
|
|
||||||
:workspace-name="workspaceName"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span>{{ workspaceName }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
|
|
||||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
|
||||||
|
|
||||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
|
||||||
</script>
|
|
||||||
@@ -70,31 +70,17 @@
|
|||||||
@click="onSelectLink"
|
@click="onSelectLink"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute right-4 top-2 cursor-pointer"
|
class="absolute right-3 top-2.5 cursor-pointer"
|
||||||
@click="onCopyLink"
|
@click="onCopyLink"
|
||||||
>
|
>
|
||||||
<svg
|
<i
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
:class="
|
||||||
width="16"
|
cn(
|
||||||
height="16"
|
'pi size-4',
|
||||||
viewBox="0 0 16 16"
|
justCopied ? 'pi-check text-green-500' : 'pi-copy'
|
||||||
fill="none"
|
)
|
||||||
>
|
"
|
||||||
<g clip-path="url(#clip0_2127_14348)">
|
/>
|
||||||
<path
|
|
||||||
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="1.3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_2127_14348">
|
|
||||||
<rect width="16" height="16" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +104,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
@@ -130,6 +117,7 @@ const loading = ref(false)
|
|||||||
const email = ref('')
|
const email = ref('')
|
||||||
const step = ref<'email' | 'link'>('email')
|
const step = ref<'email' | 'link'>('email')
|
||||||
const generatedLink = ref('')
|
const generatedLink = ref('')
|
||||||
|
const justCopied = ref(false)
|
||||||
|
|
||||||
const isValidEmail = computed(() => {
|
const isValidEmail = computed(() => {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
@@ -161,6 +149,10 @@ async function onCreateLink() {
|
|||||||
async function onCopyLink() {
|
async function onCopyLink() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(generatedLink.value)
|
await navigator.clipboard.writeText(generatedLink.value)
|
||||||
|
justCopied.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
justCopied.value = false
|
||||||
|
}, 759)
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||||
|
>
|
||||||
|
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||||
|
{{
|
||||||
|
isActiveSubscription
|
||||||
|
? $t('workspacePanel.inviteUpsellDialog.titleSingleSeat')
|
||||||
|
: $t('workspacePanel.inviteUpsellDialog.titleNotSubscribed')
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
|
||||||
|
:aria-label="$t('g.close')"
|
||||||
|
@click="onDismiss"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex flex-col gap-4 px-4 py-4">
|
||||||
|
<p class="m-0 text-sm text-muted-foreground">
|
||||||
|
{{
|
||||||
|
isActiveSubscription
|
||||||
|
? $t('workspacePanel.inviteUpsellDialog.messageSingleSeat')
|
||||||
|
: $t('workspacePanel.inviteUpsellDialog.messageNotSubscribed')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||||
|
<Button variant="muted-textonly" @click="onDismiss">
|
||||||
|
{{ $t('g.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="lg" @click="onUpgrade">
|
||||||
|
{{
|
||||||
|
isActiveSubscription
|
||||||
|
? $t('workspacePanel.inviteUpsellDialog.upgradeToCreator')
|
||||||
|
: $t('workspacePanel.inviteUpsellDialog.viewPlans')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||||
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
const dialogStore = useDialogStore()
|
||||||
|
const { isActiveSubscription, showSubscriptionDialog } = useBillingContext()
|
||||||
|
|
||||||
|
function onDismiss() {
|
||||||
|
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpgrade() {
|
||||||
|
dialogStore.closeDialog({ key: 'invite-member-upsell' })
|
||||||
|
showSubscriptionDialog()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h2 :class="cn(flags.teamWorkspacesEnabled ? 'px-6' : 'px-4')">
|
|
||||||
<i class="pi pi-cog" />
|
|
||||||
<span>{{ $t('g.settings') }}</span>
|
|
||||||
<Tag
|
|
||||||
v-if="isStaging"
|
|
||||||
value="staging"
|
|
||||||
severity="warn"
|
|
||||||
class="ml-2 text-xs"
|
|
||||||
/>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Tag from 'primevue/tag'
|
|
||||||
|
|
||||||
import { isStaging } from '@/config/staging'
|
|
||||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|
||||||
import { cn } from '@comfyorg/tailwind-utils'
|
|
||||||
const { flags } = useFeatureFlags()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.pi-cog {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
.version-tag {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
<template v-if="comfyAppReady">
|
<template v-if="comfyAppReady">
|
||||||
<TitleEditor />
|
<TitleEditor />
|
||||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||||
|
<NodeContextMenu />
|
||||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||||
</template>
|
</template>
|
||||||
@@ -121,6 +122,7 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
|
|||||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||||
|
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||||
|
|||||||
@@ -42,7 +42,6 @@
|
|||||||
</Panel>
|
</Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
<NodeContextMenu />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
|
|||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||||
|
|
||||||
import NodeContextMenu from './NodeContextMenu.vue'
|
|
||||||
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
import FrameNodes from './selectionToolbox/FrameNodes.vue'
|
||||||
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
|
||||||
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
||||||
|
|
||||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
|
||||||
|
|
||||||
const meta: Meta<typeof CompletionSummaryBanner> = {
|
|
||||||
title: 'Queue/CompletionSummaryBanner',
|
|
||||||
component: CompletionSummaryBanner,
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
const thumb = (hex: string) =>
|
|
||||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
|
|
||||||
|
|
||||||
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
|
|
||||||
|
|
||||||
export const AllSuccessSingle: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'allSuccess',
|
|
||||||
completedCount: 1,
|
|
||||||
failedCount: 0,
|
|
||||||
thumbnailUrls: [thumbs[0]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AllSuccessPlural: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'allSuccess',
|
|
||||||
completedCount: 3,
|
|
||||||
failedCount: 0,
|
|
||||||
thumbnailUrls: thumbs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MixedSingleSingle: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'mixed',
|
|
||||||
completedCount: 1,
|
|
||||||
failedCount: 1,
|
|
||||||
thumbnailUrls: thumbs.slice(0, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MixedPluralPlural: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'mixed',
|
|
||||||
completedCount: 2,
|
|
||||||
failedCount: 3,
|
|
||||||
thumbnailUrls: thumbs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AllFailedSingle: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'allFailed',
|
|
||||||
completedCount: 0,
|
|
||||||
failedCount: 1,
|
|
||||||
thumbnailUrls: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AllFailedPlural: Story = {
|
|
||||||
args: {
|
|
||||||
mode: 'allFailed',
|
|
||||||
completedCount: 0,
|
|
||||||
failedCount: 4,
|
|
||||||
thumbnailUrls: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
sideToolbar: {
|
|
||||||
queueProgressOverlay: {
|
|
||||||
jobsCompleted: '{count} job completed | {count} jobs completed',
|
|
||||||
jobsFailed: '{count} job failed | {count} jobs failed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountComponent = (props: Record<string, unknown>) =>
|
|
||||||
mount(CompletionSummaryBanner, {
|
|
||||||
props: {
|
|
||||||
mode: 'allSuccess',
|
|
||||||
completedCount: 0,
|
|
||||||
failedCount: 0,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
plugins: [i18n]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CompletionSummaryBanner', () => {
|
|
||||||
it('renders success mode text, thumbnails, and aria label', () => {
|
|
||||||
const wrapper = mountComponent({
|
|
||||||
mode: 'allSuccess',
|
|
||||||
completedCount: 3,
|
|
||||||
failedCount: 0,
|
|
||||||
thumbnailUrls: [
|
|
||||||
'https://example.com/thumb-a.png',
|
|
||||||
'https://example.com/thumb-b.png'
|
|
||||||
],
|
|
||||||
ariaLabel: 'Open queue summary'
|
|
||||||
})
|
|
||||||
|
|
||||||
const button = wrapper.get('button')
|
|
||||||
expect(button.attributes('aria-label')).toBe('Open queue summary')
|
|
||||||
expect(wrapper.text()).toContain('3 jobs completed')
|
|
||||||
|
|
||||||
const thumbnailImages = wrapper.findAll('img')
|
|
||||||
expect(thumbnailImages).toHaveLength(2)
|
|
||||||
expect(thumbnailImages[0].attributes('src')).toBe(
|
|
||||||
'https://example.com/thumb-a.png'
|
|
||||||
)
|
|
||||||
expect(thumbnailImages[1].attributes('src')).toBe(
|
|
||||||
'https://example.com/thumb-b.png'
|
|
||||||
)
|
|
||||||
|
|
||||||
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
|
|
||||||
expect(thumbnailContainers[1].attributes('style')).toContain(
|
|
||||||
'margin-left: -12px'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders mixed mode with success and failure counts', () => {
|
|
||||||
const wrapper = mountComponent({
|
|
||||||
mode: 'mixed',
|
|
||||||
completedCount: 2,
|
|
||||||
failedCount: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
|
|
||||||
expect(summaryText).toContain('2 jobs completed, 1 job failed')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders failure mode icon without thumbnails', () => {
|
|
||||||
const wrapper = mountComponent({
|
|
||||||
mode: 'allFailed',
|
|
||||||
completedCount: 0,
|
|
||||||
failedCount: 4
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('4 jobs failed')
|
|
||||||
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
|
||||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
class="group w-full justify-between gap-3 p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
|
|
||||||
:aria-label="props.ariaLabel"
|
|
||||||
@click="emit('click', $event)"
|
|
||||||
>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
|
|
||||||
<i
|
|
||||||
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none text-destructive-background"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
v-if="props.mode !== 'allFailed'"
|
|
||||||
class="relative inline-flex h-6 items-center"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="(url, idx) in props.thumbnailUrls"
|
|
||||||
:key="url + idx"
|
|
||||||
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
|
|
||||||
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="url"
|
|
||||||
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-[14px] font-normal text-text-primary">
|
|
||||||
<template v-if="props.mode === 'allSuccess'">
|
|
||||||
<i18n-t
|
|
||||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
|
||||||
:plural="props.completedCount"
|
|
||||||
>
|
|
||||||
<template #count>
|
|
||||||
<span class="font-bold">{{ props.completedCount }}</span>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="props.mode === 'mixed'">
|
|
||||||
<i18n-t
|
|
||||||
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
|
|
||||||
:plural="props.completedCount"
|
|
||||||
>
|
|
||||||
<template #count>
|
|
||||||
<span class="font-bold">{{ props.completedCount }}</span>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
<span>, </span>
|
|
||||||
<i18n-t
|
|
||||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
|
||||||
:plural="props.failedCount"
|
|
||||||
>
|
|
||||||
<template #count>
|
|
||||||
<span class="font-bold">{{ props.failedCount }}</span>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<i18n-t
|
|
||||||
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
|
|
||||||
:plural="props.failedCount"
|
|
||||||
>
|
|
||||||
<template #count>
|
|
||||||
<span class="font-bold">{{ props.failedCount }}</span>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import type {
|
|
||||||
CompletionSummary,
|
|
||||||
CompletionSummaryMode
|
|
||||||
} from '@/composables/queue/useCompletionSummary'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
mode: CompletionSummaryMode
|
|
||||||
completedCount: CompletionSummary['completedCount']
|
|
||||||
failedCount: CompletionSummary['failedCount']
|
|
||||||
thumbnailUrls?: CompletionSummary['thumbnailUrls']
|
|
||||||
ariaLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
thumbnailUrls: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'click', event: MouseEvent): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
140
src/components/queue/QueueNotificationBanner.stories.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||||
|
|
||||||
|
import QueueNotificationBanner from './QueueNotificationBanner.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof QueueNotificationBanner> = {
|
||||||
|
title: 'Queue/QueueNotificationBanner',
|
||||||
|
component: QueueNotificationBanner,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
const thumbnail = (hex: string) =>
|
||||||
|
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||||
|
|
||||||
|
const args = (notification: QueueNotificationBannerItem) => ({ notification })
|
||||||
|
|
||||||
|
export const Queueing: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'queuedPending',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueueingMultiple: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'queuedPending',
|
||||||
|
count: 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Queued: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'queued',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueuedMultiple: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'queued',
|
||||||
|
count: 4
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Completed: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 1,
|
||||||
|
thumbnailUrl: thumbnail('4dabf7')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompletedMultiple: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompletedMultipleWithThumbnail: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4,
|
||||||
|
thumbnailUrls: [
|
||||||
|
thumbnail('ff6b6b'),
|
||||||
|
thumbnail('4dabf7'),
|
||||||
|
thumbnail('51cf66')
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Failed: Story = {
|
||||||
|
args: args({
|
||||||
|
type: 'failed',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Gallery: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { QueueNotificationBanner },
|
||||||
|
setup() {
|
||||||
|
const queueing = args({
|
||||||
|
type: 'queuedPending',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
const queued = args({
|
||||||
|
type: 'queued',
|
||||||
|
count: 2
|
||||||
|
})
|
||||||
|
const completed = args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 1,
|
||||||
|
thumbnailUrl: thumbnail('ff6b6b')
|
||||||
|
})
|
||||||
|
const completedMultiple = args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4
|
||||||
|
})
|
||||||
|
const completedMultipleWithThumbnail = args({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4,
|
||||||
|
thumbnailUrls: [
|
||||||
|
thumbnail('51cf66'),
|
||||||
|
thumbnail('ffd43b'),
|
||||||
|
thumbnail('ff922b')
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const failed = args({
|
||||||
|
type: 'failed',
|
||||||
|
count: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
queueing,
|
||||||
|
queued,
|
||||||
|
completed,
|
||||||
|
completedMultiple,
|
||||||
|
completedMultipleWithThumbnail,
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<QueueNotificationBanner v-bind="queueing" />
|
||||||
|
<QueueNotificationBanner v-bind="queued" />
|
||||||
|
<QueueNotificationBanner v-bind="completed" />
|
||||||
|
<QueueNotificationBanner v-bind="completedMultiple" />
|
||||||
|
<QueueNotificationBanner v-bind="completedMultipleWithThumbnail" />
|
||||||
|
<QueueNotificationBanner v-bind="failed" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
136
src/components/queue/QueueNotificationBanner.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||||
|
import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
queue: {
|
||||||
|
jobAddedToQueue: 'Job added to queue',
|
||||||
|
jobQueueing: 'Job queueing'
|
||||||
|
},
|
||||||
|
sideToolbar: {
|
||||||
|
queueProgressOverlay: {
|
||||||
|
preview: 'Preview',
|
||||||
|
jobCompleted: 'Job completed',
|
||||||
|
jobFailed: 'Job failed',
|
||||||
|
jobsAddedToQueue:
|
||||||
|
'{count} job added to queue | {count} jobs added to queue',
|
||||||
|
jobsCompleted: '{count} job completed | {count} jobs completed',
|
||||||
|
jobsFailed: '{count} job failed | {count} jobs failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountComponent = (notification: QueueNotificationBannerItem) =>
|
||||||
|
mount(QueueNotificationBanner, {
|
||||||
|
props: { notification },
|
||||||
|
global: {
|
||||||
|
plugins: [i18n]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(QueueNotificationBanner, () => {
|
||||||
|
it('renders singular queued message without count prefix', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'queued',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Job added to queue')
|
||||||
|
expect(wrapper.text()).not.toContain('1 job')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders queued message with pluralization', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'queued',
|
||||||
|
count: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('2 jobs added to queue')
|
||||||
|
expect(wrapper.html()).toContain('icon-[lucide--check]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders queued pending message with spinner icon', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'queuedPending',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Job queueing')
|
||||||
|
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
|
||||||
|
expect(wrapper.html()).toContain('animate-spin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders failed message and alert icon', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'failed',
|
||||||
|
count: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Job failed')
|
||||||
|
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders completed message with thumbnail preview when provided', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'completed',
|
||||||
|
count: 3,
|
||||||
|
thumbnailUrls: ['https://example.com/preview.png']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('3 jobs completed')
|
||||||
|
const image = wrapper.get('img')
|
||||||
|
expect(image.attributes('src')).toBe('https://example.com/preview.png')
|
||||||
|
expect(image.attributes('alt')).toBe('Preview')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders two completion thumbnail previews', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4,
|
||||||
|
thumbnailUrls: [
|
||||||
|
'https://example.com/preview-1.png',
|
||||||
|
'https://example.com/preview-2.png'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = wrapper.findAll('img')
|
||||||
|
expect(images.length).toBe(2)
|
||||||
|
expect(images[0].attributes('src')).toBe(
|
||||||
|
'https://example.com/preview-1.png'
|
||||||
|
)
|
||||||
|
expect(images[1].attributes('src')).toBe(
|
||||||
|
'https://example.com/preview-2.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps completion thumbnail previews at two', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
type: 'completed',
|
||||||
|
count: 4,
|
||||||
|
thumbnailUrls: [
|
||||||
|
'https://example.com/preview-1.png',
|
||||||
|
'https://example.com/preview-2.png',
|
||||||
|
'https://example.com/preview-3.png',
|
||||||
|
'https://example.com/preview-4.png'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = wrapper.findAll('img')
|
||||||
|
expect(images.length).toBe(2)
|
||||||
|
expect(images[0].attributes('src')).toBe(
|
||||||
|
'https://example.com/preview-1.png'
|
||||||
|
)
|
||||||
|
expect(images[1].attributes('src')).toBe(
|
||||||
|
'https://example.com/preview-2.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
154
src/components/queue/QueueNotificationBanner.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex overflow-hidden rounded-lg bg-secondary-background">
|
||||||
|
<div class="flex items-center gap-2 p-1 pr-3">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative shrink-0 items-center rounded-[4px]',
|
||||||
|
showsCompletionPreview && showThumbnails
|
||||||
|
? 'flex h-8 overflow-visible p-0'
|
||||||
|
: showsCompletionPreview
|
||||||
|
? 'flex size-8 justify-center overflow-hidden p-0'
|
||||||
|
: 'flex size-8 justify-center p-1'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template v-if="showThumbnails">
|
||||||
|
<div class="flex h-8 shrink-0 items-center">
|
||||||
|
<div
|
||||||
|
v-for="(thumbnailUrl, index) in thumbnailUrls"
|
||||||
|
:key="`completion-preview-${index}`"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative size-8 shrink-0 overflow-hidden rounded-[4px]',
|
||||||
|
index > 0 && '-ml-3 ring-2 ring-secondary-background'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="thumbnailUrl"
|
||||||
|
:alt="t('sideToolbar.queueProgressOverlay.preview')"
|
||||||
|
class="size-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else-if="showCompletionGradientFallback"
|
||||||
|
class="size-full bg-linear-to-br from-coral-500 via-coral-500 to-azure-600"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
:class="cn(iconClass, 'size-4', iconColorClass)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-full items-center">
|
||||||
|
<span
|
||||||
|
class="overflow-hidden text-ellipsis text-center font-inter text-[12px] leading-normal font-normal text-base-foreground"
|
||||||
|
>
|
||||||
|
{{ bannerText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { QueueNotificationBanner } from '@/composables/queue/useQueueNotificationBanners'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const { notification } = defineProps<{
|
||||||
|
notification: QueueNotificationBanner
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, n } = useI18n()
|
||||||
|
|
||||||
|
const thumbnailUrls = computed(() => {
|
||||||
|
if (notification.type !== 'completed') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (typeof notification.thumbnailUrl === 'string') {
|
||||||
|
return notification.thumbnailUrl.length > 0
|
||||||
|
? [notification.thumbnailUrl]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
return notification.thumbnailUrls?.slice(0, 2) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const showThumbnails = computed(() => {
|
||||||
|
if (notification.type !== 'completed') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return thumbnailUrls.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCompletionGradientFallback = computed(
|
||||||
|
() => notification.type === 'completed' && !showThumbnails.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const showsCompletionPreview = computed(
|
||||||
|
() => showThumbnails.value || showCompletionGradientFallback.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const bannerText = computed(() => {
|
||||||
|
const count = notification.count
|
||||||
|
if (notification.type === 'queuedPending') {
|
||||||
|
return t('queue.jobQueueing')
|
||||||
|
}
|
||||||
|
if (notification.type === 'queued') {
|
||||||
|
if (count === 1) {
|
||||||
|
return t('queue.jobAddedToQueue')
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'sideToolbar.queueProgressOverlay.jobsAddedToQueue',
|
||||||
|
{ count: n(count) },
|
||||||
|
count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (notification.type === 'failed') {
|
||||||
|
if (count === 1) {
|
||||||
|
return t('sideToolbar.queueProgressOverlay.jobFailed')
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'sideToolbar.queueProgressOverlay.jobsFailed',
|
||||||
|
{ count: n(count) },
|
||||||
|
count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return t('sideToolbar.queueProgressOverlay.jobCompleted')
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
'sideToolbar.queueProgressOverlay.jobsCompleted',
|
||||||
|
{ count: n(count) },
|
||||||
|
count
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconClass = computed(() => {
|
||||||
|
if (notification.type === 'queuedPending') {
|
||||||
|
return 'icon-[lucide--loader-circle]'
|
||||||
|
}
|
||||||
|
if (notification.type === 'queued') {
|
||||||
|
return 'icon-[lucide--check]'
|
||||||
|
}
|
||||||
|
if (notification.type === 'failed') {
|
||||||
|
return 'icon-[lucide--circle-alert]'
|
||||||
|
}
|
||||||
|
return 'icon-[lucide--image]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconColorClass = computed(() => {
|
||||||
|
if (notification.type === 'queuedPending') {
|
||||||
|
return 'animate-spin text-slate-100'
|
||||||
|
}
|
||||||
|
if (notification.type === 'failed') {
|
||||||
|
return 'text-danger-200'
|
||||||
|
}
|
||||||
|
return 'text-slate-100'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
18
src/components/queue/QueueNotificationBannerHost.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="currentNotification"
|
||||||
|
class="flex justify-end"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<QueueNotificationBanner :notification="currentNotification" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue'
|
||||||
|
import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners'
|
||||||
|
|
||||||
|
const { currentNotification } = useQueueNotificationBanners()
|
||||||
|
</script>
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
|
|
||||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
sideToolbar: {
|
|
||||||
queueProgressOverlay: {
|
|
||||||
expandCollapsedQueue: 'Expand job queue',
|
|
||||||
noActiveJobs: 'No active jobs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const CompletionSummaryBannerStub = {
|
|
||||||
name: 'CompletionSummaryBanner',
|
|
||||||
props: [
|
|
||||||
'mode',
|
|
||||||
'completedCount',
|
|
||||||
'failedCount',
|
|
||||||
'thumbnailUrls',
|
|
||||||
'ariaLabel'
|
|
||||||
],
|
|
||||||
emits: ['click'],
|
|
||||||
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
|
|
||||||
}
|
|
||||||
|
|
||||||
const mountComponent = (summary: CompletionSummary) =>
|
|
||||||
mount(QueueOverlayEmpty, {
|
|
||||||
props: { summary },
|
|
||||||
global: {
|
|
||||||
plugins: [i18n],
|
|
||||||
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('QueueOverlayEmpty', () => {
|
|
||||||
it('renders completion summary banner and proxies click', async () => {
|
|
||||||
const summary: CompletionSummary = {
|
|
||||||
mode: 'mixed',
|
|
||||||
completedCount: 2,
|
|
||||||
failedCount: 1,
|
|
||||||
thumbnailUrls: ['thumb-a']
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = mountComponent(summary)
|
|
||||||
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
|
|
||||||
|
|
||||||
expect(summaryBanner.exists()).toBe(true)
|
|
||||||
expect(summaryBanner.props()).toMatchObject({
|
|
||||||
mode: 'mixed',
|
|
||||||
completedCount: 2,
|
|
||||||
failedCount: 1,
|
|
||||||
thumbnailUrls: ['thumb-a'],
|
|
||||||
ariaLabel: 'Expand job queue'
|
|
||||||
})
|
|
||||||
|
|
||||||
await summaryBanner.trigger('click')
|
|
||||||
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pointer-events-auto">
|
|
||||||
<CompletionSummaryBanner
|
|
||||||
:mode="summary.mode"
|
|
||||||
:completed-count="summary.completedCount"
|
|
||||||
:failed-count="summary.failedCount"
|
|
||||||
:thumbnail-urls="summary.thumbnailUrls"
|
|
||||||
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
|
|
||||||
@click="$emit('summaryClick')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
|
|
||||||
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
|
|
||||||
|
|
||||||
defineProps<{ summary: CompletionSummary }>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
(e: 'summaryClick'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
</script>
|
|
||||||
@@ -44,12 +44,6 @@
|
|||||||
@clear-queued="cancelQueuedWorkflows"
|
@clear-queued="cancelQueuedWorkflows"
|
||||||
@view-all-jobs="viewAllJobs"
|
@view-all-jobs="viewAllJobs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<QueueOverlayEmpty
|
|
||||||
v-else-if="completionSummary"
|
|
||||||
:summary="completionSummary"
|
|
||||||
@summary-click="onSummaryClick"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,11 +58,9 @@ import { computed, nextTick, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
|
||||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
|
||||||
import { useJobList } from '@/composables/queue/useJobList'
|
import { useJobList } from '@/composables/queue/useJobList'
|
||||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||||
@@ -84,7 +76,7 @@ import { useExecutionStore } from '@/stores/executionStore'
|
|||||||
import { useQueueStore } from '@/stores/queueStore'
|
import { useQueueStore } from '@/stores/queueStore'
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
|
|
||||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
type OverlayState = 'hidden' | 'active' | 'expanded'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -130,9 +122,6 @@ const isExpanded = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { summary: completionSummary, clearSummary } = useCompletionSummary()
|
|
||||||
const hasCompletionSummary = computed(() => completionSummary.value !== null)
|
|
||||||
|
|
||||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||||
const isExecuting = computed(() => !executionStore.isIdle)
|
const isExecuting = computed(() => !executionStore.isIdle)
|
||||||
@@ -142,14 +131,12 @@ const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
|
|||||||
const overlayState = computed<OverlayState>(() => {
|
const overlayState = computed<OverlayState>(() => {
|
||||||
if (isExpanded.value) return 'expanded'
|
if (isExpanded.value) return 'expanded'
|
||||||
if (hasActiveJob.value) return 'active'
|
if (hasActiveJob.value) return 'active'
|
||||||
if (hasCompletionSummary.value) return 'empty'
|
|
||||||
return 'hidden'
|
return 'hidden'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showBackground = computed(
|
const showBackground = computed(
|
||||||
() =>
|
() =>
|
||||||
overlayState.value === 'expanded' ||
|
overlayState.value === 'expanded' ||
|
||||||
overlayState.value === 'empty' ||
|
|
||||||
(overlayState.value === 'active' && isOverlayHovered.value)
|
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,19 +217,10 @@ const setExpanded = (expanded: boolean) => {
|
|||||||
isExpanded.value = expanded
|
isExpanded.value = expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
const openExpandedFromEmpty = () => {
|
|
||||||
setExpanded(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewAllJobs = () => {
|
const viewAllJobs = () => {
|
||||||
setExpanded(true)
|
setExpanded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSummaryClick = () => {
|
|
||||||
openExpandedFromEmpty()
|
|
||||||
clearSummary()
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAssetsSidebar = () => {
|
const openAssetsSidebar = () => {
|
||||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import TabError from './TabError.vue'
|
||||||
import TabInfo from './info/TabInfo.vue'
|
import TabInfo from './info/TabInfo.vue'
|
||||||
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
||||||
import TabNodes from './parameters/TabNodes.vue'
|
import TabNodes from './parameters/TabNodes.vue'
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const executionStore = useExecutionStore()
|
||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -87,10 +90,25 @@ function closePanel() {
|
|||||||
type RightSidePanelTabList = Array<{
|
type RightSidePanelTabList = Array<{
|
||||||
label: () => string
|
label: () => string
|
||||||
value: RightSidePanelTab
|
value: RightSidePanelTab
|
||||||
|
icon?: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
//FIXME all errors if nothing selected?
|
||||||
|
const selectedNodeErrors = computed(() =>
|
||||||
|
selectedNodes.value
|
||||||
|
.map((node) => executionStore.getNodeErrors(`${node.id}`))
|
||||||
|
.filter((nodeError) => !!nodeError)
|
||||||
|
)
|
||||||
|
|
||||||
const tabs = computed<RightSidePanelTabList>(() => {
|
const tabs = computed<RightSidePanelTabList>(() => {
|
||||||
const list: RightSidePanelTabList = []
|
const list: RightSidePanelTabList = []
|
||||||
|
if (selectedNodeErrors.value.length) {
|
||||||
|
list.push({
|
||||||
|
label: () => t('g.error'),
|
||||||
|
value: 'error',
|
||||||
|
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
list.push({
|
list.push({
|
||||||
label: () =>
|
label: () =>
|
||||||
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
|||||||
:value="tab.value"
|
:value="tab.value"
|
||||||
>
|
>
|
||||||
{{ tab.label() }}
|
{{ tab.label() }}
|
||||||
|
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
|
|||||||
:node="selectedSingleNode"
|
:node="selectedSingleNode"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
|
||||||
<TabSubgraphInputs
|
<TabSubgraphInputs
|
||||||
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
||||||
:node="selectedSingleNode as SubgraphNode"
|
:node="selectedSingleNode as SubgraphNode"
|
||||||
|
|||||||
30
src/components/rightSidePanel/TabError.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
|
import type { NodeError } from '@/schemas/apiSchema'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
errors: NodeError[]
|
||||||
|
}>()
|
||||||
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="m-4">
|
||||||
|
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
|
||||||
|
{{ t('g.copy') }}
|
||||||
|
<i class="icon-[lucide--copy] size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
|
||||||
|
:key="index"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
|
<h3 class="text-error" v-text="error.message" />
|
||||||
|
<div class="text-muted-foreground" v-text="error.details" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,9 @@ import type {
|
|||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
|
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||||
|
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||||
|
|
||||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||||
@@ -57,6 +60,7 @@ watchEffect(() => (widgets.value = widgetsProp))
|
|||||||
provide(HideLayoutFieldKey, true)
|
provide(HideLayoutFieldKey, true)
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
|
const nodeDefStore = useNodeDefStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||||
@@ -118,15 +122,31 @@ function handleLocateNode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWidgetValueUpdate(
|
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
|
||||||
widget: IBaseWidget,
|
widget.value = value
|
||||||
newValue: string | number | boolean | object
|
widget.callback?.(value)
|
||||||
) {
|
|
||||||
widget.value = newValue
|
|
||||||
widget.callback?.(newValue)
|
|
||||||
canvasStore.canvas?.setDirty(true, true)
|
canvasStore.canvas?.setDirty(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleResetAllWidgets() {
|
||||||
|
for (const { widget, node: widgetNode } of widgetsProp) {
|
||||||
|
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
|
||||||
|
const defaultValue = getWidgetDefaultValue(spec)
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
writeWidgetValue(widget, defaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
|
||||||
|
if (newValue === undefined) return
|
||||||
|
writeWidgetValue(widget, newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
|
||||||
|
writeWidgetValue(widget, newValue)
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
widgetsContainer,
|
widgetsContainer,
|
||||||
rootElement
|
rootElement
|
||||||
@@ -157,6 +177,17 @@ defineExpose({
|
|||||||
{{ parentGroup.title }}
|
{{ parentGroup.title }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="!isEmpty"
|
||||||
|
variant="textonly"
|
||||||
|
size="icon-sm"
|
||||||
|
class="subbutton shrink-0 size-8 cursor-pointer text-muted-foreground hover:text-base-foreground"
|
||||||
|
:title="t('rightSidePanel.resetAllParameters')"
|
||||||
|
:aria-label="t('rightSidePanel.resetAllParameters')"
|
||||||
|
@click.stop="handleResetAllWidgets"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="canShowLocateButton"
|
v-if="canShowLocateButton"
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
@@ -189,6 +220,7 @@ defineExpose({
|
|||||||
:parents="parents"
|
:parents="parents"
|
||||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||||
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
|
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
|
||||||
|
@reset-to-default="handleWidgetReset(widget, $event)"
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
209
src/components/rightSidePanel/parameters/WidgetActions.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { setActivePinia } from 'pinia'
|
||||||
|
import type { Slots } from 'vue'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
|
|
||||||
|
import WidgetActions from './WidgetActions.vue'
|
||||||
|
|
||||||
|
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||||
|
mockGetInputSpecForWidget: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/nodeDefStore', () => ({
|
||||||
|
useNodeDefStore: () => ({
|
||||||
|
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||||
|
useCanvasStore: () => ({
|
||||||
|
canvas: { setDirty: vi.fn() }
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||||
|
useFavoritedWidgetsStore: () => ({
|
||||||
|
isFavorited: vi.fn().mockReturnValue(false),
|
||||||
|
toggleFavorite: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/services/dialogService', () => ({
|
||||||
|
useDialogService: () => ({
|
||||||
|
prompt: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||||
|
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||||
|
h('div', slots.default?.({ close: () => {} }))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
g: {
|
||||||
|
rename: 'Rename',
|
||||||
|
enterNewName: 'Enter new name'
|
||||||
|
},
|
||||||
|
rightSidePanel: {
|
||||||
|
hideInput: 'Hide input',
|
||||||
|
showInput: 'Show input',
|
||||||
|
addFavorite: 'Favorite',
|
||||||
|
removeFavorite: 'Unfavorite',
|
||||||
|
resetToDefault: 'Reset to default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WidgetActions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
vi.resetAllMocks()
|
||||||
|
mockGetInputSpecForWidget.mockReturnValue({
|
||||||
|
type: 'INT',
|
||||||
|
default: 42
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function createMockWidget(
|
||||||
|
value: number = 100,
|
||||||
|
callback?: () => void
|
||||||
|
): IBaseWidget {
|
||||||
|
return {
|
||||||
|
name: 'test_widget',
|
||||||
|
type: 'number',
|
||||||
|
value,
|
||||||
|
label: 'Test Widget',
|
||||||
|
options: {},
|
||||||
|
y: 0,
|
||||||
|
callback
|
||||||
|
} as IBaseWidget
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockNode(): LGraphNode {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
type: 'TestNode'
|
||||||
|
} as LGraphNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
|
||||||
|
return mount(WidgetActions, {
|
||||||
|
props: {
|
||||||
|
widget,
|
||||||
|
node,
|
||||||
|
label: 'Test Widget'
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [i18n]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows reset button when widget has default value', () => {
|
||||||
|
const widget = createMockWidget()
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
expect(resetButton).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits resetToDefault with default value when reset button clicked', async () => {
|
||||||
|
const widget = createMockWidget(100)
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
|
||||||
|
await resetButton?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('resetToDefault')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual([42])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables reset button when value equals default', () => {
|
||||||
|
const widget = createMockWidget(42)
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
|
||||||
|
expect(resetButton?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show reset button when no default value exists', () => {
|
||||||
|
mockGetInputSpecForWidget.mockReturnValue({
|
||||||
|
type: 'CUSTOM'
|
||||||
|
})
|
||||||
|
|
||||||
|
const widget = createMockWidget(100)
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
|
||||||
|
expect(resetButton).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses fallback default for INT type without explicit default', async () => {
|
||||||
|
mockGetInputSpecForWidget.mockReturnValue({
|
||||||
|
type: 'INT'
|
||||||
|
})
|
||||||
|
|
||||||
|
const widget = createMockWidget(100)
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
|
||||||
|
await resetButton?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual([0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses first option as default for combo without explicit default', async () => {
|
||||||
|
mockGetInputSpecForWidget.mockReturnValue({
|
||||||
|
type: 'COMBO',
|
||||||
|
options: ['option1', 'option2', 'option3']
|
||||||
|
})
|
||||||
|
|
||||||
|
const widget = createMockWidget(100)
|
||||||
|
const node = createMockNode()
|
||||||
|
|
||||||
|
const wrapper = mountWidgetActions(widget, node)
|
||||||
|
|
||||||
|
const resetButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((b) => b.text().includes('Reset'))
|
||||||
|
|
||||||
|
await resetButton?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '@comfyorg/tailwind-utils'
|
import { cn } from '@comfyorg/tailwind-utils'
|
||||||
|
import { isEqual } from 'es-toolkit'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
|||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||||
|
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||||
|
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
widget,
|
widget,
|
||||||
@@ -28,10 +32,15 @@ const {
|
|||||||
isShownOnParents?: boolean
|
isShownOnParents?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
resetToDefault: [value: WidgetValue]
|
||||||
|
}>()
|
||||||
|
|
||||||
const label = defineModel<string>('label', { required: true })
|
const label = defineModel<string>('label', { required: true })
|
||||||
|
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||||
|
const nodeDefStore = useNodeDefStore()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -43,6 +52,19 @@ const isFavorited = computed(() =>
|
|||||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const inputSpec = computed(() =>
|
||||||
|
nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
|
||||||
|
|
||||||
|
const hasDefault = computed(() => defaultValue.value !== undefined)
|
||||||
|
|
||||||
|
const isCurrentValueDefault = computed(() => {
|
||||||
|
if (!hasDefault.value) return true
|
||||||
|
return isEqual(widget.value, defaultValue.value)
|
||||||
|
})
|
||||||
|
|
||||||
async function handleRename() {
|
async function handleRename() {
|
||||||
const newLabel = await dialogService.prompt({
|
const newLabel = await dialogService.prompt({
|
||||||
title: t('g.rename'),
|
title: t('g.rename'),
|
||||||
@@ -97,6 +119,11 @@ function handleToggleFavorite() {
|
|||||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleResetToDefault() {
|
||||||
|
if (!hasDefault.value) return
|
||||||
|
emit('resetToDefault', defaultValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
const buttonClasses = cn([
|
const buttonClasses = cn([
|
||||||
'border-none bg-transparent',
|
'border-none bg-transparent',
|
||||||
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
'w-full flex items-center gap-2 rounded px-3 py-2 text-sm',
|
||||||
@@ -162,6 +189,21 @@ const buttonClasses = cn([
|
|||||||
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
<span>{{ t('rightSidePanel.addFavorite') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasDefault"
|
||||||
|
:class="cn(buttonClasses, isCurrentValueDefault && 'opacity-50')"
|
||||||
|
:disabled="isCurrentValueDefault"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
handleResetToDefault()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||||
|
<span>{{ t('rightSidePanel.resetToDefault') }}</span>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</MoreButton>
|
</MoreButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
|||||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
import { renameWidget } from '@/utils/widgetUtil'
|
import { renameWidget } from '@/utils/widgetUtil'
|
||||||
|
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||||
|
|
||||||
import WidgetActions from './WidgetActions.vue'
|
import WidgetActions from './WidgetActions.vue'
|
||||||
|
|
||||||
@@ -42,7 +43,8 @@ const {
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:widgetValue': [value: string | number | boolean | object]
|
'update:widgetValue': [value: WidgetValue]
|
||||||
|
resetToDefault: [value: WidgetValue]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -83,11 +85,8 @@ const favoriteNode = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const widgetValue = computed({
|
const widgetValue = computed({
|
||||||
get: () => {
|
get: () => widget.value,
|
||||||
widget.vueTrack?.()
|
set: (newValue: WidgetValue) => {
|
||||||
return widget.value
|
|
||||||
},
|
|
||||||
set: (newValue: string | number | boolean | object) => {
|
|
||||||
emit('update:widgetValue', newValue)
|
emit('update:widgetValue', newValue)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -157,6 +156,7 @@ const displayLabel = customRef((track, trigger) => {
|
|||||||
:node="node"
|
:node="node"
|
||||||
:parents="parents"
|
:parents="parents"
|
||||||
:is-shown-on-parents="isShownOnParents"
|
:is-shown-on-parents="isShownOnParents"
|
||||||
|
@reset-to-default="emit('resetToDefault', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
|||||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||||
@@ -20,7 +20,7 @@ import LayoutField from './LayoutField.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const dialogService = useDialogService()
|
const settingsDialog = useSettingsDialog()
|
||||||
|
|
||||||
// NODES settings
|
// NODES settings
|
||||||
const showAdvancedParameters = computed({
|
const showAdvancedParameters = computed({
|
||||||
@@ -92,7 +92,7 @@ function updateGridSpacingFromInput(value: number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFullSettings() {
|
function openFullSettings() {
|
||||||
dialogService.showSettingsDialog()
|
settingsDialog.show()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -108,15 +108,14 @@ import ToggleSwitch from 'primevue/toggleswitch'
|
|||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
|
||||||
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
|
||||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||||
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import type { SettingPanelType } from '@/platform/settings/types'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
|
||||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
@@ -129,7 +128,7 @@ const commandStore = useCommandStore()
|
|||||||
const menuItemStore = useMenuItemStore()
|
const menuItemStore = useMenuItemStore()
|
||||||
const colorPaletteStore = useColorPaletteStore()
|
const colorPaletteStore = useColorPaletteStore()
|
||||||
const colorPaletteService = useColorPaletteService()
|
const colorPaletteService = useColorPaletteService()
|
||||||
const dialogStore = useDialogStore()
|
const settingsDialog = useSettingsDialog()
|
||||||
const managerState = useManagerState()
|
const managerState = useManagerState()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
@@ -166,15 +165,8 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSettings = (defaultPanel?: string) => {
|
const showSettings = (defaultPanel?: SettingPanelType) => {
|
||||||
dialogStore.showDialog({
|
settingsDialog.show(defaultPanel)
|
||||||
key: 'global-settings',
|
|
||||||
headerComponent: SettingDialogHeader,
|
|
||||||
component: SettingDialogContent,
|
|
||||||
props: {
|
|
||||||
defaultPanel
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showManageExtensions = async () => {
|
const showManageExtensions = async () => {
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- Active Jobs Grid -->
|
||||||
|
<div
|
||||||
|
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
|
||||||
|
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||||
|
:style="gridStyle"
|
||||||
|
>
|
||||||
|
<ActiveMediaAssetCard
|
||||||
|
v-for="job in activeJobItems"
|
||||||
|
:key="job.id"
|
||||||
|
:job="job"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Assets Header -->
|
<!-- Assets Header -->
|
||||||
<div v-if="assets.length" class="px-2 2xl:px-4">
|
<div
|
||||||
|
v-if="assets.length"
|
||||||
|
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||||
>
|
>
|
||||||
@@ -43,18 +59,25 @@ import { computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
|
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
|
||||||
|
import { useJobList } from '@/composables/queue/useJobList'
|
||||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { isActiveJobState } from '@/utils/queueUtil'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
assets,
|
assets,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
isInFolderView = false,
|
||||||
assetType = 'output',
|
assetType = 'output',
|
||||||
showOutputCount,
|
showOutputCount,
|
||||||
getOutputCount
|
getOutputCount
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
assets: AssetItem[]
|
assets: AssetItem[]
|
||||||
isSelected: (assetId: string) => boolean
|
isSelected: (assetId: string) => boolean
|
||||||
|
isInFolderView?: boolean
|
||||||
assetType?: 'input' | 'output'
|
assetType?: 'input' | 'output'
|
||||||
showOutputCount: (asset: AssetItem) => boolean
|
showOutputCount: (asset: AssetItem) => boolean
|
||||||
getOutputCount: (asset: AssetItem) => number
|
getOutputCount: (asset: AssetItem) => number
|
||||||
@@ -69,9 +92,19 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { jobItems } = useJobList()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
const isQueuePanelV2Enabled = computed(() =>
|
||||||
|
settingStore.get('Comfy.Queue.QPOV2')
|
||||||
|
)
|
||||||
|
|
||||||
type AssetGridItem = { key: string; asset: AssetItem }
|
type AssetGridItem = { key: string; asset: AssetItem }
|
||||||
|
|
||||||
|
const activeJobItems = computed(() =>
|
||||||
|
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
|
||||||
|
)
|
||||||
|
|
||||||
const assetItems = computed<AssetGridItem[]>(() =>
|
const assetItems = computed<AssetGridItem[]>(() =>
|
||||||
assets.map((asset) => ({
|
assets.map((asset) => ({
|
||||||
key: `asset-${asset.id}`,
|
key: `asset-${asset.id}`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||||
|
|
||||||
@@ -9,12 +10,51 @@ vi.mock('vue-i18n', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/queue/useJobActions', () => ({
|
||||||
|
useJobActions: () => ({
|
||||||
|
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
|
||||||
|
canCancelJob: ref(false),
|
||||||
|
runCancelJob: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockJobItems = ref<
|
||||||
|
Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
meta: string
|
||||||
|
state: string
|
||||||
|
createTime?: number
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
vi.mock('@/composables/queue/useJobList', () => ({
|
||||||
|
useJobList: () => ({
|
||||||
|
jobItems: mockJobItems
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/assetsStore', () => ({
|
vi.mock('@/stores/assetsStore', () => ({
|
||||||
useAssetsStore: () => ({
|
useAssetsStore: () => ({
|
||||||
isAssetDeleting: () => false
|
isAssetDeleting: () => false
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
|
useSettingStore: () => ({
|
||||||
|
get: (key: string) => key === 'Comfy.Queue.QPOV2'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/queueUtil', () => ({
|
||||||
|
isActiveJobState: (state: string) =>
|
||||||
|
state === 'pending' || state === 'running'
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/queueDisplay', () => ({
|
||||||
|
iconForJobState: () => 'pi pi-spinner'
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
|
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
|
||||||
getOutputAssetMetadata: () => undefined
|
getOutputAssetMetadata: () => undefined
|
||||||
}))
|
}))
|
||||||
@@ -33,6 +73,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
|||||||
describe('AssetsSidebarListView', () => {
|
describe('AssetsSidebarListView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockJobItems.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -43,14 +84,67 @@ describe('AssetsSidebarListView', () => {
|
|||||||
toggleStack: async () => {}
|
toggleStack: async () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('renders without errors with empty assets', () => {
|
it('displays active jobs in oldest-first order (FIFO)', () => {
|
||||||
|
mockJobItems.value = [
|
||||||
|
{
|
||||||
|
id: 'newest',
|
||||||
|
title: 'Newest Job',
|
||||||
|
meta: '',
|
||||||
|
state: 'pending',
|
||||||
|
createTime: 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'middle',
|
||||||
|
title: 'Middle Job',
|
||||||
|
meta: '',
|
||||||
|
state: 'running',
|
||||||
|
createTime: 2000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'oldest',
|
||||||
|
title: 'Oldest Job',
|
||||||
|
meta: '',
|
||||||
|
state: 'pending',
|
||||||
|
createTime: 1000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const wrapper = mount(AssetsSidebarListView, {
|
const wrapper = mount(AssetsSidebarListView, {
|
||||||
props: defaultProps,
|
props: defaultProps,
|
||||||
shallow: true
|
shallow: true
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.exists()).toBe(true)
|
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||||
const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
expect(jobListItems).toHaveLength(3)
|
||||||
expect(listItems).toHaveLength(0)
|
|
||||||
|
const displayedTitles = jobListItems.map((item) =>
|
||||||
|
item.props('primaryText')
|
||||||
|
)
|
||||||
|
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes completed and failed jobs from active jobs section', () => {
|
||||||
|
mockJobItems.value = [
|
||||||
|
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
|
||||||
|
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
|
||||||
|
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
|
||||||
|
{ id: 'running', title: 'Running', meta: '', state: 'running' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mount(AssetsSidebarListView, {
|
||||||
|
props: defaultProps,
|
||||||
|
shallow: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||||
|
expect(jobListItems).toHaveLength(2)
|
||||||
|
|
||||||
|
const displayedTitles = jobListItems.map((item) =>
|
||||||
|
item.props('primaryText')
|
||||||
|
)
|
||||||
|
expect(displayedTitles).toContain('Running')
|
||||||
|
expect(displayedTitles).toContain('Pending')
|
||||||
|
expect(displayedTitles).not.toContain('Completed')
|
||||||
|
expect(displayedTitles).not.toContain('Failed')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<div v-if="assetItems.length" class="px-2">
|
<div
|
||||||
|
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||||
|
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||||
|
>
|
||||||
|
<AssetsListItem
|
||||||
|
v-for="job in activeJobItems"
|
||||||
|
:key="job.id"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||||
|
'cursor-default'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:preview-url="job.iconImageUrl"
|
||||||
|
:preview-alt="job.title"
|
||||||
|
:icon-name="job.iconName"
|
||||||
|
:icon-class="getJobIconClass(job)"
|
||||||
|
:primary-text="job.title"
|
||||||
|
:secondary-text="job.meta"
|
||||||
|
:progress-total-percent="job.progressTotalPercent"
|
||||||
|
:progress-current-percent="job.progressCurrentPercent"
|
||||||
|
@mouseenter="onJobEnter(job.id)"
|
||||||
|
@mouseleave="onJobLeave(job.id)"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<template v-if="hoveredJobId === job.id" #actions>
|
||||||
|
<Button
|
||||||
|
v-if="canCancelJob"
|
||||||
|
:variant="cancelAction.variant"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="cancelAction.label"
|
||||||
|
@click.stop="runCancelJob()"
|
||||||
|
>
|
||||||
|
<i :class="cancelAction.icon" class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</AssetsListItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="assetItems.length"
|
||||||
|
:class="cn('px-2', activeJobItems.length && 'mt-2')"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||||
>
|
>
|
||||||
@@ -77,25 +119,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||||
|
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||||
|
import { useJobList } from '@/composables/queue/useJobList'
|
||||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||||
import { useAssetsStore } from '@/stores/assetsStore'
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
|
import { isActiveJobState } from '@/utils/queueUtil'
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatSize,
|
formatSize,
|
||||||
getMediaTypeFromFilename,
|
getMediaTypeFromFilename,
|
||||||
truncateFilename
|
truncateFilename
|
||||||
} from '@/utils/formatUtil'
|
} from '@/utils/formatUtil'
|
||||||
|
import { iconForJobState } from '@/utils/queueDisplay'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
assetItems,
|
assetItems,
|
||||||
@@ -122,7 +170,24 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { jobItems } = useJobList()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
|
const isQueuePanelV2Enabled = computed(() =>
|
||||||
|
settingStore.get('Comfy.Queue.QPOV2')
|
||||||
|
)
|
||||||
|
const hoveredJobId = ref<string | null>(null)
|
||||||
const hoveredAssetId = ref<string | null>(null)
|
const hoveredAssetId = ref<string | null>(null)
|
||||||
|
const activeJobItems = computed(() =>
|
||||||
|
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
|
||||||
|
)
|
||||||
|
const hoveredJob = computed(() =>
|
||||||
|
hoveredJobId.value
|
||||||
|
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
||||||
|
null)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
||||||
|
|
||||||
const listGridStyle = {
|
const listGridStyle = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -175,6 +240,16 @@ function getAssetCardClass(selected: boolean): string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onJobEnter(jobId: string) {
|
||||||
|
hoveredJobId.value = jobId
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobLeave(jobId: string) {
|
||||||
|
if (hoveredJobId.value === jobId) {
|
||||||
|
hoveredJobId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onAssetEnter(assetId: string) {
|
function onAssetEnter(assetId: string) {
|
||||||
hoveredAssetId.value = assetId
|
hoveredAssetId.value = assetId
|
||||||
}
|
}
|
||||||
@@ -184,4 +259,13 @@ function onAssetLeave(assetId: string) {
|
|||||||
hoveredAssetId.value = null
|
hoveredAssetId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getJobIconClass(job: JobListItem): string | undefined {
|
||||||
|
const classes = []
|
||||||
|
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||||
|
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
||||||
|
classes.push('animate-spin')
|
||||||
|
}
|
||||||
|
return classes.length ? classes.join(' ') : undefined
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,15 +26,6 @@
|
|||||||
<template #tool-buttons>
|
<template #tool-buttons>
|
||||||
<!-- Normal Tab View -->
|
<!-- Normal Tab View -->
|
||||||
<TabList v-if="!isInFolderView" v-model="activeTab">
|
<TabList v-if="!isInFolderView" v-model="activeTab">
|
||||||
<Tab v-if="isQueuePanelV2Enabled" class="font-inter" value="queue">
|
|
||||||
{{ $t('sideToolbar.labels.queue') }}
|
|
||||||
<span
|
|
||||||
v-if="activeJobsCount > 0"
|
|
||||||
class="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-xs text-base-foreground font-medium h-5"
|
|
||||||
>
|
|
||||||
{{ activeJobsCount }}
|
|
||||||
</span>
|
|
||||||
</Tab>
|
|
||||||
<Tab class="font-inter" value="output">{{
|
<Tab class="font-inter" value="output">{{
|
||||||
$t('sideToolbar.labels.generated')
|
$t('sideToolbar.labels.generated')
|
||||||
}}</Tab>
|
}}</Tab>
|
||||||
@@ -52,9 +43,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Bar (hidden on queue tab) -->
|
<!-- Filter Bar -->
|
||||||
<MediaAssetFilterBar
|
<MediaAssetFilterBar
|
||||||
v-if="!isQueueTab"
|
|
||||||
v-model:search-query="searchQuery"
|
v-model:search-query="searchQuery"
|
||||||
v-model:sort-by="sortBy"
|
v-model:sort-by="sortBy"
|
||||||
v-model:view-mode="viewMode"
|
v-model:view-mode="viewMode"
|
||||||
@@ -63,29 +53,30 @@
|
|||||||
:show-generation-time-sort="activeTab === 'output'"
|
:show-generation-time-sort="activeTab === 'output'"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="isQueueTab && !isInFolderView"
|
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||||
class="flex items-center justify-between px-4 2xl:px-6"
|
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ activeJobsLabel }}
|
{{ activeJobsLabel }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MediaAssetViewModeToggle v-model:view-mode="viewMode" />
|
<span class="text-sm text-base-foreground">
|
||||||
|
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="clearQueueTooltip"
|
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||||
"
|
"
|
||||||
:disabled="queueStore.pendingTasks.length === 0"
|
:disabled="queuedCount === 0"
|
||||||
@click="handleClearQueue"
|
@click="handleClearQueue"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--list-x] size-4" />
|
<i class="icon-[lucide--list-x] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider v-else-if="!isQueueTab" type="dashed" class="my-2" />
|
<Divider v-else type="dashed" class="my-2" />
|
||||||
</template>
|
</template>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div v-if="showLoadingState">
|
<div v-if="showLoadingState">
|
||||||
@@ -96,32 +87,23 @@
|
|||||||
icon="pi pi-info-circle"
|
icon="pi pi-info-circle"
|
||||||
:title="
|
:title="
|
||||||
$t(
|
$t(
|
||||||
isQueueTab
|
activeTab === 'input'
|
||||||
? 'sideToolbar.noQueueItems'
|
? 'sideToolbar.noImportedFiles'
|
||||||
: activeTab === 'input'
|
: 'sideToolbar.noGeneratedFiles'
|
||||||
? 'sideToolbar.noImportedFiles'
|
|
||||||
: 'sideToolbar.noGeneratedFiles'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:message="
|
|
||||||
$t(
|
|
||||||
isQueueTab
|
|
||||||
? 'sideToolbar.noQueueItemsMessage'
|
|
||||||
: 'sideToolbar.noFilesFoundMessage'
|
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
:message="$t('sideToolbar.noFilesFoundMessage')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||||
<QueueAssetView v-if="isQueueTab" :view-mode="viewMode" />
|
|
||||||
<AssetsSidebarListView
|
<AssetsSidebarListView
|
||||||
v-else-if="isListView"
|
v-if="isListView"
|
||||||
:asset-items="listViewAssetItems"
|
:asset-items="listViewAssetItems"
|
||||||
:is-selected="isSelected"
|
:is-selected="isSelected"
|
||||||
:selectable-assets="listViewSelectableAssets"
|
:selectable-assets="listViewSelectableAssets"
|
||||||
:is-stack-expanded="isListViewStackExpanded"
|
:is-stack-expanded="isListViewStackExpanded"
|
||||||
:toggle-stack="toggleListViewStack"
|
:toggle-stack="toggleListViewStack"
|
||||||
:asset-type="assetTabType"
|
:asset-type="activeTab"
|
||||||
@select-asset="handleAssetSelect"
|
@select-asset="handleAssetSelect"
|
||||||
@context-menu="handleAssetContextMenu"
|
@context-menu="handleAssetContextMenu"
|
||||||
@approach-end="handleApproachEnd"
|
@approach-end="handleApproachEnd"
|
||||||
@@ -130,7 +112,8 @@
|
|||||||
v-else
|
v-else
|
||||||
:assets="displayAssets"
|
:assets="displayAssets"
|
||||||
:is-selected="isSelected"
|
:is-selected="isSelected"
|
||||||
:asset-type="assetTabType"
|
:is-in-folder-view="isInFolderView"
|
||||||
|
:asset-type="activeTab"
|
||||||
:show-output-count="shouldShowOutputCount"
|
:show-output-count="shouldShowOutputCount"
|
||||||
:get-output-count="getOutputCount"
|
:get-output-count="getOutputCount"
|
||||||
@select-asset="handleAssetSelect"
|
@select-asset="handleAssetSelect"
|
||||||
@@ -241,16 +224,13 @@ const Load3dViewerContent = () =>
|
|||||||
import('@/components/load3d/Load3dViewerContent.vue')
|
import('@/components/load3d/Load3dViewerContent.vue')
|
||||||
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
|
||||||
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
|
||||||
import QueueAssetView from '@/components/sidebar/tabs/QueueAssetView.vue'
|
|
||||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||||
import Tab from '@/components/tab/Tab.vue'
|
import Tab from '@/components/tab/Tab.vue'
|
||||||
import TabList from '@/components/tab/TabList.vue'
|
import TabList from '@/components/tab/TabList.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
|
||||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||||
import MediaAssetViewModeToggle from '@/platform/assets/components/MediaAssetViewModeToggle.vue'
|
|
||||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||||
@@ -277,7 +257,7 @@ const { activeJobsCount } = storeToRefs(queueStore)
|
|||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
const activeTab = ref<'input' | 'output' | 'queue'>('output')
|
const activeTab = ref<'input' | 'output'>('output')
|
||||||
const folderPromptId = ref<string | null>(null)
|
const folderPromptId = ref<string | null>(null)
|
||||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||||
@@ -288,10 +268,6 @@ const viewMode = useStorage<'list' | 'grid'>(
|
|||||||
const isQueuePanelV2Enabled = computed(() =>
|
const isQueuePanelV2Enabled = computed(() =>
|
||||||
settingStore.get('Comfy.Queue.QPOV2')
|
settingStore.get('Comfy.Queue.QPOV2')
|
||||||
)
|
)
|
||||||
const isQueueTab = computed(() => activeTab.value === 'queue')
|
|
||||||
const assetTabType = computed<'input' | 'output'>(() =>
|
|
||||||
activeTab.value === 'input' ? 'input' : 'output'
|
|
||||||
)
|
|
||||||
const isListView = computed(
|
const isListView = computed(
|
||||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||||
)
|
)
|
||||||
@@ -326,9 +302,7 @@ const formattedExecutionTime = computed(() => {
|
|||||||
return formatDuration(folderExecutionTime.value * 1000)
|
return formatDuration(folderExecutionTime.value * 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
const clearQueueTooltip = computed(() =>
|
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
|
|
||||||
)
|
|
||||||
const activeJobsLabel = computed(() => {
|
const activeJobsLabel = computed(() => {
|
||||||
const count = activeJobsCount.value
|
const count = activeJobsCount.value
|
||||||
return t(
|
return t(
|
||||||
@@ -441,15 +415,18 @@ const isBulkMode = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showLoadingState = computed(
|
const showLoadingState = computed(
|
||||||
() => !isQueueTab.value && loading.value && displayAssets.value.length === 0
|
() =>
|
||||||
|
loading.value &&
|
||||||
|
displayAssets.value.length === 0 &&
|
||||||
|
activeJobsCount.value === 0
|
||||||
)
|
)
|
||||||
|
|
||||||
const showEmptyState = computed(() => {
|
const showEmptyState = computed(
|
||||||
if (isQueueTab.value) {
|
() =>
|
||||||
return activeJobsCount.value === 0
|
!loading.value &&
|
||||||
}
|
displayAssets.value.length === 0 &&
|
||||||
return !loading.value && displayAssets.value.length === 0
|
activeJobsCount.value === 0
|
||||||
})
|
)
|
||||||
|
|
||||||
watch(visibleAssets, (newAssets) => {
|
watch(visibleAssets, (newAssets) => {
|
||||||
// Alternative: keep hidden selections and surface them in UI; for now prune
|
// Alternative: keep hidden selections and surface them in UI; for now prune
|
||||||
@@ -506,21 +483,12 @@ watch(
|
|||||||
clearSelection()
|
clearSelection()
|
||||||
// Clear search when switching tabs
|
// Clear search when switching tabs
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
// Skip asset fetch for queue tab
|
// Reset pagination state when tab changes
|
||||||
if (activeTab.value !== 'queue') {
|
void refreshAssets()
|
||||||
void refreshAssets()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reset to output tab if QPOV2 is disabled while on queue tab
|
|
||||||
watch(isQueuePanelV2Enabled, (enabled) => {
|
|
||||||
if (!enabled && activeTab.value === 'queue') {
|
|
||||||
activeTab.value = 'output'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||||
const assetList = assets ?? visibleAssets.value
|
const assetList = assets ?? visibleAssets.value
|
||||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
|
||||||
|
|
||||||
import QueueAssetView from './QueueAssetView.vue'
|
|
||||||
|
|
||||||
const { mockJobItems } = vi.hoisted<{
|
|
||||||
mockJobItems: Ref<JobListItem[]>
|
|
||||||
}>(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { ref: hoistedRef } = require('vue')
|
|
||||||
return { mockJobItems: hoistedRef([]) }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/composables/queue/useJobList', () => ({
|
|
||||||
useJobList: () => ({
|
|
||||||
jobItems: mockJobItems
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/composables/queue/useJobActions', () => ({
|
|
||||||
useJobActions: () => ({
|
|
||||||
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
|
|
||||||
canCancelJob: ref(false),
|
|
||||||
runCancelJob: vi.fn()
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/utils/queueUtil', () => ({
|
|
||||||
isActiveJobState: (state: string) =>
|
|
||||||
state === 'pending' || state === 'running' || state === 'initialization'
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/utils/queueDisplay', () => ({
|
|
||||||
iconForJobState: () => 'pi pi-spinner'
|
|
||||||
}))
|
|
||||||
|
|
||||||
function makeJob(overrides: Partial<JobListItem>): JobListItem {
|
|
||||||
return {
|
|
||||||
id: 'job-1',
|
|
||||||
title: 'Job 1',
|
|
||||||
meta: '',
|
|
||||||
state: 'pending',
|
|
||||||
...overrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('QueueAssetView', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockJobItems.value = []
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays active jobs in oldest-first order (FIFO)', () => {
|
|
||||||
mockJobItems.value = [
|
|
||||||
makeJob({ id: 'newest', title: 'Newest Job', state: 'pending' }),
|
|
||||||
makeJob({ id: 'middle', title: 'Middle Job', state: 'running' }),
|
|
||||||
makeJob({ id: 'oldest', title: 'Oldest Job', state: 'pending' })
|
|
||||||
]
|
|
||||||
|
|
||||||
const wrapper = mount(QueueAssetView, {
|
|
||||||
props: { viewMode: 'list' },
|
|
||||||
shallow: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
|
||||||
expect(items).toHaveLength(3)
|
|
||||||
|
|
||||||
const titles = items.map((item) => item.props('primaryText'))
|
|
||||||
expect(titles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('excludes completed and failed jobs', () => {
|
|
||||||
mockJobItems.value = [
|
|
||||||
makeJob({ id: 'pending', title: 'Pending', state: 'pending' }),
|
|
||||||
makeJob({ id: 'completed', title: 'Completed', state: 'completed' }),
|
|
||||||
makeJob({ id: 'failed', title: 'Failed', state: 'failed' }),
|
|
||||||
makeJob({ id: 'running', title: 'Running', state: 'running' })
|
|
||||||
]
|
|
||||||
|
|
||||||
const wrapper = mount(QueueAssetView, {
|
|
||||||
props: { viewMode: 'list' },
|
|
||||||
shallow: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
|
||||||
expect(items).toHaveLength(2)
|
|
||||||
|
|
||||||
const titles = items.map((item) => item.props('primaryText'))
|
|
||||||
expect(titles).toContain('Running')
|
|
||||||
expect(titles).toContain('Pending')
|
|
||||||
expect(titles).not.toContain('Completed')
|
|
||||||
expect(titles).not.toContain('Failed')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<!-- Grid View -->
|
|
||||||
<VirtualGrid
|
|
||||||
v-if="viewMode === 'grid'"
|
|
||||||
class="flex-1"
|
|
||||||
:items="gridItems"
|
|
||||||
:grid-style="gridStyle"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<ActiveMediaAssetCard :job="item.job" />
|
|
||||||
</template>
|
|
||||||
</VirtualGrid>
|
|
||||||
|
|
||||||
<!-- List View -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-1 scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
|
||||||
>
|
|
||||||
<AssetsListItem
|
|
||||||
v-for="job in activeJobItems"
|
|
||||||
:key="job.id"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
|
|
||||||
'cursor-default'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:preview-url="job.iconImageUrl"
|
|
||||||
:preview-alt="job.title"
|
|
||||||
:icon-name="job.iconName"
|
|
||||||
:icon-class="getJobIconClass(job)"
|
|
||||||
:primary-text="job.title"
|
|
||||||
:secondary-text="job.meta"
|
|
||||||
:progress-total-percent="job.progressTotalPercent"
|
|
||||||
:progress-current-percent="job.progressCurrentPercent"
|
|
||||||
@mouseenter="onJobEnter(job.id)"
|
|
||||||
@mouseleave="onJobLeave(job.id)"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<template v-if="hoveredJobId === job.id" #actions>
|
|
||||||
<Button
|
|
||||||
v-if="canCancelJob"
|
|
||||||
:variant="cancelAction.variant"
|
|
||||||
size="icon"
|
|
||||||
:aria-label="cancelAction.label"
|
|
||||||
@click.stop="runCancelJob()"
|
|
||||||
>
|
|
||||||
<i :class="cancelAction.icon" class="size-4" />
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</AssetsListItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
|
||||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
|
||||||
import { useJobList } from '@/composables/queue/useJobList'
|
|
||||||
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
|
|
||||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
|
||||||
import { isActiveJobState } from '@/utils/queueUtil'
|
|
||||||
import { iconForJobState } from '@/utils/queueDisplay'
|
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
|
||||||
|
|
||||||
const { viewMode = 'grid' } = defineProps<{
|
|
||||||
viewMode?: 'list' | 'grid'
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { jobItems } = useJobList()
|
|
||||||
|
|
||||||
const activeJobItems = computed(() =>
|
|
||||||
jobItems.value
|
|
||||||
.filter((item) => isActiveJobState(item.state))
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
)
|
|
||||||
|
|
||||||
const gridItems = computed(() =>
|
|
||||||
activeJobItems.value.map((job) => ({
|
|
||||||
key: `queue-${job.id}`,
|
|
||||||
job
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const gridStyle = {
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
||||||
padding: '0 0.5rem',
|
|
||||||
gap: '0.5rem'
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoveredJobId = ref<string | null>(null)
|
|
||||||
const hoveredJob = computed(() =>
|
|
||||||
hoveredJobId.value
|
|
||||||
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
|
|
||||||
null)
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
|
|
||||||
|
|
||||||
function onJobEnter(jobId: string) {
|
|
||||||
hoveredJobId.value = jobId
|
|
||||||
}
|
|
||||||
|
|
||||||
function onJobLeave(jobId: string) {
|
|
||||||
if (hoveredJobId.value === jobId) {
|
|
||||||
hoveredJobId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJobIconClass(job: JobListItem): string | undefined {
|
|
||||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
|
||||||
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
|
|
||||||
return 'animate-spin'
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
<template #before-label="{ node: treeNode }">
|
<template #before-label="{ node: treeNode }">
|
||||||
<span
|
<span
|
||||||
v-if="
|
v-if="
|
||||||
treeNode.data?.isModified || !treeNode.data?.isPersisted
|
(treeNode.data as ComfyWorkflow)?.isModified ||
|
||||||
|
!(treeNode.data as ComfyWorkflow)?.isPersisted
|
||||||
"
|
"
|
||||||
>*</span
|
>*</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
|
interface TreeExplorerExposed {
|
||||||
|
addFolderCommand: (targetNodeKey: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeExplorerRef = ref<TreeExplorerExposed | null>(null)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
|
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const expandedKeys = inject(InjectKeyExpandedKeys)
|
const expandedKeys = inject(InjectKeyExpandedKeys)
|
||||||
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
|
const handleItemDrop = (node: RenderedTreeExplorerNode<ComfyNodeDefImpl>) => {
|
||||||
if (!expandedKeys) return
|
if (!expandedKeys) return
|
||||||
expandedKeys.value[node.key] = true
|
expandedKeys.value[node.key] = true
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/toast/InviteAcceptedToast.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<Toast group="invite-accepted" position="top-right">
|
||||||
|
<template #message="slotProps">
|
||||||
|
<div class="flex items-center gap-2 justify-between w-full">
|
||||||
|
<div class="flex flex-col justify-start">
|
||||||
|
<div class="text-base">
|
||||||
|
{{ slotProps.message.summary }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-foreground">
|
||||||
|
{{ slotProps.message.detail.text }} <br />
|
||||||
|
{{ slotProps.message.detail.workspaceName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="inverted"
|
||||||
|
@click="viewWorkspace(slotProps.message.detail.workspaceId)"
|
||||||
|
>
|
||||||
|
{{ t('workspace.viewWorkspace') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Toast>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast } from 'primevue'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||||
|
|
||||||
|
function viewWorkspace(workspaceId: string) {
|
||||||
|
void switchWithConfirmation(workspaceId)
|
||||||
|
toast.removeGroup('invite-accepted')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -31,6 +31,15 @@ vi.mock('pinia')
|
|||||||
const mockShowSettingsDialog = vi.fn()
|
const mockShowSettingsDialog = vi.fn()
|
||||||
const mockShowTopUpCreditsDialog = vi.fn()
|
const mockShowTopUpCreditsDialog = vi.fn()
|
||||||
|
|
||||||
|
// Mock the settings dialog composable
|
||||||
|
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||||
|
useSettingsDialog: vi.fn(() => ({
|
||||||
|
show: mockShowSettingsDialog,
|
||||||
|
hide: vi.fn(),
|
||||||
|
showAbout: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
// Mock window.open
|
// Mock window.open
|
||||||
const originalWindowOpen = window.open
|
const originalWindowOpen = window.open
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -64,7 +73,6 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
|||||||
// Mock the dialog service
|
// Mock the dialog service
|
||||||
vi.mock('@/services/dialogService', () => ({
|
vi.mock('@/services/dialogService', () => ({
|
||||||
useDialogService: vi.fn(() => ({
|
useDialogService: vi.fn(() => ({
|
||||||
showSettingsDialog: mockShowSettingsDialog,
|
|
||||||
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
|||||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
|||||||
useCurrentUser()
|
useCurrentUser()
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
const authStore = useFirebaseAuthStore()
|
const authStore = useFirebaseAuthStore()
|
||||||
|
const settingsDialog = useSettingsDialog()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const {
|
const {
|
||||||
isActiveSubscription,
|
isActiveSubscription,
|
||||||
@@ -198,7 +200,7 @@ const canUpgrade = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleOpenUserSettings = () => {
|
const handleOpenUserSettings = () => {
|
||||||
dialogService.showSettingsDialog('user')
|
settingsDialog.show('user')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,9 +211,9 @@ const handleOpenPlansAndPricing = () => {
|
|||||||
|
|
||||||
const handleOpenPlanAndCreditsSettings = () => {
|
const handleOpenPlanAndCreditsSettings = () => {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
dialogService.showSettingsDialog('subscription')
|
settingsDialog.show('subscription')
|
||||||
} else {
|
} else {
|
||||||
dialogService.showSettingsDialog('credits')
|
settingsDialog.show('credits')
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@@ -55,63 +55,61 @@
|
|||||||
/>
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
<!-- Credits Section -->
|
||||||
<template v-if="showCreditsSection">
|
|
||||||
<div class="flex items-center gap-2 px-4 py-2">
|
|
||||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
|
||||||
<Skeleton
|
|
||||||
v-if="isLoadingBalance"
|
|
||||||
width="4rem"
|
|
||||||
height="1.25rem"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
|
||||||
displayedCredits
|
|
||||||
}}</span>
|
|
||||||
<i
|
|
||||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
|
||||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<!-- Subscribed: Show Add Credits button -->
|
|
||||||
<Button
|
|
||||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
class="text-base-foreground"
|
|
||||||
data-testid="add-credits-button"
|
|
||||||
@click="handleTopUp"
|
|
||||||
>
|
|
||||||
{{ $t('subscription.addCredits') }}
|
|
||||||
</Button>
|
|
||||||
<!-- Unsubscribed: Show Subscribe button -->
|
|
||||||
<SubscribeButton
|
|
||||||
v-else-if="isPersonalWorkspace"
|
|
||||||
:fluid="false"
|
|
||||||
:label="
|
|
||||||
isCancelled
|
|
||||||
? $t('subscription.resubscribe')
|
|
||||||
: $t('workspaceSwitcher.subscribe')
|
|
||||||
"
|
|
||||||
size="sm"
|
|
||||||
variant="gradient"
|
|
||||||
/>
|
|
||||||
<!-- Non-personal workspace: Show pricing table -->
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
@click="handleOpenPlansAndPricing"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isCancelled
|
|
||||||
? $t('subscription.resubscribe')
|
|
||||||
: $t('workspaceSwitcher.subscribe')
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider class="mx-0 my-2" />
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
</template>
|
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||||
|
<Skeleton
|
||||||
|
v-if="isLoadingBalance"
|
||||||
|
width="4rem"
|
||||||
|
height="1.25rem"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||||
|
displayedCredits
|
||||||
|
}}</span>
|
||||||
|
<i
|
||||||
|
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||||
|
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<!-- Add Credits (subscribed + personal or workspace owner only) -->
|
||||||
|
<Button
|
||||||
|
v-if="isActiveSubscription && permissions.canTopUp"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="text-base-foreground"
|
||||||
|
data-testid="add-credits-button"
|
||||||
|
@click="handleTopUp"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.addCredits') }}
|
||||||
|
</Button>
|
||||||
|
<!-- Subscribe/Resubscribe (only when not subscribed or cancelled) -->
|
||||||
|
<SubscribeButton
|
||||||
|
v-if="showSubscribeAction && isPersonalWorkspace"
|
||||||
|
:fluid="false"
|
||||||
|
:label="
|
||||||
|
isCancelled
|
||||||
|
? $t('subscription.resubscribe')
|
||||||
|
: $t('workspaceSwitcher.subscribe')
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showSubscribeAction && !isPersonalWorkspace"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
@click="handleOpenPlansAndPricing"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isCancelled
|
||||||
|
? $t('subscription.resubscribe')
|
||||||
|
: $t('workspaceSwitcher.subscribe')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="mx-0 my-2" />
|
||||||
|
|
||||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||||
<div
|
<div
|
||||||
@@ -222,16 +220,16 @@ import { isCloud } from '@/platform/distribution/types'
|
|||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
|
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
|
||||||
const workspaceStore = useTeamWorkspaceStore()
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
const {
|
const {
|
||||||
initState,
|
initState,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
isInPersonalWorkspace: isPersonalWorkspace,
|
isInPersonalWorkspace: isPersonalWorkspace
|
||||||
isWorkspaceSubscribed
|
|
||||||
} = storeToRefs(workspaceStore)
|
} = storeToRefs(workspaceStore)
|
||||||
const { workspaceRole } = useWorkspaceUI()
|
const { permissions } = useWorkspaceUI()
|
||||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -242,6 +240,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
|
|||||||
|
|
||||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||||
useCurrentUser()
|
useCurrentUser()
|
||||||
|
const settingsDialog = useSettingsDialog()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
const { isActiveSubscription, subscription, balance, isLoading, fetchBalance } =
|
||||||
useBillingContext()
|
useBillingContext()
|
||||||
@@ -275,22 +274,24 @@ const canUpgrade = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showPlansAndPricing = computed(
|
const showPlansAndPricing = computed(
|
||||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
() => permissions.value.canManageSubscription
|
||||||
)
|
)
|
||||||
const showManagePlan = computed(
|
const showManagePlan = computed(
|
||||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
() => permissions.value.canManageSubscription && isActiveSubscription.value
|
||||||
)
|
)
|
||||||
const showCreditsSection = computed(
|
const showSubscribeAction = computed(
|
||||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
() =>
|
||||||
|
permissions.value.canManageSubscription &&
|
||||||
|
(!isActiveSubscription.value || isCancelled.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleOpenUserSettings = () => {
|
const handleOpenUserSettings = () => {
|
||||||
dialogService.showSettingsDialog('user')
|
settingsDialog.show('user')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenWorkspaceSettings = () => {
|
const handleOpenWorkspaceSettings = () => {
|
||||||
dialogService.showSettingsDialog('workspace')
|
settingsDialog.show('workspace')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,9 +302,9 @@ const handleOpenPlansAndPricing = () => {
|
|||||||
|
|
||||||
const handleOpenPlanAndCreditsSettings = () => {
|
const handleOpenPlanAndCreditsSettings = () => {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
dialogService.showSettingsDialog('workspace')
|
settingsDialog.show('workspace')
|
||||||
} else {
|
} else {
|
||||||
dialogService.showSettingsDialog('credits')
|
settingsDialog.show('credits')
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ defineProps<{
|
|||||||
:side-offset="5"
|
:side-offset="5"
|
||||||
:collision-padding="10"
|
:collision-padding="10"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<div class="flex flex-col p-1">
|
<div class="flex flex-col p-1">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
:class="cn('rounded-2xl overflow-hidden relative', sizeClasses)"
|
||||||
@keydown.esc.capture="handleEscape"
|
@keydown.esc.capture="handleEscape"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -141,14 +141,31 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { contentTitle, rightPanelTitle } = defineProps<{
|
const SIZE_CLASSES = {
|
||||||
|
sm: 'h-[80vh] w-[90vw] max-w-[960px]',
|
||||||
|
md: 'h-[80vh] w-[90vw] max-w-[1400px]',
|
||||||
|
lg: 'h-[80vh] w-[90vw] max-w-[1280px] aspect-[20/13] min-[1450px]:max-w-[1724px]',
|
||||||
|
full: 'h-full w-full max-w-[1400px] 2xl:max-w-[1600px]'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type ModalSize = keyof typeof SIZE_CLASSES
|
||||||
|
|
||||||
|
const {
|
||||||
|
contentTitle,
|
||||||
|
rightPanelTitle,
|
||||||
|
size = 'lg'
|
||||||
|
} = defineProps<{
|
||||||
contentTitle: string
|
contentTitle: string
|
||||||
rightPanelTitle?: string
|
rightPanelTitle?: string
|
||||||
|
size?: ModalSize
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||||
|
|
||||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||||
default: false
|
default: false
|
||||||
})
|
})
|
||||||
@@ -215,17 +232,3 @@ function handleEscape(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
|
||||||
.base-widget-layout {
|
|
||||||
height: 80vh;
|
|
||||||
width: 90vw;
|
|
||||||
max-width: 1280px;
|
|
||||||
aspect-ratio: 20/13;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1450px) {
|
|
||||||
.base-widget-layout {
|
|
||||||
max-width: 1724px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
|
||||||
|
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
import type {
|
import type {
|
||||||
Plan,
|
Plan,
|
||||||
PreviewSubscribeResponse,
|
PreviewSubscribeResponse,
|
||||||
@@ -73,4 +74,5 @@ export interface BillingState {
|
|||||||
|
|
||||||
export interface BillingContext extends BillingState, BillingActions {
|
export interface BillingContext extends BillingState, BillingActions {
|
||||||
type: ComputedRef<BillingType>
|
type: ComputedRef<BillingType>
|
||||||
|
getMaxSeats: (tierKey: TierKey) => number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,50 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||||
|
|
||||||
import { useBillingContext } from './useBillingContext'
|
import { useBillingContext } from './useBillingContext'
|
||||||
|
|
||||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||||
const isInPersonalWorkspace = { value: true }
|
() => ({
|
||||||
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
|
mockTeamWorkspacesEnabled: { value: false },
|
||||||
|
mockIsPersonal: { value: true },
|
||||||
|
mockPlans: { value: [] as Plan[] }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||||
|
const original = await importOriginal()
|
||||||
return {
|
return {
|
||||||
useTeamWorkspaceStore: () => ({
|
...(original as Record<string, unknown>),
|
||||||
isInPersonalWorkspace: isInPersonalWorkspace.value,
|
createSharedComposable: (fn: (...args: unknown[]) => unknown) => fn
|
||||||
activeWorkspace: activeWorkspace.value,
|
|
||||||
_setPersonalWorkspace: (value: boolean) => {
|
|
||||||
isInPersonalWorkspace.value = value
|
|
||||||
activeWorkspace.value = value
|
|
||||||
? { id: 'personal-123', type: 'personal' }
|
|
||||||
: { id: 'team-456', type: 'team' }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||||
|
useFeatureFlags: () => ({
|
||||||
|
flags: {
|
||||||
|
get teamWorkspacesEnabled() {
|
||||||
|
return mockTeamWorkspacesEnabled.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||||
|
useTeamWorkspaceStore: () => ({
|
||||||
|
get isInPersonalWorkspace() {
|
||||||
|
return mockIsPersonal.value
|
||||||
|
},
|
||||||
|
get activeWorkspace() {
|
||||||
|
return mockIsPersonal.value
|
||||||
|
? { id: 'personal-123', type: 'personal' }
|
||||||
|
: { id: 'team-456', type: 'team' }
|
||||||
|
},
|
||||||
|
updateActiveWorkspace: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||||
useSubscription: () => ({
|
useSubscription: () => ({
|
||||||
isActiveSubscription: { value: true },
|
isActiveSubscription: { value: true },
|
||||||
@@ -52,20 +77,18 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => {
|
vi.mock('@/platform/cloud/subscription/composables/useBillingPlans', () => ({
|
||||||
const plans = { value: [] }
|
useBillingPlans: () => ({
|
||||||
const currentPlanSlug = { value: null }
|
get plans() {
|
||||||
return {
|
return mockPlans
|
||||||
useBillingPlans: () => ({
|
},
|
||||||
plans,
|
currentPlanSlug: { value: null },
|
||||||
currentPlanSlug,
|
isLoading: { value: false },
|
||||||
isLoading: { value: false },
|
error: { value: null },
|
||||||
error: { value: null },
|
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
||||||
fetchPlans: vi.fn().mockResolvedValue(undefined),
|
getPlanBySlug: vi.fn().mockReturnValue(null)
|
||||||
getPlanBySlug: vi.fn().mockReturnValue(null)
|
})
|
||||||
})
|
}))
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||||
workspaceApi: {
|
workspaceApi: {
|
||||||
@@ -88,6 +111,9 @@ describe('useBillingContext', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockTeamWorkspacesEnabled.value = false
|
||||||
|
mockIsPersonal.value = true
|
||||||
|
mockPlans.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns legacy type for personal workspace', () => {
|
it('returns legacy type for personal workspace', () => {
|
||||||
@@ -161,4 +187,51 @@ describe('useBillingContext', () => {
|
|||||||
const { showSubscriptionDialog } = useBillingContext()
|
const { showSubscriptionDialog } = useBillingContext()
|
||||||
expect(() => showSubscriptionDialog()).not.toThrow()
|
expect(() => showSubscriptionDialog()).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getMaxSeats', () => {
|
||||||
|
it('returns 1 for personal workspaces regardless of tier', () => {
|
||||||
|
const { getMaxSeats } = useBillingContext()
|
||||||
|
expect(getMaxSeats('standard')).toBe(1)
|
||||||
|
expect(getMaxSeats('creator')).toBe(1)
|
||||||
|
expect(getMaxSeats('pro')).toBe(1)
|
||||||
|
expect(getMaxSeats('founder')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to hardcoded values when no API plans available', () => {
|
||||||
|
mockTeamWorkspacesEnabled.value = true
|
||||||
|
mockIsPersonal.value = false
|
||||||
|
|
||||||
|
const { getMaxSeats } = useBillingContext()
|
||||||
|
expect(getMaxSeats('standard')).toBe(1)
|
||||||
|
expect(getMaxSeats('creator')).toBe(5)
|
||||||
|
expect(getMaxSeats('pro')).toBe(20)
|
||||||
|
expect(getMaxSeats('founder')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers API max_seats when plans are loaded', () => {
|
||||||
|
mockTeamWorkspacesEnabled.value = true
|
||||||
|
mockIsPersonal.value = false
|
||||||
|
mockPlans.value = [
|
||||||
|
{
|
||||||
|
slug: 'pro-monthly',
|
||||||
|
tier: 'PRO',
|
||||||
|
duration: 'MONTHLY',
|
||||||
|
price_cents: 10000,
|
||||||
|
credits_cents: 2110000,
|
||||||
|
max_seats: 50,
|
||||||
|
availability: { available: true },
|
||||||
|
seat_summary: {
|
||||||
|
seat_count: 1,
|
||||||
|
total_cost_cents: 10000,
|
||||||
|
total_credits_cents: 2110000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const { getMaxSeats } = useBillingContext()
|
||||||
|
expect(getMaxSeats('pro')).toBe(50)
|
||||||
|
// Tiers without API plans still fall back to hardcoded values
|
||||||
|
expect(getMaxSeats('creator')).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
|||||||
import { createSharedComposable } from '@vueuse/core'
|
import { createSharedComposable } from '@vueuse/core'
|
||||||
|
|
||||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import {
|
||||||
|
KEY_TO_TIER,
|
||||||
|
getTierFeatures
|
||||||
|
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
|
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -115,6 +120,16 @@ function useBillingContextInternal(): BillingContext {
|
|||||||
toValue(activeContext.value.isActiveSubscription)
|
toValue(activeContext.value.isActiveSubscription)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function getMaxSeats(tierKey: TierKey): number {
|
||||||
|
if (type.value === 'legacy') return 1
|
||||||
|
|
||||||
|
const apiTier = KEY_TO_TIER[tierKey]
|
||||||
|
const plan = plans.value.find(
|
||||||
|
(p) => p.tier === apiTier && p.duration === 'MONTHLY'
|
||||||
|
)
|
||||||
|
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||||
|
}
|
||||||
|
|
||||||
// Sync subscription info to workspace store for display in workspace switcher
|
// Sync subscription info to workspace store for display in workspace switcher
|
||||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||||
@@ -223,6 +238,7 @@ function useBillingContextInternal(): BillingContext {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isActiveSubscription,
|
isActiveSubscription,
|
||||||
|
getMaxSeats,
|
||||||
|
|
||||||
initialize,
|
initialize,
|
||||||
fetchStatus,
|
fetchStatus,
|
||||||
|
|||||||
@@ -1,49 +1,76 @@
|
|||||||
import { setActivePinia } from 'pinia'
|
import { setActivePinia } from 'pinia'
|
||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick, watch } from 'vue'
|
import { computed, nextTick, watch } from 'vue'
|
||||||
|
|
||||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||||
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||||
setActivePinia(createTestingPinia())
|
|
||||||
|
|
||||||
function createTestGraph() {
|
|
||||||
const graph = new LGraph()
|
|
||||||
const node = new LGraphNode('test')
|
|
||||||
node.addInput('input', 'INT')
|
|
||||||
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
|
||||||
graph.add(node)
|
|
||||||
|
|
||||||
const { vueNodeData } = useGraphNodeManager(graph)
|
|
||||||
const onReactivityUpdate = vi.fn()
|
|
||||||
watch(vueNodeData, onReactivityUpdate)
|
|
||||||
|
|
||||||
return [node, graph, onReactivityUpdate] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Node Reactivity', () => {
|
describe('Node Reactivity', () => {
|
||||||
it('should trigger on callback', async () => {
|
beforeEach(() => {
|
||||||
const [node, , onReactivityUpdate] = createTestGraph()
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||||
|
|
||||||
node.widgets![0].callback!(2)
|
|
||||||
await nextTick()
|
|
||||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remain reactive after a connection is made', async () => {
|
function createTestGraph() {
|
||||||
const [node, graph, onReactivityUpdate] = createTestGraph()
|
const graph = new LGraph()
|
||||||
|
const node = new LGraphNode('test')
|
||||||
|
node.addInput('input', 'INT')
|
||||||
|
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||||
|
graph.add(node)
|
||||||
|
|
||||||
|
const { vueNodeData } = useGraphNodeManager(graph)
|
||||||
|
|
||||||
|
return { node, graph, vueNodeData }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('widget values are reactive through the store', async () => {
|
||||||
|
const { node } = createTestGraph()
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const widget = node.widgets![0]
|
||||||
|
|
||||||
|
// Verify widget is a BaseWidget with correct value and node assignment
|
||||||
|
expect(widget).toBeInstanceOf(BaseWidget)
|
||||||
|
expect(widget.value).toBe(2)
|
||||||
|
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||||
|
|
||||||
|
// Initial value should be in store after setNodeId was called
|
||||||
|
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)
|
||||||
|
|
||||||
|
const onValueChange = vi.fn()
|
||||||
|
const widgetValue = computed(
|
||||||
|
() => store.getWidget(node.id, 'testnum')?.value
|
||||||
|
)
|
||||||
|
watch(widgetValue, onValueChange)
|
||||||
|
|
||||||
|
widget.value = 42
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(widgetValue.value).toBe(42)
|
||||||
|
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('widget values remain reactive after a connection is made', async () => {
|
||||||
|
const { node, graph } = createTestGraph()
|
||||||
|
const store = useWidgetValueStore()
|
||||||
|
const onValueChange = vi.fn()
|
||||||
|
|
||||||
graph.trigger('node:slot-links:changed', {
|
graph.trigger('node:slot-links:changed', {
|
||||||
nodeId: '1',
|
nodeId: String(node.id),
|
||||||
slotType: NodeSlotType.INPUT
|
slotType: NodeSlotType.INPUT
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
onReactivityUpdate.mockClear()
|
|
||||||
|
|
||||||
node.widgets![0].callback!(2)
|
const widgetValue = computed(
|
||||||
|
() => store.getWidget(node.id, 'testnum')?.value
|
||||||
|
)
|
||||||
|
watch(widgetValue, onValueChange)
|
||||||
|
|
||||||
|
node.widgets![0].value = 99
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
|
||||||
|
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||||
|
expect(widgetValue.value).toBe(99)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides event-driven reactivity with performance optimizations
|
* Provides event-driven reactivity with performance optimizations
|
||||||
*/
|
*/
|
||||||
import { reactiveComputed } from '@vueuse/core'
|
import { reactiveComputed } from '@vueuse/core'
|
||||||
import { customRef, reactive, shallowReactive } from 'vue'
|
import { reactive, shallowReactive } from 'vue'
|
||||||
|
|
||||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||||
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
|
||||||
@@ -11,10 +11,7 @@ import type {
|
|||||||
INodeInputSlot,
|
INodeInputSlot,
|
||||||
INodeOutputSlot
|
INodeOutputSlot
|
||||||
} from '@/lib/litegraph/src/interfaces'
|
} from '@/lib/litegraph/src/interfaces'
|
||||||
import type {
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
IBaseWidget,
|
|
||||||
IWidgetOptions
|
|
||||||
} from '@/lib/litegraph/src/types/widgets'
|
|
||||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||||
import type { NodeId } from '@/renderer/core/layout/types'
|
import type { NodeId } from '@/renderer/core/layout/types'
|
||||||
@@ -41,19 +38,35 @@ export interface WidgetSlotMetadata {
|
|||||||
linked: boolean
|
linked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||||
|
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||||
|
*/
|
||||||
export interface SafeWidgetData {
|
export interface SafeWidgetData {
|
||||||
|
nodeId?: NodeId
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: string
|
||||||
value: WidgetValue
|
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||||
borderStyle?: string
|
|
||||||
callback?: ((value: unknown) => void) | undefined
|
callback?: ((value: unknown) => void) | undefined
|
||||||
|
/** Control widget for seed randomization/increment/decrement */
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
|
/** Whether widget has custom layout size computation */
|
||||||
hasLayoutSize?: boolean
|
hasLayoutSize?: boolean
|
||||||
|
/** Whether widget is a DOM widget */
|
||||||
isDOMWidget?: boolean
|
isDOMWidget?: boolean
|
||||||
label?: string
|
/**
|
||||||
nodeType?: string
|
* Widget options needed for render decisions.
|
||||||
options?: IWidgetOptions
|
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
|
||||||
|
*/
|
||||||
|
options?: {
|
||||||
|
canvasOnly?: boolean
|
||||||
|
advanced?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
read_only?: boolean
|
||||||
|
}
|
||||||
|
/** Input specification from node definition */
|
||||||
spec?: InputSpec
|
spec?: InputSpec
|
||||||
|
/** Input slot metadata (index and link status) */
|
||||||
slotMetadata?: WidgetSlotMetadata
|
slotMetadata?: WidgetSlotMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,23 +108,6 @@ export interface GraphNodeManager {
|
|||||||
cleanup(): void
|
cleanup(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
function widgetWithVueTrack(
|
|
||||||
widget: IBaseWidget
|
|
||||||
): asserts widget is IBaseWidget & { vueTrack: () => void } {
|
|
||||||
if (widget.vueTrack) return
|
|
||||||
|
|
||||||
customRef((track, trigger) => {
|
|
||||||
widget.callback = useChainCallback(widget.callback, trigger)
|
|
||||||
widget.vueTrack = track
|
|
||||||
return { get() {}, set() {} }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function useReactiveWidgetValue(widget: IBaseWidget) {
|
|
||||||
widgetWithVueTrack(widget)
|
|
||||||
widget.vueTrack()
|
|
||||||
return widget.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||||
const cagWidget = widget.linkedWidgets?.find(
|
const cagWidget = widget.linkedWidgets?.find(
|
||||||
(w) => w.name == 'control_after_generate'
|
(w) => w.name == 'control_after_generate'
|
||||||
@@ -123,36 +119,20 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
|
|
||||||
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
|
|
||||||
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
|
|
||||||
return subNode?.type
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
|
||||||
*/
|
*/
|
||||||
interface SharedWidgetEnhancements {
|
interface SharedWidgetEnhancements {
|
||||||
/** Reactive widget value that updates when the widget changes */
|
|
||||||
value: WidgetValue
|
|
||||||
/** Control widget for seed randomization/increment/decrement */
|
/** Control widget for seed randomization/increment/decrement */
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
/** Input specification from node definition */
|
/** Input specification from node definition */
|
||||||
spec?: InputSpec
|
spec?: InputSpec
|
||||||
/** Node type (for subgraph promoted widgets) */
|
|
||||||
nodeType?: string
|
|
||||||
/** Border style for promoted/advanced widgets */
|
|
||||||
borderStyle?: string
|
|
||||||
/** Widget label */
|
|
||||||
label?: string
|
|
||||||
/** Widget options */
|
|
||||||
options?: IWidgetOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts common widget enhancements shared across different rendering contexts.
|
* Extracts common widget enhancements shared across different rendering contexts.
|
||||||
* This function centralizes the logic for extracting metadata and reactive values
|
* This function centralizes the logic for extracting metadata from widgets.
|
||||||
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
|
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
|
||||||
*/
|
*/
|
||||||
export function getSharedWidgetEnhancements(
|
export function getSharedWidgetEnhancements(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
@@ -161,17 +141,8 @@ export function getSharedWidgetEnhancements(
|
|||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: useReactiveWidgetValue(widget),
|
|
||||||
controlWidget: getControlWidget(widget),
|
controlWidget: getControlWidget(widget),
|
||||||
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
|
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||||
nodeType: getNodeType(node, widget),
|
|
||||||
borderStyle: widget.promoted
|
|
||||||
? 'ring ring-component-node-widget-promoted'
|
|
||||||
: widget.advanced
|
|
||||||
? 'ring ring-component-node-widget-advanced'
|
|
||||||
: undefined,
|
|
||||||
label: widget.label,
|
|
||||||
options: widget.options as IWidgetOptions
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +183,7 @@ function safeWidgetMapper(
|
|||||||
): (widget: IBaseWidget) => SafeWidgetData {
|
): (widget: IBaseWidget) => SafeWidgetData {
|
||||||
return function (widget) {
|
return function (widget) {
|
||||||
try {
|
try {
|
||||||
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
|
// Get shared enhancements (controlWidget, spec, nodeType)
|
||||||
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
|
||||||
const slotInfo = slotMetadata.get(widget.name)
|
const slotInfo = slotMetadata.get(widget.name)
|
||||||
|
|
||||||
@@ -228,20 +199,41 @@ function safeWidgetMapper(
|
|||||||
node.widgets?.forEach((w) => w.triggerDraw?.())
|
node.widgets?.forEach((w) => w.triggerDraw?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract only render-critical options (canvasOnly, advanced, read_only)
|
||||||
|
const options = widget.options
|
||||||
|
? {
|
||||||
|
canvasOnly: widget.options.canvasOnly,
|
||||||
|
advanced: widget.advanced,
|
||||||
|
hidden: widget.options.hidden,
|
||||||
|
read_only: widget.options.read_only
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||||
|
|
||||||
|
const localId = isProxyWidget(widget)
|
||||||
|
? widget._overlay?.nodeId
|
||||||
|
: undefined
|
||||||
|
const nodeId =
|
||||||
|
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||||
|
const name = isProxyWidget(widget)
|
||||||
|
? widget._overlay.widgetName
|
||||||
|
: widget.name
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: widget.name,
|
nodeId,
|
||||||
|
name,
|
||||||
type: widget.type,
|
type: widget.type,
|
||||||
...sharedEnhancements,
|
...sharedEnhancements,
|
||||||
callback,
|
callback,
|
||||||
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
|
||||||
isDOMWidget: isDOMWidget(widget),
|
isDOMWidget: isDOMWidget(widget),
|
||||||
|
options,
|
||||||
slotMetadata: slotInfo
|
slotMetadata: slotInfo
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
name: widget.name || 'unknown',
|
name: widget.name || 'unknown',
|
||||||
type: widget.type || 'text',
|
type: widget.type || 'text'
|
||||||
value: undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
|
|||||||
return badges
|
return badges
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
|
function isCreditsBadge(
|
||||||
|
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||||
|
): boolean {
|
||||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||||
return badgeInstance.icon?.image === componentIconSvg
|
return badgeInstance.icon?.image === componentIconSvg
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
getCreditsBadge,
|
getCreditsBadge,
|
||||||
|
isCreditsBadge,
|
||||||
updateSubgraphCredits
|
updateSubgraphCredits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||