Compare commits

...

3 Commits

Author SHA1 Message Date
Deep Mehta
9000ec146f fix: add dismiss expiry, aria-label, and tests to AnnouncementBanner
- Dismissed announcements now expire after 7 days (matches
  versionCompatibilityStore pattern)
- Added i18n aria-label to close button for screen readers
- Added 6 Vitest tests covering render, dismiss, severity classes,
  and dismissible:false behavior
2026-03-21 17:33:29 -07:00
GitHub Action
4dc9e3a5d4 [automated] Apply ESLint and Oxfmt fixes 2026-03-22 00:03:44 +00:00
Deep Mehta
02cf67d775 feat: add dismissible announcement banner component
Add AnnouncementBanner component that reads announcements from
remoteConfig and displays them as a full-width banner at the top
of the app. Supports info, warning, and critical severity levels.
Dismiss state persisted in localStorage. Cloud-only.
2026-03-21 17:00:28 -07:00
4 changed files with 205 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Announcement } from '@/platform/remoteConfig/types'
const mockConfig = vi.hoisted(() => ({
value: {} as { announcements?: Announcement[] }
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockConfig
}))
vi.mock('@/utils/tailwindUtil', () => ({
cn: (...args: string[]) => args.filter(Boolean).join(' ')
}))
import AnnouncementBanner from './AnnouncementBanner.vue'
const globalMocks = {
global: {
mocks: {
$t: (key: string) => key
}
}
}
describe('AnnouncementBanner', () => {
beforeEach(() => {
mockConfig.value = {}
localStorage.clear()
})
it('renders announcements from remoteConfig', () => {
mockConfig.value = {
announcements: [
{
id: 'test-1',
message: 'Test announcement',
severity: 'info',
dismissible: true
}
]
}
const wrapper = mount(AnnouncementBanner, globalMocks)
expect(wrapper.text()).toContain('Test announcement')
})
it('renders nothing when no announcements', () => {
const wrapper = mount(AnnouncementBanner, globalMocks)
expect(wrapper.text()).toBe('')
})
it('hides announcement after dismiss', async () => {
mockConfig.value = {
announcements: [
{
id: 'dismiss-me',
message: 'Will be dismissed',
severity: 'warning',
dismissible: true
}
]
}
const wrapper = mount(AnnouncementBanner, globalMocks)
expect(wrapper.text()).toContain('Will be dismissed')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).not.toContain('Will be dismissed')
})
it('does not show dismiss button when dismissible is false', () => {
mockConfig.value = {
announcements: [
{
id: 'sticky',
message: 'Cannot dismiss',
severity: 'critical',
dismissible: false
}
]
}
const wrapper = mount(AnnouncementBanner, globalMocks)
expect(wrapper.text()).toContain('Cannot dismiss')
expect(wrapper.find('button').exists()).toBe(false)
})
it('renders multiple announcements', () => {
mockConfig.value = {
announcements: [
{ id: 'a', message: 'First', severity: 'info' },
{ id: 'b', message: 'Second', severity: 'warning' }
]
}
const wrapper = mount(AnnouncementBanner, globalMocks)
expect(wrapper.text()).toContain('First')
expect(wrapper.text()).toContain('Second')
})
it('applies correct severity classes', () => {
mockConfig.value = {
announcements: [
{ id: 'info-banner', message: 'Info', severity: 'info' },
{ id: 'warn-banner', message: 'Warn', severity: 'warning' },
{ id: 'crit-banner', message: 'Crit', severity: 'critical' }
]
}
const wrapper = mount(AnnouncementBanner, globalMocks)
const banners = wrapper.findAll('[role="status"]')
expect(banners[0].classes()).toContain('bg-blue-600')
expect(banners[1].classes()).toContain('bg-gold-600')
expect(banners[2].classes()).toContain('bg-danger-100')
})
})

View File

@@ -0,0 +1,72 @@
<template>
<div
v-for="announcement in visibleAnnouncements"
:key="announcement.id"
role="status"
:class="
cn(
'flex items-center justify-between gap-3 px-4 py-2 text-sm',
severityClasses[announcement.severity]
)
"
>
<div class="flex items-center gap-2">
<i :class="severityIcons[announcement.severity]" />
<span>{{ announcement.message }}</span>
</div>
<button
v-if="announcement.dismissible !== false"
:aria-label="$t('g.dismiss')"
class="shrink-0 cursor-pointer rounded-sm p-0.5 opacity-70 transition-opacity hover:opacity-100"
@click="dismiss(announcement.id)"
>
<i class="pi pi-times text-xs" />
</button>
</div>
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { cn } from '@/utils/tailwindUtil'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const dismissedIds = useStorage<Record<string, number>>(
'comfy.announcements.dismissed',
{},
localStorage
)
function isDismissed(id: string): boolean {
const dismissedAt = dismissedIds.value[id]
if (!dismissedAt) return false
return Date.now() - dismissedAt < DISMISSAL_DURATION_MS
}
const severityClasses: Record<string, string> = {
info: 'bg-blue-600 text-white',
warning: 'bg-gold-600 text-black',
critical: 'bg-danger-100 text-white'
}
const severityIcons: Record<string, string> = {
info: 'pi pi-info-circle',
warning: 'icon-[lucide--triangle-alert]',
critical: 'pi pi-exclamation-circle'
}
const visibleAnnouncements = computed(() => {
const announcements = remoteConfig.value.announcements ?? []
return announcements.filter((a) => !isDismissed(a.id))
})
function dismiss(id: string) {
dismissedIds.value = {
...dismissedIds.value,
[id]: Date.now()
}
}
</script>

View File

@@ -12,6 +12,16 @@ type ServerHealthAlert = {
badge?: string
}
/**
* Announcement banner configuration from the backend
*/
export type Announcement = {
id: string
message: string
severity: 'info' | 'warning' | 'critical'
dismissible?: boolean
}
type FirebaseRuntimeConfig = {
apiKey: string
authDomain: string
@@ -55,4 +65,5 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
sentry_dsn?: string
announcements?: Announcement[]
}

View File

@@ -1,4 +1,5 @@
<template>
<AnnouncementBanner v-if="isCloud" />
<div class="comfyui-body grid size-full overflow-hidden">
<div id="comfyui-body-top" class="comfyui-body-top" />
<div id="comfyui-body-bottom" class="comfyui-body-bottom" />
@@ -47,6 +48,7 @@ import {
import { useI18n } from 'vue-i18n'
import { runWhenGlobalIdle } from '@/base/common/async'
import AnnouncementBanner from '@/components/banner/AnnouncementBanner.vue'
import MenuHamburger from '@/components/MenuHamburger.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'