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/tests-ui/tests/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts similarity index 61% rename from tests-ui/tests/components/TopMenuSection.test.ts rename to src/components/TopMenuSection.test.ts index 5d92a0cc9..6640f9b88 100644 --- a/tests-ui/tests/components/TopMenuSection.test.ts +++ b/src/components/TopMenuSection.test.ts @@ -1,11 +1,17 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { computed } from 'vue' +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 })) @@ -27,16 +33,49 @@ vi.mock('@/stores/firebaseAuthStore', () => ({ })) function createWrapper() { - return mount(TopMenuSection, { - global: { - plugins: [createTestingPinia({ createSpy: vi.fn })], - stubs: { - SubgraphBreadcrumb: true, - CurrentUserButton: true, - LoginButton: true + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + sideToolbar: { + queueProgressOverlay: { + viewJobHistory: 'View job history', + expandCollapsedQueue: 'Expand collapsed queue', + activeJobsShort: '{count} active | {count} active' + } + } } } }) + + return mount(TopMenuSection, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn }), i18n], + stubs: { + SubgraphBreadcrumb: true, + QueueProgressOverlay: true, + CurrentUserButton: true, + LoginButton: true + }, + 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', () => { @@ -80,4 +119,19 @@ describe('TopMenuSection', () => { }) }) }) + + 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') + }) }) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index d170305a4..a20537038 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -1,41 +1,161 @@ - + +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 dbcc492ee..5ea860852 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -1,5 +1,5 @@ diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue index 6795ee97d..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/UserCredit.test.ts b/src/components/common/UserCredit.test.ts new file mode 100644 index 000000000..7fcb42f49 --- /dev/null +++ b/src/components/common/UserCredit.test.ts @@ -0,0 +1,134 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import UserCredit from './UserCredit.vue' + +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(), + getApp: vi.fn() +})) + +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(), + setPersistence: vi.fn(), + browserLocalPersistence: {}, + onAuthStateChanged: vi.fn(), + signInWithEmailAndPassword: vi.fn(), + signOut: vi.fn() +})) + +vi.mock('pinia') + +const mockBalance = vi.hoisted(() => ({ + value: { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } +})) + +const mockIsFetchingBalance = vi.hoisted(() => ({ value: false })) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + balance: mockBalance.value, + isFetchingBalance: mockIsFetchingBalance.value + })) +})) + +describe('UserCredit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } + mockIsFetchingBalance.value = false + }) + + const mountComponent = (props = {}) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + return mount(UserCredit, { + props, + global: { + plugins: [i18n], + stubs: { + Skeleton: true, + Tag: true + } + } + }) + } + + describe('effective_balance_micros handling', () => { + it('uses effective_balance_micros when present (positive balance)', () => { + mockBalance.value = { + amount_micros: 200_000, + effective_balance_micros: 150_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('uses effective_balance_micros when zero', () => { + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 0, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + + it('uses effective_balance_micros when negative', () => { + mockBalance.value = { + amount_micros: 0, + effective_balance_micros: -50_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('-') + }) + + it('falls back to amount_micros when effective_balance_micros is missing', () => { + mockBalance.value = { + amount_micros: 100_000, + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => { + mockBalance.value = { + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + }) + + describe('loading state', () => { + it('shows skeleton when loading', () => { + mockIsFetchingBalance.value = true + + const wrapper = mountComponent() + expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true) + }) + }) +}) diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue index aac405b90..987406ede 100644 --- a/src/components/common/UserCredit.vue +++ b/src/components/common/UserCredit.vue @@ -8,12 +8,18 @@
-
{{ formattedBalance }}
+ > + +
+
+ {{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }} +
@@ -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/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 890f14806..aeb98971b 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -22,16 +22,17 @@ @@ -92,7 +93,7 @@ class="w-62.5" > @@ -174,6 +175,7 @@ - - + @@ -382,19 +384,18 @@ import ProgressSpinner from 'primevue/progressspinner' import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import IconButton from '@/components/button/IconButton.vue' -import IconTextButton from '@/components/button/IconTextButton.vue' import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' import SquareChip from '@/components/chip/SquareChip.vue' +import SearchBox from '@/components/common/SearchBox.vue' import MultiSelect from '@/components/input/MultiSelect.vue' -import SearchBox from '@/components/input/SearchBox.vue' import SingleSelect from '@/components/input/SingleSelect.vue' import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue' import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue' import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue' import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue' +import Button from '@/components/ui/button/Button.vue' import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue' import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' import { useIntersectionObserver } from '@/composables/useIntersectionObserver' @@ -405,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry' import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows' import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import type { TemplateInfo } from '@/platform/workflow/templates/types/template' +import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template' +import { useSystemStatsStore } from '@/stores/systemStatsStore' import type { NavGroupData, NavItemData } from '@/types/navTypes' import { OnCloseKey } from '@/types/widgetTypes' import { createGridStyle } from '@/utils/gridUtil' @@ -423,6 +426,30 @@ onMounted(() => { sessionStartTime.value = Date.now() }) +const systemStatsStore = useSystemStatsStore() + +const distributions = computed(() => { + // eslint-disable-next-line no-undef + switch (__DISTRIBUTION__) { + case 'cloud': + return [TemplateIncludeOnDistributionEnum.Cloud] + case 'localhost': + return [TemplateIncludeOnDistributionEnum.Local] + case 'desktop': + default: + if (systemStatsStore.systemStats?.system.os === 'darwin') { + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Mac + ] + } + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Windows + ] + } +}) + // Wrap onClose to track session end const onClose = () => { if (isCloud) { @@ -511,6 +538,9 @@ const allTemplates = computed(() => { return workflowTemplatesStore.enhancedTemplates }) +// Navigation +const selectedNavItem = ref('all') + // Filter templates based on selected navigation item const navigationFilteredTemplates = computed(() => { if (!selectedNavItem.value) { @@ -533,9 +563,40 @@ const { availableRunsOn, filteredCount, totalCount, - resetFilters + resetFilters, + loadFuseOptions } = useTemplateFiltering(navigationFilteredTemplates) +/** + * Coordinates state between the selected navigation item and the sort order to + * create deterministic, predictable behavior. + * @param source The origin of the change ('nav' or 'sort'). + */ +const coordinateNavAndSort = (source: 'nav' | 'sort') => { + const isPopularNav = selectedNavItem.value === 'popular' + const isPopularSort = sortBy.value === 'popular' + + if (source === 'nav') { + if (isPopularNav && !isPopularSort) { + // When navigating to 'Popular' category, automatically set sort to 'Popular'. + sortBy.value = 'popular' + } else if (!isPopularNav && isPopularSort) { + // When navigating away from 'Popular' category while sort is 'Popular', reset sort to default. + sortBy.value = 'default' + } + } else if (source === 'sort') { + // When sort is changed away from 'Popular' while in the 'Popular' category, + // reset the category to 'All Templates' to avoid a confusing state. + if (isPopularNav && !isPopularSort) { + selectedNavItem.value = 'all' + } + } +} + +// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator. +watch(selectedNavItem, () => coordinateNavAndSort('nav')) +watch(sortBy, () => coordinateNavAndSort('sort')) + // Convert between string array and object array for MultiSelect component const selectedModelObjects = computed({ get() { @@ -578,9 +639,6 @@ const cardRefs = ref([]) // Force re-render key for templates when sorting changes const templateListKey = ref(0) -// Navigation -const selectedNavItem = ref('all') - // Search text for model filter const modelSearchText = ref('') @@ -645,11 +703,19 @@ const runsOnFilterLabel = computed(() => { // Sort options const sortOptions = computed(() => [ - { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.default', 'Default'), value: 'default' }, + { + name: t('templateWorkflows.sort.recommended', 'Recommended'), + value: 'recommended' + }, + { + name: t('templateWorkflows.sort.popular', 'Popular'), + value: 'popular' + }, + { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'), value: 'vram-low-to-high' @@ -750,10 +816,10 @@ const pageTitle = computed(() => { // Initialize templates loading with useAsyncState const { isLoading } = useAsyncState( async () => { - // Run both operations in parallel for better performance await Promise.all([ loadTemplates(), - workflowTemplatesStore.loadWorkflowTemplates() + workflowTemplatesStore.loadWorkflowTemplates(), + loadFuseOptions() ]) return true }, @@ -763,6 +829,14 @@ const { isLoading } = useAsyncState( } ) +const isTemplateVisibleOnDistribution = (template: TemplateInfo) => { + return (template.includeOnDistributions?.length ?? 0) > 0 + ? distributions.value.some((d) => + template.includeOnDistributions?.includes(d) + ) + : true +} + onBeforeUnmount(() => { cardRefs.value = [] // Release DOM refs }) diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue index 4566b0684..afc056d61 100644 --- a/src/components/dialog/GlobalDialog.vue +++ b/src/components/dialog/GlobalDialog.vue @@ -14,6 +14,7 @@

@@ -54,17 +55,4 @@ const dialogStore = useDialogStore() @apply p-2 2xl:p-[var(--p-dialog-content-padding)]; @apply pt-0; } - -.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/confirm/ConfirmBody.vue b/src/components/dialog/confirm/ConfirmBody.vue new file mode 100644 index 000000000..9a1cd5980 --- /dev/null +++ b/src/components/dialog/confirm/ConfirmBody.vue @@ -0,0 +1,19 @@ + + diff --git a/src/components/dialog/confirm/ConfirmFooter.vue b/src/components/dialog/confirm/ConfirmFooter.vue new file mode 100644 index 000000000..9cdd6e37b --- /dev/null +++ b/src/components/dialog/confirm/ConfirmFooter.vue @@ -0,0 +1,40 @@ + + diff --git a/src/components/dialog/confirm/ConfirmHeader.vue b/src/components/dialog/confirm/ConfirmHeader.vue new file mode 100644 index 000000000..3c8337733 --- /dev/null +++ b/src/components/dialog/confirm/ConfirmHeader.vue @@ -0,0 +1,12 @@ + + diff --git a/src/components/dialog/confirm/confirmDialog.ts b/src/components/dialog/confirm/confirmDialog.ts new file mode 100644 index 000000000..c615e6475 --- /dev/null +++ b/src/components/dialog/confirm/confirmDialog.ts @@ -0,0 +1,31 @@ +import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue' +import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue' +import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue' +import { useDialogStore } from '@/stores/dialogStore' +import type { ComponentAttrs } from 'vue-component-type-helpers' + +interface ConfirmDialogOptions { + headerProps?: ComponentAttrs + props?: ComponentAttrs + footerProps?: ComponentAttrs +} + +export function showConfirmDialog(options: ConfirmDialogOptions = {}) { + const dialogStore = useDialogStore() + const { headerProps, props, footerProps } = options + return dialogStore.showDialog({ + headerComponent: ConfirmHeader, + component: ConfirmBody, + footerComponent: ConfirmFooter, + headerProps, + props, + footerProps, + dialogComponentProps: { + pt: { + header: 'py-0! px-0!', + content: 'p-0!', + footer: 'p-0!' + } + } + }) +} diff --git a/src/components/dialog/content/ApiNodesSignInContent.vue b/src/components/dialog/content/ApiNodesSignInContent.vue index a3139daaf..41ad903c7 100644 --- a/src/components/dialog/content/ApiNodesSignInContent.vue +++ b/src/components/dialog/content/ApiNodesSignInContent.vue @@ -11,24 +11,25 @@
-
- +
diff --git a/src/components/dialog/content/UpdatePasswordContent.vue b/src/components/dialog/content/UpdatePasswordContent.vue index dc116e9c2..ef99a7788 100644 --- a/src/components/dialog/content/UpdatePasswordContent.vue +++ b/src/components/dialog/content/UpdatePasswordContent.vue @@ -7,12 +7,9 @@ - @@ -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 f134aba5e..c67c273a2 100644 --- a/src/components/dialog/content/credit/CreditTopUpOption.vue +++ b/src/components/dialog/content/credit/CreditTopUpOption.vue @@ -1,81 +1,45 @@ diff --git a/src/components/dialog/content/error/FindIssueButton.vue b/src/components/dialog/content/error/FindIssueButton.vue index 20f4f6489..767202251 100644 --- a/src/components/dialog/content/error/FindIssueButton.vue +++ b/src/components/dialog/content/error/FindIssueButton.vue @@ -1,16 +1,14 @@ 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/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/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.vue b/src/components/input/MultiSelect.vue index 74a7035b1..21ba0d6a2 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -17,7 +17,7 @@ root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ class: cn( 'h-10 relative inline-flex cursor-pointer select-none', - 'rounded-lg bg-base-background text-base-foreground', + 'rounded-lg bg-secondary-background text-base-foreground', 'transition-all duration-200 ease-in-out', 'border-[2.5px] border-solid', selectedCount > 0 @@ -62,7 +62,7 @@ // Option row hover and focus tone option: ({ context }: MultiSelectPassThroughMethodOptions) => ({ class: cn( - 'flex gap-2 items-center h-10 px-2 rounded-lg', + '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 && @@ -83,7 +83,7 @@ role="combobox" :aria-expanded="false" aria-haspopup="listbox" - tabindex="0" + :tabindex="0" > diff --git a/src/components/toast/ProgressToastItem.stories.ts b/src/components/toast/ProgressToastItem.stories.ts new file mode 100644 index 000000000..cdfa8e28e --- /dev/null +++ b/src/components/toast/ProgressToastItem.stories.ts @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { AssetDownload } from '@/stores/assetDownloadStore' + +import ProgressToastItem from './ProgressToastItem.vue' + +const meta: Meta = { + title: 'Toast/ProgressToastItem', + component: ProgressToastItem, + parameters: { + layout: 'padded' + }, + decorators: [ + () => ({ + template: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +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 + } +} + +export const Pending: Story = { + args: { + job: createMockJob({ + status: 'created', + assetName: 'sd-xl-base-1.0.safetensors' + }) + } +} + +export const Running: Story = { + args: { + job: createMockJob({ + status: 'running', + progress: 0.45, + assetName: 'lora-detail-enhancer.safetensors' + }) + } +} + +export const RunningAlmostComplete: Story = { + args: { + job: createMockJob({ + status: 'running', + progress: 0.92, + assetName: 'vae-ft-mse-840000.safetensors' + }) + } +} + +export const Completed: Story = { + args: { + job: createMockJob({ + status: 'completed', + progress: 1, + assetName: 'controlnet-canny.safetensors' + }) + } +} + +export const Failed: Story = { + args: { + job: createMockJob({ + status: 'failed', + progress: 0.23, + assetName: 'unreachable-model.safetensors' + }) + } +} + +export const LongFileName: Story = { + args: { + job: createMockJob({ + status: 'running', + progress: 0.67, + assetName: + 'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors' + }) + } +} diff --git a/src/components/toast/ProgressToastItem.vue b/src/components/toast/ProgressToastItem.vue new file mode 100644 index 000000000..079be418f --- /dev/null +++ b/src/components/toast/ProgressToastItem.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/toast/RerouteMigrationToast.vue b/src/components/toast/RerouteMigrationToast.vue index c084926d1..e807994a3 100644 --- a/src/components/toast/RerouteMigrationToast.vue +++ b/src/components/toast/RerouteMigrationToast.vue @@ -5,13 +5,9 @@
{{ t('toastMessages.migrateToLitegraphReroute') }}
- @@ -19,10 +15,10 @@ diff --git a/src/components/topbar/ActionBarButtons.vue b/src/components/topbar/ActionBarButtons.vue index 81b5c42ad..e87b1d62f 100644 --- a/src/components/topbar/ActionBarButtons.vue +++ b/src/components/topbar/ActionBarButtons.vue @@ -1,29 +1,30 @@ diff --git a/src/components/topbar/CurrentUserButton.test.ts b/src/components/topbar/CurrentUserButton.test.ts index 60c46ff4a..db5349b49 100644 --- a/src/components/topbar/CurrentUserButton.test.ts +++ b/src/components/topbar/CurrentUserButton.test.ts @@ -1,6 +1,6 @@ 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 { beforeEach, describe, expect, it, vi } from 'vitest' import { h } from 'vue' import { createI18n } from 'vue-i18n' diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index d64a6dfe2..151dfd405 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -3,37 +3,55 @@
- +
diff --git a/src/components/topbar/TopbarBadge.test.ts b/src/components/topbar/TopbarBadge.test.ts index ffb8abdcd..1383c002f 100644 --- a/src/components/topbar/TopbarBadge.test.ts +++ b/src/components/topbar/TopbarBadge.test.ts @@ -161,7 +161,7 @@ describe('TopbarBadge', () => { ) expect(wrapper.find('.bg-gold-600').exists()).toBe(true) - expect(wrapper.find('.text-gold-600').exists()).toBe(true) + expect(wrapper.find('.text-warning-background').exists()).toBe(true) }) it('uses default error icon for error variant', () => { @@ -185,7 +185,9 @@ describe('TopbarBadge', () => { 'full' ) - expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true) + expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe( + true + ) }) }) diff --git a/src/components/topbar/TopbarBadge.vue b/src/components/topbar/TopbarBadge.vue index 552daead0..34fd6454d 100644 --- a/src/components/topbar/TopbarBadge.vue +++ b/src/components/topbar/TopbarBadge.vue @@ -174,7 +174,7 @@ const textClasses = computed(() => { case 'error': return 'text-danger-100' case 'warning': - return 'text-gold-600' + return 'text-warning-background' case 'info': default: return 'text-text-primary' @@ -191,7 +191,7 @@ const iconClass = computed(() => { case 'error': return 'pi pi-exclamation-circle' case 'warning': - return 'pi pi-exclamation-triangle' + return 'icon-[lucide--triangle-alert]' case 'info': default: return undefined diff --git a/src/components/topbar/TryVueNodeBanner.vue b/src/components/topbar/TryVueNodeBanner.vue deleted file mode 100644 index a852b8e9e..000000000 --- a/src/components/topbar/TryVueNodeBanner.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/src/components/topbar/WorkflowOverflowMenu.vue b/src/components/topbar/WorkflowOverflowMenu.vue index 33b8475a3..f22ebfe13 100644 --- a/src/components/topbar/WorkflowOverflowMenu.vue +++ b/src/components/topbar/WorkflowOverflowMenu.vue @@ -2,13 +2,14 @@
+ + diff --git a/src/components/ui/TypeformPopoverButton.vue b/src/components/ui/TypeformPopoverButton.vue new file mode 100644 index 000000000..e6808e8bc --- /dev/null +++ b/src/components/ui/TypeformPopoverButton.vue @@ -0,0 +1,29 @@ + + diff --git a/src/components/ui/ZoomPane.vue b/src/components/ui/ZoomPane.vue new file mode 100644 index 000000000..2cbb286ad --- /dev/null +++ b/src/components/ui/ZoomPane.vue @@ -0,0 +1,59 @@ + + diff --git a/src/components/ui/button/Button.stories.ts b/src/components/ui/button/Button.stories.ts new file mode 100644 index 000000000..68cea7e5b --- /dev/null +++ b/src/components/ui/button/Button.stories.ts @@ -0,0 +1,98 @@ +import type { + Meta, + StoryObj, + ComponentPropsAndSlots +} from '@storybook/vue3-vite' + +import Button from './Button.vue' +import { FOR_STORIES } from '@/components/ui/button/button.variants' + +interface ButtonPropsAndStoryArgs extends ComponentPropsAndSlots< + typeof Button +> { + icon?: 'left' | 'right' +} + +const { variants, sizes } = FOR_STORIES +const meta: Meta = { + title: 'Components/Button/Button', + component: Button, + tags: ['autodocs'], + argTypes: { + size: { + control: { type: 'select' }, + options: sizes, + defaultValue: 'md' + }, + variant: { + control: { type: 'select' }, + options: variants, + defaultValue: 'primary' + }, + as: { defaultValue: 'button' }, + asChild: { defaultValue: false }, + default: { + control: { type: 'text' }, + defaultValue: 'Button' + }, + icon: { + control: { type: 'select' }, + options: [undefined, 'left', 'right'] + } + }, + args: { + variant: 'secondary', + size: 'md', + default: 'Button', + icon: undefined + } +} + +export default meta +type Story = StoryObj + +export const SingleButton: Story = { + render: (args) => ({ + components: { Button }, + setup() { + return { args } + }, + template: ` + ` + }) +} + +function generateVariants() { + const variantButtons: string[] = [] + for (const variant of variants) { + for (const size of sizes) { + variantButtons.push( + `` + ) + } + } + return variantButtons +} + +// Note: Keep the number of columns here aligned with the number of sizes above. +export const AllVariants: Story = { + render: () => ({ + components: { Button }, + template: ` +
+ ${generateVariants().join('\n')} + +
+ ` + }) +} diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue new file mode 100644 index 000000000..f7b7f69dd --- /dev/null +++ b/src/components/ui/button/Button.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts new file mode 100644 index 000000000..768332325 --- /dev/null +++ b/src/components/ui/button/button.variants.ts @@ -0,0 +1,55 @@ +import type { VariantProps } from 'cva' +import { cva } from 'cva' + +export const buttonVariants = cva({ + base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + variants: { + variant: { + secondary: + 'bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover', + primary: + 'bg-primary-background text-base-foreground hover:bg-primary-background-hover', + inverted: + 'bg-base-foreground text-base-background hover:bg-base-foreground/80', + destructive: + 'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover', + textonly: + 'text-base-foreground bg-transparent hover:bg-secondary-background-hover', + 'muted-textonly': + 'text-muted-foreground bg-transparent hover:bg-secondary-background-hover', + 'destructive-textonly': + 'text-destructive-background bg-transparent hover:bg-destructive-background/10', + 'overlay-white': 'bg-white text-gray-600 hover:bg-white/90' + }, + size: { + sm: 'h-6 rounded-sm px-2 py-1 text-xs', + 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' + } + }, + + defaultVariants: { + variant: 'secondary', + size: 'md' + } +}) + +export type ButtonVariants = VariantProps + +const variants = [ + 'secondary', + 'primary', + 'inverted', + 'destructive', + 'textonly', + 'muted-textonly', + 'destructive-textonly', + 'overlay-white' +] as const satisfies Array +const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array< + ButtonVariants['size'] +> + +export const FOR_STORIES = { variants, sizes } as const diff --git a/src/components/ui/select/Select.stories.ts b/src/components/ui/select/Select.stories.ts new file mode 100644 index 000000000..ba2a37e48 --- /dev/null +++ b/src/components/ui/select/Select.stories.ts @@ -0,0 +1,261 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import Select from './Select.vue' +import SelectContent from './SelectContent.vue' +import SelectGroup from './SelectGroup.vue' +import SelectItem from './SelectItem.vue' +import SelectLabel from './SelectLabel.vue' +import SelectSeparator from './SelectSeparator.vue' +import SelectTrigger from './SelectTrigger.vue' +import SelectValue from './SelectValue.vue' + +const meta = { + title: 'Components/Select', + component: Select, + tags: ['autodocs'], + argTypes: { + modelValue: { + control: 'text', + description: 'Selected value' + }, + disabled: { + control: 'boolean', + description: 'When true, disables the select' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref(args.modelValue || '') + return { value, args } + }, + template: ` + +
+ Selected: {{ value || 'None' }} +
+ ` + }), + args: { + disabled: false + } +} + +export const WithPlaceholder: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` + + ` + }), + args: { + disabled: false + } +} + +export const Disabled: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('apple') + return { value, args } + }, + template: ` + + ` + }) +} + +export const WithGroups: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` + +
+ Selected: {{ value || 'None' }} +
+ ` + }), + args: { + disabled: false + } +} + +export const Scrollable: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + const items = Array.from({ length: 20 }, (_, i) => ({ + value: `item-${i + 1}`, + label: `Option ${i + 1}` + })) + return { value, items, args } + }, + template: ` + + ` + }), + args: { + disabled: false + } +} + +export const CustomWidth: Story = { + render: (args) => ({ + components: { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue + }, + setup() { + const value = ref('') + return { value, args } + }, + template: ` +
+ + + +
+ ` + }), + args: { + disabled: false + } +} diff --git a/src/components/ui/select/Select.vue b/src/components/ui/select/Select.vue new file mode 100644 index 000000000..f685b3189 --- /dev/null +++ b/src/components/ui/select/Select.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/ui/select/SelectContent.vue b/src/components/ui/select/SelectContent.vue new file mode 100644 index 000000000..a88e26b9f --- /dev/null +++ b/src/components/ui/select/SelectContent.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/ui/select/SelectGroup.vue b/src/components/ui/select/SelectGroup.vue new file mode 100644 index 000000000..11f3da9f6 --- /dev/null +++ b/src/components/ui/select/SelectGroup.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/select/SelectItem.vue b/src/components/ui/select/SelectItem.vue new file mode 100644 index 000000000..4edeeb3ca --- /dev/null +++ b/src/components/ui/select/SelectItem.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/ui/select/SelectLabel.vue b/src/components/ui/select/SelectLabel.vue new file mode 100644 index 000000000..bafe45da9 --- /dev/null +++ b/src/components/ui/select/SelectLabel.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/select/SelectScrollDownButton.vue b/src/components/ui/select/SelectScrollDownButton.vue new file mode 100644 index 000000000..1b1dc1a27 --- /dev/null +++ b/src/components/ui/select/SelectScrollDownButton.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/select/SelectScrollUpButton.vue b/src/components/ui/select/SelectScrollUpButton.vue new file mode 100644 index 000000000..ee1ef9263 --- /dev/null +++ b/src/components/ui/select/SelectScrollUpButton.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/select/SelectSeparator.vue b/src/components/ui/select/SelectSeparator.vue new file mode 100644 index 000000000..37947fd0d --- /dev/null +++ b/src/components/ui/select/SelectSeparator.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/select/SelectTrigger.vue b/src/components/ui/select/SelectTrigger.vue new file mode 100644 index 000000000..768048ab1 --- /dev/null +++ b/src/components/ui/select/SelectTrigger.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/select/SelectValue.vue b/src/components/ui/select/SelectValue.vue new file mode 100644 index 000000000..4ffa580ca --- /dev/null +++ b/src/components/ui/select/SelectValue.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/slider/Slider.vue b/src/components/ui/slider/Slider.vue index 2ecfc794a..7d7a95121 100644 --- a/src/components/ui/slider/Slider.vue +++ b/src/components/ui/slider/Slider.vue @@ -14,6 +14,7 @@ import type { HTMLAttributes } from 'vue' import { cn } from '@/utils/tailwindUtil' const props = defineProps< + // eslint-disable-next-line vue/no-unused-properties SliderRootProps & { class?: HTMLAttributes['class'] } >() diff --git a/src/components/ui/stepper/FormattedNumberStepper.vue b/src/components/ui/stepper/FormattedNumberStepper.vue new file mode 100644 index 000000000..2de6bbf13 --- /dev/null +++ b/src/components/ui/stepper/FormattedNumberStepper.vue @@ -0,0 +1,181 @@ + + + diff --git a/src/components/ui/tags-input/TagsInput.stories.ts b/src/components/ui/tags-input/TagsInput.stories.ts new file mode 100644 index 000000000..eeb75a39a --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.stories.ts @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import type { ComponentExposed } from 'vue-component-type-helpers' +import { ref } from 'vue' + +import TagsInput from './TagsInput.vue' +import TagsInputInput from './TagsInputInput.vue' +import TagsInputItem from './TagsInputItem.vue' +import TagsInputItemDelete from './TagsInputItemDelete.vue' +import TagsInputItemText from './TagsInputItemText.vue' + +interface GenericMeta extends Omit, 'component'> { + component: ComponentExposed +} + +const meta: GenericMeta = { + title: 'Components/TagsInput', + component: TagsInput, + tags: ['autodocs'], + argTypes: { + modelValue: { + control: 'object', + description: 'Array of tag values' + }, + disabled: { + control: 'boolean', + description: + 'When true, completely disables the component. When false (default), shows read-only state with edit icon until clicked.' + }, + 'onUpdate:modelValue': { action: 'update:modelValue' } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(args.modelValue || ['tag1', 'tag2']) + return { tags, args } + }, + template: ` + + + + + + + +
+ Tags: {{ tags.join(', ') }} +
+ ` + }), + args: { + modelValue: ['Vue', 'TypeScript'], + disabled: false + } +} + +export const Empty: Story = { + args: { + disabled: false + }, + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref([]) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const ManyTags: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref([ + 'JavaScript', + 'TypeScript', + 'Vue', + 'React', + 'Svelte', + 'Node.js', + 'Python', + 'Rust' + ]) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const Disabled: Story = { + args: { + disabled: true + }, + + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(['Read', 'Only', 'Tags']) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} + +export const CustomWidth: Story = { + render: (args) => ({ + components: { + TagsInput, + TagsInputInput, + TagsInputItem, + TagsInputItemDelete, + TagsInputItemText + }, + setup() { + const tags = ref(['Full', 'Width']) + return { tags, args } + }, + template: ` + + + + + + + + ` + }) +} diff --git a/src/components/ui/tags-input/TagsInput.test.ts b/src/components/ui/tags-input/TagsInput.test.ts new file mode 100644 index 000000000..a31ebfd98 --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.test.ts @@ -0,0 +1,161 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { h, nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import TagsInput from './TagsInput.vue' +import TagsInputInput from './TagsInputInput.vue' +import TagsInputItem from './TagsInputItem.vue' +import TagsInputItemDelete from './TagsInputItemDelete.vue' +import TagsInputItemText from './TagsInputItemText.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: { g: { removeTag: 'Remove tag' } } } +}) + +describe('TagsInput', () => { + function mountTagsInput(props = {}, slots = {}) { + return mount(TagsInput, { + props: { + modelValue: [], + ...props + }, + slots + }) + } + + it('renders slot content', () => { + const wrapper = mountTagsInput({}, { default: 'Slot Content' }) + + expect(wrapper.text()).toContain('Slot Content') + }) +}) + +describe('TagsInput with child components', () => { + function mountFullTagsInput(tags: string[] = ['tag1', 'tag2']) { + return mount(TagsInput, { + global: { plugins: [i18n] }, + props: { + modelValue: tags + }, + slots: { + default: () => [ + ...tags.map((tag) => + h(TagsInputItem, { key: tag, value: tag }, () => [ + h(TagsInputItemText), + h(TagsInputItemDelete) + ]) + ), + h(TagsInputInput, { placeholder: 'Add tag...' }) + ] + } + }) + } + + it('renders tags structure and content', () => { + const tags = ['tag1', 'tag2'] + const wrapper = mountFullTagsInput(tags) + + const items = wrapper.findAllComponents(TagsInputItem) + const textElements = wrapper.findAllComponents(TagsInputItemText) + const deleteButtons = wrapper.findAllComponents(TagsInputItemDelete) + + expect(items).toHaveLength(tags.length) + expect(textElements).toHaveLength(tags.length) + expect(deleteButtons).toHaveLength(tags.length) + + textElements.forEach((el, i) => { + expect(el.text()).toBe(tags[i]) + }) + + expect(wrapper.findComponent(TagsInputInput).exists()).toBe(true) + }) + + it('updates model value when adding a tag', async () => { + let currentTags = ['existing'] + + const wrapper = mount>(TagsInput, { + props: { + modelValue: currentTags, + 'onUpdate:modelValue': (payload) => { + currentTags = payload + } + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + } + }) + + await wrapper.trigger('click') + await nextTick() + + const input = wrapper.find('input') + await input.setValue('newTag') + await input.trigger('keydown', { key: 'Enter' }) + await nextTick() + + expect(currentTags).toContain('newTag') + }) + + it('does not enter edit mode when disabled', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: ['tag1'], + disabled: true + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + } + }) + + expect(wrapper.find('input').exists()).toBe(false) + + await wrapper.trigger('click') + await nextTick() + + expect(wrapper.find('input').exists()).toBe(false) + }) + + it('exits edit mode when clicking outside', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: ['tag1'] + }, + slots: { + default: () => h(TagsInputInput, { placeholder: 'Add tag...' }) + }, + attachTo: document.body + }) + + await wrapper.trigger('click') + await nextTick() + expect(wrapper.find('input').exists()).toBe(true) + + document.body.click() + await nextTick() + + expect(wrapper.find('input').exists()).toBe(false) + + wrapper.unmount() + }) + + it('shows placeholder when modelValue is empty', async () => { + const wrapper = mount>(TagsInput, { + props: { + modelValue: [] + }, + slots: { + default: ({ isEmpty }: { isEmpty: boolean }) => + h(TagsInputInput, { placeholder: 'Add tag...', isEmpty }) + } + }) + + await nextTick() + + const input = wrapper.find('input') + expect(input.exists()).toBe(true) + expect(input.attributes('placeholder')).toBe('Add tag...') + }) +}) diff --git a/src/components/ui/tags-input/TagsInput.vue b/src/components/ui/tags-input/TagsInput.vue new file mode 100644 index 000000000..f4a3fa001 --- /dev/null +++ b/src/components/ui/tags-input/TagsInput.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputInput.vue b/src/components/ui/tags-input/TagsInputInput.vue new file mode 100644 index 000000000..320b89f97 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputInput.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItem.vue b/src/components/ui/tags-input/TagsInputItem.vue new file mode 100644 index 000000000..40cdab511 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItem.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemDelete.vue b/src/components/ui/tags-input/TagsInputItemDelete.vue new file mode 100644 index 000000000..d5cc0a933 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemDelete.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/ui/tags-input/TagsInputItemText.vue b/src/components/ui/tags-input/TagsInputItemText.vue new file mode 100644 index 000000000..f65119c72 --- /dev/null +++ b/src/components/ui/tags-input/TagsInputItemText.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/tags-input/tagsInputContext.ts b/src/components/ui/tags-input/tagsInputContext.ts new file mode 100644 index 000000000..05fff98e5 --- /dev/null +++ b/src/components/ui/tags-input/tagsInputContext.ts @@ -0,0 +1,10 @@ +import type { InjectionKey, Ref } from 'vue' + +export type FocusCallback = (() => void) | undefined + +export const tagsInputFocusKey: InjectionKey< + (callback: FocusCallback) => void +> = Symbol('tagsInputFocus') + +export const tagsInputIsEditingKey: InjectionKey> = + Symbol('tagsInputIsEditing') diff --git a/src/components/widget/SampleModelSelector.vue b/src/components/widget/SampleModelSelector.vue index 3f06ce277..6e1db7e7c 100644 --- a/src/components/widget/SampleModelSelector.vue +++ b/src/components/widget/SampleModelSelector.vue @@ -17,43 +17,34 @@