mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
35 Commits
feat/tree-
...
onsite/sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5769f96f55 | ||
|
|
3d41d555ff | ||
|
|
d3e664b2dd | ||
|
|
29220f6562 | ||
|
|
ba5380395d | ||
|
|
702c917e57 | ||
|
|
7b0830a4ca | ||
|
|
4771565486 | ||
|
|
aa6f9b7009 | ||
|
|
133427b08e | ||
|
|
e8022f9dee | ||
|
|
3bfd62b9fc | ||
|
|
15655ddb76 | ||
|
|
e5583fe955 | ||
|
|
b1d8bf0b13 | ||
|
|
6b6b467e68 | ||
|
|
ef2d34c560 | ||
|
|
1b1356951e | ||
|
|
3e9a390c25 | ||
|
|
7c61dadaf2 | ||
|
|
7246ec7f1c | ||
|
|
2d0980cb1c | ||
|
|
d9e1122677 | ||
|
|
69b57eaba5 | ||
|
|
f647c8f9ee | ||
|
|
7952eb477e | ||
|
|
df85c4d463 | ||
|
|
6bbea48d8e | ||
|
|
219d86db2c | ||
|
|
941cd2b4a5 | ||
|
|
9efcbe682f | ||
|
|
4b1a30e330 | ||
|
|
524c7e9b95 | ||
|
|
a08ccb55c1 | ||
|
|
5c142275ad |
@@ -119,8 +119,7 @@ class NodeSlotReference {
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
console.warn(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
nodePos: [node.pos[0], node.pos[1]],
|
||||
|
||||
@@ -232,11 +232,25 @@ test.describe('Node Color Adjustments', () => {
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
const saveWorkflowInterval = 1000
|
||||
const workflow = await comfyPage.page.evaluate(() => {
|
||||
return localStorage.getItem('workflow')
|
||||
})
|
||||
for (const node of JSON.parse(workflow ?? '{}').nodes) {
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.type === 'feature_flags') {
|
||||
window.__capturedMessages.clientFeatureFlags = parsed
|
||||
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, ignore
|
||||
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags &&
|
||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages.serverFeatureFlags =
|
||||
window.__capturedMessages!.serverFeatureFlags =
|
||||
window['app'].api.serverFeatureFlags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
|
||||
// Wait for both client and server feature flags
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages.serverFeatureFlags !== null,
|
||||
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(
|
||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverFeatureFlags
|
||||
return window['app']!.api.serverFeatureFlags
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature(
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
})
|
||||
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
return window['app']!.api.serverSupportsFeature(
|
||||
'non_existent_feature_xyz'
|
||||
)
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window['app'].api.serverFeatureFlags
|
||||
window['app'].api.serverFeatureFlags = {
|
||||
const original = window['app']!.api.serverFeatureFlags
|
||||
window['app']!.api.serverFeatureFlags = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
|
||||
}
|
||||
|
||||
const results = {
|
||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
||||
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app']!.api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app']!.api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app']!.api.serverSupportsFeature('null_value')
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window['app'].api.serverFeatureFlags = original
|
||||
window['app']!.api.serverFeatureFlags = original
|
||||
return results
|
||||
})
|
||||
|
||||
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeature method
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('supports_preview_metadata')
|
||||
return window['app']!.api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('max_upload_size')
|
||||
return window['app']!.api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature(
|
||||
return window['app']!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
// Test getServerFeatures returns all flags
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeatures()
|
||||
return window['app']!.api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
// Test that getClientFeatureFlags returns a copy
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
||||
const flags1 = window['app']!.api.getClientFeatureFlags()
|
||||
const flags2 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
// Modify the first object
|
||||
flags1.test_modification = true
|
||||
|
||||
// Get flags again to check if original was modified
|
||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
||||
const flags3 = window['app']!.api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
|
||||
}) => {
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
// Get a copy of server features
|
||||
const features1 = window['app'].api.getServerFeatures()
|
||||
const features1 = window['app']!.api.getServerFeatures()
|
||||
|
||||
// Try to modify it
|
||||
features1.supports_preview_metadata = false
|
||||
features1.new_feature = 'added'
|
||||
|
||||
// Get another copy
|
||||
const features2 = window['app'].api.getServerFeatures()
|
||||
const features2 = window['app']!.api.getServerFeatures()
|
||||
|
||||
return {
|
||||
modifiedValue: features1.supports_preview_metadata,
|
||||
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// Track when various app components are ready
|
||||
;(window as any).__appReadiness = {
|
||||
|
||||
window.__appReadiness = {
|
||||
featureFlagsReceived: false,
|
||||
apiInitialized: false,
|
||||
appInitialized: false
|
||||
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
|
||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
) {
|
||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
featureFlagsReceived: true
|
||||
}
|
||||
clearInterval(checkFeatureFlags)
|
||||
}
|
||||
}, 10)
|
||||
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor API initialization
|
||||
const checkApi = setInterval(() => {
|
||||
if (window['app']?.api) {
|
||||
;(window as any).__appReadiness.apiInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
apiInitialized: true
|
||||
}
|
||||
clearInterval(checkApi)
|
||||
}
|
||||
}, 10)
|
||||
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
|
||||
// Monitor app initialization
|
||||
const checkApp = setInterval(() => {
|
||||
if (window['app']?.graph) {
|
||||
;(window as any).__appReadiness.appInitialized = true
|
||||
window.__appReadiness = {
|
||||
...window.__appReadiness,
|
||||
appInitialized: true
|
||||
}
|
||||
clearInterval(checkApp)
|
||||
}
|
||||
}, 10)
|
||||
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...(window as any).__appReadiness,
|
||||
currentFlags: window['app'].api.serverFeatureFlags
|
||||
...window.__appReadiness,
|
||||
currentFlags: window['app']!.api.serverFeatureFlags
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const app = window['app']
|
||||
const app = window['app']!
|
||||
const canvas = app.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
@@ -345,7 +347,7 @@ This is documentation for a custom node.
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
||||
return window['app']!.graph!.nodes.map((n) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: any) => {
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
|
||||
@@ -82,9 +82,7 @@ test.describe('Templates', () => {
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.page
|
||||
.locator(
|
||||
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
|
||||
)
|
||||
.getByRole('button', { name: 'Getting Started' })
|
||||
.click()
|
||||
await comfyPage.templates.loadTemplate('default')
|
||||
await expect(comfyPage.templates.content).toBeHidden()
|
||||
|
||||
@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
// This avoids relying on an exact path hit-test position.
|
||||
await comfyPage.page.evaluate(
|
||||
([targetNodeId, targetSlot, clientPoint]) => {
|
||||
const app = (window as any)['app']
|
||||
const app = window['app']
|
||||
const graph = app?.canvas?.graph ?? app?.graph
|
||||
if (!graph) throw new Error('Graph not available')
|
||||
const node = graph.getNodeById(targetNodeId)
|
||||
|
||||
16
index.html
16
index.html
@@ -10,7 +10,21 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
/* Setting it early for background during load */
|
||||
--bg-color: #202020;
|
||||
}
|
||||
}
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
background-image: var(--bg-img);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
"background_color": "#172dd7",
|
||||
"theme_color": "#f0ff41"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.38.9",
|
||||
"version": "1.38.11",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -169,6 +169,7 @@
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^11.0.3",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
|
||||
@@ -584,8 +584,6 @@ body {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color) var(--bg-img);
|
||||
color: var(--fg-color);
|
||||
min-height: -webkit-fill-available;
|
||||
max-height: -webkit-fill-available;
|
||||
min-width: -webkit-fill-available;
|
||||
|
||||
@@ -120,8 +120,8 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
it('should handle null and undefined gracefully', () => {
|
||||
expect(getMediaTypeFromFilename(null as any)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle special characters in filenames', () => {
|
||||
|
||||
@@ -537,7 +537,9 @@ export function truncateFilename(
|
||||
* @param filename The filename to analyze
|
||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||
*/
|
||||
export function getMediaTypeFromFilename(filename: string): MediaType {
|
||||
export function getMediaTypeFromFilename(
|
||||
filename: string | null | undefined
|
||||
): MediaType {
|
||||
if (!filename) return 'image'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return 'image'
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -186,6 +186,9 @@ catalogs:
|
||||
jsdom:
|
||||
specifier: ^27.4.0
|
||||
version: 27.4.0
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
knip:
|
||||
specifier: ^5.75.1
|
||||
version: 5.75.1
|
||||
@@ -449,6 +452,9 @@ importers:
|
||||
glob:
|
||||
specifier: ^11.0.3
|
||||
version: 11.0.3
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
@@ -6045,6 +6051,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonata@2.1.0:
|
||||
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -14403,6 +14413,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonata@2.1.0: {}
|
||||
|
||||
jsonc-eslint-parser@2.4.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
@@ -62,6 +62,7 @@ catalog:
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsonata: ^2.1.0
|
||||
jsdom: ^27.4.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
|
||||
31
src/App.vue
31
src/App.vue
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { captureException } from '@sentry/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
@@ -16,10 +17,6 @@ import { computed, onMounted } from 'vue'
|
||||
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { t } from '@/i18n'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
@@ -27,8 +24,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const conflictDetection = useConflictDetection()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
workspaceStore.shiftDown = e.shiftKey
|
||||
@@ -54,23 +49,15 @@ onMounted(() => {
|
||||
document.addEventListener('contextmenu', showContextMenu)
|
||||
}
|
||||
|
||||
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
|
||||
window.addEventListener('vite:preloadError', async (_event) => {
|
||||
// Auto-reload if app is not ready or there are no unsaved changes
|
||||
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
|
||||
window.location.reload()
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault()
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ === 'cloud') {
|
||||
captureException(event.payload, {
|
||||
tags: { error_type: 'vite_preload_error' }
|
||||
})
|
||||
} else {
|
||||
// Show confirmation dialog if there are unsaved changes
|
||||
await dialogService
|
||||
.confirm({
|
||||
title: t('g.vitePreloadErrorTitle'),
|
||||
message: t('g.vitePreloadErrorMessage')
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (confirmed) {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
console.error('[vite:preloadError]', event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface IdleDeadline {
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
type GlobalWindow = typeof globalThis
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
@@ -21,7 +22,7 @@ interface IDisposable {
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
targetWindow: GlobalWindow,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -11,7 +12,10 @@ import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
||||
@@ -32,7 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
function createWrapper() {
|
||||
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -42,7 +46,8 @@ function createWrapper() {
|
||||
queueProgressOverlay: {
|
||||
viewJobHistory: 'View job history',
|
||||
expandCollapsedQueue: 'Expand collapsed queue',
|
||||
activeJobsShort: '{count} active | {count} active'
|
||||
activeJobsShort: '{count} active | {count} active',
|
||||
clearQueueTooltip: 'Clear queue'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,12 +56,17 @@ function createWrapper() {
|
||||
|
||||
return mount(TopMenuSection, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
SubgraphBreadcrumb: true,
|
||||
QueueProgressOverlay: true,
|
||||
CurrentUserButton: true,
|
||||
LoginButton: true
|
||||
LoginButton: true,
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
@@ -134,4 +144,89 @@ describe('TopMenuSection', () => {
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const commandStore = useCommandStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.Queue.ToggleOverlay'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
})
|
||||
|
||||
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper(pinia)
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,18 +45,30 @@
|
||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||
type="destructive"
|
||||
size="md"
|
||||
:aria-pressed="isQueueOverlayExpanded"
|
||||
:aria-pressed="
|
||||
isQueuePanelV2Enabled
|
||||
? activeSidebarTabId === 'assets'
|
||||
: isQueueProgressOverlayEnabled
|
||||
? isQueueOverlayExpanded
|
||||
: undefined
|
||||
"
|
||||
class="px-3"
|
||||
data-testid="queue-overlay-toggle"
|
||||
@click="toggleQueueOverlay"
|
||||
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||
>
|
||||
<span class="text-sm font-normal tabular-nums">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<span class="sr-only">
|
||||
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||
{{
|
||||
isQueuePanelV2Enabled
|
||||
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
|
||||
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
@@ -75,6 +87,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<QueueProgressOverlay
|
||||
v-if="isQueueProgressOverlayEnabled"
|
||||
v-model:expanded="isQueueOverlayExpanded"
|
||||
:menu-hovered="isTopMenuHovered"
|
||||
/>
|
||||
@@ -84,6 +97,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -101,8 +116,10 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
@@ -119,9 +136,12 @@ const { t, n } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
|
||||
const releaseStore = useReleaseStore()
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -138,12 +158,30 @@ const activeJobsLabel = computed(() => {
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isQueueProgressOverlayEnabled = computed(
|
||||
() => !isQueuePanelV2Enabled.value
|
||||
)
|
||||
const queueHistoryTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||
)
|
||||
const customNodesManagerTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('menu.customNodesManager'))
|
||||
)
|
||||
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||
class: '*:text-destructive-background',
|
||||
disabled: queueStore.pendingTasks.length === 0,
|
||||
command: () => {
|
||||
void handleClearQueue()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Use either release red dot or conflict red dot
|
||||
const shouldShowRedDot = computed((): boolean => {
|
||||
@@ -167,9 +205,26 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const toggleQueueOverlay = () => {
|
||||
if (isQueuePanelV2Enabled.value) {
|
||||
sidebarTabStore.toggleSidebarTab('assets')
|
||||
return
|
||||
}
|
||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||
}
|
||||
|
||||
const showQueueContextMenu = (event: MouseEvent) => {
|
||||
queueContextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingPromptIds = queueStore.pendingTasks
|
||||
.map((task) => task.promptId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||
}
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
|
||||
// Trigger the selection change callback that was registered during mount
|
||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
|
||||
.calls[0][0]
|
||||
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||
const selectionCallback = mockCalls[0][0] as () => void
|
||||
selectionCallback()
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createApp } from 'vue'
|
||||
import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
import FormRadioGroup from './FormRadioGroup.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('FormRadioGroup', () => {
|
||||
beforeAll(() => {
|
||||
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||
return mount(FormRadioGroup, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
|
||||
|
||||
it('handles custom object with optionLabel and optionValue', () => {
|
||||
const options = [
|
||||
{ name: 'First Option', id: 1 },
|
||||
{ name: 'Second Option', id: 2 },
|
||||
{ name: 'Third Option', id: 3 }
|
||||
{ name: 'First Option', id: '1' },
|
||||
{ name: 'Second Option', id: '2' },
|
||||
{ name: 'Third Option', id: '3' }
|
||||
]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(3)
|
||||
|
||||
expect(radioButtons[0].props('value')).toBe(1)
|
||||
expect(radioButtons[1].props('value')).toBe(2)
|
||||
expect(radioButtons[2].props('value')).toBe(3)
|
||||
expect(radioButtons[0].props('value')).toBe('1')
|
||||
expect(radioButtons[1].props('value')).toBe('2')
|
||||
expect(radioButtons[2].props('value')).toBe('3')
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('First Option')
|
||||
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
it('handles object with missing properties gracefully', () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', val: 'opt1' },
|
||||
{ text: 'Option 2', value: 'opt2' }
|
||||
]
|
||||
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||
|
||||
const wrapper = mountComponent({
|
||||
modelValue: 'opt1',
|
||||
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
|
||||
})
|
||||
|
||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||
expect(radioButtons).toHaveLength(2)
|
||||
expect(radioButtons).toHaveLength(1)
|
||||
|
||||
const labels = wrapper.findAll('label')
|
||||
expect(labels[0].text()).toBe('Unknown')
|
||||
expect(labels[1].text()).toBe('Option 2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any
|
||||
options: (SettingOption | string)[]
|
||||
options?: (string | SettingOption | Record<string, string>)[]
|
||||
optionLabel?: string
|
||||
optionValue?: string
|
||||
id?: string
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import UrlInput from './UrlInput.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any, options = {}) => {
|
||||
const mountComponent = (
|
||||
props: ComponentProps<typeof UrlInput> & {
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
},
|
||||
options = {}
|
||||
) => {
|
||||
return mount(UrlInput, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
|
||||
await input.setValue(' https://leading-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
|
||||
expect(input.element.value).toBe('https://leading-space.com')
|
||||
|
||||
// Test trailing whitespace
|
||||
await input.setValue('https://trailing-space.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
|
||||
expect(input.element.value).toBe('https://trailing-space.com')
|
||||
|
||||
// Test both leading and trailing whitespace
|
||||
await input.setValue(' https://both-spaces.com ')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
|
||||
expect(input.element.value).toBe('https://both-spaces.com')
|
||||
|
||||
// Test whitespace in the middle of the URL
|
||||
await input.setValue('https:// middle-space.com')
|
||||
await input.trigger('input')
|
||||
await nextTick()
|
||||
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
|
||||
expect(input.element.value).toBe('https://middle-space.com')
|
||||
})
|
||||
|
||||
it('trims whitespace when value set externally', async () => {
|
||||
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
|
||||
placeholder: 'Enter URL'
|
||||
})
|
||||
|
||||
const input = wrapper.find('input')
|
||||
|
||||
// Check initial value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
|
||||
expect(input.element.value).toBe('https://initial-value.com')
|
||||
|
||||
// Update props with whitespace
|
||||
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
||||
await nextTick()
|
||||
|
||||
// Check updated value is trimmed
|
||||
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
|
||||
expect(input.element.value).toBe('https://updated-value.com')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
|
||||
app.use(PrimeVue)
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||
return mount(UserAvatar, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal file
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
workspaceName: string
|
||||
}>()
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
const seed = letter.value.charCodeAt(0)
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const hue1 = Math.floor(rand() * 360)
|
||||
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
|
||||
const sat = 65 + Math.floor(rand() * 20)
|
||||
const light = 55 + Math.floor(rand() * 15)
|
||||
|
||||
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
|
||||
})
|
||||
</script>
|
||||
@@ -3,17 +3,14 @@
|
||||
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
|
||||
class="workflow-template-selector-dialog"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[comfy--template]" />
|
||||
<h2 class="text-neutral text-base">
|
||||
{{ $t('sideToolbar.templates', 'Templates') }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[comfy--template]" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">{{
|
||||
$t('sideToolbar.templates', 'Templates')
|
||||
}}</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
:class="[
|
||||
'global-dialog',
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||
? 'settings-dialog-workspace'
|
||||
: ''
|
||||
]"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt="getDialogPt(item)"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -36,11 +41,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import type { DialogPassThroughOptions } from 'primevue/dialog'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { DialogComponentProps } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
)
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function getDialogPt(item: {
|
||||
key: string
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}): DialogPassThroughOptions {
|
||||
const isWorkspaceSettingsDialog =
|
||||
item.key === 'global-settings' && teamWorkspacesEnabled.value
|
||||
const basePt = item.dialogComponentProps.pt || {}
|
||||
|
||||
if (isWorkspaceSettingsDialog) {
|
||||
return merge(basePt, {
|
||||
mask: { class: 'p-8' }
|
||||
})
|
||||
}
|
||||
return basePt
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -55,4 +87,30 @@ const dialogStore = useDialogStore()
|
||||
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
|
||||
@apply pt-0;
|
||||
}
|
||||
|
||||
/* Workspace mode: wider settings dialog */
|
||||
.settings-dialog-workspace {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-dialog-workspace .p-dialog-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manager-dialog {
|
||||
height: 80vh;
|
||||
max-width: 1724px;
|
||||
max-height: 1026px;
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.manager-dialog {
|
||||
max-width: 2200px;
|
||||
max-height: 1320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,7 +31,12 @@
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" autofocus @click="onCancel">
|
||||
<Button
|
||||
v-if="type !== 'info'"
|
||||
variant="secondary"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
>
|
||||
<i class="pi pi-undo" />
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
@@ -73,6 +78,10 @@
|
||||
<i class="pi pi-eraser" />
|
||||
{{ $t('desktopMenu.reinstall') }}
|
||||
</Button>
|
||||
<!-- Info - just show an OK button -->
|
||||
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
|
||||
{{ $t('g.ok') }}
|
||||
</Button>
|
||||
<!-- Invalid - just show a close button. -->
|
||||
<Button v-else variant="primary" @click="onCancel">
|
||||
<i class="pi pi-times" />
|
||||
|
||||
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
v-if="uiConfig.showMembersList"
|
||||
class="text-base font-semibold text-base-foreground"
|
||||
>
|
||||
<template v-if="activeView === 'active'">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.pendingInvitesCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'active'"
|
||||
>
|
||||
{{ $t('workspacePanel.members.tabs.active') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="
|
||||
activeView === 'pending' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Date column headers -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<div />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('joinDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.joinDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canRemoveMembers" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Active Members -->
|
||||
<template v-if="activeView === 'active'">
|
||||
<!-- Personal Workspace: show only current user -->
|
||||
<template v-if="isPersonalWorkspace">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ userDisplayName }}
|
||||
<span class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.roleOwner') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ userEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(member, index) in filteredMembers"
|
||||
:key="member.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.membersGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="
|
||||
isCurrentUser(member) ? userPhotoUrl : undefined
|
||||
"
|
||||
:pt:icon:class="{
|
||||
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
|
||||
}"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span
|
||||
v-if="isCurrentUser(member)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uiConfig.showRoleBadge"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ getRoleBadgeLabel(member.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Join date -->
|
||||
<span
|
||||
v-if="uiConfig.showDateColumn"
|
||||
class="text-sm text-muted-foreground text-right"
|
||||
>
|
||||
{{ formatDate(member.joinDate) }}
|
||||
</span>
|
||||
<!-- Remove member action (OWNER only, can't remove yourself) -->
|
||||
<div
|
||||
v-if="permissions.canRemoveMembers"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<Button
|
||||
v-if="!isCurrentUser(member)"
|
||||
v-tooltip="{
|
||||
value: $t('g.moreOptions'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="showMemberMenu($event, member)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member actions menu (shared for all members) -->
|
||||
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(invite, index) in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
uiConfig.pendingGridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Invite info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ getInviteInitial(invite.email) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ getInviteDisplayName(invite.email) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invite date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</span>
|
||||
<!-- Expiry date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.copyLink'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="$t('workspacePanel.members.actions.copyLink')"
|
||||
@click="handleCopyInviteLink(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--link] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: $t('workspacePanel.members.actions.revokeInvite'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:aria-label="
|
||||
$t('workspacePanel.members.actions.revokeInvite')
|
||||
"
|
||||
@click="handleRevokeInvite(invite)"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredPendingInvites.length === 0"
|
||||
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Personal Workspace Message -->
|
||||
<div v-if="isPersonalWorkspace" class="flex items-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
|
||||
</p>
|
||||
<button
|
||||
class="underline bg-transparent border-none cursor-pointer"
|
||||
@click="handleCreateWorkspace"
|
||||
>
|
||||
{{ $t('workspacePanel.members.createNewWorkspace') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type {
|
||||
PendingInvite,
|
||||
WorkspaceMember
|
||||
} from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { d, t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
|
||||
const {
|
||||
showRemoveMemberDialog,
|
||||
showRevokeInviteDialog,
|
||||
showCreateWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
members,
|
||||
pendingInvites,
|
||||
isInPersonalWorkspace: isPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { copyInviteLink } = workspaceStore
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<'active' | 'pending'>('active')
|
||||
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
const selectedMember = ref<WorkspaceMember | null>(null)
|
||||
|
||||
function getInviteDisplayName(email: string): string {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function getInviteInitial(email: string): string {
|
||||
return email.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
const memberMenuItems = computed(() => [
|
||||
{
|
||||
label: t('workspacePanel.members.actions.removeMember'),
|
||||
icon: 'pi pi-user-minus',
|
||||
command: () => {
|
||||
if (selectedMember.value) {
|
||||
handleRemoveMember(selectedMember.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function showMemberMenu(event: Event, member: WorkspaceMember) {
|
||||
selectedMember.value = member
|
||||
memberMenu.value?.toggle(event)
|
||||
}
|
||||
|
||||
function isCurrentUser(member: WorkspaceMember): boolean {
|
||||
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
|
||||
}
|
||||
|
||||
// All members sorted: owners first, current user second, then rest by join date
|
||||
const filteredMembers = computed(() => {
|
||||
let result = [...members.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(member) =>
|
||||
member.name.toLowerCase().includes(query) ||
|
||||
member.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
// Owners always come first
|
||||
if (a.role === 'owner' && b.role !== 'owner') return -1
|
||||
if (a.role !== 'owner' && b.role === 'owner') return 1
|
||||
|
||||
// Current user comes second (after owner)
|
||||
const aIsCurrentUser = isCurrentUser(a)
|
||||
const bIsCurrentUser = isCurrentUser(b)
|
||||
if (aIsCurrentUser && !bIsCurrentUser) return -1
|
||||
if (!aIsCurrentUser && bIsCurrentUser) return 1
|
||||
|
||||
// Then sort by join date
|
||||
const aValue = a.joinDate.getTime()
|
||||
const bValue = b.joinDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
|
||||
return role === 'owner'
|
||||
? t('workspaceSwitcher.roleOwner')
|
||||
: t('workspaceSwitcher.roleMember')
|
||||
}
|
||||
|
||||
const filteredPendingInvites = computed(() => {
|
||||
let result = [...pendingInvites.value]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter((invite) =>
|
||||
invite.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
|
||||
result.sort((a, b) => {
|
||||
const aDate = a[field]
|
||||
const bDate = b[field]
|
||||
if (!aDate || !bDate) return 0
|
||||
const aValue = aDate.getTime()
|
||||
const bValue = bDate.getTime()
|
||||
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
async function handleCopyInviteLink(invite: PendingInvite) {
|
||||
try {
|
||||
await copyInviteLink(invite.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.copied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRevokeInvite(invite: PendingInvite) {
|
||||
showRevokeInviteDialog(invite.id)
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
showCreateWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleRemoveMember(member: WorkspaceMember) {
|
||||
showRemoveMemberDialog(member.id)
|
||||
}
|
||||
</script>
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
}))
|
||||
|
||||
describe('SettingItem', () => {
|
||||
const mountComponent = (props: any, options = {}): any => {
|
||||
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||
return mount(SettingItem, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
@@ -32,6 +32,7 @@ describe('SettingItem', () => {
|
||||
'i-material-symbols:experiment-outline': true
|
||||
}
|
||||
},
|
||||
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
|
||||
props,
|
||||
...options
|
||||
})
|
||||
@@ -48,8 +49,9 @@ describe('SettingItem', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Get the options property of the FormItem
|
||||
const options = wrapper.vm.formItem.options
|
||||
// Check the FormItem component's item prop for the options
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
const options = formItem.props('item').options
|
||||
expect(options).toEqual([
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
@@ -67,7 +69,8 @@ describe('SettingItem', () => {
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
expect(formItem.props('item').tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
@@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: any[]
|
||||
events: Partial<AuditLog>[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
238
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
238
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="pb-8 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 !text-3xl"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</div>
|
||||
<Tabs unstyled :value="activeTab" @update:value="setActiveTab">
|
||||
<div class="flex w-full items-center">
|
||||
<TabList unstyled class="flex w-full gap-2">
|
||||
<Tab
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'plan' && 'text-base-foreground no-underline'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</Tab>
|
||||
<Tab
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: activeTab === 'members' ? 'secondary' : 'textonly',
|
||||
size: 'md'
|
||||
}),
|
||||
activeTab === 'members' && 'text-base-foreground no-underline',
|
||||
'ml-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.tabs.membersCount', {
|
||||
count: isInPersonalWorkspace ? 1 : members.length
|
||||
})
|
||||
}}
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteLimitReached"
|
||||
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus ml-1 text-sm" />
|
||||
</Button>
|
||||
<template v-if="permissions.canAccessWorkspaceMenu">
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
class="ml-2"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
@click="menu?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
<Menu ref="menu" :model="menuItems" :popup="true">
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-tooltip="
|
||||
item.disabled && deleteTooltip
|
||||
? { value: deleteTooltip, showDelay: 0 }
|
||||
: null
|
||||
"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
item.class,
|
||||
item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
|
||||
]"
|
||||
@click="
|
||||
item.command?.({
|
||||
originalEvent: $event,
|
||||
item
|
||||
})
|
||||
"
|
||||
>
|
||||
<i :class="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<TabPanels unstyled>
|
||||
<TabPanel value="plan">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabPanel>
|
||||
<TabPanel value="members">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buttonVariants } from '@/components/ui/button/button.variants'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showInviteMemberDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
members,
|
||||
isInviteLimitReached,
|
||||
isWorkspaceSubscribed,
|
||||
isInPersonalWorkspace
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
useWorkspaceUI()
|
||||
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleEditWorkspace() {
|
||||
showEditWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||
const isDeleteDisabled = computed(
|
||||
() =>
|
||||
uiConfig.value.workspaceMenuAction === 'delete' &&
|
||||
isWorkspaceSubscribed.value
|
||||
)
|
||||
|
||||
const deleteTooltip = computed(() => {
|
||||
if (!isDeleteDisabled.value) return null
|
||||
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
|
||||
return tooltipKey ? t(tooltipKey) : null
|
||||
})
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
if (!isInviteLimitReached.value) return null
|
||||
return t('workspacePanel.inviteLimitReached')
|
||||
})
|
||||
|
||||
function handleInviteMember() {
|
||||
if (isInviteLimitReached.value) return
|
||||
showInviteMemberDialog()
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
// Add edit option for owners
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: handleEditWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value
|
||||
? 'text-danger/50 cursor-not-allowed'
|
||||
: 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||
})
|
||||
} else if (action === 'leave') {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: handleLeaveWorkspace
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setActiveTab(defaultTab)
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
|
||||
mockLoading.mockReset()
|
||||
})
|
||||
|
||||
const mountComponent = (props: any = {}) => {
|
||||
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
|
||||
return mount(ApiKeyForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
|
||||
@@ -112,8 +112,10 @@ describe('SignInForm', () => {
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Click forgot password link while email is empty
|
||||
await forgotPasswordSpan.trigger('click')
|
||||
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('calls handleForgotPassword with email when link is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Spy on handleForgotPassword
|
||||
const handleForgotPasswordSpy = vi.spyOn(
|
||||
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
|
||||
describe('Form Submission', () => {
|
||||
it('emits submit event when onSubmit is called with valid data', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit directly with valid data
|
||||
component.onSubmit({
|
||||
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not emit submit event when form is invalid', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Call onSubmit with invalid form
|
||||
component.onSubmit({ valid: false, values: {} })
|
||||
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
|
||||
describe('Focus Behavior', () => {
|
||||
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById to track focus
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with no email
|
||||
await component.handleForgotPassword('', false)
|
||||
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
|
||||
|
||||
it('does not focus email input when valid email is provided', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const component = wrapper.vm as any
|
||||
const component = wrapper.vm as typeof wrapper.vm & {
|
||||
handleForgotPassword: (email: string, valid: boolean) => void
|
||||
onSubmit: (data: { valid: boolean; values: unknown }) => void
|
||||
}
|
||||
|
||||
// Mock getElementById
|
||||
const mockFocus = vi.fn()
|
||||
const mockElement = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
|
||||
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
|
||||
vi.spyOn(document, 'getElementById').mockReturnValue(
|
||||
mockElement as HTMLElement
|
||||
)
|
||||
|
||||
// Call handleForgotPassword with valid email
|
||||
await component.handleForgotPassword('test@example.com', true)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<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">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="
|
||||
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.deleteDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{
|
||||
workspaceName
|
||||
? $t('workspacePanel.deleteDialog.messageWithName', {
|
||||
name: workspaceName
|
||||
})
|
||||
: $t('workspacePanel.deleteDialog.message')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onDelete">
|
||||
{{ $t('g.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { workspaceId, workspaceName } = defineProps<{
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
|
||||
await workspaceStore.deleteWorkspace(workspaceId)
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
@keydown.enter="isValidName && onSave()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.toast.workspaceUpdated.title'),
|
||||
detail: t('workspacePanel.toast.workspaceUpdated.message'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<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">
|
||||
{{
|
||||
step === 'email'
|
||||
? $t('workspacePanel.inviteMemberDialog.title')
|
||||
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
|
||||
}}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: Email Step -->
|
||||
<template v-if="step === 'email'">
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.message') }}
|
||||
</p>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
|
||||
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Email Step -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidEmail"
|
||||
@click="onCreateLink"
|
||||
>
|
||||
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: Link Step -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
|
||||
</p>
|
||||
<p class="m-0 text-sm font-medium text-base-foreground">
|
||||
{{ email }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="generatedLink"
|
||||
readonly
|
||||
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
|
||||
@click="onSelectLink"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-4 top-2 cursor-pointer"
|
||||
@click="onCopyLink"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
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>
|
||||
|
||||
<!-- Footer: Link Step -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" @click="onCopyLink">
|
||||
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const step = ref<'email' | 'link'>('email')
|
||||
const generatedLink = ref('')
|
||||
|
||||
const isValidEmail = computed(() => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email.value)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'invite-member' })
|
||||
}
|
||||
|
||||
async function onCreateLink() {
|
||||
if (!isValidEmail.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
generatedLink.value = await workspaceStore.createInviteLink(email.value)
|
||||
step.value = 'link'
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedLink.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectLink(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.leaveDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onLeave">
|
||||
{{ $t('workspacePanel.leaveDialog.leave') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
}
|
||||
|
||||
async function onLeave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// leaveWorkspace() handles switching to personal workspace internally and reloads
|
||||
await workspaceStore.leaveWorkspace()
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.removeMemberDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.removeMemberDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onRemove">
|
||||
{{ $t('workspacePanel.removeMemberDialog.remove') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { memberId } = defineProps<{
|
||||
memberId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.removeMember(memberId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.removeMemberDialog.success'),
|
||||
life: 2000
|
||||
})
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.removeMemberDialog.error'),
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.title') }}
|
||||
</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="onCancel"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.message') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-4 px-4 py-4">
|
||||
<Button variant="muted-textonly" @click="onCancel">
|
||||
{{ $t('g.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="lg" :loading @click="onRevoke">
|
||||
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { inviteId } = defineProps<{
|
||||
inviteId: string
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
}
|
||||
|
||||
async function onRevoke() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.revokeInvite(inviteId)
|
||||
dialogStore.closeDialog({ key: 'revoke-invite' })
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : undefined,
|
||||
life: 3000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -160,6 +160,9 @@ import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
@@ -394,6 +397,9 @@ const loadCustomNodesI18n = async () => {
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up invite loader during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -459,6 +465,22 @@ onMounted(async () => {
|
||||
// Load template from URL if present
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// Uses watch because feature flags load asynchronously - flag may be false initially
|
||||
// then become true once remoteConfig or websocket features are loaded
|
||||
if (inviteUrlLoader) {
|
||||
const stopWatching = watch(
|
||||
() => flags.teamWorkspacesEnabled,
|
||||
async (enabled) => {
|
||||
if (enabled) {
|
||||
stopWatching()
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -5,9 +5,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import {
|
||||
createMockCanvas,
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
|
||||
return {
|
||||
extensionCommands: { value: new Map() },
|
||||
loadExtensions: vi.fn(),
|
||||
registerExtension: vi.fn(),
|
||||
invokeExtensions: vi.fn(() => []),
|
||||
invokeExtensionsAsync: vi.fn()
|
||||
} as Partial<ReturnType<typeof useExtensionService>> as ReturnType<
|
||||
typeof useExtensionService
|
||||
>
|
||||
}
|
||||
|
||||
// Mock the composables and services
|
||||
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
|
||||
@@ -112,12 +129,7 @@ describe('SelectionToolbox', () => {
|
||||
canvasStore = useCanvasStore()
|
||||
|
||||
// Mock the canvas to avoid "getCanvas: canvas is null" errors
|
||||
canvasStore.canvas = {
|
||||
setDirty: vi.fn(),
|
||||
state: {
|
||||
selectionChanged: false
|
||||
}
|
||||
} as any
|
||||
canvasStore.canvas = createMockCanvas()
|
||||
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
@@ -184,30 +196,27 @@ describe('SelectionToolbox', () => {
|
||||
describe('Button Visibility Logic', () => {
|
||||
beforeEach(() => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
})
|
||||
|
||||
it('should show info button only for single selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.info-button').exists()).toBe(true)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
{ type: 'TestNode1' },
|
||||
{ type: 'TestNode2' }
|
||||
] as any
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.info-button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not show info button when node definition is not found', () => {
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
// mock nodedef and return null
|
||||
nodeDefMock = null
|
||||
// remount component
|
||||
@@ -217,7 +226,7 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should show color picker for all selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="color-picker-button"]').exists()).toBe(
|
||||
true
|
||||
@@ -225,9 +234,9 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
{ type: 'TestNode1' },
|
||||
{ type: 'TestNode2' }
|
||||
] as any
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(
|
||||
@@ -237,15 +246,15 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should show frame nodes only for multiple selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.frame-nodes').exists()).toBe(false)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
{ type: 'TestNode1' },
|
||||
{ type: 'TestNode2' }
|
||||
] as any
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.frame-nodes').exists()).toBe(true)
|
||||
@@ -253,22 +262,22 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should show bypass button for appropriate selections', () => {
|
||||
// Single node selection
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||
|
||||
// Multiple node selection
|
||||
canvasStore.selectedItems = [
|
||||
{ type: 'TestNode1' },
|
||||
{ type: 'TestNode2' }
|
||||
] as any
|
||||
createMockPositionable(),
|
||||
createMockPositionable()
|
||||
]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('[data-testid="bypass-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should show common buttons for all selections', () => {
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-testid="delete-button"]').exists()).toBe(true)
|
||||
@@ -286,13 +295,13 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
// Single image node
|
||||
isImageNodeSpy.mockReturnValue(true)
|
||||
canvasStore.selectedItems = [{ type: 'ImageNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.mask-editor-button').exists()).toBe(true)
|
||||
|
||||
// Single non-image node
|
||||
isImageNodeSpy.mockReturnValue(false)
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
|
||||
@@ -304,13 +313,13 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
// Single Load3D node
|
||||
isLoad3dNodeSpy.mockReturnValue(true)
|
||||
canvasStore.selectedItems = [{ type: 'Load3DNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.load-3d-viewer-button').exists()).toBe(true)
|
||||
|
||||
// Single non-Load3D node
|
||||
isLoad3dNodeSpy.mockReturnValue(false)
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
|
||||
@@ -326,17 +335,17 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
// With output node selected
|
||||
isOutputNodeSpy.mockReturnValue(true)
|
||||
filterOutputNodesSpy.mockReturnValue([{ type: 'SaveImage' }] as any)
|
||||
canvasStore.selectedItems = [
|
||||
{ type: 'SaveImage', constructor: { nodeData: { output_node: true } } }
|
||||
] as any
|
||||
filterOutputNodesSpy.mockReturnValue([
|
||||
{ type: 'SaveImage' }
|
||||
] as LGraphNode[])
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.execute-button').exists()).toBe(true)
|
||||
|
||||
// Without output node selected
|
||||
isOutputNodeSpy.mockReturnValue(false)
|
||||
filterOutputNodesSpy.mockReturnValue([])
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
expect(wrapper2.find('.execute-button').exists()).toBe(false)
|
||||
@@ -352,7 +361,7 @@ describe('SelectionToolbox', () => {
|
||||
describe('Divider Visibility Logic', () => {
|
||||
it('should show dividers between button groups when both groups have buttons', () => {
|
||||
// Setup single node to show info + other buttons
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const dividers = wrapper.findAll('.vertical-divider')
|
||||
@@ -378,10 +387,13 @@ describe('SelectionToolbox', () => {
|
||||
['test-command', { id: 'test-command', title: 'Test Command' }]
|
||||
])
|
||||
},
|
||||
invokeExtensions: vi.fn(() => ['test-command'])
|
||||
} as any)
|
||||
loadExtensions: vi.fn(),
|
||||
registerExtension: vi.fn(),
|
||||
invokeExtensions: vi.fn(() => ['test-command']),
|
||||
invokeExtensionsAsync: vi.fn()
|
||||
} as ReturnType<typeof useExtensionService>)
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('.extension-command-button').exists()).toBe(true)
|
||||
@@ -389,12 +401,9 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should not render extension commands when none available', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('.extension-command-button').exists()).toBe(false)
|
||||
@@ -404,12 +413,9 @@ describe('SelectionToolbox', () => {
|
||||
describe('Container Styling', () => {
|
||||
it('should apply minimap container styles', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
@@ -418,12 +424,9 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should have correct CSS classes', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
@@ -435,12 +438,9 @@ describe('SelectionToolbox', () => {
|
||||
|
||||
it('should handle animation class conditionally', () => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
@@ -453,16 +453,18 @@ describe('SelectionToolbox', () => {
|
||||
const mockCanvasInteractions = vi.mocked(useCanvasInteractions)
|
||||
const forwardEventToCanvasSpy = vi.fn()
|
||||
mockCanvasInteractions.mockReturnValue({
|
||||
forwardEventToCanvas: forwardEventToCanvasSpy
|
||||
} as any)
|
||||
handleWheel: vi.fn(),
|
||||
handlePointer: vi.fn(),
|
||||
forwardEventToCanvas: forwardEventToCanvasSpy,
|
||||
shouldHandleNodePointerEvents: { value: true } as ReturnType<
|
||||
typeof useCanvasInteractions
|
||||
>['shouldHandleNodePointerEvents']
|
||||
} as ReturnType<typeof useCanvasInteractions>)
|
||||
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
|
||||
canvasStore.selectedItems = [{ type: 'TestNode' }] as any
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const panel = wrapper.find('.panel')
|
||||
@@ -475,10 +477,7 @@ describe('SelectionToolbox', () => {
|
||||
describe('No Selection State', () => {
|
||||
beforeEach(() => {
|
||||
const mockExtensionService = vi.mocked(useExtensionService)
|
||||
mockExtensionService.mockReturnValue({
|
||||
extensionCommands: { value: new Map() },
|
||||
invokeExtensions: vi.fn(() => [])
|
||||
} as any)
|
||||
mockExtensionService.mockReturnValue(createMockExtensionService())
|
||||
})
|
||||
|
||||
it('should hide most buttons when no items selected', () => {
|
||||
|
||||
@@ -6,14 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockLGraphNode = {
|
||||
type: 'TestNode',
|
||||
title: 'Test Node',
|
||||
mode: LGraphEventMode.ALWAYS
|
||||
function getMockLGraphNode(): LGraphNode {
|
||||
return createMockLGraphNode({ type: 'TestNode' })
|
||||
}
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
@@ -59,21 +59,21 @@ describe('BypassButton', () => {
|
||||
}
|
||||
|
||||
it('should render bypass button', () => {
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
canvasStore.selectedItems = [getMockLGraphNode()]
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should have correct test id', () => {
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
canvasStore.selectedItems = [getMockLGraphNode()]
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('[data-testid="bypass-button"]')
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should execute bypass command when clicked', async () => {
|
||||
canvasStore.selectedItems = [mockLGraphNode] as any
|
||||
canvasStore.selectedItems = [getMockLGraphNode()]
|
||||
const executeSpy = vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
|
||||
const wrapper = mountComponent()
|
||||
@@ -85,8 +85,10 @@ describe('BypassButton', () => {
|
||||
})
|
||||
|
||||
it('should show bypassed styling when node is bypassed', async () => {
|
||||
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
|
||||
canvasStore.selectedItems = [bypassedNode] as any
|
||||
const bypassedNode = Object.assign(getMockLGraphNode(), {
|
||||
mode: LGraphEventMode.BYPASS
|
||||
})
|
||||
canvasStore.selectedItems = [bypassedNode]
|
||||
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -100,7 +102,7 @@ describe('BypassButton', () => {
|
||||
|
||||
it('should handle multiple selected items', () => {
|
||||
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
canvasStore.selectedItems = [mockLGraphNode, mockLGraphNode] as any
|
||||
canvasStore.selectedItems = [getMockLGraphNode(), getMockLGraphNode()]
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -8,7 +9,20 @@ import { createI18n } from 'vue-i18n'
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createMockWorkflow(
|
||||
overrides: Partial<LoadedComfyWorkflow> = {}
|
||||
): LoadedComfyWorkflow {
|
||||
return {
|
||||
changeTracker: {
|
||||
checkState: vi.fn() as Mock
|
||||
},
|
||||
...overrides
|
||||
} as Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
// Mock the litegraph module
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async () => {
|
||||
@@ -70,11 +84,7 @@ describe('ColorPickerButton', () => {
|
||||
canvasStore.selectedItems = []
|
||||
|
||||
// Mock workflow store
|
||||
workflowStore.activeWorkflow = {
|
||||
changeTracker: {
|
||||
checkState: vi.fn()
|
||||
}
|
||||
} as any
|
||||
workflowStore.activeWorkflow = createMockWorkflow()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
@@ -90,13 +100,13 @@ describe('ColorPickerButton', () => {
|
||||
|
||||
it('should render when nodes are selected', () => {
|
||||
// Add a mock node to selectedItems
|
||||
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle color picker visibility on button click', async () => {
|
||||
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = createWrapper()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
// Mock the stores
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock the utils
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn((node) => !!node?.type)
|
||||
@@ -37,10 +30,8 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
}))
|
||||
|
||||
describe('ExecuteButton', () => {
|
||||
let mockCanvas: any
|
||||
let mockCanvasStore: any
|
||||
let mockCommandStore: any
|
||||
let mockSelectedNodes: any[]
|
||||
let mockCanvas: LGraphCanvas
|
||||
let mockSelectedNodes: LGraphNode[]
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -57,27 +48,27 @@ describe('ExecuteButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
// Set up Pinia with testing utilities
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
|
||||
// Reset mocks
|
||||
mockCanvas = {
|
||||
const partialCanvas: Partial<LGraphCanvas> = {
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockCanvas = partialCanvas as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
mockSelectedNodes = []
|
||||
|
||||
mockCanvasStore = {
|
||||
getCanvas: vi.fn(() => mockCanvas),
|
||||
selectedItems: []
|
||||
}
|
||||
// Get store instances and mock methods
|
||||
const canvasStore = useCanvasStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
mockCommandStore = {
|
||||
execute: vi.fn()
|
||||
}
|
||||
|
||||
// Setup store mocks
|
||||
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore as any)
|
||||
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
|
||||
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
|
||||
// Update the useSelectionState mock
|
||||
const { useSelectionState } = vi.mocked(
|
||||
@@ -87,7 +78,7 @@ describe('ExecuteButton', () => {
|
||||
selectedNodes: {
|
||||
value: mockSelectedNodes
|
||||
}
|
||||
} as any)
|
||||
} as ReturnType<typeof useSelectionState>)
|
||||
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -114,15 +105,16 @@ describe('ExecuteButton', () => {
|
||||
|
||||
describe('Click Handler', () => {
|
||||
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
|
||||
const commandStore = useCommandStore()
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
|
||||
await button.trigger('click')
|
||||
|
||||
expect(mockCommandStore.execute).toHaveBeenCalledWith(
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.QueueSelectedOutputNodes'
|
||||
)
|
||||
expect(mockCommandStore.execute).toHaveBeenCalledTimes(1)
|
||||
expect(commandStore.execute).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
@@ -24,18 +24,7 @@ const nodes = computed((): LGraphNode[] => {
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.value.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
|
||||
.map((widget) => ({ node, widget }))
|
||||
return {
|
||||
widgets: shownWidgets,
|
||||
node
|
||||
}
|
||||
})
|
||||
})
|
||||
const { widgetsSectionDataList } = computedSectionDataList(nodes)
|
||||
|
||||
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
|
||||
widgetsSectionDataList.value
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { searchWidgetsAndNodes } from '../shared'
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
import type { NodeWidgetsListList } from '../shared'
|
||||
import SectionWidgets from './SectionWidgets.vue'
|
||||
|
||||
@@ -21,21 +21,14 @@ const { t } = useI18n()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return nodes.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
|
||||
() => nodes
|
||||
)
|
||||
|
||||
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
if (includesAdvanced.value) {
|
||||
return []
|
||||
}
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const { widgets = [] } = node
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -23,7 +23,11 @@ const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
// NODES settings
|
||||
const showAdvancedParameters = ref(false) // Placeholder for future implementation
|
||||
const showAdvancedParameters = computed({
|
||||
get: () => settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'),
|
||||
set: (value) =>
|
||||
settingStore.set('Comfy.Node.AlwaysShowAdvancedWidgets', value)
|
||||
})
|
||||
|
||||
const showToolbox = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),
|
||||
|
||||
@@ -38,10 +38,22 @@ describe('searchWidgets', () => {
|
||||
|
||||
expect(searchWidgets(widgets, 'width')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'slider')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'high')).toHaveLength(1)
|
||||
expect(searchWidgets(widgets, 'image')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should support fuzzy matching (e.g., "high" matches both "height" and value "high")', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Size Control'),
|
||||
createWidget('height', 'slider', '200', 'Image Height'),
|
||||
createWidget('quality', 'text', 'high', 'Quality')
|
||||
]
|
||||
|
||||
const results = searchWidgets(widgets, 'high')
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results.some((r) => r.widget.name === 'height')).toBe(true)
|
||||
expect(results.some((r) => r.widget.name === 'quality')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multiple search words', () => {
|
||||
const widgets = [
|
||||
createWidget('width', 'number', '100', 'Image Width'),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
export const GetNodeParentGroupKey: InjectionKey<
|
||||
(node: LGraphNode) => LGraphGroup | null
|
||||
@@ -17,10 +20,18 @@ export type NodeWidgetsListList = Array<{
|
||||
widgets: NodeWidgetsList
|
||||
}>
|
||||
|
||||
interface WidgetSearchItem {
|
||||
index: number
|
||||
searchableLabel: string
|
||||
searchableName: string
|
||||
searchableType: string
|
||||
searchableValue: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches widgets in a list and returns search results.
|
||||
* Searches widgets in a list using fuzzy search and returns search results.
|
||||
* Uses Fuse.js for better matching with typo tolerance and relevance ranking.
|
||||
* Filters by name, localized label, type, and user-input value.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgets<T extends { widget: IBaseWidget }[]>(
|
||||
list: T,
|
||||
@@ -29,27 +40,48 @@ export function searchWidgets<T extends { widget: IBaseWidget }[]>(
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
return list.filter(({ widget }) => {
|
||||
const label = widget.label?.toLowerCase()
|
||||
const name = widget.name.toLowerCase()
|
||||
const type = widget.type.toLowerCase()
|
||||
const value = widget.value?.toString().toLowerCase()
|
||||
return words.every(
|
||||
(word) =>
|
||||
name.includes(word) ||
|
||||
label?.includes(word) ||
|
||||
type?.includes(word) ||
|
||||
value?.includes(word)
|
||||
)
|
||||
}) as T
|
||||
|
||||
const searchableList: WidgetSearchItem[] = list.map((item, index) => {
|
||||
const searchableItem = {
|
||||
index,
|
||||
searchableLabel: item.widget.label?.toLowerCase() || '',
|
||||
searchableName: item.widget.name.toLowerCase(),
|
||||
searchableType: item.widget.type.toLowerCase(),
|
||||
searchableValue: item.widget.value?.toString().toLowerCase() || ''
|
||||
}
|
||||
return searchableItem
|
||||
})
|
||||
|
||||
const fuseOptions: IFuseOptions<WidgetSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableName', weight: 0.4 },
|
||||
{ name: 'searchableLabel', weight: 0.3 },
|
||||
{ name: 'searchableValue', weight: 0.3 },
|
||||
{ name: 'searchableType', weight: 0.2 }
|
||||
],
|
||||
threshold: 0.3
|
||||
}
|
||||
|
||||
const fuse = new Fuse(searchableList, fuseOptions)
|
||||
const results = fuse.search(query.trim())
|
||||
|
||||
const matchedItems = new Set(
|
||||
results.map((result) => list[result.item.index]!)
|
||||
)
|
||||
|
||||
return list.filter((item) => matchedItems.has(item)) as T
|
||||
}
|
||||
|
||||
type NodeSearchItem = {
|
||||
nodeId: NodeId
|
||||
searchableTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches widgets and nodes in a list and returns search results.
|
||||
* Searches widgets and nodes in a list using fuzzy search and returns search results.
|
||||
* Uses Fuse.js for node title matching with typo tolerance and relevance ranking.
|
||||
* First checks if the node title matches the query (if so, keeps entire node).
|
||||
* Otherwise, filters widgets using searchWidgets.
|
||||
* Performs basic tokenization of the query string.
|
||||
*/
|
||||
export function searchWidgetsAndNodes(
|
||||
list: NodeWidgetsListList,
|
||||
@@ -58,12 +90,26 @@ export function searchWidgetsAndNodes(
|
||||
if (query.trim() === '') {
|
||||
return list
|
||||
}
|
||||
const words = query.trim().toLowerCase().split(' ')
|
||||
|
||||
const searchableList: NodeSearchItem[] = list.map((item) => ({
|
||||
nodeId: item.node.id,
|
||||
searchableTitle: (item.node.getTitle() ?? '').toLowerCase()
|
||||
}))
|
||||
|
||||
const fuseOptions: IFuseOptions<NodeSearchItem> = {
|
||||
keys: [{ name: 'searchableTitle', weight: 1.0 }],
|
||||
threshold: 0.3
|
||||
}
|
||||
|
||||
const fuse = new Fuse(searchableList, fuseOptions)
|
||||
const nodeMatches = fuse.search(query.trim())
|
||||
const matchedNodeIds = new Set(
|
||||
nodeMatches.map((result) => result.item.nodeId)
|
||||
)
|
||||
|
||||
return list
|
||||
.map((item) => {
|
||||
const { node } = item
|
||||
const title = node.getTitle().toLowerCase()
|
||||
if (words.every((word) => title.includes(word))) {
|
||||
if (matchedNodeIds.has(item.node.id)) {
|
||||
return { ...item, keep: true }
|
||||
}
|
||||
return {
|
||||
@@ -203,3 +249,33 @@ function repeatItems<T>(items: T[]): T[] {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const includesAdvanced = computed(() =>
|
||||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
||||
)
|
||||
|
||||
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
return toValue(nodes).map((node) => {
|
||||
const { widgets = [] } = node
|
||||
const shownWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
(w.options?.advanced && !includesAdvanced.value)
|
||||
)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: shownWidgets, node }
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
widgetsSectionDataList,
|
||||
includesAdvanced
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ const extraMenuItems = computed(() => [
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
icon: 'icon-[lucide--settings]',
|
||||
command: () => {
|
||||
telemetry?.trackUiButtonClicked({
|
||||
button_id: 'sidebar_settings_menu_opened'
|
||||
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
icon: 'icon-[lucide--puzzle]',
|
||||
command: showManageExtensions
|
||||
}
|
||||
])
|
||||
|
||||
@@ -5,22 +5,35 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
function toggleLinearMode() {
|
||||
useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'button' }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 bg-secondary-background rounded-lg w-10">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.linearMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:title="t('linearMode.linearMode')"
|
||||
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('linearMode.graphMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
:title="t('linearMode.graphMode')"
|
||||
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
|
||||
@click="useCommandStore().execute('Comfy.ToggleLinear')"
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i class="icon-[comfy--workflow]" />
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<ActiveJobCard v-for="job in activeJobItems" :key="job.id" :job="job" />
|
||||
<ActiveMediaAssetCard
|
||||
v-for="job in activeJobItems"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Assets Header -->
|
||||
@@ -55,7 +59,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.vue'
|
||||
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
@@ -201,7 +201,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
@@ -255,7 +260,10 @@ const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const viewMode = useStorage<'list' | 'grid'>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
'grid'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('ResultGallery', () => {
|
||||
}
|
||||
},
|
||||
props: {
|
||||
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
|
||||
allGalleryItems: mockGalleryItems as ResultItemImpl[],
|
||||
activeIndex: 0,
|
||||
...props
|
||||
},
|
||||
@@ -117,7 +117,10 @@ describe('ResultGallery', () => {
|
||||
const wrapper = mountGallery({ activeIndex: -1 })
|
||||
|
||||
// Initially galleryVisible should be false
|
||||
const vm: any = wrapper.vm
|
||||
type GalleryVM = typeof wrapper.vm & {
|
||||
galleryVisible: boolean
|
||||
}
|
||||
const vm = wrapper.vm as GalleryVM
|
||||
expect(vm.galleryVisible).toBe(false)
|
||||
|
||||
// Change activeIndex
|
||||
@@ -167,7 +170,11 @@ describe('ResultGallery', () => {
|
||||
expect(galleria.exists()).toBe(true)
|
||||
|
||||
// Check that our PT props for positioning work correctly
|
||||
const pt = galleria.props('pt') as any
|
||||
interface GalleriaPT {
|
||||
prevButton?: { style?: string }
|
||||
nextButton?: { style?: string }
|
||||
}
|
||||
const pt = galleria.props('pt') as GalleriaPT
|
||||
expect(pt?.prevButton?.style).toContain('position: fixed')
|
||||
expect(pt?.nextButton?.style).toContain('position: fixed')
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import { nextTick } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof BaseThumbnail> & {
|
||||
error: boolean
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useEventListener: vi.fn()
|
||||
}))
|
||||
@@ -45,7 +49,7 @@ describe('BaseThumbnail', () => {
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const vm = wrapper.vm as any
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
// Manually set error since useEventListener is mocked
|
||||
vm.error = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- A button that shows current authenticated user's avatar -->
|
||||
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||
<template>
|
||||
<div>
|
||||
<Button
|
||||
@@ -16,9 +16,18 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
||||
<WorkspaceProfilePic
|
||||
v-if="showWorkspaceIcon"
|
||||
:workspace-name="workspaceName"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -27,38 +36,65 @@
|
||||
:show-arrow="false"
|
||||
:pt="{
|
||||
root: {
|
||||
class: 'rounded-lg'
|
||||
class: 'rounded-lg w-80'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<CurrentUserPopover @close="closePopover" />
|
||||
<!-- Workspace mode: workspace-aware popover -->
|
||||
<CurrentUserPopoverWorkspace
|
||||
v-if="teamWorkspacesEnabled"
|
||||
@close="closePopover"
|
||||
/>
|
||||
<!-- Legacy mode: original popover -->
|
||||
<CurrentUserPopover v-else @close="closePopover" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||
|
||||
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||
)
|
||||
|
||||
const { showArrow = true, compact = false } = defineProps<{
|
||||
showArrow?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||
|
||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
const photoURL = computed<string | undefined>(
|
||||
() => userPhotoUrl.value ?? undefined
|
||||
)
|
||||
|
||||
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
|
||||
|
||||
const workspaceName = computed(() => {
|
||||
if (!showWorkspaceIcon.value) return ''
|
||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||
return workspaceName.value
|
||||
})
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const closePopover = () => {
|
||||
popover.value?.hide()
|
||||
}
|
||||
|
||||
322
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
322
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<!-- A popover that shows current user information and actions -->
|
||||
<template>
|
||||
<div
|
||||
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<!-- User Info Section -->
|
||||
<div class="flex flex-col items-center px-0 py-3 mb-4">
|
||||
<UserAvatar
|
||||
class="mb-1"
|
||||
:photo-url="userPhotoUrl"
|
||||
:pt:icon:class="{
|
||||
'text-2xl!': !userPhotoUrl
|
||||
}"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- User Details -->
|
||||
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
|
||||
{{ userDisplayName || $t('g.user') }}
|
||||
</h3>
|
||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||
{{ userEmail }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Selector -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||
@click="toggleWorkspaceSwitcher"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WorkspaceProfilePic
|
||||
class="size-6 shrink-0 text-xs"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<span class="truncate text-sm text-base-foreground">{{
|
||||
workspaceName
|
||||
}}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
ref="workspaceSwitcherPopover"
|
||||
append-to="body"
|
||||
:pt="{
|
||||
content: {
|
||||
class: 'p-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<WorkspaceSwitcherPopover
|
||||
@select="workspaceSwitcherPopover?.hide()"
|
||||
@create="handleCreateWorkspace"
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||
<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="$t('workspaceSwitcher.subscribe')"
|
||||
size="sm"
|
||||
variant="gradient"
|
||||
/>
|
||||
<!-- Non-personal workspace: Navigate to workspace settings -->
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.subscribe') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
</template>
|
||||
|
||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||
<div
|
||||
v-if="showPlansAndPricing"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="plans-pricing-menu-item"
|
||||
@click="handleOpenPlansAndPricing"
|
||||
>
|
||||
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.plansAndPricing')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="canUpgrade"
|
||||
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
||||
>
|
||||
{{ $t('subscription.upgrade') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
||||
<div
|
||||
v-if="showManagePlan"
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="manage-plan-menu-item"
|
||||
@click="handleOpenPlanAndCreditsSettings"
|
||||
>
|
||||
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.managePlan')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Partner Nodes Pricing (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="partner-nodes-menu-item"
|
||||
@click="handleOpenPartnerNodesInfo"
|
||||
>
|
||||
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('subscription.partnerNodesCredits')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Workspace Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="workspace-settings-menu-item"
|
||||
@click="handleOpenWorkspaceSettings"
|
||||
>
|
||||
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.workspaceSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="user-settings-menu-item"
|
||||
@click="handleOpenUserSettings"
|
||||
>
|
||||
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('userSettings.accountSettings')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-0 my-2" />
|
||||
|
||||
<!-- Logout (always shown) -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||
data-testid="logout-menu-item"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
||||
<span class="flex-1 text-sm text-base-foreground">{{
|
||||
$t('auth.signOut.signOut')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
workspaceName,
|
||||
isInPersonalWorkspace: isPersonalWorkspace,
|
||||
isWorkspaceSubscribed
|
||||
} = storeToRefs(workspaceStore)
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
|
||||
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||
useCurrentUser()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const dialogService = useDialogService()
|
||||
const { isActiveSubscription } = useSubscription()
|
||||
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
const displayedCredits = computed(() => {
|
||||
const isSubscribed = isPersonalWorkspace.value
|
||||
? isActiveSubscription.value
|
||||
: isWorkspaceSubscribed.value
|
||||
return isSubscribed ? totalCredits.value : '0'
|
||||
})
|
||||
|
||||
const canUpgrade = computed(() => {
|
||||
// PRO is currently the only/highest tier, so no upgrades available
|
||||
// This will need updating when additional tiers are added
|
||||
return false
|
||||
})
|
||||
|
||||
const showPlansAndPricing = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
const showManagePlan = computed(
|
||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||
)
|
||||
const showCreditsSection = computed(
|
||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||
)
|
||||
|
||||
const handleOpenUserSettings = () => {
|
||||
dialogService.showSettingsDialog('user')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceSettings = () => {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.show()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPlanAndCreditsSettings = () => {
|
||||
if (isCloud) {
|
||||
dialogService.showSettingsDialog('workspace')
|
||||
} else {
|
||||
dialogService.showSettingsDialog('credits')
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await handleSignOut()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
workspaceSwitcherPopover.value?.hide()
|
||||
dialogService.showCreateWorkspaceDialog()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||
workspaceSwitcherPopover.value?.toggle(event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void authActions.fetchBalance()
|
||||
})
|
||||
</script>
|
||||
196
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
196
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
|
||||
<div class="flex flex-col overflow-y-auto">
|
||||
<!-- Loading state -->
|
||||
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
|
||||
<div
|
||||
v-for="i in 2"
|
||||
:key="i"
|
||||
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
|
||||
>
|
||||
<div class="size-8 rounded-full bg-secondary-background" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="h-4 w-24 rounded bg-secondary-background" />
|
||||
<div class="h-3 w-16 rounded bg-secondary-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace list -->
|
||||
<template v-else>
|
||||
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
|
||||
<div class="border-b border-border-default p-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
|
||||
'hover:bg-secondary-background-hover',
|
||||
isCurrentWorkspace(workspace) && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
|
||||
@click="handleSelectWorkspace(workspace)"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{
|
||||
workspace.type === 'personal'
|
||||
? $t('workspaceSwitcher.personal')
|
||||
: workspace.name
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="getTierLabel(workspace)"
|
||||
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
|
||||
>
|
||||
{{ getTierLabel(workspace) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ getRoleLabel(workspace.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="isCurrentWorkspace(workspace)"
|
||||
class="pi pi-check text-sm text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Create workspace button -->
|
||||
<div class="px-2 py-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
|
||||
canCreateWorkspace
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'cursor-default'
|
||||
)
|
||||
"
|
||||
@click="canCreateWorkspace && handleCreateWorkspace()"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
|
||||
!canCreateWorkspace && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="pi pi-plus text-sm text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
v-if="canCreateWorkspace"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspaceSwitcher.createWorkspace') }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-muted-foreground">
|
||||
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
WorkspaceRole,
|
||||
WorkspaceType
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface AvailableWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
role: WorkspaceRole
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [workspace: AvailableWorkspace]
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
|
||||
storeToRefs(workspaceStore)
|
||||
|
||||
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
|
||||
workspaces.value.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
role: w.role,
|
||||
isSubscribed: w.isSubscribed,
|
||||
subscriptionPlan: w.subscriptionPlan
|
||||
}))
|
||||
)
|
||||
|
||||
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
return workspace.id === workspaceId.value
|
||||
}
|
||||
|
||||
function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
|
||||
if (role === 'member') return t('workspaceSwitcher.roleMember')
|
||||
return ''
|
||||
}
|
||||
|
||||
function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
// Personal workspace: use user's subscription tier
|
||||
if (workspace.type === 'personal') {
|
||||
return userSubscriptionTierName.value || null
|
||||
}
|
||||
// Team workspace: use workspace subscription plan
|
||||
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
|
||||
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
|
||||
return t('subscription.tiers.pro.name')
|
||||
if (workspace.subscriptionPlan === 'PRO_YEARLY')
|
||||
return t('subscription.tierNameYearly', {
|
||||
name: t('subscription.tiers.pro.name')
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateWorkspace() {
|
||||
emit('create')
|
||||
}
|
||||
</script>
|
||||
@@ -26,7 +26,8 @@ export const buttonVariants = cva({
|
||||
md: 'h-8 rounded-lg p-2 text-xs',
|
||||
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
|
||||
icon: 'size-8',
|
||||
'icon-sm': 'size-5 p-0'
|
||||
'icon-sm': 'size-5 p-0',
|
||||
unset: ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
|
||||
<i
|
||||
v-if="!disabled && !isEditing"
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
|
||||
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
</TagsInputRoot>
|
||||
</template>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<template #rightPanel>
|
||||
<RightSidePanel></RightSidePanel>
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</template>
|
||||
@@ -136,7 +136,6 @@ import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
@@ -15,7 +15,6 @@ import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
import LeftSidePanel from '../panel/LeftSidePanel.vue'
|
||||
import RightSidePanel from '../panel/RightSidePanel.vue'
|
||||
import BaseModalLayout from './BaseModalLayout.vue'
|
||||
|
||||
interface StoryArgs {
|
||||
@@ -69,7 +68,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
components: {
|
||||
BaseModalLayout,
|
||||
LeftSidePanel,
|
||||
RightSidePanel,
|
||||
SearchBox,
|
||||
MultiSelect,
|
||||
SingleSelect,
|
||||
@@ -175,16 +173,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
template: `
|
||||
<div>
|
||||
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Left Panel Header Title -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -299,16 +296,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
|
||||
<!-- Same content but WITH right panel -->
|
||||
<!-- Left Panel Header Title -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
|
||||
<!-- Left Panel -->
|
||||
<template v-if="args.hasLeftPanel" #leftPanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Title</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
|
||||
</template>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -415,7 +411,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
|
||||
<!-- Right Panel - Only when hasRightPanel is true -->
|
||||
<template #rightPanel>
|
||||
<RightSidePanel />
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
|
||||
</template>
|
||||
</BaseModalLayout>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,26 @@
|
||||
:style="gridStyle"
|
||||
>
|
||||
<nav
|
||||
class="h-full overflow-hidden"
|
||||
class="h-full overflow-hidden bg-modal-panel-background flex flex-col"
|
||||
:inert="!showLeftPanel"
|
||||
:aria-hidden="!showLeftPanel"
|
||||
>
|
||||
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
|
||||
<slot name="leftPanel" />
|
||||
</div>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex w-full h-18 shrink-0 gap-2 pl-6 pr-3 items-center-safe"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
v-if="!notMobile && showLeftPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0 ml-auto"
|
||||
:aria-label="t('g.hideLeftPanel')"
|
||||
@click="toggleLeftPanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-left-close]" />
|
||||
</Button>
|
||||
</header>
|
||||
<slot name="leftPanel" />
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col bg-base-background overflow-hidden">
|
||||
@@ -24,22 +37,13 @@
|
||||
>
|
||||
<div class="flex flex-1 shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="!notMobile"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
|
||||
"
|
||||
v-if="!notMobile && !showLeftPanel"
|
||||
size="lg"
|
||||
class="w-10 p-0"
|
||||
:aria-label="t('g.showLeftPanel')"
|
||||
@click="toggleLeftPanel"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
showLeftPanel
|
||||
? 'icon-[lucide--panel-left]'
|
||||
: 'icon-[lucide--panel-left-close]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--panel-left]" />
|
||||
</Button>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
@@ -69,7 +73,7 @@
|
||||
<slot name="contentFilter" />
|
||||
<h2
|
||||
v-if="!hasLeftPanel"
|
||||
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
||||
class="text-xxl m-0 select-none px-6 pt-2 pb-6 capitalize"
|
||||
>
|
||||
{{ contentTitle }}
|
||||
</h2>
|
||||
@@ -94,7 +98,10 @@
|
||||
data-component-id="RightPanelHeader"
|
||||
class="flex h-18 shrink-0 items-center gap-2 px-6"
|
||||
>
|
||||
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
|
||||
<h2
|
||||
v-if="rightPanelTitle"
|
||||
class="flex-1 select-none text-base font-semibold"
|
||||
>
|
||||
{{ rightPanelTitle }}
|
||||
</h2>
|
||||
<div v-else class="flex-1">
|
||||
@@ -134,7 +141,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
disabled: !isOverflowing,
|
||||
pt: { text: { class: 'whitespace-nowrap' } }
|
||||
}"
|
||||
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
|
||||
:class="
|
||||
active
|
||||
? 'bg-interface-menu-component-surface-selected'
|
||||
|
||||
@@ -7,20 +7,6 @@ const meta: Meta<typeof LeftSidePanel> = {
|
||||
title: 'Components/Widget/Panel/LeftSidePanel',
|
||||
component: LeftSidePanel,
|
||||
argTypes: {
|
||||
'header-icon': {
|
||||
table: {
|
||||
type: { summary: 'slot' },
|
||||
defaultValue: { summary: 'undefined' }
|
||||
},
|
||||
control: false
|
||||
},
|
||||
'header-title': {
|
||||
table: {
|
||||
type: { summary: 'slot' },
|
||||
defaultValue: { summary: 'undefined' }
|
||||
},
|
||||
control: false
|
||||
},
|
||||
'onUpdate:modelValue': {
|
||||
table: { disable: true }
|
||||
}
|
||||
@@ -59,14 +45,7 @@ export const Default: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Navigation</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -126,14 +105,7 @@ export const WithGroups: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Model Selector</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
<div class="mt-4 p-2 text-sm">
|
||||
Selected: {{ selectedItem }}
|
||||
</div>
|
||||
@@ -176,14 +148,7 @@ export const DefaultIcons: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 400px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--folder] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Files</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -228,14 +193,7 @@ export const LongLabels: Story = {
|
||||
},
|
||||
template: `
|
||||
<div style="height: 500px; width: 256px;">
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
|
||||
<template #header-icon>
|
||||
<i class="icon-[lucide--settings] size-4 text-neutral" />
|
||||
</template>
|
||||
<template #header-title>
|
||||
<span class="text-neutral text-base">Settings</span>
|
||||
</template>
|
||||
</LeftSidePanel>
|
||||
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -1,47 +1,41 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col bg-modal-panel-background">
|
||||
<PanelHeader>
|
||||
<template #icon>
|
||||
<slot name="header-icon"></slot>
|
||||
</template>
|
||||
<slot name="header-title"></slot>
|
||||
</PanelHeader>
|
||||
|
||||
<nav
|
||||
class="flex scrollbar-hide flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
|
||||
<div
|
||||
class="flex w-full flex-auto overflow-y-auto gap-1 min-h-0 flex-col bg-modal-panel-background scrollbar-hide px-3"
|
||||
>
|
||||
<template
|
||||
v-for="item in navItems"
|
||||
:key="'title' in item ? item.title : item.id"
|
||||
>
|
||||
<template v-for="(item, index) in navItems" :key="index">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<NavTitle
|
||||
v-model="collapsedGroups[item.title]"
|
||||
:title="item.title"
|
||||
:collapsible="item.collapsible"
|
||||
/>
|
||||
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||
<NavItem
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:badge="subItem.badge"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-if="'items' in item" class="flex flex-col gap-2">
|
||||
<NavTitle
|
||||
v-model="collapsedGroups[item.title]"
|
||||
:title="item.title"
|
||||
:collapsible="item.collapsible"
|
||||
/>
|
||||
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
v-for="subItem in item.items"
|
||||
:key="subItem.id"
|
||||
:icon="subItem.icon"
|
||||
:badge="subItem.badge"
|
||||
:active="activeItem === subItem.id"
|
||||
@click="activeItem = subItem.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
{{ subItem.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<NavItem
|
||||
:icon="item.icon"
|
||||
:badge="item.badge"
|
||||
:active="activeItem === item.id"
|
||||
@click="activeItem = item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NavItem>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -52,8 +46,6 @@ import NavItem from '@/components/widget/nav/NavItem.vue'
|
||||
import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
|
||||
import PanelHeader from './PanelHeader.vue'
|
||||
|
||||
const { navItems = [], modelValue } = defineProps<{
|
||||
navItems?: (NavItemData | NavGroupData)[]
|
||||
modelValue?: string | null
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<header class="flex h-16 items-center justify-between px-6">
|
||||
<div class="flex items-center gap-2 pl-1">
|
||||
<slot name="icon">
|
||||
<i class="text-neutral icon-[lucide--puzzle] text-base" />
|
||||
</slot>
|
||||
<h2 class="text-neutral text-base font-bold">
|
||||
<slot></slot>
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,9 @@ import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { createMockSubgraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock the app module
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
@@ -29,10 +32,12 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
}))
|
||||
|
||||
// Mock Positionable objects
|
||||
// @ts-expect-error - Mock implementation for testing
|
||||
|
||||
class MockNode implements Positionable {
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
id: NodeId
|
||||
boundingRect: ReadOnlyRect
|
||||
|
||||
constructor(
|
||||
pos: [number, number] = [0, 0],
|
||||
@@ -40,6 +45,13 @@ class MockNode implements Positionable {
|
||||
) {
|
||||
this.pos = pos
|
||||
this.size = size
|
||||
this.id = 'mock-node'
|
||||
this.boundingRect = [0, 0, 0, 0]
|
||||
}
|
||||
|
||||
move(): void {}
|
||||
snapToGrid(_: number): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +73,7 @@ class MockReroute extends Reroute implements Positionable {
|
||||
|
||||
describe('useSelectedLiteGraphItems', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
let mockCanvas: any
|
||||
let mockCanvas: { selectedItems: Set<Positionable> }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -73,7 +85,9 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
}
|
||||
|
||||
// Mock getCanvas to return our mock canvas
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(
|
||||
mockCanvas as ReturnType<typeof canvasStore.getCanvas>
|
||||
)
|
||||
})
|
||||
|
||||
describe('isIgnoredItem', () => {
|
||||
@@ -86,7 +100,6 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
it('should return false for non-Reroute items', () => {
|
||||
const { isIgnoredItem } = useSelectedLiteGraphItems()
|
||||
const node = new MockNode()
|
||||
// @ts-expect-error - Test mock
|
||||
expect(isIgnoredItem(node)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -98,14 +111,11 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
const node2 = new MockNode([100, 100])
|
||||
const reroute = new MockReroute([50, 50])
|
||||
|
||||
// @ts-expect-error - Test mocks
|
||||
const items = new Set<Positionable>([node1, node2, reroute])
|
||||
const filtered = filterSelectableItems(items)
|
||||
|
||||
expect(filtered.size).toBe(2)
|
||||
// @ts-expect-error - Test mocks
|
||||
expect(filtered.has(node1)).toBe(true)
|
||||
// @ts-expect-error - Test mocks
|
||||
expect(filtered.has(node2)).toBe(true)
|
||||
expect(filtered.has(reroute)).toBe(false)
|
||||
})
|
||||
@@ -143,9 +153,7 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
|
||||
const selectableItems = getSelectableItems()
|
||||
expect(selectableItems.size).toBe(2)
|
||||
// @ts-expect-error - Test mock
|
||||
expect(selectableItems.has(node1)).toBe(true)
|
||||
// @ts-expect-error - Test mock
|
||||
expect(selectableItems.has(node2)).toBe(true)
|
||||
expect(selectableItems.has(reroute)).toBe(false)
|
||||
})
|
||||
@@ -255,14 +263,7 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
@@ -279,14 +280,7 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode
|
||||
const subgraphNode = {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.ALWAYS,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2])
|
||||
const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode }
|
||||
@@ -310,14 +304,10 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const subgraphNode = {
|
||||
const subgraphNode = createMockSubgraphNode([subNode1, subNode2], {
|
||||
id: 1,
|
||||
mode: LGraphEventMode.NEVER, // Already in NEVER mode
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: {
|
||||
nodes: [subNode1, subNode2]
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
mode: LGraphEventMode.NEVER // Already in NEVER mode
|
||||
})
|
||||
|
||||
app.canvas.selected_nodes = { '0': subgraphNode }
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
/**
|
||||
* Shorthand for {@link Parameters} of optional callbacks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { onClick } = CustomClass.prototype
|
||||
* CustomClass.prototype.onClick = function (...args: CallbackParams<typeof onClick>) {
|
||||
* const r = onClick?.apply(this, args)
|
||||
* // ...
|
||||
* return r
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type CallbackParams<T extends ((...args: any) => any) | undefined> =
|
||||
Parameters<Exclude<T, undefined>>
|
||||
|
||||
/**
|
||||
* Chain multiple callbacks together.
|
||||
*
|
||||
@@ -21,15 +5,21 @@ export type CallbackParams<T extends ((...args: any) => any) | undefined> =
|
||||
* @param callbacks - The callbacks to chain.
|
||||
* @returns A new callback that chains the original callback with the callbacks.
|
||||
*/
|
||||
export const useChainCallback = <
|
||||
O,
|
||||
T extends (this: O, ...args: any[]) => void
|
||||
>(
|
||||
export function useChainCallback<O, T>(
|
||||
originalCallback: T | undefined,
|
||||
...callbacks: ((this: O, ...args: Parameters<T>) => void)[]
|
||||
) => {
|
||||
return function (this: O, ...args: Parameters<T>) {
|
||||
originalCallback?.call(this, ...args)
|
||||
for (const callback of callbacks) callback.call(this, ...args)
|
||||
}
|
||||
...callbacks: NonNullable<T> extends (this: O, ...args: infer P) => unknown
|
||||
? ((this: O, ...args: P) => void)[]
|
||||
: never
|
||||
) {
|
||||
type Args = NonNullable<T> extends (...args: infer P) => unknown ? P : never
|
||||
type Ret = NonNullable<T> extends (...args: unknown[]) => infer R ? R : never
|
||||
|
||||
return function (this: O, ...args: Args) {
|
||||
if (typeof originalCallback === 'function') {
|
||||
;(originalCallback as (this: O, ...args: Args) => Ret).call(this, ...args)
|
||||
}
|
||||
for (const callback of callbacks) {
|
||||
callback.call(this, ...args)
|
||||
}
|
||||
} as (this: O, ...args: Args) => Ret
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import * as measure from '@/lib/litegraph/src/measure'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
createMockLGraphGroup
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { useGraphHierarchy } from './useGraphHierarchy'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore')
|
||||
|
||||
function createMockNode(overrides: Partial<LGraphNode> = {}): LGraphNode {
|
||||
return Object.assign(
|
||||
createMockLGraphNode(),
|
||||
{
|
||||
boundingRect: new Rectangle(100, 100, 50, 50)
|
||||
},
|
||||
overrides
|
||||
)
|
||||
}
|
||||
|
||||
function createMockGroup(overrides: Partial<LGraphGroup> = {}): LGraphGroup {
|
||||
return createMockLGraphGroup(overrides)
|
||||
}
|
||||
|
||||
describe('useGraphHierarchy', () => {
|
||||
let mockCanvasStore: ReturnType<typeof useCanvasStore>
|
||||
let mockCanvasStore: Partial<ReturnType<typeof useCanvasStore>>
|
||||
let mockNode: LGraphNode
|
||||
let mockGroups: LGraphGroup[]
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
boundingRect: [100, 100, 50, 50]
|
||||
} as unknown as LGraphNode
|
||||
|
||||
mockNode = createMockNode()
|
||||
mockGroups = []
|
||||
|
||||
mockCanvasStore = {
|
||||
@@ -25,10 +41,21 @@ describe('useGraphHierarchy', () => {
|
||||
graph: {
|
||||
groups: mockGroups
|
||||
}
|
||||
}
|
||||
} as any
|
||||
},
|
||||
$id: 'canvas',
|
||||
$state: {},
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {}
|
||||
} as unknown as Partial<ReturnType<typeof useCanvasStore>>
|
||||
|
||||
vi.mocked(useCanvasStore).mockReturnValue(mockCanvasStore)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
mockCanvasStore as ReturnType<typeof useCanvasStore>
|
||||
)
|
||||
})
|
||||
|
||||
describe('findParentGroup', () => {
|
||||
@@ -41,9 +68,9 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('returns null when node is not in any group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 50, 50]
|
||||
} as unknown as LGraphGroup
|
||||
const group = createMockGroup({
|
||||
boundingRect: new Rectangle(0, 0, 50, 50)
|
||||
})
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(false)
|
||||
@@ -55,9 +82,9 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('returns the only group when node is in exactly one group', () => {
|
||||
const group = {
|
||||
boundingRect: [0, 0, 200, 200]
|
||||
} as unknown as LGraphGroup
|
||||
const group = createMockGroup({
|
||||
boundingRect: new Rectangle(0, 0, 200, 200)
|
||||
})
|
||||
mockGroups.push(group)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
@@ -69,12 +96,12 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('returns the smallest group when node is in multiple groups', () => {
|
||||
const largeGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const smallGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
const largeGroup = createMockGroup({
|
||||
boundingRect: new Rectangle(0, 0, 300, 300)
|
||||
})
|
||||
const smallGroup = createMockGroup({
|
||||
boundingRect: new Rectangle(50, 50, 100, 100)
|
||||
})
|
||||
mockGroups.push(largeGroup, smallGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
@@ -87,12 +114,12 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('returns the inner group when one group contains another', () => {
|
||||
const outerGroup = {
|
||||
boundingRect: [0, 0, 300, 300]
|
||||
} as unknown as LGraphGroup
|
||||
const innerGroup = {
|
||||
boundingRect: [50, 50, 100, 100]
|
||||
} as unknown as LGraphGroup
|
||||
const outerGroup = createMockGroup({
|
||||
boundingRect: new Rectangle(0, 0, 300, 300)
|
||||
})
|
||||
const innerGroup = createMockGroup({
|
||||
boundingRect: new Rectangle(50, 50, 100, 100)
|
||||
})
|
||||
mockGroups.push(outerGroup, innerGroup)
|
||||
|
||||
vi.spyOn(measure, 'containsCentre').mockReturnValue(true)
|
||||
@@ -113,7 +140,7 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('handles null canvas gracefully', () => {
|
||||
mockCanvasStore.canvas = null as any
|
||||
mockCanvasStore.canvas = null
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
@@ -122,7 +149,7 @@ describe('useGraphHierarchy', () => {
|
||||
})
|
||||
|
||||
it('handles null graph gracefully', () => {
|
||||
mockCanvasStore.canvas!.graph = null as any
|
||||
mockCanvasStore.canvas!.graph = null
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
const result = findParentGroup(mockNode)
|
||||
|
||||
@@ -1,55 +1,19 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
createMockPositionable
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Test interfaces
|
||||
interface TestNodeConfig {
|
||||
type?: string
|
||||
mode?: LGraphEventMode
|
||||
flags?: { collapsed?: boolean }
|
||||
pinned?: boolean
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
interface TestNode {
|
||||
type: string
|
||||
mode: LGraphEventMode
|
||||
flags?: { collapsed?: boolean }
|
||||
pinned?: boolean
|
||||
removable?: boolean
|
||||
isSubgraphNode: () => boolean
|
||||
}
|
||||
|
||||
type MockedItem = TestNode | { type: string; isNode: boolean }
|
||||
|
||||
// Mock all stores
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
|
||||
useNodeHelpStore: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock composables
|
||||
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
|
||||
useNodeLibrarySidebarTab: vi.fn()
|
||||
}))
|
||||
@@ -63,102 +27,28 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
filterOutputNodes: vi.fn()
|
||||
}))
|
||||
|
||||
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
|
||||
return {
|
||||
type: config.type || 'TestNode',
|
||||
mode: config.mode || LGraphEventMode.ALWAYS,
|
||||
flags: config.flags,
|
||||
pinned: config.pinned,
|
||||
removable: config.removable,
|
||||
isSubgraphNode: () => false
|
||||
}
|
||||
// Mock comment/connection objects with additional properties
|
||||
const mockComment = {
|
||||
...createMockPositionable({ id: 999 }),
|
||||
type: 'comment',
|
||||
isNode: false
|
||||
}
|
||||
const mockConnection = {
|
||||
...createMockPositionable({ id: 1000 }),
|
||||
type: 'connection',
|
||||
isNode: false
|
||||
}
|
||||
|
||||
// Mock comment/connection objects
|
||||
const mockComment = { type: 'comment', isNode: false }
|
||||
const mockConnection = { type: 'connection', isNode: false }
|
||||
|
||||
describe('useSelectionState', () => {
|
||||
// Mock store instances
|
||||
let mockSelectedItems: Ref<MockedItem[]>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Setup mock canvas store with proper ref
|
||||
mockSelectedItems = ref([])
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
selectedItems: mockSelectedItems,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'canvas',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock node def store
|
||||
vi.mocked(useNodeDefStore).mockReturnValue({
|
||||
fromLGraphNode: vi.fn((node: TestNode) => {
|
||||
if (node?.type === 'TestNode') {
|
||||
return { nodePath: 'test.TestNode', name: 'TestNode' }
|
||||
}
|
||||
return null
|
||||
}),
|
||||
// Add minimal required properties for the store
|
||||
$id: 'nodeDef',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock sidebar tab store
|
||||
const mockToggleSidebarTab = vi.fn()
|
||||
vi.mocked(useSidebarTabStore).mockReturnValue({
|
||||
activeSidebarTabId: null,
|
||||
toggleSidebarTab: mockToggleSidebarTab,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'sidebarTab',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
} as any)
|
||||
|
||||
// Setup mock node help store
|
||||
const mockOpenHelp = vi.fn()
|
||||
const mockCloseHelp = vi.fn()
|
||||
const mockNodeHelpStore = {
|
||||
isHelpOpen: false,
|
||||
currentHelpNode: null,
|
||||
openHelp: mockOpenHelp,
|
||||
closeHelp: mockCloseHelp,
|
||||
// Add minimal required properties for the store
|
||||
$id: 'nodeHelp',
|
||||
$state: {} as any,
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {} as any
|
||||
}
|
||||
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
|
||||
// Create testing Pinia instance
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
|
||||
// Setup mock composables
|
||||
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
|
||||
@@ -166,7 +56,7 @@ describe('useSelectionState', () => {
|
||||
title: 'Node Library',
|
||||
type: 'custom',
|
||||
render: () => null
|
||||
} as any)
|
||||
} as ReturnType<typeof useNodeLibrarySidebarTab>)
|
||||
|
||||
// Setup mock utility functions
|
||||
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
|
||||
@@ -177,8 +67,8 @@ describe('useSelectionState', () => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'ImageNode'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation(
|
||||
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
|
||||
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
|
||||
nodes.filter((n) => n.type === 'OutputNode')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -189,10 +79,10 @@ describe('useSelectionState', () => {
|
||||
})
|
||||
|
||||
test('should return true when items selected', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const node1 = createTestNode()
|
||||
const node2 = createTestNode()
|
||||
mockSelectedItems.value = [node1, node2]
|
||||
const canvasStore = useCanvasStore()
|
||||
const node1 = createMockLGraphNode({ id: 1 })
|
||||
const node2 = createMockLGraphNode({ id: 2 })
|
||||
canvasStore.$state.selectedItems = [node1, node2]
|
||||
|
||||
const { hasAnySelection } = useSelectionState()
|
||||
expect(hasAnySelection.value).toBe(true)
|
||||
@@ -201,9 +91,13 @@ describe('useSelectionState', () => {
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
test('should pick only LGraphNodes from mixed selections', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const graphNode = createTestNode()
|
||||
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
|
||||
const canvasStore = useCanvasStore()
|
||||
const graphNode = createMockLGraphNode({ id: 3 })
|
||||
canvasStore.$state.selectedItems = [
|
||||
graphNode,
|
||||
mockComment,
|
||||
mockConnection
|
||||
]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
expect(selectedNodes.value).toHaveLength(1)
|
||||
@@ -213,9 +107,12 @@ describe('useSelectionState', () => {
|
||||
|
||||
describe('Node State Computation', () => {
|
||||
test('should detect bypassed nodes', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
|
||||
mockSelectedItems.value = [bypassedNode]
|
||||
const canvasStore = useCanvasStore()
|
||||
const bypassedNode = createMockLGraphNode({
|
||||
id: 4,
|
||||
mode: LGraphEventMode.BYPASS
|
||||
})
|
||||
canvasStore.$state.selectedItems = [bypassedNode]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isBypassed = selectedNodes.value.some(
|
||||
@@ -225,10 +122,13 @@ describe('useSelectionState', () => {
|
||||
})
|
||||
|
||||
test('should detect pinned/collapsed states', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const pinnedNode = createTestNode({ pinned: true })
|
||||
const collapsedNode = createTestNode({ flags: { collapsed: true } })
|
||||
mockSelectedItems.value = [pinnedNode, collapsedNode]
|
||||
const canvasStore = useCanvasStore()
|
||||
const pinnedNode = createMockLGraphNode({ id: 5, pinned: true })
|
||||
const collapsedNode = createMockLGraphNode({
|
||||
id: 6,
|
||||
flags: { collapsed: true }
|
||||
})
|
||||
canvasStore.$state.selectedItems = [pinnedNode, collapsedNode]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||
@@ -244,9 +144,9 @@ describe('useSelectionState', () => {
|
||||
})
|
||||
|
||||
test('should provide non-reactive state computation', () => {
|
||||
// Update the mock data before creating the composable
|
||||
const node = createTestNode({ pinned: true })
|
||||
mockSelectedItems.value = [node]
|
||||
const canvasStore = useCanvasStore()
|
||||
const node = createMockLGraphNode({ id: 7, pinned: true })
|
||||
canvasStore.$state.selectedItems = [node]
|
||||
|
||||
const { selectedNodes } = useSelectionState()
|
||||
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
|
||||
@@ -262,7 +162,7 @@ describe('useSelectionState', () => {
|
||||
expect(isBypassed).toBe(false)
|
||||
|
||||
// Test with empty selection using new composable instance
|
||||
mockSelectedItems.value = []
|
||||
canvasStore.$state.selectedItems = []
|
||||
const { selectedNodes: newSelectedNodes } = useSelectionState()
|
||||
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||
expect(newIsPinned).toBe(false)
|
||||
|
||||
@@ -75,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
} as typeof ImageBitmap
|
||||
}
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
const mockStore = {
|
||||
imgCanvas: null as any,
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCtx: null as any,
|
||||
rgbCtx: null as any,
|
||||
canvasBackground: null as any,
|
||||
imgCanvas: null! as HTMLCanvasElement,
|
||||
maskCanvas: null! as HTMLCanvasElement,
|
||||
rgbCanvas: null! as HTMLCanvasElement,
|
||||
imgCtx: null! as CanvasRenderingContext2D,
|
||||
maskCtx: null! as CanvasRenderingContext2D,
|
||||
rgbCtx: null! as CanvasRenderingContext2D,
|
||||
canvasBackground: null! as HTMLElement,
|
||||
maskColor: { r: 0, g: 0, b: 0 },
|
||||
maskBlendMode: MaskBlendMode.Black,
|
||||
maskOpacity: 0.8
|
||||
@@ -38,26 +38,30 @@ describe('useCanvasManager', () => {
|
||||
height: 100
|
||||
} as ImageData
|
||||
|
||||
mockStore.imgCtx = {
|
||||
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.maskCtx = {
|
||||
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => mockImageData),
|
||||
putImageData: vi.fn(),
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: ''
|
||||
}
|
||||
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.rgbCtx = {
|
||||
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
|
||||
drawImage: vi.fn()
|
||||
}
|
||||
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
const partialImgCanvas: Partial<HTMLCanvasElement> = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
width: 0,
|
||||
@@ -65,19 +69,19 @@ describe('useCanvasManager', () => {
|
||||
style: {
|
||||
mixBlendMode: '',
|
||||
opacity: ''
|
||||
}
|
||||
}
|
||||
} as Pick<CSSStyleDeclaration, 'mixBlendMode' | 'opacity'>
|
||||
} as HTMLCanvasElement
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
} as HTMLCanvasElement
|
||||
|
||||
mockStore.canvasBackground = {
|
||||
style: {
|
||||
backgroundColor: ''
|
||||
}
|
||||
}
|
||||
} as Pick<CSSStyleDeclaration, 'backgroundColor'>
|
||||
} as HTMLElement
|
||||
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
mockStore.maskBlendMode = MaskBlendMode.Black
|
||||
@@ -163,7 +167,7 @@ describe('useCanvasManager', () => {
|
||||
it('should throw error when canvas missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.imgCanvas = null
|
||||
mockStore.imgCanvas = null! as HTMLCanvasElement
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
@@ -176,7 +180,7 @@ describe('useCanvasManager', () => {
|
||||
it('should throw error when context missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.imgCtx = null
|
||||
mockStore.imgCtx = null! as CanvasRenderingContext2D
|
||||
|
||||
const origImage = createMockImage(512, 512)
|
||||
const maskImage = createMockImage(512, 512)
|
||||
@@ -259,7 +263,7 @@ describe('useCanvasManager', () => {
|
||||
it('should return early when canvas missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.maskCanvas = null! as HTMLCanvasElement
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
@@ -269,7 +273,7 @@ describe('useCanvasManager', () => {
|
||||
it('should return early when context missing', async () => {
|
||||
const manager = useCanvasManager()
|
||||
|
||||
mockStore.maskCtx = null
|
||||
mockStore.maskCtx = null! as CanvasRenderingContext2D
|
||||
|
||||
await manager.updateMaskColor()
|
||||
|
||||
|
||||
@@ -4,17 +4,37 @@ import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
|
||||
// Mock store interface matching the real store's nullable fields
|
||||
interface MockMaskEditorStore {
|
||||
maskCtx: CanvasRenderingContext2D | null
|
||||
imgCtx: CanvasRenderingContext2D | null
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
imgCanvas: HTMLCanvasElement | null
|
||||
rgbCtx: CanvasRenderingContext2D | null
|
||||
rgbCanvas: HTMLCanvasElement | null
|
||||
maskColor: { r: number; g: number; b: number }
|
||||
paintBucketTolerance: number
|
||||
fillOpacity: number
|
||||
colorSelectTolerance: number
|
||||
colorComparisonMethod: ColorComparisonMethod
|
||||
selectionOpacity: number
|
||||
applyWholeImage: boolean
|
||||
maskBoundary: boolean
|
||||
maskTolerance: number
|
||||
canvasHistory: { saveState: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const mockCanvasHistory = {
|
||||
saveState: vi.fn()
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
maskCtx: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCanvas: null as any,
|
||||
imgCanvas: null as any,
|
||||
rgbCtx: null as any,
|
||||
rgbCanvas: null as any,
|
||||
const mockStore: MockMaskEditorStore = {
|
||||
maskCtx: null,
|
||||
imgCtx: null,
|
||||
maskCanvas: null,
|
||||
imgCanvas: null,
|
||||
rgbCtx: null,
|
||||
rgbCanvas: null,
|
||||
maskColor: { r: 255, g: 255, b: 255 },
|
||||
paintBucketTolerance: 10,
|
||||
fillOpacity: 100,
|
||||
@@ -57,34 +77,40 @@ describe('useCanvasTools', () => {
|
||||
mockImgImageData.data[i + 3] = 255
|
||||
}
|
||||
|
||||
mockStore.maskCtx = {
|
||||
const partialMaskCtx: Partial<CanvasRenderingContext2D> = {
|
||||
getImageData: vi.fn(() => mockMaskImageData),
|
||||
putImageData: vi.fn(),
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
mockStore.maskCtx = partialMaskCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.imgCtx = {
|
||||
const partialImgCtx: Partial<CanvasRenderingContext2D> = {
|
||||
getImageData: vi.fn(() => mockImgImageData)
|
||||
}
|
||||
mockStore.imgCtx = partialImgCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.rgbCtx = {
|
||||
const partialRgbCtx: Partial<CanvasRenderingContext2D> = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
mockStore.rgbCtx = partialRgbCtx as CanvasRenderingContext2D
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
const partialMaskCanvas: Partial<HTMLCanvasElement> = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
mockStore.maskCanvas = partialMaskCanvas as HTMLCanvasElement
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
const partialImgCanvas: Partial<HTMLCanvasElement> = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
mockStore.imgCanvas = partialImgCanvas as HTMLCanvasElement
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
const partialRgbCanvas: Partial<HTMLCanvasElement> = {
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
mockStore.rgbCanvas = partialRgbCanvas as HTMLCanvasElement
|
||||
|
||||
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
||||
mockStore.paintBucketTolerance = 10
|
||||
@@ -103,13 +129,13 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
|
||||
mockMaskImageData,
|
||||
0,
|
||||
0
|
||||
@@ -154,7 +180,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.paintBucketFill({ x: -1, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', () => {
|
||||
@@ -164,7 +190,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply fill opacity', () => {
|
||||
@@ -198,14 +224,19 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.imgCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -216,7 +247,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect color tolerance', async () => {
|
||||
@@ -239,7 +270,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: -1, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when canvas missing', async () => {
|
||||
@@ -249,7 +280,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply selection opacity', async () => {
|
||||
@@ -270,7 +301,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use LAB color comparison method', async () => {
|
||||
@@ -280,7 +311,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should respect mask boundary', async () => {
|
||||
@@ -295,7 +326,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update last color select point', async () => {
|
||||
@@ -303,7 +334,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
await tools.colorSelectFill({ x: 30, y: 40 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -320,13 +351,13 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
|
||||
expect(mockStore.maskCtx!.getImageData).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100
|
||||
)
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalledWith(
|
||||
mockMaskImageData,
|
||||
0,
|
||||
0
|
||||
@@ -369,7 +400,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.invertMask()
|
||||
|
||||
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx?.getImageData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when context missing', () => {
|
||||
@@ -389,8 +420,8 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.maskCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx!.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -401,7 +432,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx?.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -412,8 +443,8 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.clearMask()
|
||||
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
expect(mockStore.rgbCtx?.clearRect).not.toHaveBeenCalled()
|
||||
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -426,26 +457,26 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.clearLastColorSelectPoint()
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle small canvas', () => {
|
||||
mockStore.maskCanvas.width = 1
|
||||
mockStore.maskCanvas.height = 1
|
||||
mockStore.maskCanvas!.width = 1
|
||||
mockStore.maskCanvas!.height = 1
|
||||
mockMaskImageData = {
|
||||
data: new Uint8ClampedArray(1 * 1 * 4),
|
||||
width: 1,
|
||||
height: 1
|
||||
} as ImageData
|
||||
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
|
||||
mockStore.maskCtx!.getImageData = vi.fn(() => mockMaskImageData)
|
||||
|
||||
const tools = useCanvasTools()
|
||||
|
||||
tools.paintBucketFill({ x: 0, y: 0 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fractional coordinates', () => {
|
||||
@@ -453,7 +484,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.paintBucketFill({ x: 50.7, y: 50.3 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle maximum tolerance', () => {
|
||||
@@ -463,7 +494,7 @@ describe('useCanvasTools', () => {
|
||||
|
||||
tools.paintBucketFill({ x: 50, y: 50 })
|
||||
|
||||
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.maskCtx!.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle zero opacity', () => {
|
||||
|
||||
@@ -95,7 +95,7 @@ if (typeof globalThis.ImageData === 'undefined') {
|
||||
this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4)
|
||||
}
|
||||
}
|
||||
} as unknown as typeof globalThis.ImageData
|
||||
} as typeof ImageData
|
||||
}
|
||||
|
||||
// Mock ImageBitmap for test environment using safe type casting
|
||||
@@ -108,7 +108,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') {
|
||||
this.height = height
|
||||
}
|
||||
close() {}
|
||||
} as unknown as typeof globalThis.ImageBitmap
|
||||
} as typeof ImageBitmap
|
||||
}
|
||||
|
||||
describe('useCanvasTransform', () => {
|
||||
|
||||
@@ -2,22 +2,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
|
||||
type MockStore = {
|
||||
imgCanvas: HTMLCanvasElement | null
|
||||
maskCanvas: HTMLCanvasElement | null
|
||||
rgbCanvas: HTMLCanvasElement | null
|
||||
imgCtx: CanvasRenderingContext2D | null
|
||||
maskCtx: CanvasRenderingContext2D | null
|
||||
image: HTMLImageElement | null
|
||||
}
|
||||
|
||||
type MockDataStore = {
|
||||
inputData: {
|
||||
baseLayer: { image: HTMLImageElement }
|
||||
maskLayer: { image: HTMLImageElement }
|
||||
paintLayer: { image: HTMLImageElement } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const mockCanvasManager = {
|
||||
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
|
||||
updateMaskColor: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
const mockStore = {
|
||||
imgCanvas: null as any,
|
||||
maskCanvas: null as any,
|
||||
rgbCanvas: null as any,
|
||||
imgCtx: null as any,
|
||||
maskCtx: null as any,
|
||||
image: null as any
|
||||
const mockStore: MockStore = {
|
||||
imgCanvas: null,
|
||||
maskCanvas: null,
|
||||
rgbCanvas: null,
|
||||
imgCtx: null,
|
||||
maskCtx: null,
|
||||
image: null
|
||||
}
|
||||
|
||||
const mockDataStore = {
|
||||
inputData: null as any
|
||||
const mockDataStore: MockDataStore = {
|
||||
inputData: null
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
@@ -33,7 +50,8 @@ vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
createSharedComposable: (fn: any) => fn
|
||||
createSharedComposable: <T extends (...args: unknown[]) => unknown>(fn: T) =>
|
||||
fn
|
||||
}))
|
||||
|
||||
describe('useImageLoader', () => {
|
||||
@@ -61,26 +79,26 @@ describe('useImageLoader', () => {
|
||||
|
||||
mockStore.imgCtx = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockStore.maskCtx = {
|
||||
clearRect: vi.fn()
|
||||
}
|
||||
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
|
||||
|
||||
mockStore.imgCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockStore.maskCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockStore.rgbCanvas = {
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
|
||||
|
||||
mockDataStore.inputData = {
|
||||
baseLayer: { image: mockBaseImage },
|
||||
@@ -104,10 +122,10 @@ describe('useImageLoader', () => {
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.maskCanvas.width).toBe(512)
|
||||
expect(mockStore.maskCanvas.height).toBe(512)
|
||||
expect(mockStore.rgbCanvas.width).toBe(512)
|
||||
expect(mockStore.rgbCanvas.height).toBe(512)
|
||||
expect(mockStore.maskCanvas?.width).toBe(512)
|
||||
expect(mockStore.maskCanvas?.height).toBe(512)
|
||||
expect(mockStore.rgbCanvas?.width).toBe(512)
|
||||
expect(mockStore.rgbCanvas?.height).toBe(512)
|
||||
})
|
||||
|
||||
it('should clear canvas contexts', async () => {
|
||||
@@ -115,8 +133,8 @@ describe('useImageLoader', () => {
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
expect(mockStore.imgCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
expect(mockStore.maskCtx?.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
||||
})
|
||||
|
||||
it('should call canvasManager methods', async () => {
|
||||
@@ -188,10 +206,10 @@ describe('useImageLoader', () => {
|
||||
|
||||
await loader.loadImages()
|
||||
|
||||
expect(mockStore.maskCanvas.width).toBe(1024)
|
||||
expect(mockStore.maskCanvas.height).toBe(768)
|
||||
expect(mockStore.rgbCanvas.width).toBe(1024)
|
||||
expect(mockStore.rgbCanvas.height).toBe(768)
|
||||
expect(mockStore.maskCanvas?.width).toBe(1024)
|
||||
expect(mockStore.maskCanvas?.height).toBe(768)
|
||||
expect(mockStore.rgbCanvas?.width).toBe(1024)
|
||||
expect(mockStore.rgbCanvas?.height).toBe(768)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
|
||||
onMounted(() => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
() => nodePricing.pricingRevision.value,
|
||||
() => {
|
||||
if (!showApiPricingBadge.value) return
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
return priceBadge.getCreditsBadge(price)
|
||||
}
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
|
||||
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
|
||||
// (We no longer rely on it to hold the current badge value.)
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
// Ensure watchers are installed; ignore the returned value.
|
||||
// (This call is what registers the widget listeners in most implementations.)
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
// Hook into connection changes to trigger price recalculation
|
||||
// This handles both connect and disconnect in VueNodes mode
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes =
|
||||
pricingConfig?.depends_on?.input_groups ?? []
|
||||
const hasRelevantInputs =
|
||||
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
|
||||
|
||||
if (hasRelevantInputs) {
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
// Only trigger if this input affects pricing
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
let lastLabel = nodePricing.getNodeDisplayPrice(node)
|
||||
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
|
||||
|
||||
const creditsBadgeGetter: () => LGraphBadge = () => {
|
||||
const label = nodePricing.getNodeDisplayPrice(node)
|
||||
if (label !== lastLabel) {
|
||||
lastLabel = label
|
||||
lastBadge = priceBadge.getCreditsBadge(label)
|
||||
}
|
||||
return lastBadge
|
||||
}
|
||||
|
||||
node.badges.push(creditsBadgeGetter)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,12 @@ import { nextTick } from 'vue'
|
||||
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock useChainCallback
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, newCallback) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
original?.call(this, ...args)
|
||||
newCallback.call(this, ...args)
|
||||
}
|
||||
@@ -18,11 +19,12 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
const createMockNode = (
|
||||
widgets: Array<{
|
||||
name: string
|
||||
value: any
|
||||
callback?: (...args: any[]) => void
|
||||
value: unknown
|
||||
callback?: (...args: unknown[]) => void
|
||||
}> = []
|
||||
) => {
|
||||
const mockNode = {
|
||||
): LGraphNode => {
|
||||
const baseNode = createMockLGraphNode()
|
||||
return Object.assign(baseNode, {
|
||||
widgets: widgets.map((widget) => ({
|
||||
name: widget.name,
|
||||
value: widget.value,
|
||||
@@ -31,9 +33,7 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return mockNode
|
||||
})
|
||||
}
|
||||
|
||||
it('should create a reactive computed that responds to widget changes', async () => {
|
||||
@@ -59,9 +59,9 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
|
||||
// Change widget value and trigger callback
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
if (widthWidget && widthWidget.callback) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
widthWidget.callback(widthWidget.value)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -89,9 +89,9 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
|
||||
// Change observed widget
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
if (widthWidget && widthWidget.callback) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
widthWidget.callback(widthWidget.value)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -117,9 +117,9 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
if (widget && widget.callback) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
widget.callback(widget.value)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -139,9 +139,9 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
if (widget && widget.callback) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
widget.callback(widget.value)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -171,8 +171,8 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
|
||||
// Trigger widget callback
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
;(widget.callback as any)?.()
|
||||
if (widget && widget.callback) {
|
||||
widget.callback(widget.value)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -11,13 +11,18 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
|
||||
const downloadFileMock = vi.fn()
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: (...args: any[]) => downloadFileMock(...args)
|
||||
downloadFile: (url: string, filename?: string) => {
|
||||
if (filename === undefined) {
|
||||
return downloadFileMock(url)
|
||||
}
|
||||
return downloadFileMock(url, filename)
|
||||
}
|
||||
}))
|
||||
|
||||
const copyToClipboardMock = vi.fn()
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
|
||||
copyToClipboard: (text: string) => copyToClipboardMock(text)
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -30,8 +35,8 @@ vi.mock('@/i18n', () => ({
|
||||
|
||||
const mapTaskOutputToAssetItemMock = vi.fn()
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
mapTaskOutputToAssetItem: (...args: any[]) =>
|
||||
mapTaskOutputToAssetItemMock(...args)
|
||||
mapTaskOutputToAssetItem: (taskItem: TaskItemImpl, output: ResultItemImpl) =>
|
||||
mapTaskOutputToAssetItemMock(taskItem, output)
|
||||
}))
|
||||
|
||||
const mediaAssetActionsMock = {
|
||||
@@ -67,14 +72,16 @@ const interruptMock = vi.fn()
|
||||
const deleteItemMock = vi.fn()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
interrupt: (...args: any[]) => interruptMock(...args),
|
||||
deleteItem: (...args: any[]) => deleteItemMock(...args)
|
||||
interrupt: (runningPromptId: string | null) =>
|
||||
interruptMock(runningPromptId),
|
||||
deleteItem: (type: string, id: string) => deleteItemMock(type, id)
|
||||
}
|
||||
}))
|
||||
|
||||
const downloadBlobMock = vi.fn()
|
||||
vi.mock('@/scripts/utils', () => ({
|
||||
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
|
||||
downloadBlob: (filename: string, blob: Blob) =>
|
||||
downloadBlobMock(filename, blob)
|
||||
}))
|
||||
|
||||
const dialogServiceMock = {
|
||||
@@ -94,11 +101,14 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => litegraphServiceMock
|
||||
}))
|
||||
|
||||
const nodeDefStoreMock = {
|
||||
nodeDefsByName: {} as Record<string, any>
|
||||
const nodeDefStoreMock: {
|
||||
nodeDefsByName: Record<string, Partial<ComfyNodeDefImpl>>
|
||||
} = {
|
||||
nodeDefsByName: {}
|
||||
}
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => nodeDefStoreMock
|
||||
useNodeDefStore: () => nodeDefStoreMock,
|
||||
ComfyNodeDefImpl: class {}
|
||||
}))
|
||||
|
||||
const queueStoreMock = {
|
||||
@@ -118,12 +128,13 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
|
||||
const getJobWorkflowMock = vi.fn()
|
||||
vi.mock('@/services/jobOutputCache', () => ({
|
||||
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)
|
||||
getJobWorkflow: (jobId: string) => getJobWorkflowMock(jobId)
|
||||
}))
|
||||
|
||||
const createAnnotatedPathMock = vi.fn()
|
||||
vi.mock('@/utils/createAnnotatedPath', () => ({
|
||||
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
|
||||
createAnnotatedPath: (filename: string, subfolder: string, type: string) =>
|
||||
createAnnotatedPathMock(filename, subfolder, type)
|
||||
}))
|
||||
|
||||
const appendJsonExtMock = vi.fn((value: string) =>
|
||||
@@ -135,7 +146,8 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
}))
|
||||
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
type MockTaskRef = Record<string, unknown>
|
||||
|
||||
@@ -193,9 +205,9 @@ describe('useJobMenu', () => {
|
||||
}))
|
||||
createAnnotatedPathMock.mockReturnValue('annotated-path')
|
||||
nodeDefStoreMock.nodeDefsByName = {
|
||||
LoadImage: { id: 'LoadImage' },
|
||||
LoadVideo: { id: 'LoadVideo' },
|
||||
LoadAudio: { id: 'LoadAudio' }
|
||||
LoadImage: { name: 'LoadImage' },
|
||||
LoadVideo: { name: 'LoadVideo' },
|
||||
LoadAudio: { name: 'LoadAudio' }
|
||||
}
|
||||
// Default: no workflow available via lazy loading
|
||||
getJobWorkflowMock.mockResolvedValue(undefined)
|
||||
@@ -257,7 +269,7 @@ describe('useJobMenu', () => {
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async (state) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: state as any }))
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob()
|
||||
|
||||
@@ -292,7 +304,9 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Something went wrong' } as any
|
||||
taskRef: {
|
||||
errorMessage: 'Something went wrong'
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
@@ -324,7 +338,7 @@ describe('useJobMenu', () => {
|
||||
errorMessage: 'CUDA out of memory',
|
||||
executionError,
|
||||
createTime: 12345
|
||||
} as any
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
@@ -344,7 +358,9 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Job failed with error' } as any
|
||||
taskRef: {
|
||||
errorMessage: 'Job failed with error'
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
@@ -366,7 +382,7 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: undefined } as any
|
||||
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
@@ -514,7 +530,12 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
@@ -543,7 +564,12 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('ignores download request when preview missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
@@ -751,7 +777,7 @@ describe('useJobMenu', () => {
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Some error' } as any
|
||||
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -11,13 +11,28 @@ vi.mock('@/i18n', () => ({
|
||||
}))
|
||||
|
||||
// Mock the execution store
|
||||
const executionStore = reactive({
|
||||
const executionStore = reactive<{
|
||||
isIdle: boolean
|
||||
executionProgress: number
|
||||
executingNode: unknown
|
||||
executingNodeProgress: number
|
||||
nodeProgressStates: Record<string, unknown>
|
||||
activePrompt: {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: { id: number; type: string }[]
|
||||
}
|
||||
}
|
||||
}
|
||||
} | null
|
||||
}>({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null as any,
|
||||
executingNode: null,
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {} as any,
|
||||
activePrompt: null as any
|
||||
nodeProgressStates: {},
|
||||
activePrompt: null
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -25,15 +40,21 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
|
||||
// Mock the setting store
|
||||
const settingStore = reactive({
|
||||
get: vi.fn(() => 'Enabled')
|
||||
get: vi.fn((_key: string) => 'Enabled')
|
||||
})
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => settingStore
|
||||
}))
|
||||
|
||||
// Mock the workflow store
|
||||
const workflowStore = reactive({
|
||||
activeWorkflow: null as any
|
||||
const workflowStore = reactive<{
|
||||
activeWorkflow: {
|
||||
filename: string
|
||||
isModified: boolean
|
||||
isPersisted: boolean
|
||||
} | null
|
||||
}>({
|
||||
activeWorkflow: null
|
||||
})
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => workflowStore
|
||||
@@ -52,13 +73,13 @@ describe('useBrowserTabTitle', () => {
|
||||
// reset execution store
|
||||
executionStore.isIdle = true
|
||||
executionStore.executionProgress = 0
|
||||
executionStore.executingNode = null as any
|
||||
executionStore.executingNode = null
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activePrompt = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = null
|
||||
workspaceStore.shiftDown = false
|
||||
|
||||
@@ -74,7 +95,7 @@ describe('useBrowserTabTitle', () => {
|
||||
})
|
||||
|
||||
it('sets workflow name as title when workflow exists and menu enabled', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: false,
|
||||
@@ -88,7 +109,7 @@ describe('useBrowserTabTitle', () => {
|
||||
})
|
||||
|
||||
it('adds asterisk for unsaved workflow', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
vi.mocked(settingStore.get).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: true,
|
||||
@@ -102,7 +123,7 @@ describe('useBrowserTabTitle', () => {
|
||||
})
|
||||
|
||||
it('hides asterisk when autosave is enabled', async () => {
|
||||
;(settingStore.get as any).mockImplementation((key: string) => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
|
||||
if (key === 'Comfy.UseNewMenu') return 'Enabled'
|
||||
return 'Enabled'
|
||||
@@ -118,7 +139,7 @@ describe('useBrowserTabTitle', () => {
|
||||
})
|
||||
|
||||
it('hides asterisk while Shift key is held', async () => {
|
||||
;(settingStore.get as any).mockImplementation((key: string) => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Workflow.AutoSave') return 'off'
|
||||
if (key === 'Comfy.UseNewMenu') return 'Enabled'
|
||||
return 'Enabled'
|
||||
@@ -137,7 +158,7 @@ describe('useBrowserTabTitle', () => {
|
||||
// Fails when run together with other tests. Suspect to be caused by leaked
|
||||
// state from previous tests.
|
||||
it.skip('disables workflow title when menu disabled', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Disabled')
|
||||
vi.mocked(settingStore.get).mockReturnValue('Disabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
|
||||
|
||||
describe('useCachedRequest', () => {
|
||||
let mockRequestFn: (
|
||||
params: any,
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
) => Promise<unknown | null>
|
||||
let abortSpy: () => void
|
||||
@@ -25,7 +25,7 @@ describe('useCachedRequest', () => {
|
||||
)
|
||||
|
||||
// Create a mock request function that returns different results based on params
|
||||
mockRequestFn = vi.fn(async (params: any) => {
|
||||
mockRequestFn = vi.fn(async (params: unknown) => {
|
||||
// Simulate a request that takes some time
|
||||
await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
|
||||
@@ -138,12 +138,18 @@ describe('useCachedRequest', () => {
|
||||
|
||||
it('should use custom cache key function if provided', async () => {
|
||||
// Create a cache key function that sorts object keys
|
||||
const cacheKeyFn = (params: any) => {
|
||||
const cacheKeyFn = (params: unknown) => {
|
||||
if (typeof params !== 'object' || params === null) return String(params)
|
||||
return JSON.stringify(
|
||||
Object.keys(params)
|
||||
Object.keys(params as Record<string, unknown>)
|
||||
.sort()
|
||||
.reduce((acc, key) => ({ ...acc, [key]: params[key] }), {})
|
||||
.reduce(
|
||||
(acc, key) => ({
|
||||
...acc,
|
||||
[key]: (params as Record<string, unknown>)[key]
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
// Mock vue-i18n for useExternalLink
|
||||
const mockLocale = ref('en')
|
||||
@@ -106,30 +108,84 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
|
||||
describe('useCoreCommands', () => {
|
||||
const mockSubgraph = {
|
||||
nodes: [
|
||||
// Mock input node
|
||||
{
|
||||
constructor: { comfyClass: 'SubgraphInputNode' },
|
||||
id: 'input1'
|
||||
},
|
||||
// Mock output node
|
||||
{
|
||||
constructor: { comfyClass: 'SubgraphOutputNode' },
|
||||
id: 'output1'
|
||||
},
|
||||
// Mock user node
|
||||
{
|
||||
constructor: { comfyClass: 'SomeUserNode' },
|
||||
id: 'user1'
|
||||
},
|
||||
// Another mock user node
|
||||
{
|
||||
constructor: { comfyClass: 'AnotherUserNode' },
|
||||
id: 'user2'
|
||||
const createMockNode = (id: number, comfyClass: string): LGraphNode => {
|
||||
const baseNode = createMockLGraphNode({ id })
|
||||
return Object.assign(baseNode, {
|
||||
constructor: {
|
||||
...baseNode.constructor,
|
||||
comfyClass
|
||||
}
|
||||
],
|
||||
remove: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
const createMockSubgraph = () => {
|
||||
const mockNodes = [
|
||||
// Mock input node
|
||||
createMockNode(1, 'SubgraphInputNode'),
|
||||
// Mock output node
|
||||
createMockNode(2, 'SubgraphOutputNode'),
|
||||
// Mock user node
|
||||
createMockNode(3, 'SomeUserNode'),
|
||||
// Another mock user node
|
||||
createMockNode(4, 'AnotherUserNode')
|
||||
]
|
||||
|
||||
return {
|
||||
nodes: mockNodes,
|
||||
remove: vi.fn(),
|
||||
events: {
|
||||
dispatch: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
},
|
||||
name: 'test-subgraph',
|
||||
inputNode: undefined,
|
||||
outputNode: undefined,
|
||||
add: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
configure: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
runStep: vi.fn(),
|
||||
findNodeByTitle: vi.fn(),
|
||||
findNodesByTitle: vi.fn(),
|
||||
findNodesByType: vi.fn(),
|
||||
findNodeById: vi.fn(),
|
||||
getNodeById: vi.fn(),
|
||||
setDirtyCanvas: vi.fn(),
|
||||
sendActionToCanvas: vi.fn()
|
||||
} as Partial<typeof app.canvas.subgraph> as typeof app.canvas.subgraph
|
||||
}
|
||||
|
||||
const mockSubgraph = createMockSubgraph()
|
||||
|
||||
function createMockSettingStore(
|
||||
getReturnValue: boolean
|
||||
): ReturnType<typeof useSettingStore> {
|
||||
return {
|
||||
get: vi.fn().mockReturnValue(getReturnValue),
|
||||
addSetting: vi.fn(),
|
||||
loadSettingValues: vi.fn(),
|
||||
set: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
getDefaultValue: vi.fn(),
|
||||
settingValues: {},
|
||||
settingsById: {},
|
||||
$id: 'setting',
|
||||
$state: {
|
||||
settingValues: {},
|
||||
settingsById: {}
|
||||
},
|
||||
$patch: vi.fn(),
|
||||
$reset: vi.fn(),
|
||||
$subscribe: vi.fn(),
|
||||
$onAction: vi.fn(),
|
||||
$dispose: vi.fn(),
|
||||
_customProperties: new Set(),
|
||||
_p: {}
|
||||
} as ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -142,9 +198,7 @@ describe('useCoreCommands', () => {
|
||||
app.canvas.subgraph = undefined
|
||||
|
||||
// Mock settings store
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue(false) // Skip confirmation dialog
|
||||
} as any)
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
|
||||
|
||||
// Mock global confirm
|
||||
global.confirm = vi.fn().mockReturnValue(true)
|
||||
@@ -167,7 +221,7 @@ describe('useCoreCommands', () => {
|
||||
|
||||
it('should preserve input/output nodes when clearing subgraph', async () => {
|
||||
// Set up subgraph context
|
||||
app.canvas.subgraph = mockSubgraph as any
|
||||
app.canvas.subgraph = mockSubgraph
|
||||
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
@@ -181,24 +235,19 @@ describe('useCoreCommands', () => {
|
||||
expect(app.rootGraph.clear).not.toHaveBeenCalled()
|
||||
|
||||
// Should only remove user nodes, not input/output nodes
|
||||
expect(mockSubgraph.remove).toHaveBeenCalledTimes(2)
|
||||
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1
|
||||
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2
|
||||
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
|
||||
mockSubgraph.nodes[0]
|
||||
) // input1
|
||||
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
|
||||
mockSubgraph.nodes[1]
|
||||
) // output1
|
||||
const subgraph = app.canvas.subgraph!
|
||||
expect(subgraph.remove).toHaveBeenCalledTimes(2)
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
|
||||
|
||||
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
|
||||
})
|
||||
|
||||
it('should respect confirmation setting', async () => {
|
||||
// Mock confirmation required
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue(true) // Require confirmation
|
||||
} as any)
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
|
||||
|
||||
global.confirm = vi.fn().mockReturnValue(false) // User cancels
|
||||
|
||||
|
||||
@@ -1235,8 +1235,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Toggle Simple Mode',
|
||||
function: () => {
|
||||
function: (metadata?: Record<string, unknown>) => {
|
||||
const source =
|
||||
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
|
||||
const newMode = !canvasStore.linearMode
|
||||
if (newMode) useTelemetry()?.trackEnterLinear({ source })
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
canvasStore.linearMode = newMode
|
||||
|
||||
@@ -30,8 +30,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should access supportsPreviewMetadata', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return true as any
|
||||
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
@@ -46,8 +45,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should access maxUploadSize', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
|
||||
return 209715200 as any // 200MB
|
||||
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200 // 200MB
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
@@ -62,7 +60,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should access supportsManagerV4', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true as any
|
||||
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
@@ -76,7 +74,7 @@ describe('useFeatureFlags', () => {
|
||||
|
||||
it('should return undefined when features are not available and no default provided', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue as any
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
@@ -90,7 +88,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should create reactive computed for custom feature flags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === 'custom.feature') return 'custom-value' as any
|
||||
if (path === 'custom.feature') return 'custom-value'
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
@@ -108,7 +106,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should handle nested paths', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === 'extension.custom.nested.feature') return true as any
|
||||
if (path === 'extension.custom.nested.feature') return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
@@ -122,8 +120,7 @@ describe('useFeatureFlags', () => {
|
||||
it('should work with ServerFeatureFlag enum', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
|
||||
return 104857600 as any
|
||||
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 104857600
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type { Size } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
createMockCanvasPointerEvent,
|
||||
createMockLGraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
@@ -36,15 +44,15 @@ vi.mock('@/i18n', () => ({
|
||||
}))
|
||||
|
||||
describe('useLoad3d', () => {
|
||||
let mockLoad3d: any
|
||||
let mockNode: any
|
||||
let mockToastStore: any
|
||||
let mockLoad3d: Partial<Load3d>
|
||||
let mockNode: LGraphNode
|
||||
let mockToastStore: ReturnType<typeof useToastStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
|
||||
mockNode = {
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
'Scene Config': {
|
||||
showGrid: true,
|
||||
@@ -68,18 +76,21 @@ describe('useLoad3d', () => {
|
||||
'Resource Folder': ''
|
||||
},
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
{ name: 'width', value: 512, type: 'number' } as IWidget,
|
||||
{ name: 'height', value: 512, type: 'number' } as IWidget
|
||||
],
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
},
|
||||
} as Partial<LGraph> as LGraph,
|
||||
flags: {},
|
||||
onMouseEnter: null,
|
||||
onMouseLeave: null,
|
||||
onResize: null,
|
||||
onDrawBackground: null
|
||||
}
|
||||
onMouseEnter: undefined,
|
||||
onMouseLeave: undefined,
|
||||
onResize: undefined,
|
||||
onDrawBackground: undefined
|
||||
})
|
||||
|
||||
const mockCanvas = document.createElement('canvas')
|
||||
mockCanvas.hidden = false
|
||||
|
||||
mockLoad3d = {
|
||||
toggleGrid: vi.fn(),
|
||||
@@ -114,19 +125,20 @@ describe('useLoad3d', () => {
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
renderer: {
|
||||
domElement: {
|
||||
hidden: false
|
||||
}
|
||||
}
|
||||
domElement: mockCanvas
|
||||
} as Partial<Load3d['renderer']> as Load3d['renderer']
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(function () {
|
||||
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
|
||||
Object.assign(this, mockLoad3d)
|
||||
return this
|
||||
})
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
}
|
||||
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
|
||||
typeof useToastStore
|
||||
>
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
})
|
||||
|
||||
@@ -208,14 +220,14 @@ describe('useLoad3d', () => {
|
||||
expect(mockNode.onDrawBackground).toBeDefined()
|
||||
|
||||
// Test the handlers
|
||||
mockNode.onMouseEnter()
|
||||
mockNode.onMouseEnter?.(createMockCanvasPointerEvent(0, 0))
|
||||
expect(mockLoad3d.refreshViewport).toHaveBeenCalled()
|
||||
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(true)
|
||||
|
||||
mockNode.onMouseLeave()
|
||||
mockNode.onMouseLeave?.(createMockCanvasPointerEvent(0, 0))
|
||||
expect(mockLoad3d.updateStatusMouseOnNode).toHaveBeenCalledWith(false)
|
||||
|
||||
mockNode.onResize()
|
||||
mockNode.onResize?.([512, 512] as Size)
|
||||
expect(mockLoad3d.handleResize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -226,13 +238,17 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockNode.flags.collapsed = true
|
||||
mockNode.onDrawBackground()
|
||||
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
|
||||
|
||||
expect(mockLoad3d.renderer.domElement.hidden).toBe(true)
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should load model if model_file widget exists', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'subfolder',
|
||||
'test.glb'
|
||||
@@ -255,8 +271,12 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should restore camera state after loading model', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'test.glb' })
|
||||
mockNode.properties['Camera Config'].state = {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
;(mockNode.properties!['Camera Config'] as { state: unknown }).state = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
}
|
||||
@@ -312,13 +332,13 @@ describe('useLoad3d', () => {
|
||||
it('should handle missing container or node', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
await composable.initializeLoad3d(null as any)
|
||||
await composable.initializeLoad3d(null!)
|
||||
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept ref as parameter', () => {
|
||||
const nodeRef = ref(mockNode)
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
@@ -370,9 +390,9 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
mockLoad3d.toggleGrid.mockClear()
|
||||
mockLoad3d.setBackgroundColor.mockClear()
|
||||
mockLoad3d.setBackgroundImage.mockClear()
|
||||
vi.mocked(mockLoad3d.toggleGrid!).mockClear()
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockClear()
|
||||
vi.mocked(mockLoad3d.setBackgroundImage!).mockClear()
|
||||
|
||||
composable.sceneConfig.value = {
|
||||
showGrid: false,
|
||||
@@ -403,8 +423,8 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
mockLoad3d.setUpDirection.mockClear()
|
||||
mockLoad3d.setMaterialMode.mockClear()
|
||||
vi.mocked(mockLoad3d.setUpDirection!).mockClear()
|
||||
vi.mocked(mockLoad3d.setMaterialMode!).mockClear()
|
||||
|
||||
composable.modelConfig.value.upDirection = '+y'
|
||||
composable.modelConfig.value.materialMode = 'wireframe'
|
||||
@@ -426,8 +446,8 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
mockLoad3d.toggleCamera.mockClear()
|
||||
mockLoad3d.setFOV.mockClear()
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
@@ -449,7 +469,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
mockLoad3d.setLightIntensity.mockClear()
|
||||
vi.mocked(mockLoad3d.setLightIntensity!).mockClear()
|
||||
|
||||
composable.lightConfig.value.intensity = 10
|
||||
await nextTick()
|
||||
@@ -589,7 +609,7 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should use resource folder for upload', async () => {
|
||||
mockNode.properties['Resource Folder'] = 'subfolder'
|
||||
mockNode.properties!['Resource Folder'] = 'subfolder'
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
@@ -641,7 +661,9 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle export errors', async () => {
|
||||
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
|
||||
vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce(
|
||||
new Error('Export failed')
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -719,12 +741,12 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle materialModeChange event', async () => {
|
||||
let materialModeHandler: any
|
||||
let materialModeHandler: ((mode: string) => void) | undefined
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'materialModeChange') {
|
||||
materialModeHandler = handler
|
||||
materialModeHandler = handler as (mode: string) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -734,21 +756,21 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
materialModeHandler('wireframe')
|
||||
materialModeHandler?.('wireframe')
|
||||
|
||||
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('should handle loading events', async () => {
|
||||
let modelLoadingStartHandler: any
|
||||
let modelLoadingEndHandler: any
|
||||
let modelLoadingStartHandler: (() => void) | undefined
|
||||
let modelLoadingEndHandler: (() => void) | undefined
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'modelLoadingStart') {
|
||||
modelLoadingStartHandler = handler
|
||||
modelLoadingStartHandler = handler as () => void
|
||||
} else if (event === 'modelLoadingEnd') {
|
||||
modelLoadingEndHandler = handler
|
||||
modelLoadingEndHandler = handler as () => void
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -758,22 +780,22 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
modelLoadingStartHandler()
|
||||
modelLoadingStartHandler?.()
|
||||
expect(composable.loading.value).toBe(true)
|
||||
expect(composable.loadingMessage.value).toBe('load3d.loadingModel')
|
||||
|
||||
modelLoadingEndHandler()
|
||||
modelLoadingEndHandler?.()
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('should handle recordingStatusChange event', async () => {
|
||||
let recordingStatusHandler: any
|
||||
let recordingStatusHandler: ((status: boolean) => void) | undefined
|
||||
|
||||
mockLoad3d.addEventListener.mockImplementation(
|
||||
(event: string, handler: any) => {
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'recordingStatusChange') {
|
||||
recordingStatusHandler = handler
|
||||
recordingStatusHandler = handler as (status: boolean) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -783,7 +805,7 @@ describe('useLoad3d', () => {
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
recordingStatusHandler(false)
|
||||
recordingStatusHandler?.(false)
|
||||
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.recordingDuration.value).toBe(10)
|
||||
@@ -814,10 +836,11 @@ describe('useLoad3d', () => {
|
||||
|
||||
describe('getModelUrl', () => {
|
||||
it('should handle http URLs directly', async () => {
|
||||
mockNode.widgets.push({
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'http://example.com/model.glb'
|
||||
})
|
||||
value: 'http://example.com/model.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -830,7 +853,11 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should construct URL for local files', async () => {
|
||||
mockNode.widgets.push({ name: 'model_file', value: 'models/test.glb' })
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'models/test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'models',
|
||||
'test.glb'
|
||||
@@ -860,7 +887,9 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should use output type for preview mode', async () => {
|
||||
mockNode.widgets = [{ name: 'model_file', value: 'test.glb' }] // No width/height widgets
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget
|
||||
] // No width/height widgets
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
@@ -894,10 +923,10 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle missing configurations', async () => {
|
||||
delete mockNode.properties['Scene Config']
|
||||
delete mockNode.properties['Model Config']
|
||||
delete mockNode.properties['Camera Config']
|
||||
delete mockNode.properties['Light Config']
|
||||
delete mockNode.properties!['Scene Config']
|
||||
delete mockNode.properties!['Model Config']
|
||||
delete mockNode.properties!['Camera Config']
|
||||
delete mockNode.properties!['Light Config']
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -909,7 +938,11 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
it('should handle background image with existing config', async () => {
|
||||
mockNode.properties['Scene Config'].backgroundImage = 'existing.jpg'
|
||||
;(
|
||||
mockNode.properties!['Scene Config'] as {
|
||||
backgroundImage: string
|
||||
}
|
||||
).backgroundImage = 'existing.jpg'
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
|
||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn()
|
||||
@@ -19,22 +20,22 @@ function createMockDragEvent(
|
||||
const files = options.files || []
|
||||
const types = options.hasFiles ? ['Files'] : []
|
||||
|
||||
const dataTransfer = {
|
||||
const dataTransfer: Partial<DataTransfer> = {
|
||||
types,
|
||||
files,
|
||||
files: createMockFileList(files),
|
||||
dropEffect: 'none' as DataTransfer['dropEffect']
|
||||
}
|
||||
|
||||
const event = {
|
||||
const event: Partial<DragEvent> = {
|
||||
type,
|
||||
dataTransfer
|
||||
} as unknown as DragEvent
|
||||
dataTransfer: dataTransfer as DataTransfer
|
||||
}
|
||||
|
||||
return event
|
||||
return event as DragEvent
|
||||
}
|
||||
|
||||
describe('useLoad3dDrag', () => {
|
||||
let mockToastStore: any
|
||||
let mockToastStore: ReturnType<typeof useToastStore>
|
||||
let mockOnModelDrop: (file: File) => void | Promise<void>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -42,7 +43,9 @@ describe('useLoad3dDrag', () => {
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
}
|
||||
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
|
||||
typeof useToastStore
|
||||
>
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
|
||||
mockOnModelDrop = vi.fn()
|
||||
|
||||
@@ -4,8 +4,11 @@ import { nextTick } from 'vue'
|
||||
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: vi.fn()
|
||||
@@ -29,17 +32,32 @@ vi.mock('@/extensions/core/load3d/Load3d', () => ({
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockSceneManager(): Load3d['sceneManager'] {
|
||||
const mock: Partial<Load3d['sceneManager']> = {
|
||||
scene: {} as Load3d['sceneManager']['scene'],
|
||||
backgroundScene: {} as Load3d['sceneManager']['backgroundScene'],
|
||||
backgroundCamera: {} as Load3d['sceneManager']['backgroundCamera'],
|
||||
currentBackgroundColor: '#282828',
|
||||
gridHelper: { visible: true } as Load3d['sceneManager']['gridHelper'],
|
||||
getCurrentBackgroundInfo: vi.fn().mockReturnValue({
|
||||
type: 'color',
|
||||
value: '#282828'
|
||||
})
|
||||
}
|
||||
return mock as Load3d['sceneManager']
|
||||
}
|
||||
|
||||
describe('useLoad3dViewer', () => {
|
||||
let mockLoad3d: any
|
||||
let mockSourceLoad3d: any
|
||||
let mockLoad3dService: any
|
||||
let mockToastStore: any
|
||||
let mockNode: any
|
||||
let mockLoad3d: Partial<Load3d>
|
||||
let mockSourceLoad3d: Partial<Load3d>
|
||||
let mockLoad3dService: ReturnType<typeof useLoad3dService>
|
||||
let mockToastStore: ReturnType<typeof useToastStore>
|
||||
let mockNode: LGraphNode
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockNode = {
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
'Scene Config': {
|
||||
backgroundColor: '#282828',
|
||||
@@ -62,9 +80,9 @@ describe('useLoad3dViewer', () => {
|
||||
},
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
},
|
||||
} as Partial<LGraph> as LGraph,
|
||||
widgets: []
|
||||
} as any
|
||||
})
|
||||
|
||||
mockLoad3d = {
|
||||
setBackgroundColor: vi.fn(),
|
||||
@@ -97,24 +115,17 @@ describe('useLoad3dViewer', () => {
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}),
|
||||
sceneManager: {
|
||||
currentBackgroundColor: '#282828',
|
||||
gridHelper: { visible: true },
|
||||
getCurrentBackgroundInfo: vi.fn().mockReturnValue({
|
||||
type: 'color',
|
||||
value: '#282828'
|
||||
})
|
||||
},
|
||||
sceneManager: createMockSceneManager(),
|
||||
lightingManager: {
|
||||
lights: [null, { intensity: 1 }]
|
||||
},
|
||||
} as Load3d['lightingManager'],
|
||||
cameraManager: {
|
||||
perspectiveCamera: { fov: 75 }
|
||||
},
|
||||
} as Load3d['cameraManager'],
|
||||
modelManager: {
|
||||
currentUpDirection: 'original',
|
||||
materialMode: 'original'
|
||||
},
|
||||
} as Load3d['modelManager'],
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
forceRender: vi.fn()
|
||||
@@ -128,12 +139,16 @@ describe('useLoad3dViewer', () => {
|
||||
copyLoad3dState: vi.fn().mockResolvedValue(undefined),
|
||||
handleViewportRefresh: vi.fn(),
|
||||
getLoad3d: vi.fn().mockReturnValue(mockSourceLoad3d)
|
||||
}
|
||||
} as Partial<ReturnType<typeof useLoad3dService>> as ReturnType<
|
||||
typeof useLoad3dService
|
||||
>
|
||||
vi.mocked(useLoad3dService).mockReturnValue(mockLoad3dService)
|
||||
|
||||
mockToastStore = {
|
||||
addAlert: vi.fn()
|
||||
}
|
||||
} as Partial<ReturnType<typeof useToastStore>> as ReturnType<
|
||||
typeof useToastStore
|
||||
>
|
||||
vi.mocked(useToastStore).mockReturnValue(mockToastStore)
|
||||
})
|
||||
|
||||
@@ -160,7 +175,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(Load3d).toHaveBeenCalledWith(containerRef, {
|
||||
width: undefined,
|
||||
@@ -184,16 +199,20 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
it('should handle background image during initialization', async () => {
|
||||
mockSourceLoad3d.sceneManager.getCurrentBackgroundInfo.mockReturnValue({
|
||||
vi.mocked(
|
||||
mockSourceLoad3d.sceneManager!.getCurrentBackgroundInfo
|
||||
).mockReturnValue({
|
||||
type: 'image',
|
||||
value: ''
|
||||
})
|
||||
mockNode.properties['Scene Config'].backgroundImage = 'test-image.jpg'
|
||||
;(
|
||||
mockNode.properties!['Scene Config'] as Record<string, unknown>
|
||||
).backgroundImage = 'test-image.jpg'
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.backgroundImage.value).toBe('test-image.jpg')
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
@@ -207,7 +226,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToInitializeLoad3dViewer'
|
||||
@@ -220,7 +239,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
await nextTick()
|
||||
@@ -232,7 +251,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.showGrid.value = false
|
||||
await nextTick()
|
||||
@@ -244,7 +263,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
await nextTick()
|
||||
@@ -256,7 +275,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.fov.value = 90
|
||||
await nextTick()
|
||||
@@ -268,7 +287,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.lightIntensity.value = 2
|
||||
await nextTick()
|
||||
@@ -280,7 +299,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundImage.value = 'new-bg.jpg'
|
||||
await nextTick()
|
||||
@@ -293,7 +312,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.upDirection.value = '+y'
|
||||
await nextTick()
|
||||
@@ -305,7 +324,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.materialMode.value = 'wireframe'
|
||||
await nextTick()
|
||||
@@ -314,14 +333,16 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
it('should handle watcher errors gracefully', async () => {
|
||||
mockLoad3d.setBackgroundColor.mockImplementationOnce(function () {
|
||||
throw new Error('Color update failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
|
||||
function () {
|
||||
throw new Error('Color update failed')
|
||||
}
|
||||
)
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
await nextTick()
|
||||
@@ -337,7 +358,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
await viewer.exportModel('glb')
|
||||
|
||||
@@ -345,12 +366,14 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
it('should handle export errors', async () => {
|
||||
mockLoad3d.exportModel.mockRejectedValueOnce(new Error('Export failed'))
|
||||
vi.mocked(mockLoad3d.exportModel!).mockRejectedValueOnce(
|
||||
new Error('Export failed')
|
||||
)
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
await viewer.exportModel('glb')
|
||||
|
||||
@@ -373,7 +396,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.handleResize()
|
||||
|
||||
@@ -384,7 +407,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.handleMouseEnter()
|
||||
|
||||
@@ -395,7 +418,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.handleMouseLeave()
|
||||
|
||||
@@ -408,22 +431,35 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
|
||||
mockNode.properties['Scene Config'].backgroundColor = '#ff0000'
|
||||
mockNode.properties['Scene Config'].showGrid = false
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
;(
|
||||
mockNode.properties!['Scene Config'] as Record<string, unknown>
|
||||
).backgroundColor = '#ff0000'
|
||||
;(
|
||||
mockNode.properties!['Scene Config'] as Record<string, unknown>
|
||||
).showGrid = false
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
|
||||
'#282828'
|
||||
)
|
||||
expect(mockNode.properties['Scene Config'].showGrid).toBe(true)
|
||||
expect(mockNode.properties['Camera Config'].cameraType).toBe(
|
||||
'perspective'
|
||||
)
|
||||
expect(mockNode.properties['Camera Config'].fov).toBe(75)
|
||||
expect(mockNode.properties['Light Config'].intensity).toBe(1)
|
||||
expect(
|
||||
(mockNode.properties!['Scene Config'] as Record<string, unknown>)
|
||||
.backgroundColor
|
||||
).toBe('#282828')
|
||||
expect(
|
||||
(mockNode.properties!['Scene Config'] as Record<string, unknown>)
|
||||
.showGrid
|
||||
).toBe(true)
|
||||
expect(
|
||||
(mockNode.properties!['Camera Config'] as Record<string, unknown>)
|
||||
.cameraType
|
||||
).toBe('perspective')
|
||||
expect(
|
||||
(mockNode.properties!['Camera Config'] as Record<string, unknown>).fov
|
||||
).toBe(75)
|
||||
expect(
|
||||
(mockNode.properties!['Light Config'] as Record<string, unknown>)
|
||||
.intensity
|
||||
).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,7 +468,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
viewer.showGrid.value = false
|
||||
@@ -440,23 +476,27 @@ describe('useLoad3dViewer', () => {
|
||||
const result = await viewer.applyChanges()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockNode.properties['Scene Config'].backgroundColor).toBe(
|
||||
'#ff0000'
|
||||
)
|
||||
expect(mockNode.properties['Scene Config'].showGrid).toBe(false)
|
||||
expect(
|
||||
(mockNode.properties!['Scene Config'] as Record<string, unknown>)
|
||||
.backgroundColor
|
||||
).toBe('#ff0000')
|
||||
expect(
|
||||
(mockNode.properties!['Scene Config'] as Record<string, unknown>)
|
||||
.showGrid
|
||||
).toBe(false)
|
||||
expect(mockLoad3dService.copyLoad3dState).toHaveBeenCalledWith(
|
||||
mockLoad3d,
|
||||
mockSourceLoad3d
|
||||
)
|
||||
expect(mockSourceLoad3d.forceRender).toHaveBeenCalled()
|
||||
expect(mockNode.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
expect(mockNode.graph!.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should handle background image during apply', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundImage.value = 'new-bg.jpg'
|
||||
|
||||
@@ -481,7 +521,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.refreshViewport()
|
||||
|
||||
@@ -498,7 +538,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
await viewer.handleBackgroundImageUpdate(file)
|
||||
@@ -515,7 +555,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
await viewer.handleBackgroundImageUpdate(file)
|
||||
@@ -527,7 +567,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundImage.value = 'existing.jpg'
|
||||
viewer.hasBackgroundImage.value = true
|
||||
@@ -546,7 +586,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
await viewer.handleBackgroundImageUpdate(file)
|
||||
@@ -562,7 +602,7 @@ describe('useLoad3dViewer', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.cleanup()
|
||||
|
||||
@@ -580,33 +620,36 @@ describe('useLoad3dViewer', () => {
|
||||
it('should handle missing container ref', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
await viewer.initializeViewer(null as any, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(null!, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle orthographic camera', async () => {
|
||||
mockSourceLoad3d.getCurrentCameraType.mockReturnValue('orthographic')
|
||||
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
|
||||
'orthographic'
|
||||
)
|
||||
mockSourceLoad3d.cameraManager = {
|
||||
perspectiveCamera: { fov: 75 }
|
||||
}
|
||||
delete mockNode.properties['Camera Config'].cameraType
|
||||
} as Partial<Load3d['cameraManager']> as Load3d['cameraManager']
|
||||
delete (mockNode.properties!['Camera Config'] as Record<string, unknown>)
|
||||
.cameraType
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.cameraType.value).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('should handle missing lights', async () => {
|
||||
mockSourceLoad3d.lightingManager.lights = []
|
||||
mockSourceLoad3d.lightingManager!.lights = []
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d)
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.lightIntensity.value).toBe(1) // Default value
|
||||
})
|
||||
|
||||
@@ -630,6 +630,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
handleSeek,
|
||||
cleanup
|
||||
cleanup,
|
||||
|
||||
hasSkeleton: false,
|
||||
intensity: lightIntensity,
|
||||
showSkeleton: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import { refThrottled, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -84,7 +84,7 @@ export function useTemplateFiltering(
|
||||
return ['ComfyUI', 'External or Remote API']
|
||||
})
|
||||
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
||||
const debouncedSearchQuery = refThrottled(searchQuery, 50)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
|
||||
@@ -47,8 +47,9 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
if (ClipspaceDialog.instance) {
|
||||
const self = ClipspaceDialog.instance
|
||||
// allow reconstruct controls when copying from non-image to image content.
|
||||
const imgSettings = self.createImgSettings()
|
||||
const children = $el('div.comfy-modal-content', [
|
||||
self.createImgSettings(),
|
||||
...(imgSettings ? [imgSettings] : []),
|
||||
...self.createButtons()
|
||||
])
|
||||
|
||||
@@ -103,7 +104,7 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
return buttons
|
||||
}
|
||||
|
||||
createImgSettings() {
|
||||
createImgSettings(): HTMLTableElement | null {
|
||||
if (ComfyApp.clipspace?.imgs) {
|
||||
const combo_items = []
|
||||
const imgs = ComfyApp.clipspace.imgs
|
||||
@@ -167,14 +168,14 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
|
||||
return $el('table', {}, [row1, row2, row3])
|
||||
} else {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
createImgPreview() {
|
||||
createImgPreview(): HTMLImageElement | null {
|
||||
if (ComfyApp.clipspace?.imgs) {
|
||||
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
|
||||
} else return []
|
||||
} else return null
|
||||
}
|
||||
|
||||
override show() {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { t } from '@/i18n'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
// Zendesk feedback URL - update this with the actual URL
|
||||
const ZENDESK_FEEDBACK_URL =
|
||||
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
@@ -12,7 +19,7 @@ const buttons: ActionBarButton[] = [
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,7 +2,6 @@ import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
@@ -26,7 +25,7 @@ useExtensionService().registerExtension({
|
||||
{ debounce: 256, immediate: true }
|
||||
)
|
||||
|
||||
// Poll for config updates every 10 minutes
|
||||
setInterval(() => void loadRemoteConfig(), 600_000)
|
||||
// Poll for config updates every 10 minutes (with auth)
|
||||
setInterval(() => void refreshRemoteConfig(), 600_000)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/**
|
||||
@@ -18,11 +22,12 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
|
||||
// Extension monkey-patches the method
|
||||
const original = LGraphCanvas.prototype.getCanvasMenuOptions
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
items.push({ content: 'My Custom Menu Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions =
|
||||
function (): (IContextMenuValue | null)[] {
|
||||
const items = original.call(this)
|
||||
items.push({ content: 'My Custom Menu Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
// Clear extension (happens after setup completes)
|
||||
legacyMenuCompat.setCurrentExtension(null)
|
||||
@@ -49,8 +54,8 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
|
||||
// Extension monkey-patches the method
|
||||
const original = LGraphCanvas.prototype.getNodeMenuOptions
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (...args: any[]) {
|
||||
const items = (original as any).apply(this, args)
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node: LGraphNode) {
|
||||
const items = original.call(this, node)
|
||||
items.push({ content: 'My Node Menu Item', callback: () => {} })
|
||||
return items
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user