diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 30b0da08e..ea5a0b78f 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -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]], diff --git a/browser_tests/tests/featureFlags.spec.ts b/browser_tests/tests/featureFlags.spec.ts index 707976829..9155ae910 100644 --- a/browser_tests/tests/featureFlags.spec.ts +++ b/browser_tests/tests/featureFlags.spec.ts @@ -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 } }) diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index cfb04bc46..709c19a4b 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -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]) diff --git a/browser_tests/tests/selectionToolboxSubmenus.spec.ts b/browser_tests/tests/selectionToolboxSubmenus.spec.ts index f526b07e0..fd4c55faf 100644 --- a/browser_tests/tests/selectionToolboxSubmenus.spec.ts +++ b/browser_tests/tests/selectionToolboxSubmenus.spec.ts @@ -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') diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts index 315758f5c..9b007afb2 100644 --- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts @@ -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) diff --git a/package.json b/package.json index 8f240b1c5..810d73c9e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index ac15a78b1..75d8b8db2 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -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', () => { diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 032d1c9ed..fde399eb9 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -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' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15ccfa824..eced908c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5856550a0..0c91ec424 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/base/common/async.ts b/src/base/common/async.ts index a97f6f1bd..d5aa30679 100644 --- a/src/base/common/async.ts +++ b/src/base/common/async.ts @@ -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' || diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts index 6640f9b88..944ce714c 100644 --- a/src/components/TopMenuSection.test.ts +++ b/src/components/TopMenuSection.test.ts @@ -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' @@ -42,7 +43,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' } } } @@ -56,7 +58,12 @@ function createWrapper() { SubgraphBreadcrumb: true, QueueProgressOverlay: true, CurrentUserButton: true, - LoginButton: true + LoginButton: true, + ContextMenu: { + name: 'ContextMenu', + props: ['model'], + template: '
' + } }, directives: { tooltip: () => {} @@ -134,4 +141,24 @@ describe('TopMenuSection', () => { const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]') expect(queueButton.text()).toContain('3 active') }) + + 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) + }) }) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index a20537038..b6b7bc4f3 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -49,6 +49,7 @@ class="px-3" data-testid="queue-overlay-toggle" @click="toggleQueueOverlay" + @contextmenu.stop.prevent="showQueueContextMenu" > {{ activeJobsLabel }} @@ -57,6 +58,7 @@ {{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }} + 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,6 +105,7 @@ 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 { useWorkspaceStore } from '@/stores/workspaceStore' @@ -119,6 +124,7 @@ const { t, n } = useI18n() const { toastErrorHandler } = useErrorHandling() const commandStore = useCommandStore() const queueStore = useQueueStore() +const executionStore = useExecutionStore() const queueUIStore = useQueueUIStore() const { activeJobsCount } = storeToRefs(queueStore) const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore) @@ -144,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() => const customNodesManagerTooltipConfig = computed(() => buildTooltipConfig(t('menu.customNodesManager')) ) +const queueContextMenu = ref | null>(null) +const queueContextMenuItems = computed(() => [ + { + 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 => { @@ -170,6 +188,19 @@ const toggleQueueOverlay = () => { 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({ diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts index faa20f654..b99e54ea1 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts @@ -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() diff --git a/src/components/common/FormRadioGroup.test.ts b/src/components/common/FormRadioGroup.test.ts index a014b8ecd..2ec26e9af 100644 --- a/src/components/common/FormRadioGroup.test.ts +++ b/src/components/common/FormRadioGroup.test.ts @@ -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 + 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') }) }) diff --git a/src/components/common/FormRadioGroup.vue b/src/components/common/FormRadioGroup.vue index 19889132b..6155bed0f 100644 --- a/src/components/common/FormRadioGroup.vue +++ b/src/components/common/FormRadioGroup.vue @@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types' const props = defineProps<{ modelValue: any - options: (SettingOption | string)[] + options?: (string | SettingOption | Record)[] optionLabel?: string optionValue?: string id?: string diff --git a/src/components/common/UrlInput.test.ts b/src/components/common/UrlInput.test.ts index e3fc81d29..9c34c11c5 100644 --- a/src/components/common/UrlInput.test.ts +++ b/src/components/common/UrlInput.test.ts @@ -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 & { + 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') }) }) }) diff --git a/src/components/common/UserAvatar.test.ts b/src/components/common/UserAvatar.test.ts index 0c6b26e4e..0b5df7052 100644 --- a/src/components/common/UserAvatar.test.ts +++ b/src/components/common/UserAvatar.test.ts @@ -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 = {}) => { return mount(UserAvatar, { global: { plugins: [PrimeVue, i18n], diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue new file mode 100644 index 000000000..642317267 --- /dev/null +++ b/src/components/common/WorkspaceProfilePic.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue index afc056d61..2a1f0ef3d 100644 --- a/src/components/dialog/GlobalDialog.vue +++ b/src/components/dialog/GlobalDialog.vue @@ -4,7 +4,12 @@ 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" :aria-labelledby="item.key" @@ -38,7 +43,15 @@ @@ -55,4 +68,27 @@ 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; +} + +.settings-dialog-workspace .p-dialog-content { + width: 100%; +} + +.manager-dialog { + height: 80vh; + max-width: 1724px; + max-height: 1026px; +} + +@media (min-width: 3000px) { + .manager-dialog { + max-width: 2200px; + max-height: 1320px; + } +} diff --git a/src/components/dialog/content/setting/SettingItem.test.ts b/src/components/dialog/content/setting/SettingItem.test.ts index 17e3bab06..673fe5894 100644 --- a/src/components/dialog/content/setting/SettingItem.test.ts +++ b/src/components/dialog/content/setting/SettingItem.test.ts @@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({ })) describe('SettingItem', () => { - const mountComponent = (props: any, options = {}): any => { + const mountComponent = (props: Record, 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' ) }) diff --git a/src/components/dialog/content/setting/UsageLogsTable.test.ts b/src/components/dialog/content/setting/UsageLogsTable.test.ts index b98664668..72a4fbdba 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.test.ts +++ b/src/components/dialog/content/setting/UsageLogsTable.test.ts @@ -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 & { loading: boolean error: string | null - events: any[] + events: Partial[] pagination: { page: number limit: number diff --git a/src/components/dialog/content/setting/WorkspacePanel.vue b/src/components/dialog/content/setting/WorkspacePanel.vue new file mode 100644 index 000000000..aff8f3733 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanel.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue new file mode 100644 index 000000000..9366a573f --- /dev/null +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/components/dialog/content/setting/WorkspaceSidebarItem.vue b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue new file mode 100644 index 000000000..cab92c7a8 --- /dev/null +++ b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/dialog/content/signin/ApiKeyForm.test.ts b/src/components/dialog/content/signin/ApiKeyForm.test.ts index 5d6a726b8..4d073cb08 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.test.ts +++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts @@ -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 = {}) => { return mount(ApiKeyForm, { global: { plugins: [PrimeVue, createPinia(), i18n], diff --git a/src/components/dialog/content/signin/SignInForm.test.ts b/src/components/dialog/content/signin/SignInForm.test.ts index da898532a..c27d15929 100644 --- a/src/components/dialog/content/signin/SignInForm.test.ts +++ b/src/components/dialog/content/signin/SignInForm.test.ts @@ -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 = { 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 = { 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 = { focus: mockFocus } + vi.spyOn(document, 'getElementById').mockReturnValue( + mockElement as HTMLElement + ) // Call handleForgotPassword with valid email await component.handleForgotPassword('test@example.com', true) diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue new file mode 100644 index 000000000..b9444ce58 --- /dev/null +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue new file mode 100644 index 000000000..dea2da18d --- /dev/null +++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue @@ -0,0 +1,89 @@ + + + diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue new file mode 100644 index 000000000..62b650a4e --- /dev/null +++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue new file mode 100644 index 000000000..6a3d16c36 --- /dev/null +++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index 151dfd405..849a9ba25 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -1,4 +1,4 @@ - + diff --git a/src/components/topbar/WorkspaceSwitcherPopover.vue b/src/components/topbar/WorkspaceSwitcherPopover.vue new file mode 100644 index 000000000..c8236535d --- /dev/null +++ b/src/components/topbar/WorkspaceSwitcherPopover.vue @@ -0,0 +1,166 @@ + + + diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts index 768332325..faf9a4444 100644 --- a/src/components/ui/button/button.variants.ts +++ b/src/components/ui/button/button.variants.ts @@ -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: '' } }, diff --git a/src/components/ui/tags-input/TagsInput.vue b/src/components/ui/tags-input/TagsInput.vue index f4a3fa001..c07dcdf7f 100644 --- a/src/components/ui/tags-input/TagsInput.vue +++ b/src/components/ui/tags-input/TagsInput.vue @@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {