mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 22:37:32 +00:00
[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:
38
src/components/topbar/CloudBadge.vue
Normal file
38
src/components/topbar/CloudBadge.vue
Normal 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>
|
||||
231
src/components/topbar/TopbarBadge.test.ts
Normal file
231
src/components/topbar/TopbarBadge.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user