[backport rh-test] make topbar badges responsive and fix server health badges showing on unrelated dialogs (#6297)

Backport of #6291 to `rh-test`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6297-backport-rh-test-make-topbar-badges-responsive-and-fix-server-health-badges-showing-on--2986d73d365081d1ba58fb40eb8d2776)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Comfy Org PR Bot
2025-10-26 15:49:25 +09:00
committed by GitHub
parent 76bd9ab43e
commit 09bad9c1e8
6 changed files with 436 additions and 13 deletions

View File

@@ -0,0 +1,38 @@
<template>
<TopbarBadge
:badge="cloudBadge"
:display-mode="displayMode"
:reverse-order="reverseOrder"
:no-padding="noPadding"
:background-color="backgroundColor"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
import TopbarBadge from './TopbarBadge.vue'
withDefaults(
defineProps<{
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>(),
{
displayMode: 'full',
reverseOrder: false,
noPadding: false,
backgroundColor: 'var(--comfy-menu-bg)'
}
)
const cloudBadge = computed<TopbarBadgeType>(() => ({
label: t('g.beta'),
text: 'Comfy Cloud'
}))
</script>

View File

@@ -0,0 +1,231 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Popover from 'primevue/popover'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { TopbarBadge as TopbarBadgeType } from '@/types/comfy'
import TopbarBadge from './TopbarBadge.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {}
}
})
describe('TopbarBadge', () => {
const exampleBadge: TopbarBadgeType = {
text: 'Test Badge',
label: 'BETA',
variant: 'info'
}
const mountTopbarBadge = (
badge: Partial<TopbarBadgeType> = {},
displayMode: 'full' | 'compact' | 'icon-only' = 'full'
) => {
return mount(TopbarBadge, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
components: { Popover }
},
props: {
badge: { ...exampleBadge, ...badge },
displayMode
}
})
}
describe('full display mode', () => {
it('renders all badge elements (icon, label, text)', () => {
const wrapper = mountTopbarBadge(
{
text: 'Comfy Cloud',
label: 'BETA',
icon: 'pi pi-cloud'
},
'full'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).toContain('BETA')
expect(wrapper.text()).toContain('Comfy Cloud')
})
it('renders without icon when not provided', () => {
const wrapper = mountTopbarBadge(
{
text: 'Test',
label: 'NEW'
},
'full'
)
expect(wrapper.find('i').exists()).toBe(false)
expect(wrapper.text()).toContain('NEW')
expect(wrapper.text()).toContain('Test')
})
})
describe('compact display mode', () => {
it('renders icon and label but not text', () => {
const wrapper = mountTopbarBadge(
{
text: 'Hidden Text',
label: 'BETA',
icon: 'pi pi-cloud'
},
'compact'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).toContain('BETA')
expect(wrapper.text()).not.toContain('Hidden Text')
})
it('opens popover on click', async () => {
const wrapper = mountTopbarBadge(
{
text: 'Full Text',
label: 'ALERT'
},
'compact'
)
const clickableArea = wrapper.find('[class*="flex h-full"]')
await clickableArea.trigger('click')
const popover = wrapper.findComponent(Popover)
expect(popover.exists()).toBe(true)
})
})
describe('icon-only display mode', () => {
it('renders only icon', () => {
const wrapper = mountTopbarBadge(
{
text: 'Hidden Text',
label: 'BETA',
icon: 'pi pi-cloud'
},
'icon-only'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).not.toContain('BETA')
expect(wrapper.text()).not.toContain('Hidden Text')
})
it('renders label when no icon provided', () => {
const wrapper = mountTopbarBadge(
{
text: 'Hidden Text',
label: 'NEW'
},
'icon-only'
)
expect(wrapper.text()).toContain('NEW')
expect(wrapper.text()).not.toContain('Hidden Text')
})
})
describe('badge variants', () => {
it('applies error variant styles', () => {
const wrapper = mountTopbarBadge(
{
text: 'Error Message',
label: 'ERROR',
variant: 'error'
},
'full'
)
expect(wrapper.find('.bg-danger-100').exists()).toBe(true)
expect(wrapper.find('.text-danger-100').exists()).toBe(true)
})
it('applies warning variant styles', () => {
const wrapper = mountTopbarBadge(
{
text: 'Warning Message',
label: 'WARN',
variant: 'warning'
},
'full'
)
expect(wrapper.find('.bg-warning-100').exists()).toBe(true)
expect(wrapper.find('.text-warning-100').exists()).toBe(true)
})
it('uses default error icon for error variant', () => {
const wrapper = mountTopbarBadge(
{
text: 'Error',
variant: 'error'
},
'full'
)
expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(true)
})
it('uses default warning icon for warning variant', () => {
const wrapper = mountTopbarBadge(
{
text: 'Warning',
variant: 'warning'
},
'full'
)
expect(wrapper.find('.pi-exclamation-triangle').exists()).toBe(true)
})
})
describe('popover', () => {
it('includes popover component in compact and icon-only modes', () => {
const compactWrapper = mountTopbarBadge({}, 'compact')
const iconOnlyWrapper = mountTopbarBadge({}, 'icon-only')
const fullWrapper = mountTopbarBadge({}, 'full')
expect(compactWrapper.findComponent(Popover).exists()).toBe(true)
expect(iconOnlyWrapper.findComponent(Popover).exists()).toBe(true)
expect(fullWrapper.findComponent(Popover).exists()).toBe(false)
})
})
describe('edge cases', () => {
it('handles badge with only text', () => {
const wrapper = mountTopbarBadge(
{
text: 'Simple Badge'
},
'full'
)
expect(wrapper.text()).toContain('Simple Badge')
expect(wrapper.find('i').exists()).toBe(false)
})
it('handles custom icon override', () => {
const wrapper = mountTopbarBadge(
{
text: 'Custom',
variant: 'error',
icon: 'pi pi-custom-icon'
},
'full'
)
expect(wrapper.find('.pi-custom-icon').exists()).toBe(true)
expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(false)
})
})
})

View File

@@ -1,9 +1,110 @@
<template>
<!-- Icon-only mode with Popover -->
<div
v-if="displayMode === 'icon-only'"
class="relative inline-flex h-full shrink-0 items-center justify-center px-2"
:class="clickableClasses"
:style="menuBackgroundStyle"
@click="togglePopover"
>
<i
v-if="iconClass"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div
v-else-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
</div>
<div v-else class="size-2 shrink-0 rounded-full" :class="dotClasses" />
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
</div>
</Popover>
</div>
<!-- Compact mode: Icon + Label only with Popover -->
<div
v-else-if="displayMode === 'compact'"
class="relative inline-flex h-full"
:style="menuBackgroundStyle"
>
<div
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[
{ 'flex-row-reverse': reverseOrder },
noPadding ? '' : 'px-3',
clickableClasses
]"
@click="togglePopover"
>
<i
v-if="iconClass"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div
v-if="badge.label"
class="shrink-0 rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
</div>
</div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex max-w-xs min-w-40 flex-col gap-2 p-3">
<div
v-if="badge.label"
class="w-fit rounded-full px-1.5 py-0.5 text-xxxs font-semibold"
:class="labelClasses"
>
{{ badge.label }}
</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
</div>
</Popover>
</div>
<!-- Full mode: Icon + Label + Text -->
<div
v-else
v-tooltip="badge.tooltip"
class="flex h-full shrink-0 items-center gap-2 whitespace-nowrap"
:class="[{ 'flex-row-reverse': reverseOrder }, noPadding ? '' : 'px-3']"
:style="{ backgroundColor: 'var(--comfy-menu-bg)' }"
:style="menuBackgroundStyle"
>
<i
v-if="iconClass"
@@ -22,24 +123,40 @@
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import type { TopbarBadge } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<{
badge: TopbarBadge
displayMode?: 'full' | 'compact' | 'icon-only'
reverseOrder?: boolean
noPadding?: boolean
backgroundColor?: string
}>(),
{
displayMode: 'full',
reverseOrder: false,
noPadding: false
noPadding: false,
backgroundColor: 'var(--comfy-menu-bg)'
}
)
const popover = ref<InstanceType<typeof Popover>>()
const togglePopover = (event: Event) => {
popover.value?.toggle(event)
}
const variant = computed(() => props.badge.variant ?? 'info')
const menuBackgroundStyle = computed(() => ({
backgroundColor: props.backgroundColor
}))
const labelClasses = computed(() => {
switch (variant.value) {
case 'error':
@@ -80,4 +197,33 @@ const iconClass = computed(() => {
return undefined
}
})
const clickableClasses = 'cursor-pointer transition-opacity hover:opacity-80'
const dotClasses = computed(() => {
switch (variant.value) {
case 'error':
return 'bg-danger-100'
case 'warning':
return 'bg-warning-100'
case 'info':
default:
return 'bg-slate-100'
}
})
const popoverPt = computed(() => ({
root: {
class: cn('absolute z-50')
},
content: {
class: cn(
'mt-1 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-zinc-200 dark-theme:border-zinc-700'
)
}
}))
</script>

View File

@@ -1,9 +1,10 @@
<template>
<div v-if="notMobile" class="flex h-full shrink-0 items-center">
<div class="flex h-full shrink-0 items-center">
<TopbarBadge
v-for="badge in topbarBadgeStore.badges"
:key="badge.text"
:badge
:display-mode="displayMode"
:reverse-order="reverseOrder"
:no-padding="noPadding"
/>
@@ -11,7 +12,8 @@
</template>
<script lang="ts" setup>
import { useBreakpoints } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
@@ -28,9 +30,15 @@ withDefaults(
}
)
const BREAKPOINTS = { md: 880 }
const breakpoints = useBreakpoints(BREAKPOINTS)
const notMobile = breakpoints.greater('md')
const breakpoints = useBreakpoints(breakpointsTailwind)
const isXl = breakpoints.greaterOrEqual('xl')
const isLg = breakpoints.greaterOrEqual('lg')
const displayMode = computed<'full' | 'compact' | 'icon-only'>(() => {
if (isXl.value) return 'full'
if (isLg.value) return 'compact'
return 'icon-only'
})
const topbarBadgeStore = useTopbarBadgeStore()
</script>

View File

@@ -5,7 +5,7 @@
<h2 class="text-2xl">
{{ $t('subscription.title') }}
</h2>
<TopbarBadges reverse-order />
<CloudBadge reverse-order />
</div>
<div class="grow overflow-auto">
@@ -196,7 +196,7 @@ import Button from 'primevue/button'
import TabPanel from 'primevue/tabpanel'
import { computed, onMounted, ref } from 'vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'

View File

@@ -25,10 +25,10 @@
<div class="text-sm text-muted">
{{ $t('subscription.required.title') }}
</div>
<TopbarBadges
<CloudBadge
reverse-order
no-padding
text-class="!text-sm !font-normal"
background-color="var(--p-dialog-background)"
/>
</div>
@@ -49,7 +49,7 @@
</template>
<script setup lang="ts">
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'