diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md index d82e22ec6..71dee96a5 100644 --- a/.claude/commands/setup_repo.md +++ b/.claude/commands/setup_repo.md @@ -122,7 +122,7 @@ echo " pnpm build - Build for production" echo " pnpm test:unit - Run unit tests" echo " pnpm typecheck - Run TypeScript checks" echo " pnpm lint - Run ESLint" -echo " pnpm format - Format code with Prettier" +echo " pnpm format - Format code with oxfmt" echo "" echo "Next steps:" echo "1. Run 'pnpm dev' to start developing" diff --git a/.cursor/rules/unit-test.mdc b/.cursor/rules/unit-test.mdc deleted file mode 100644 index 2c6704f3e..000000000 --- a/.cursor/rules/unit-test.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Creating unit tests -globs: -alwaysApply: false ---- - -# Creating unit tests - -- This project uses `vitest` for unit testing -- Tests are stored in the `test/` directory -- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux - - e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms -- Tests should be mocked properly - - Mocks should be cleanly written and easy to understand - - Mocks should be re-usable where possible - -## Unit test style - -- Prefer the use of `test.extend` over loose variables - - To achieve this, import `test as baseTest` from `vitest` -- Never use `it`; `test` should be used in place of this \ No newline at end of file diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 6f43623a3..000000000 --- a/.cursorrules +++ /dev/null @@ -1,61 +0,0 @@ -# Vue 3 Composition API Project Rules - -## Vue 3 Composition API Best Practices -- Use setup() function for component logic -- Utilize ref and reactive for reactive state -- Implement computed properties with computed() -- Use watch and watchEffect for side effects -- Implement lifecycle hooks with onMounted, onUpdated, etc. -- Utilize provide/inject for dependency injection -- Use vue 3.5 style of default prop declaration. Example: - -```typescript -const { nodes, showTotal = true } = defineProps<{ - nodes: ApiNodeCost[] - showTotal?: boolean -}>() -``` - -- Organize vue component in diff --git a/src/components/MenuHamburger.vue b/src/components/MenuHamburger.vue index 467561b54..fa99e032c 100644 --- a/src/components/MenuHamburger.vue +++ b/src/components/MenuHamburger.vue @@ -1,27 +1,27 @@ - - diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts new file mode 100644 index 000000000..944ce714c --- /dev/null +++ b/src/components/TopMenuSection.test.ts @@ -0,0 +1,164 @@ +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' + +import TopMenuSection from '@/components/TopMenuSection.vue' +import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' +import LoginButton from '@/components/topbar/LoginButton.vue' +import type { + JobListItem, + JobStatus +} from '@/platform/remote/comfyui/jobs/jobTypes' +import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' +import { isElectron } from '@/utils/envUtil' + +const mockData = vi.hoisted(() => ({ isLoggedIn: false })) + +vi.mock('@/composables/auth/useCurrentUser', () => ({ + useCurrentUser: () => { + return { + isLoggedIn: computed(() => mockData.isLoggedIn) + } + } +})) + +vi.mock('@/utils/envUtil') +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + currentUser: null, + loading: false + })) +})) + +function createWrapper() { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + sideToolbar: { + queueProgressOverlay: { + viewJobHistory: 'View job history', + expandCollapsedQueue: 'Expand collapsed queue', + activeJobsShort: '{count} active | {count} active', + clearQueueTooltip: 'Clear queue' + } + } + } + } + }) + + return mount(TopMenuSection, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), i18n], + stubs: { + SubgraphBreadcrumb: true, + QueueProgressOverlay: true, + CurrentUserButton: true, + LoginButton: true, + ContextMenu: { + name: 'ContextMenu', + props: ['model'], + template: '
' + } + }, + directives: { + tooltip: () => {} + } + } + }) +} + +function createJob(id: string, status: JobStatus): JobListItem { + return { + id, + status, + create_time: 0, + priority: 0 + } +} + +function createTask(id: string, status: JobStatus): TaskItemImpl { + return new TaskItemImpl(createJob(id, status)) +} + +describe('TopMenuSection', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('authentication state', () => { + describe('when user is logged in', () => { + beforeEach(() => { + mockData.isLoggedIn = true + }) + + it('should display CurrentUserButton and not display LoginButton', () => { + const wrapper = createWrapper() + expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true) + expect(wrapper.findComponent(LoginButton).exists()).toBe(false) + }) + }) + + describe('when user is not logged in', () => { + beforeEach(() => { + mockData.isLoggedIn = false + }) + + describe('on desktop platform', () => { + it('should display LoginButton and not display CurrentUserButton', () => { + vi.mocked(isElectron).mockReturnValue(true) + const wrapper = createWrapper() + expect(wrapper.findComponent(LoginButton).exists()).toBe(true) + expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false) + }) + }) + + describe('on web platform', () => { + it('should not display CurrentUserButton and not display LoginButton', () => { + const wrapper = createWrapper() + expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false) + expect(wrapper.findComponent(LoginButton).exists()).toBe(false) + }) + }) + }) + }) + + it('shows the active jobs label with the current count', async () => { + const wrapper = createWrapper() + const queueStore = useQueueStore() + queueStore.pendingTasks = [createTask('pending-1', 'pending')] + queueStore.runningTasks = [ + createTask('running-1', 'in_progress'), + createTask('running-2', 'in_progress') + ] + + await nextTick() + + 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 6eafdc215..b6b7bc4f3 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -1,39 +1,179 @@ - + +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({ + initialTab: ManagerTab.All, + showToastOnLegacyError: false + }) + } catch (error) { + try { + toastErrorHandler(error) + } catch (toastError) { + console.error(error) + console.error(toastError) + } + } +} + diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index f01beee25..5ea860852 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -1,5 +1,5 @@ diff --git a/src/components/bottomPanel/BottomPanel.vue b/src/components/bottomPanel/BottomPanel.vue index f1705d0af..aa6800677 100644 --- a/src/components/bottomPanel/BottomPanel.vue +++ b/src/components/bottomPanel/BottomPanel.vue @@ -10,12 +10,12 @@ class="bg-transparent" >
-
+
@@ -79,7 +80,6 @@ - - diff --git a/tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts similarity index 97% rename from tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts rename to src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts index faa20f654..b99e54ea1 100644 --- a/tests-ui/tests/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/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index e632d0d47..655df0b65 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -11,9 +11,8 @@ value: tooltipText, showDelay: 300 }" - icon="pi pi-copy" - severity="secondary" - size="small" + variant="secondary" + size="sm" :class=" cn('absolute top-2 right-8 transition-opacity', { 'opacity-0 pointer-events-none select-none': !isHovered @@ -21,18 +20,20 @@ " :aria-label="tooltipText" @click="handleCopy" - /> + > + +
diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue index c5ee7ab71..bf4ba405a 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumb.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumb.vue @@ -1,6 +1,6 @@ @@ -42,8 +44,9 @@ import TabView from 'primevue/tabview' import { computed } from 'vue' import DeviceInfo from '@/components/common/DeviceInfo.vue' +import { isCloud } from '@/platform/distribution/types' import type { SystemStats } from '@/schemas/apiSchema' -import { formatSize } from '@/utils/formatUtil' +import { formatCommitHash, formatSize } from '@/utils/formatUtil' const props = defineProps<{ stats: SystemStats @@ -54,20 +57,53 @@ const systemInfo = computed(() => ({ argv: props.stats.system.argv.join(' ') })) -const systemColumns: { field: keyof SystemStats['system']; header: string }[] = - [ - { field: 'os', header: 'OS' }, - { field: 'python_version', header: 'Python Version' }, - { field: 'embedded_python', header: 'Embedded Python' }, - { field: 'pytorch_version', header: 'Pytorch Version' }, - { field: 'argv', header: 'Arguments' }, - { field: 'ram_total', header: 'RAM Total' }, - { field: 'ram_free', header: 'RAM Free' } - ] +const hasDevices = computed(() => props.stats.devices.length > 0) -const formatValue = (value: any, field: string) => { - if (['ram_total', 'ram_free'].includes(field)) { - return formatSize(value) +type SystemInfoKey = keyof SystemStats['system'] + +type ColumnDef = { + field: SystemInfoKey + header: string + format?: (value: string) => string + formatNumber?: (value: number) => string +} + +/** Columns for local distribution */ +const localColumns: ColumnDef[] = [ + { field: 'os', header: 'OS' }, + { field: 'python_version', header: 'Python Version' }, + { field: 'embedded_python', header: 'Embedded Python' }, + { field: 'pytorch_version', header: 'Pytorch Version' }, + { field: 'argv', header: 'Arguments' }, + { field: 'ram_total', header: 'RAM Total', formatNumber: formatSize }, + { field: 'ram_free', header: 'RAM Free', formatNumber: formatSize } +] + +/** Columns for cloud distribution */ +const cloudColumns: ColumnDef[] = [ + { field: 'cloud_version', header: 'Cloud Version' }, + { + field: 'comfyui_version', + header: 'ComfyUI Version', + format: formatCommitHash + }, + { + field: 'comfyui_frontend_version', + header: 'Frontend Version', + format: formatCommitHash + }, + { field: 'workflow_templates_version', header: 'Templates Version' } +] + +const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns)) + +const getDisplayValue = (column: ColumnDef) => { + const value = systemInfo.value[column.field] + if (column.formatNumber && typeof value === 'number') { + return column.formatNumber(value) + } + if (column.format && typeof value === 'string') { + return column.format(value) } return value } diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index c6a3dbe60..828d8ff45 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -2,7 +2,7 @@
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 d844277b9..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], @@ -56,7 +58,7 @@ describe('UserAvatar', () => { const avatar = wrapper.findComponent(Avatar) expect(avatar.exists()).toBe(true) expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('renders with default icon when provided photo Url is null', () => { @@ -67,7 +69,7 @@ describe('UserAvatar', () => { const avatar = wrapper.findComponent(Avatar) expect(avatar.exists()).toBe(true) expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('falls back to icon when image fails to load', async () => { @@ -82,7 +84,7 @@ describe('UserAvatar', () => { avatar.vm.$emit('error') await nextTick() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('uses provided ariaLabel', () => { diff --git a/src/components/common/UserAvatar.vue b/src/components/common/UserAvatar.vue index 8fce43d10..1eec243d6 100644 --- a/src/components/common/UserAvatar.vue +++ b/src/components/common/UserAvatar.vue @@ -1,7 +1,9 @@ @@ -21,19 +27,42 @@ import Skeleton from 'primevue/skeleton' import Tag from 'primevue/tag' import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { formatCreditsFromCents } from '@/base/credits/comfyCredits' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' -import { formatMetronomeCurrency } from '@/utils/formatUtil' -const { textClass } = defineProps<{ +const { textClass, showCreditsOnly } = defineProps<{ textClass?: string + showCreditsOnly?: boolean }>() const authStore = useFirebaseAuthStore() const balanceLoading = computed(() => authStore.isFetchingBalance) +const { t, locale } = useI18n() const formattedBalance = computed(() => { - if (!authStore.balance) return '0.00' - return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd') + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 + const amount = formatCreditsFromCents({ + cents, + locale: locale.value + }) + return `${amount} ${t('credits.credits')}` +}) + +const formattedCreditsOnly = computed(() => { + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 + const amount = formatCreditsFromCents({ + cents, + locale: locale.value, + numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 } + }) + return amount }) diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue index 89de6e421..fb6b4374c 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -1,16 +1,20 @@ @@ -28,19 +32,22 @@ type GridState = { const { items, + gridStyle, bufferRows = 1, scrollThrottle = 64, resizeDebounce = 64, defaultItemHeight = 200, - defaultItemWidth = 200 + defaultItemWidth = 200, + maxColumns = Infinity } = defineProps<{ items: (T & { key: string })[] - gridStyle: Partial + gridStyle: CSSProperties bufferRows?: number scrollThrottle?: number resizeDebounce?: number defaultItemHeight?: number defaultItemWidth?: number + maxColumns?: number }>() const emit = defineEmits<{ @@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, { eventListenerOptions: { passive: true } }) -const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1) +const cols = computed(() => + Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns) +) + +const mergedGridStyle = computed(() => { + if (maxColumns === Infinity) return gridStyle + return { + ...gridStyle, + gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))` + } +}) + const viewRows = computed(() => Math.ceil(height.value / itemHeight.value)) const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value)) const isValidGrid = computed(() => height.value && width.value && items?.length) @@ -83,6 +101,16 @@ const renderedItems = computed(() => isValidGrid.value ? items.slice(state.value.start, state.value.end) : [] ) +function rowsToHeight(rows: number): string { + return `${(rows / cols.value) * itemHeight.value}px` +} +const topSpacerStyle = computed(() => ({ + height: rowsToHeight(state.value.start) +})) +const bottomSpacerStyle = computed(() => ({ + height: rowsToHeight(items.length - state.value.end) +})) + whenever( () => state.value.isNearEnd, () => { @@ -109,24 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce) watch([width, height], onResize, { flush: 'post' }) whenever(() => items, updateItemSize, { flush: 'post' }) onBeforeUnmount(() => { - onResize.cancel() // Clear pending debounced calls + onResize.cancel() }) - - 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/common/statusBadge.variants.ts b/src/components/common/statusBadge.variants.ts new file mode 100644 index 000000000..479a0dda8 --- /dev/null +++ b/src/components/common/statusBadge.variants.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const statusBadgeVariants = cva({ + base: 'inline-flex items-center justify-center rounded-full', + variants: { + severity: { + default: 'bg-primary-background text-base-foreground', + secondary: 'bg-secondary-background text-base-foreground', + warn: 'bg-warning-background text-base-background', + danger: 'bg-destructive-background text-white', + contrast: 'bg-base-foreground text-base-background' + }, + variant: { + label: 'h-3.5 px-1 text-xxxs font-semibold uppercase', + dot: 'size-2', + circle: 'size-3.5 text-xxxs font-semibold' + } + }, + defaultVariants: { + severity: 'default', + variant: 'label' + } +}) + +export type StatusBadgeVariants = VariantProps diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index c64a8e19d..aeb98971b 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -22,67 +22,70 @@ @@ -20,10 +17,10 @@ import type { FormSubmitEvent } from '@primevue/forms' import { Form } from '@primevue/forms' import { zodResolver } from '@primevue/forms/resolvers/zod' -import Button from 'primevue/button' import { ref } from 'vue' import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue' +import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { updatePasswordSchema } from '@/schemas/signInSchema' diff --git a/src/components/dialog/content/credit/CreditTopUpOption.test.ts b/src/components/dialog/content/credit/CreditTopUpOption.test.ts new file mode 100644 index 000000000..7faf432e7 --- /dev/null +++ b/src/components/dialog/content/credit/CreditTopUpOption.test.ts @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} } +}) + +const mountOption = ( + props?: Partial<{ credits: number; description: string; selected: boolean }> +) => + mount(CreditTopUpOption, { + props: { + credits: 1000, + description: '~100 videos*', + selected: false, + ...props + }, + global: { + plugins: [i18n] + } + }) + +describe('CreditTopUpOption', () => { + it('renders credit amount and description', () => { + const wrapper = mountOption({ credits: 5000, description: '~500 videos*' }) + expect(wrapper.text()).toContain('5,000') + expect(wrapper.text()).toContain('~500 videos*') + }) + + it('applies unselected styling when not selected', () => { + const wrapper = mountOption({ selected: false }) + expect(wrapper.find('div').classes()).toContain( + 'bg-component-node-disabled' + ) + expect(wrapper.find('div').classes()).toContain('border-transparent') + }) + + it('emits select event when clicked', async () => { + const wrapper = mountOption() + await wrapper.find('div').trigger('click') + expect(wrapper.emitted('select')).toHaveLength(1) + }) +}) diff --git a/src/components/dialog/content/credit/CreditTopUpOption.vue b/src/components/dialog/content/credit/CreditTopUpOption.vue index 402df3ac0..c67c273a2 100644 --- a/src/components/dialog/content/credit/CreditTopUpOption.vue +++ b/src/components/dialog/content/credit/CreditTopUpOption.vue @@ -1,76 +1,45 @@ diff --git a/src/components/dialog/content/error/FindIssueButton.vue b/src/components/dialog/content/error/FindIssueButton.vue index e4c32b471..767202251 100644 --- a/src/components/dialog/content/error/FindIssueButton.vue +++ b/src/components/dialog/content/error/FindIssueButton.vue @@ -1,16 +1,16 @@ 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 bf1ec2cdd..4d073cb08 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.test.ts +++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts @@ -1,7 +1,9 @@ +import type { ComponentProps } from 'vue-component-type-helpers' + import { Form } from '@primevue/forms' import { mount } from '@vue/test-utils' import { createPinia } from 'pinia' -import Button from 'primevue/button' +import Button from '@/components/ui/button/Button.vue' import PrimeVue from 'primevue/config' import InputText from 'primevue/inputtext' import Message from 'primevue/message' @@ -9,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createApp } from 'vue' import { createI18n } from 'vue-i18n' -import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi' +import { getComfyPlatformBaseUrl } from '@/config/comfyApi' import ApiKeyForm from './ApiKeyForm.vue' @@ -63,7 +65,7 @@ describe('ApiKeyForm', () => { mockLoading.mockReset() }) - const mountComponent = (props: any = {}) => { + const mountComponent = (props: ComponentProps = {}) => { return mount(ApiKeyForm, { global: { plugins: [PrimeVue, createPinia(), i18n], @@ -99,9 +101,10 @@ describe('ApiKeyForm', () => { ) await wrapper.find('form').trigger('submit') - const submitButton = wrapper - .findAllComponents(Button) - .find((btn) => btn.text() === 'Save') + const buttons = wrapper.findAllComponents(Button) + const submitButton = buttons.find( + (btn) => btn.attributes('type') === 'submit' + ) expect(submitButton?.props('loading')).toBe(true) }) @@ -111,7 +114,7 @@ describe('ApiKeyForm', () => { const helpText = wrapper.find('small') expect(helpText.text()).toContain('Need an API key?') expect(helpText.find('a').attributes('href')).toBe( - `${COMFY_PLATFORM_BASE_URL}/login` + `${getComfyPlatformBaseUrl()}/login` ) }) }) diff --git a/src/components/dialog/content/signin/ApiKeyForm.vue b/src/components/dialog/content/signin/ApiKeyForm.vue index 2e70345fc..22ecb3205 100644 --- a/src/components/dialog/content/signin/ApiKeyForm.vue +++ b/src/components/dialog/content/signin/ApiKeyForm.vue @@ -48,7 +48,7 @@ {{ t('auth.apiKey.helpText') }} @@ -67,10 +67,15 @@
- -
@@ -82,13 +87,17 @@ import type { FormSubmitEvent } from '@primevue/forms' import { Form } from '@primevue/forms' import { zodResolver } from '@primevue/forms/resolvers/zod' -import Button from 'primevue/button' import InputText from 'primevue/inputtext' import Message from 'primevue/message' import { computed } from 'vue' import { useI18n } from 'vue-i18n' -import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi' +import Button from '@/components/ui/button/Button.vue' +import { getComfyPlatformBaseUrl } from '@/config/comfyApi' +import { + configValueOrDefault, + remoteConfig +} from '@/platform/remoteConfig/remoteConfig' import { apiKeySchema } from '@/schemas/signInSchema' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' @@ -96,6 +105,13 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' const authStore = useFirebaseAuthStore() const apiKeyStore = useApiKeyAuthStore() const loading = computed(() => authStore.loading) +const comfyPlatformBaseUrl = computed(() => + configValueOrDefault( + remoteConfig.value, + 'comfy_platform_base_url', + getComfyPlatformBaseUrl() + ) +) const { t } = useI18n() diff --git a/src/components/dialog/content/signin/SignInForm.test.ts b/src/components/dialog/content/signin/SignInForm.test.ts index d8f3721c1..c27d15929 100644 --- a/src/components/dialog/content/signin/SignInForm.test.ts +++ b/src/components/dialog/content/signin/SignInForm.test.ts @@ -1,7 +1,7 @@ import { Form } from '@primevue/forms' import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' -import Button from 'primevue/button' +import Button from '@/components/ui/button/Button.vue' import PrimeVue from 'primevue/config' import InputText from 'primevue/inputtext' import Password from 'primevue/password' @@ -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/signin/SignInForm.vue b/src/components/dialog/content/signin/SignInForm.vue index 1560911e0..cbc946fea 100644 --- a/src/components/dialog/content/signin/SignInForm.vue +++ b/src/components/dialog/content/signin/SignInForm.vue @@ -60,13 +60,15 @@ - + @@ -74,7 +76,7 @@ import type { FormSubmitEvent } from '@primevue/forms' import { Form } from '@primevue/forms' import { zodResolver } from '@primevue/forms/resolvers/zod' -import Button from 'primevue/button' +import { useThrottleFn } from '@vueuse/core' import InputText from 'primevue/inputtext' import Password from 'primevue/password' import ProgressSpinner from 'primevue/progressspinner' @@ -82,6 +84,7 @@ import { useToast } from 'primevue/usetoast' import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { signInSchema } from '@/schemas/signInSchema' import type { SignInData } from '@/schemas/signInSchema' @@ -100,11 +103,11 @@ const emit = defineEmits<{ const emailInputId = 'comfy-org-sign-in-email' -const onSubmit = (event: FormSubmitEvent) => { +const onSubmit = useThrottleFn((event: FormSubmitEvent) => { if (event.valid) { emit('submit', event.values as SignInData) } -} +}, 1_500) const handleForgotPassword = async ( email: string, diff --git a/src/components/dialog/content/signin/SignUpForm.vue b/src/components/dialog/content/signin/SignUpForm.vue index 90a526363..58b44578f 100644 --- a/src/components/dialog/content/signin/SignUpForm.vue +++ b/src/components/dialog/content/signin/SignUpForm.vue @@ -1,5 +1,6 @@ @@ -40,24 +45,30 @@ import type { FormSubmitEvent } from '@primevue/forms' import { Form, FormField } from '@primevue/forms' import { zodResolver } from '@primevue/forms/resolvers/zod' -import Button from 'primevue/button' +import { useThrottleFn } from '@vueuse/core' import InputText from 'primevue/inputtext' +import ProgressSpinner from 'primevue/progressspinner' +import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import Button from '@/components/ui/button/Button.vue' import { signUpSchema } from '@/schemas/signInSchema' import type { SignUpData } from '@/schemas/signInSchema' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import PasswordFields from './PasswordFields.vue' const { t } = useI18n() +const authStore = useFirebaseAuthStore() +const loading = computed(() => authStore.loading) const emit = defineEmits<{ submit: [values: SignUpData] }>() -const onSubmit = (event: FormSubmitEvent) => { +const onSubmit = useThrottleFn((event: FormSubmitEvent) => { if (event.valid) { emit('submit', event.values as SignUpData) } -} +}, 1_500) 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/dialog/header/SettingDialogHeader.vue b/src/components/dialog/header/SettingDialogHeader.vue index 66765846f..959cfa14d 100644 --- a/src/components/dialog/header/SettingDialogHeader.vue +++ b/src/components/dialog/header/SettingDialogHeader.vue @@ -15,9 +15,7 @@ diff --git a/src/components/graph/selectionToolbox/BypassButton.test.ts b/src/components/graph/selectionToolbox/BypassButton.test.ts index c966e180a..9fdcd971f 100644 --- a/src/components/graph/selectionToolbox/BypassButton.test.ts +++ b/src/components/graph/selectionToolbox/BypassButton.test.ts @@ -84,18 +84,6 @@ describe('BypassButton', () => { ) }) - it('should show normal styling when node is not bypassed', () => { - const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS } - canvasStore.selectedItems = [normalNode] as any - - const wrapper = mountComponent() - const button = wrapper.find('button') - - expect(button.classes()).not.toContain( - 'dark-theme:[&:not(:active)]:!bg-[#262729]' - ) - }) - it('should show bypassed styling when node is bypassed', async () => { const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS } canvasStore.selectedItems = [bypassedNode] as any diff --git a/src/components/graph/selectionToolbox/BypassButton.vue b/src/components/graph/selectionToolbox/BypassButton.vue index 84882a285..f9fc19187 100644 --- a/src/components/graph/selectionToolbox/BypassButton.vue +++ b/src/components/graph/selectionToolbox/BypassButton.vue @@ -1,28 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue index 5c361aa73..5535d7727 100644 --- a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue +++ b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue @@ -1,17 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue index 21779c35e..7352b54cf 100644 --- a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue +++ b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue @@ -2,44 +2,39 @@ diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index 5187f0c02..22cc2d409 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,21 +1,19 @@ diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue deleted file mode 100644 index 9965ffa6d..000000000 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ /dev/null @@ -1,324 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/NodeOptionsButton.vue b/src/components/graph/selectionToolbox/NodeOptionsButton.vue index 2b4e613c2..df70ad490 100644 --- a/src/components/graph/selectionToolbox/NodeOptionsButton.vue +++ b/src/components/graph/selectionToolbox/NodeOptionsButton.vue @@ -1,33 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index ec0f44b2f..5edebc9e1 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -2,19 +2,19 @@ diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index e28920018..f20aac370 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -143,8 +143,8 @@ onMounted(() => { widget.options.selectOn ?? ['focus', 'click'], () => { const lgCanvas = canvasStore.canvas - lgCanvas?.selectNode(widget.node) - lgCanvas?.bringToFront(widget.node) + lgCanvas?.selectNode(widgetState.widget.node) + lgCanvas?.bringToFront(widgetState.widget.node) } ) }) diff --git a/src/components/graph/widgets/TextPreviewWidget.vue b/src/components/graph/widgets/TextPreviewWidget.vue index 33ab74874..9e1709f2e 100644 --- a/src/components/graph/widgets/TextPreviewWidget.vue +++ b/src/components/graph/widgets/TextPreviewWidget.vue @@ -21,13 +21,38 @@ import { linkifyHtml, nl2br } from '@/utils/formatUtil' const modelValue = defineModel({ required: true }) const props = defineProps<{ - widget?: object nodeId: NodeId }>() const executionStore = useExecutionStore() const isParentNodeExecuting = ref(true) -const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value))) +const formattedText = computed(() => { + const src = modelValue.value + // Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml + const tokens: { label: string; url: string }[] = [] + const holed = src.replace( + /\[\[([^|\]]+)\|([^\]]+)\]\]/g, + (_m, label, url) => { + tokens.push({ label: String(label), url: String(url) }) + return `__LNK${tokens.length - 1}__` + } + ) + + // Keep current behavior (auto-link bare URLs + \n ->
) + let html = nl2br(linkifyHtml(holed)) + + // Restore placeholders as
... (minimal escaping + http default) + html = html.replace(/__LNK(\d+)__/g, (_m, i) => { + const { label, url } = tokens[+i] + const safeHref = url.replace(/"/g, '"') + const safeLabel = label.replace(//g, '>') + return /^https?:\/\//i.test(url) + ? `${safeLabel}` + : safeLabel + }) + + return html +}) let parentNodeId: NodeId | null = null onMounted(() => { diff --git a/src/components/graph/widgets/chatHistory/CopyButton.vue b/src/components/graph/widgets/chatHistory/CopyButton.vue deleted file mode 100644 index 933a1b921..000000000 --- a/src/components/graph/widgets/chatHistory/CopyButton.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue b/src/components/graph/widgets/chatHistory/ResponseBlurb.vue deleted file mode 100644 index e2326c431..000000000 --- a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index bfa1cbc96..d912a2941 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -1,34 +1,52 @@ diff --git a/src/components/honeyToast/HoneyToast.stories.ts b/src/components/honeyToast/HoneyToast.stories.ts new file mode 100644 index 000000000..74331d49f --- /dev/null +++ b/src/components/honeyToast/HoneyToast.stories.ts @@ -0,0 +1,293 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ProgressToastItem from '@/components/toast/ProgressToastItem.vue' +import Button from '@/components/ui/button/Button.vue' +import type { AssetDownload } from '@/stores/assetDownloadStore' +import { cn } from '@/utils/tailwindUtil' + +import HoneyToast from './HoneyToast.vue' + +function createMockJob(overrides: Partial = {}): AssetDownload { + return { + taskId: 'task-1', + assetId: 'asset-1', + assetName: 'model-v1.safetensors', + bytesTotal: 1000000, + bytesDownloaded: 0, + progress: 0, + status: 'created', + lastUpdate: Date.now(), + ...overrides + } +} + +const meta: Meta = { + title: 'Toast/HoneyToast', + component: HoneyToast, + parameters: { + layout: 'fullscreen' + }, + decorators: [ + () => ({ + template: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(false) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'completed', + progress: 1 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'running', + progress: 0.45 + }), + createMockJob({ + taskId: 'task-3', + assetName: 'vae-decoder.safetensors', + status: 'created' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Expanded: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(true) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'completed', + progress: 1 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'running', + progress: 0.45 + }), + createMockJob({ + taskId: 'task-3', + assetName: 'vae-decoder.safetensors', + status: 'created' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Completed: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(false) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + bytesDownloaded: 1000000, + progress: 1, + status: 'completed' + }), + createMockJob({ + taskId: 'task-2', + assetId: 'asset-2', + assetName: 'lora-style.safetensors', + bytesTotal: 500000, + bytesDownloaded: 500000, + progress: 1, + status: 'completed' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const WithError: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(true) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'failed', + progress: 0.23 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'completed', + progress: 1 + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Hidden: Story = { + render: () => ({ + components: { HoneyToast }, + template: ` +
+

HoneyToast is hidden when visible=false. Nothing appears at the bottom.

+ + + + + +
+ ` + }) +} diff --git a/src/components/honeyToast/HoneyToast.test.ts b/src/components/honeyToast/HoneyToast.test.ts new file mode 100644 index 000000000..ada123053 --- /dev/null +++ b/src/components/honeyToast/HoneyToast.test.ts @@ -0,0 +1,137 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' + +import HoneyToast from './HoneyToast.vue' + +describe('HoneyToast', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + function mountComponent( + props: { visible: boolean; expanded?: boolean } = { visible: true } + ): VueWrapper { + return mount(HoneyToast, { + props, + slots: { + default: (slotProps: { isExpanded: boolean }) => + h( + 'div', + { 'data-testid': 'content' }, + slotProps.isExpanded ? 'expanded' : 'collapsed' + ), + footer: (slotProps: { isExpanded: boolean; toggle: () => void }) => + h( + 'button', + { + 'data-testid': 'toggle-btn', + onClick: slotProps.toggle + }, + slotProps.isExpanded ? 'Collapse' : 'Expand' + ) + }, + attachTo: document.body + }) + } + + it('renders when visible is true', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast).toBeTruthy() + + wrapper.unmount() + }) + + it('does not render when visible is false', async () => { + const wrapper = mountComponent({ visible: false }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast).toBeFalsy() + + wrapper.unmount() + }) + + it('passes is-expanded=false to slots by default', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const content = document.body.querySelector('[data-testid="content"]') + expect(content?.textContent).toBe('collapsed') + + wrapper.unmount() + }) + + it('applies collapsed max-height class when collapsed', async () => { + const wrapper = mountComponent({ visible: true, expanded: false }) + await nextTick() + + const expandableArea = document.body.querySelector( + '[role="status"] > div:first-child' + ) + expect(expandableArea?.classList.contains('max-h-0')).toBe(true) + + wrapper.unmount() + }) + + it('has aria-live="polite" for accessibility', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast?.getAttribute('aria-live')).toBe('polite') + + wrapper.unmount() + }) + + it('supports v-model:expanded with reactive parent state', async () => { + const TestWrapper = defineComponent({ + components: { HoneyToast }, + setup() { + const expanded = ref(false) + return { expanded } + }, + template: ` + + + + + ` + }) + + const wrapper = mount(TestWrapper, { attachTo: document.body }) + await nextTick() + + const content = document.body.querySelector('[data-testid="content"]') + expect(content?.textContent).toBe('collapsed') + + const toggleBtn = document.body.querySelector( + '[data-testid="toggle-btn"]' + ) as HTMLButtonElement + expect(toggleBtn?.textContent?.trim()).toBe('Expand') + + toggleBtn?.click() + await nextTick() + + expect(content?.textContent).toBe('expanded') + expect(toggleBtn?.textContent?.trim()).toBe('Collapse') + + const expandableArea = document.body.querySelector( + '[role="status"] > div:first-child' + ) + expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true) + + wrapper.unmount() + }) +}) diff --git a/src/components/honeyToast/HoneyToast.vue b/src/components/honeyToast/HoneyToast.vue new file mode 100644 index 000000000..a7d86ba77 --- /dev/null +++ b/src/components/honeyToast/HoneyToast.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/icons/ComfyLogoTransparent.vue b/src/components/icons/ComfyLogo.vue similarity index 87% rename from src/components/icons/ComfyLogoTransparent.vue rename to src/components/icons/ComfyLogo.vue index 7fbd84154..493e2275d 100644 --- a/src/components/icons/ComfyLogoTransparent.vue +++ b/src/components/icons/ComfyLogo.vue @@ -4,13 +4,11 @@ :width="size" :height="size" viewBox="0 0 18 18" - fill="none" xmlns="http://www.w3.org/2000/svg" > @@ -22,11 +20,18 @@ interface Props { size?: number | string color?: string class?: string + mode?: 'outline' | 'fill' } const { size = 16, color = 'currentColor', + mode = 'outline', class: className } = defineProps() const iconClass = computed(() => className || '') +const attributes = computed(() => ({ + stroke: mode === 'outline' ? color : undefined, + strokeWidth: mode === 'outline' ? 1 : undefined, + fill: mode === 'fill' ? color : 'none' +})) diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue new file mode 100644 index 000000000..4a1c39ef6 --- /dev/null +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/components/input/MultiSelect.accessibility.stories.ts b/src/components/input/MultiSelect.accessibility.stories.ts deleted file mode 100644 index 716bfab0f..000000000 --- a/src/components/input/MultiSelect.accessibility.stories.ts +++ /dev/null @@ -1,380 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3-vite' -import type { MultiSelectProps } from 'primevue/multiselect' -import { ref } from 'vue' - -import MultiSelect from './MultiSelect.vue' -import type { SelectOption } from './types' - -// Combine our component props with PrimeVue MultiSelect props -interface ExtendedProps extends Partial { - // Our custom props - label?: string - showSearchBox?: boolean - showSelectedCount?: boolean - showClearButton?: boolean - searchPlaceholder?: string - listMaxHeight?: string - popoverMinWidth?: string - popoverMaxWidth?: string - // Override modelValue type to match our Option type - modelValue?: SelectOption[] -} - -const meta: Meta = { - title: 'Components/Input/MultiSelect/Accessibility', - component: MultiSelect, - tags: ['autodocs'], - parameters: { - docs: { - description: { - component: ` -# MultiSelect Accessibility Guide - -This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines. - -## Keyboard Navigation - -- **Tab** - Focus the trigger button -- **Enter/Space** - Open/close dropdown when focused -- **Arrow Up/Down** - Navigate through options when dropdown is open -- **Enter/Space** - Select/deselect options when navigating -- **Escape** - Close dropdown - -## Screen Reader Support - -- Uses \`role="combobox"\` to identify as dropdown -- \`aria-haspopup="listbox"\` indicates popup contains list -- \`aria-expanded\` shows dropdown state -- \`aria-label\` provides accessible name with i18n fallback -- Selected count announced to screen readers - -## Testing Instructions - -1. **Tab Navigation**: Use Tab key to focus the component -2. **Keyboard Opening**: Press Enter or Space to open dropdown -3. **Option Navigation**: Use Arrow keys to navigate options -4. **Selection**: Press Enter/Space to select options -5. **Closing**: Press Escape to close dropdown -6. **Screen Reader**: Test with screen reader software - -Try these stories with keyboard-only navigation! - ` - } - } - }, - argTypes: { - label: { - control: 'text', - description: 'Label for the trigger button' - }, - showSearchBox: { - control: 'boolean', - description: 'Show search box in dropdown header' - }, - showSelectedCount: { - control: 'boolean', - description: 'Show selected count in dropdown header' - }, - showClearButton: { - control: 'boolean', - description: 'Show clear all button in dropdown header' - } - } -} - -export default meta -type Story = StoryObj - -const frameworkOptions = [ - { name: 'React', value: 'react' }, - { name: 'Vue', value: 'vue' }, - { name: 'Angular', value: 'angular' }, - { name: 'Svelte', value: 'svelte' }, - { name: 'TypeScript', value: 'typescript' }, - { name: 'JavaScript', value: 'javascript' } -] - -export const KeyboardNavigationDemo: Story = { - render: (args) => ({ - components: { MultiSelect }, - setup() { - const selectedFrameworks = ref([]) - const searchQuery = ref('') - - return { - args: { - ...args, - options: frameworkOptions, - modelValue: selectedFrameworks, - 'onUpdate:modelValue': (value: SelectOption[]) => { - selectedFrameworks.value = value - }, - 'onUpdate:searchQuery': (value: string) => { - searchQuery.value = value - } - }, - selectedFrameworks, - searchQuery - } - }, - template: ` -
-
-

🎯 Keyboard Navigation Test

-

- Use your keyboard to navigate this MultiSelect: -

-
    -
  1. Tab to focus the dropdown
  2. -
  3. Enter/Space to open dropdown
  4. -
  5. Arrow Up/Down to navigate options
  6. -
  7. Enter/Space to select options
  8. -
  9. Escape to close dropdown
  10. -
-
- -
- - -

- Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }} -

-
-
- ` - }), - args: { - label: 'Choose Frameworks', - showSearchBox: true, - showSelectedCount: true, - showClearButton: true - } -} - -export const ScreenReaderFriendly: Story = { - render: (args) => ({ - components: { MultiSelect }, - setup() { - const selectedColors = ref([]) - const selectedSizes = ref([]) - - const colorOptions = [ - { name: 'Red', value: 'red' }, - { name: 'Blue', value: 'blue' }, - { name: 'Green', value: 'green' }, - { name: 'Yellow', value: 'yellow' } - ] - - const sizeOptions = [ - { name: 'Small', value: 'sm' }, - { name: 'Medium', value: 'md' }, - { name: 'Large', value: 'lg' }, - { name: 'Extra Large', value: 'xl' } - ] - - return { - selectedColors, - selectedSizes, - colorOptions, - sizeOptions, - args - } - }, - template: ` -
-
-

♿ Screen Reader Test

-

- These dropdowns have proper ARIA attributes and labels for screen readers: -

-
    -
  • role="combobox" identifies as dropdown
  • -
  • aria-haspopup="listbox" indicates popup type
  • -
  • aria-expanded shows open/closed state
  • -
  • aria-label provides accessible name
  • -
  • Selection count announced to assistive technology
  • -
-
- -
-
- - -

- {{ selectedColors.length }} color(s) selected -

-
- -
- - -

- {{ selectedSizes.length }} size(s) selected -

-
-
-
- ` - }) -} - -export const FocusManagement: Story = { - render: (args) => ({ - components: { MultiSelect }, - setup() { - const selectedItems = ref([]) - const focusTestOptions = [ - { name: 'Option A', value: 'a' }, - { name: 'Option B', value: 'b' }, - { name: 'Option C', value: 'c' } - ] - - return { - selectedItems, - focusTestOptions, - args - } - }, - template: ` -
-
-

🎯 Focus Management Test

-

- Test focus behavior with multiple form elements: -

-
- -
-
- - -
- -
- - -
- -
- - -
- - -
- -
- Test: Tab through all elements and verify focus rings are visible and logical. -
-
- ` - }) -} - -export const AccessibilityChecklist: Story = { - render: () => ({ - template: ` -
-
-

♿ MultiSelect Accessibility Checklist

- -
-
-

✅ Implemented Features

-
    -
  • - - Keyboard Navigation: Tab, Enter, Space, Arrow keys, Escape -
  • -
  • - - ARIA Attributes: role, aria-haspopup, aria-expanded, aria-label -
  • -
  • - - Focus Management: Visible focus rings and logical tab order -
  • -
  • - - Internationalization: Translatable aria-label fallbacks -
  • -
  • - - Screen Reader Support: Proper announcements and state -
  • -
  • - - Color Contrast: Meets WCAG AA requirements -
  • -
-
- -
-

📋 Testing Guidelines

-
    -
  1. Keyboard Only: Navigate using only keyboard
  2. -
  3. Screen Reader: Test with NVDA, JAWS, or VoiceOver
  4. -
  5. Focus Visible: Ensure focus rings are always visible
  6. -
  7. Tab Order: Verify logical progression
  8. -
  9. Announcements: Check state changes are announced
  10. -
  11. Escape Behavior: Escape always closes dropdown
  12. -
-
-
- -
-

🎯 Quick Test

-

- Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above. - If you can successfully navigate and make selections, the accessibility implementation is working! -

-
-
-
- ` - }) -} diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts index e6b5d9144..d66a70653 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/input/MultiSelect.stories.ts @@ -102,7 +102,7 @@ export const Default: Story = { :showClearButton="args.showClearButton" :searchPlaceholder="args.searchPlaceholder" /> -
+

Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}

@@ -135,7 +135,7 @@ export const WithPreselectedValues: Story = { :showClearButton="args.showClearButton" :searchPlaceholder="args.searchPlaceholder" /> -
+

Selected: {{ selected.map(s => s.name).join(', ') }}

@@ -229,7 +229,7 @@ export const MultipleSelectors: Story = { /> -
+

Current Selection:

Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}

diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index e461eed32..21ba0d6a2 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -13,7 +13,72 @@ option-label="name" unstyled :max-selected-labels="0" - :pt="pt" + :pt="{ + root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ + class: cn( + 'h-10 relative inline-flex cursor-pointer select-none', + 'rounded-lg bg-secondary-background text-base-foreground', + 'transition-all duration-200 ease-in-out', + 'border-[2.5px] border-solid', + selectedCount > 0 + ? 'border-node-component-border' + : 'border-transparent', + 'focus-within:border-node-component-border', + { 'opacity-60 cursor-default': props.disabled } + ) + }), + labelContainer: { + class: + 'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 ' + }, + label: { + class: 'p-0' + }, + dropdown: { + class: 'flex shrink-0 cursor-pointer items-center justify-center px-3' + }, + header: () => ({ + class: + showSearchBox || showSelectedCount || showClearButton + ? 'block' + : 'hidden' + }), + // Overlay & list visuals unchanged + overlay: { + class: cn( + 'mt-2 rounded-lg py-2 px-2', + 'bg-base-background', + 'text-base-foreground', + 'border border-solid border-border-default' + ) + }, + listContainer: () => ({ + style: { maxHeight: `min(${listMaxHeight}, 50vh)` }, + class: 'scrollbar-custom' + }), + list: { + class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm' + }, + // Option row hover and focus tone + option: ({ context }: MultiSelectPassThroughMethodOptions) => ({ + class: cn( + 'flex gap-2 items-center h-10 px-2 rounded-lg cursor-pointer', + 'hover:bg-secondary-background-hover', + // Add focus/highlight state for keyboard navigation + context?.focused && + 'bg-secondary-background-selected hover:bg-secondary-background-selected' + ) + }), + // Hide built-in checkboxes entirely via PT (no :deep) + pcHeaderCheckbox: { + root: { class: 'hidden' }, + style: { display: 'none' } + }, + pcOptionCheckbox: { + root: { class: 'hidden' }, + style: { display: 'none' } + } + }" :aria-label="label || t('g.multiSelectDropdown')" role="combobox" :aria-expanded="false" @@ -39,7 +104,7 @@ > {{ selectedCount > 0 @@ -47,27 +112,27 @@ : $t('g.itemSelected', { selectedCount }) }} - + > + {{ $t('g.clearAll') }} +
-
+