Compare commits

...

10 Commits

Author SHA1 Message Date
dante01yoon
3f6993744a test: add visual regression tests for Badge component
Add screenshot tests for SearchFilterChip and node library tree
to catch unintended sizing/styling regressions.
2026-03-26 23:39:12 +09:00
dante01yoon
d3077b231a fix: replace inline .p-chip-remove-icon selector in nodeSearchBox spec 2026-03-26 21:28:39 +09:00
dante01yoon
0344fea717 fix: update E2E selector for chip remove button after PrimeVue removal 2026-03-26 21:15:38 +09:00
dante01yoon
e7df1f7c13 fix: add type=button and aria-label to SearchFilterChip remove button 2026-03-26 20:54:09 +09:00
dante01yoon
196f7baae0 fix: replace PrimeVue Chip with native implementation in SearchFilterChip
Remove the last PrimeVue Chip import and replace with a Tailwind-styled
span + lucide X button, fixing the sizing regression and aligning with
the project guideline to avoid new PrimeVue usage.
2026-03-26 20:37:24 +09:00
dante01yoon
82f6b12ac2 fix: resolve twMerge color clobbering and use Badge in SearchFilterChip
- Use text-[color:*] arbitrary values in badge severity variants to
  prevent tailwind-merge from treating color and font-size as conflicting
  text-* classes
- Replace raw <span> with Badge component in SearchFilterChip to fix
  sizing regression
2026-03-25 21:55:17 +09:00
dante01yoon
55e67ecc3b test: update customerEventsService severity assertions for Badge compat 2026-03-24 20:32:23 +09:00
dante01yoon
190e1442d9 fix: address CodeRabbit review feedback
- Use dynamic variant for TreeExplorerTreeNode badge to prevent
  multi-digit count truncation
- Add BadgeVariants return type to getEventSeverity for type safety
- Add explicit variant="dot" on AssetCard indicator badge
- Remove unrelated browser_tests files from commit
2026-03-24 20:21:21 +09:00
dante01yoon
9d72372ed6 refactor: rename StatusBadge to Badge 2026-03-24 20:03:19 +09:00
dante01yoon
75b47c7970 refactor: migrate badge to design system and remove PrimeVue Badge
- Update StatusBadge severity colors to match Figma design system
- Move Storybook story from Common/ to Components/Badges/
- Replace PrimeVue Badge with StatusBadge in TreeExplorerTreeNode,
  UsageLogsTable, and SearchFilterChip
- Update customerEventsService severity values for StatusBadge compat
2026-03-24 19:55:23 +09:00
25 changed files with 306 additions and 177 deletions

View File

@@ -85,7 +85,10 @@ export class ComfyNodeSearchBox {
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
await this.filterChips
.nth(index)
.getByRole('button', { name: 'Remove' })
.click()
}
/**

View File

@@ -0,0 +1,89 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Badge visual regression',
{ tag: ['@screenshot', '@ui'] },
() => {
async function dismissToasts(comfyPage: { page: Page }) {
// Dismiss all toast notifications that may block interaction
const toastCloseButtons = comfyPage.page.locator('.p-toast-close-button')
const count = await toastCloseButtons.count()
for (let i = 0; i < count; i++) {
await toastCloseButtons
.nth(i)
.click()
.catch(() => {})
}
if (count > 0) {
await comfyPage.page
.locator('.p-toast-message')
.first()
.waitFor({ state: 'hidden', timeout: 3000 })
.catch(() => {})
}
}
test.describe('SearchFilterChip badge', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.NodeSearchBoxImpl',
'v1 (legacy)'
)
await dismissToasts(comfyPage)
})
test('Single filter chip renders correctly', async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.addFilter('CONDITIONING', 'Input Type')
const searchContainer = comfyPage.page.locator(
'.comfy-vue-node-search-container'
)
await expect(searchContainer).toHaveScreenshot(
'filter-chip-conditioning.png'
)
})
test('Multiple filter chips render correctly', async ({ comfyPage }) => {
await comfyPage.canvasOps.doubleClick()
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
const searchContainer = comfyPage.page.locator(
'.comfy-vue-node-search-container'
)
await expect(searchContainer).toHaveScreenshot(
'filter-chips-multiple.png'
)
})
})
test.describe('Node library tree badge', () => {
test('Folder node count badge renders correctly', async ({
comfyPage
}) => {
await dismissToasts(comfyPage)
// Open the Node Library sidebar
const sidebarButton = comfyPage.page.getByRole('button', {
name: 'Node Library'
})
await sidebarButton.click()
// Wait for tree folders to load
const sidebar = comfyPage.page.getByRole('complementary', {
name: 'Sidebar'
})
await sidebar
.getByRole('treeitem')
.first()
.waitFor({ state: 'visible', timeout: 10000 })
await expect(sidebar).toHaveScreenshot('node-library-tree-badges.png')
})
})
}
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -110,7 +110,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
async ({ comfyPage }) => {
await comfyPage.canvasOps.disconnectEdge()
await expect(comfyPage.searchBox.input).toHaveCount(1)
await comfyPage.page.locator('.p-chip-remove-icon').click()
await comfyPage.searchBox.removeFilter(0)
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler', {
exact: true
})

View File

@@ -44,7 +44,7 @@ const mountActionbar = (showRunProgressBar: boolean) => {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
Badge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'

View File

@@ -59,7 +59,7 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
<Badge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
@@ -104,7 +104,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from './Badge.vue'
const meta = {
title: 'Components/Badges/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'NEW',
severity: 'default'
}
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Secondary: Story = {
args: {
label: 'NEW',
severity: 'secondary'
}
}
export const Warn: Story = {
args: {
label: 'NEW',
severity: 'warn'
}
}
export const Danger: Story = {
args: {
label: 'NEW',
severity: 'danger'
}
}
export const Contrast: Story = {
args: {
label: 'NEW',
severity: 'contrast'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeveritiesLabel: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge label="NEW" severity="default" />
<Badge label="NEW" severity="secondary" />
<Badge label="NEW" severity="warn" />
<Badge label="NEW" severity="danger" />
<Badge label="NEW" severity="contrast" />
</div>
`
})
}
export const AllSeveritiesDot: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge variant="dot" severity="default" />
<Badge variant="dot" severity="secondary" />
<Badge variant="dot" severity="warn" />
<Badge variant="dot" severity="danger" />
<Badge variant="dot" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<Badge label="NEW" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -3,8 +3,8 @@ import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
@@ -13,14 +13,14 @@ const {
class: className
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
statusBadgeVariants({
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),

View File

@@ -1,17 +1,28 @@
<template>
<Chip removable @remove="emit('remove', $event)">
<Badge size="small" :class="semanticBadgeClass">
{{ badge }}
</Badge>
<span
class="inline-flex items-center gap-1 rounded-2xl bg-surface-700 py-0.5 pr-1 pl-2 text-xs"
>
<Badge :label="badge" :class="semanticBadgeClass" />
{{ text }}
</Chip>
<button
type="button"
:aria-label="$t('g.remove')"
class="inline-flex cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-surface-600"
@click="emit('remove', $event)"
>
<i
class="icon-[lucide--x] size-3 text-muted-foreground"
aria-hidden="true"
/>
</button>
</span>
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import Chip from 'primevue/chip'
import { computed } from 'vue'
import Badge from '@/components/common/Badge.vue'
export interface SearchFilter {
text: string
badge: string
@@ -20,9 +31,9 @@ export interface SearchFilter {
}
const semanticClassMap: Record<string, string> = {
'i-badge': 'bg-green-500 text-white',
'o-badge': 'bg-red-500 text-white',
'c-badge': 'bg-blue-500 text-white',
'i-badge': 'bg-green-500 text-[color:white]',
'o-badge': 'bg-red-500 text-[color:white]',
'c-badge': 'bg-blue-500 text-[color:white]',
's-badge': 'bg-yellow-500'
}

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -61,8 +61,7 @@ describe('TreeExplorerTreeNode', () => {
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
// @ts-expect-error fixme ts strict error
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
expect(wrapper.findComponent(Badge).props('label')).toBe('3')
})
it('makes node label editable when renamingEditingNode matches', async () => {

View File

@@ -23,9 +23,10 @@
</span>
<Badge
v-if="showNodeBadgeText"
:value="nodeBadgeText"
:label="nodeBadgeText"
severity="secondary"
class="leaf-count-badge"
:variant="nodeBadgeText.length > 1 ? 'label' : 'circle'"
class="ml-2"
/>
</div>
<div
@@ -38,7 +39,7 @@
<script setup lang="ts" generic="T">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import { computed, inject, ref } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
@@ -146,9 +147,6 @@ if (props.node.droppable) {
align-items: center;
justify-content: space-between;
}
.leaf-count-badge {
margin-left: 0.5rem;
}
.node-content {
display: flex;
align-items: center;

View File

@@ -0,0 +1,28 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
/* eslint-disable better-tailwindcss/enforce-canonical-classes -- text-[color:*] prevents twMerge from clobbering color with font-size */
severity: {
default: 'bg-primary-background text-[color:white]',
secondary: 'bg-secondary-background-hover text-[color:white]',
warn: 'bg-warning-background text-[color:white]',
danger: 'bg-destructive-background text-[color:white]',
contrast: 'bg-base-foreground text-[color:var(--color-base-background)]'
},
/* eslint-enable better-tailwindcss/enforce-canonical-classes */
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 BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -1,26 +0,0 @@
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<typeof statusBadgeVariants>

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import Column from 'primevue/column'
import PrimeVue from 'primevue/config'
@@ -125,13 +125,13 @@ describe('UsageLogsTable', () => {
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
switch (type) {
case EventType.CREDIT_ADDED:
return 'success'
return 'default'
case EventType.ACCOUNT_CREATED:
return 'info'
return 'secondary'
case EventType.API_USAGE_COMPLETED:
return 'warning'
return 'warn'
default:
return 'info'
return 'secondary'
}
})
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {

View File

@@ -20,7 +20,7 @@
<Column field="event_type" :header="$t('credits.eventType')">
<template #body="{ data }">
<Badge
:value="customerEventService.formatEventType(data.event_type)"
:label="customerEventService.formatEventType(data.event_type)"
:severity="customerEventService.getEventSeverity(data.event_type)"
/>
</template>
@@ -91,7 +91,7 @@
</template>
<script setup lang="ts">
import Badge from 'primevue/badge'
import Badge from '@/components/common/Badge.vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Message from 'primevue/message'

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
@@ -40,11 +40,11 @@ const isPending = computed(() => job.status === 'created')
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<StatusBadge :label="t('progressToast.failed')" severity="danger" />
<Badge :label="t('progressToast.failed')" severity="danger" />
</template>
<template v-else-if="isCompleted">
<StatusBadge :label="t('progressToast.finished')" severity="contrast" />
<Badge :label="t('progressToast.finished')" severity="contrast" />
</template>
<template v-else-if="isRunning">

View File

@@ -20,7 +20,7 @@
<span ref="textRef" class="min-w-0 truncate">
<slot />
</span>
<StatusBadge
<Badge
v-if="badge !== undefined"
:label="String(badge)"
severity="contrast"
@@ -33,7 +33,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'

View File

@@ -119,9 +119,10 @@
@click.stop="handleSelect"
>
{{ $t('g.use') }}
<StatusBadge
<Badge
v-if="isNewlyImported"
severity="contrast"
variant="dot"
class="absolute -top-0.5 -right-0.5"
/>
</Button>
@@ -137,7 +138,7 @@ import { useI18n } from 'vue-i18n'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'

View File

@@ -28,7 +28,7 @@
"
@click="$emit('stepClick', step.name)"
>
<StatusBadge
<Badge
:label="step.number"
variant="circle"
severity="contrast"
@@ -67,7 +67,7 @@ import { computed } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -80,7 +80,7 @@
<span class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</span>
<StatusBadge
<Badge
v-if="isCancelled"
:label="$t('subscription.canceled')"
severity="warn"
@@ -365,7 +365,7 @@ import { useI18n } from 'vue-i18n'
import { useToast } from 'primevue/usetoast'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Badge from '@/components/common/Badge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'

View File

@@ -216,15 +216,17 @@ describe('useCustomerEventsService', () => {
describe('getEventSeverity', () => {
it('should return correct severity for known event types', () => {
expect(service.getEventSeverity(EventType.CREDIT_ADDED)).toBe('success')
expect(service.getEventSeverity(EventType.ACCOUNT_CREATED)).toBe('info')
expect(service.getEventSeverity(EventType.CREDIT_ADDED)).toBe('default')
expect(service.getEventSeverity(EventType.ACCOUNT_CREATED)).toBe(
'secondary'
)
expect(service.getEventSeverity(EventType.API_USAGE_COMPLETED)).toBe(
'warning'
'warn'
)
})
it('should return default severity for unknown event types', () => {
expect(service.getEventSeverity('unknown_event')).toBe('info')
expect(service.getEventSeverity('unknown_event')).toBe('secondary')
})
})

View File

@@ -2,6 +2,7 @@ import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref, watch } from 'vue'
import type { BadgeVariants } from '@/components/common/badge.variants'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -133,16 +134,18 @@ export const useCustomerEventsService = () => {
return value
}
function getEventSeverity(eventType: string) {
function getEventSeverity(
eventType: string
): NonNullable<BadgeVariants['severity']> {
switch (eventType) {
case 'credit_added':
return 'success'
return 'default'
case 'account_created':
return 'info'
return 'secondary'
case 'api_usage_completed':
return 'warning'
return 'warn'
default:
return 'info'
return 'secondary'
}
}