style: update ui and design of system notification components (what's new, new release notification, help center) (#6300)

## Summary

Migrated help center and release notification components from hardcoded
colors to semantic design tokens for automatic light/dark theme support.

<img width="808" height="874" alt="Selection_2298"
src="https://github.com/user-attachments/assets/c7fb956e-700b-49df-bba0-b85705e89ce7"
/>

<img width="852" height="710" alt="Selection_2265"
src="https://github.com/user-attachments/assets/618205e1-5068-499d-80ab-72626b32d7e1"
/>

<img width="493" height="838" alt="Screenshot from 2025-10-25 21-46-11"
src="https://github.com/user-attachments/assets/7b696673-ec19-4a16-a0b5-ca744ae62fe1"
/>

<img width="493" height="838" alt="Screenshot from 2025-10-25 21-46-25"
src="https://github.com/user-attachments/assets/2767d722-a0e1-426d-82d9-6d5a59f373ee"
/>

## Changes

- **What**: Replaced hardcoded hex/rgb colors with semantic tokens in
HelpCenterMenuContent, WhatsNewPopup, and ReleaseNotificationToast
components
- **Design System**: Added `--interface-menu-surface` and
`--interface-menu-stroke` tokens to style.css for consistent menu
theming
- **UX**: Updated help center menu structure - added "Give Feedback"
button, renamed "Help & Feedback" to "Help & Support", switched to
Lucide icons (except Discord brand logo), added external-link icons

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6300-style-update-ui-and-design-of-system-notification-components-what-s-new-new-release-no-2986d73d365081238458ea7d304b641e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Christian Byrne
2025-12-08 23:07:02 -08:00
committed by GitHub
parent ad630cfbfe
commit 51a336fd36
11 changed files with 1390 additions and 1001 deletions

View File

@@ -50,7 +50,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows the release
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release version
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section shows no releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show "No recent releases" message
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Should show no releases due to error
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(
whatsNewSection.locator('text=No recent releases')
).toBeVisible()
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is hidden
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
// Should not show any popups or toasts
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Should show the release
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
await helpCenterButton.click()
// Verify "What's New?" section is visible
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).toBeVisible()
// Close help center
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
await expect(helpMenu).toBeVisible()
// Section should be hidden regardless of empty releases
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
const whatsNewSection = comfyPage.page.getByTestId('whats-new-section')
await expect(whatsNewSection).not.toBeVisible()
})
})

View File

@@ -89,6 +89,8 @@
--color-danger-100: #c02323;
--color-danger-200: #d62952;
--color-coral-red-600: #973a40;
--color-coral-red-500: #c53f49;
--color-coral-red-400: #dd424e;
@@ -183,9 +185,13 @@
--interface-menu-component-surface-hovered: var(--color-smoke-200);
--interface-menu-component-surface-selected: var(--color-smoke-400);
--interface-menu-keybind-surface-default: var(--color-smoke-500);
--interface-menu-surface: var(--color-white);
--interface-menu-stroke: var(--color-smoke-600);
--interface-panel-surface: var(--color-white);
--interface-stroke: var(--color-smoke-300);
--nav-background: var(--color-white);
--node-border: var(--color-smoke-300);
@@ -301,6 +307,8 @@
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-menu-surface: var(--color-charcoal-800);
--interface-menu-stroke: var(--color-ash-800);
--interface-panel-surface: var(--color-charcoal-800);
--interface-stroke: var(--color-charcoal-400);
@@ -416,6 +424,8 @@
--color-interface-menu-keybind-surface-default: var(
--interface-menu-keybind-surface-default
);
--color-interface-menu-surface: var(--interface-menu-surface);
--color-interface-menu-stroke: var(--interface-menu-stroke);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(

View File

@@ -1,38 +1,52 @@
<template>
<div
class="help-center-menu"
class="help-center-menu flex flex-col items-start gap-1"
role="menu"
:aria-label="$t('helpCenter.helpFeedback')"
:aria-label="$t('help.helpCenterMenu')"
>
<!-- Main Menu Items -->
<nav class="help-menu-section" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
<div class="w-full">
<nav class="flex w-full flex-col gap-2" role="menubar">
<button
v-for="menuItem in menuItems"
v-show="menuItem.visible !== false"
:key="menuItem.key"
type="button"
class="help-menu-item"
:class="{ 'more-item': menuItem.key === 'more' }"
role="menuitem"
@click="menuItem.action"
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
</nav>
<span class="menu-label">{{ menuItem.label }}</span>
<i
v-if="menuItem.showExternalIcon"
class="icon-[lucide--external-link] text-primary w-4 h-4 ml-auto"
/>
<i
v-if="menuItem.key === 'more'"
class="pi pi-chevron-right ml-auto"
/>
</button>
</nav>
<div
class="flex h-4 flex-col items-center justify-between self-stretch p-2"
>
<div class="w-full border-b border-interface-menu-stroke" />
</div>
</div>
<!-- More Submenu -->
<Teleport to="body">
@@ -68,26 +82,34 @@
</Teleport>
<!-- What's New Section -->
<section v-if="showVersionUpdates" class="whats-new-section">
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
<section
v-if="showVersionUpdates"
class="w-full"
data-testid="whats-new-section"
>
<h3
class="section-description flex items-center gap-2.5 self-stretch px-8 pt-2 pb-2"
>
{{ $t('helpCenter.whatsNew') }}
</h3>
<!-- Release Items -->
<div
v-if="hasReleases"
role="group"
:aria-label="$t('helpCenter.recentReleases')"
:aria-label="$t('help.recentReleases')"
>
<article
v-for="release in releaseStore.recentReleases"
:key="release.id || release.version"
class="help-menu-item release-menu-item"
class="release-menu-item flex h-12 min-h-6 cursor-pointer items-center gap-2 self-stretch rounded p-2 transition-colors hover:bg-interface-menu-component-surface-hovered"
role="button"
tabindex="0"
@click="onReleaseClick(release)"
@keydown.enter="onReleaseClick(release)"
@keydown.space.prevent="onReleaseClick(release)"
>
<i class="pi pi-refresh help-menu-icon" aria-hidden="true" />
<i class="help-menu-icon icon-[lucide--package]" aria-hidden="true" />
<div class="release-content">
<span class="release-title">
{{
@@ -106,13 +128,6 @@
</span>
</time>
</div>
<Button
v-if="shouldShowUpdateButton(release)"
:label="$t('helpCenter.updateAvailable')"
size="small"
class="update-button"
@click.stop="onUpdate(release)"
/>
</article>
</div>
@@ -137,7 +152,6 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -166,6 +180,7 @@ interface MenuItem {
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
showExternalIcon?: boolean
}
// Constants
@@ -192,6 +207,9 @@ const commandStore = useCommandStore()
const settingStore = useSettingStore()
const telemetry = useTelemetry()
// Track when help center was opened
const openedAt = ref(Date.now())
// Emits
const emit = defineEmits<{
close: []
@@ -202,7 +220,6 @@ const isSubmenuVisible = ref(false)
const submenuRef = ref<HTMLElement | null>(null)
const submenuStyle = ref<CSSProperties>({})
let hoverTimeout: number | null = null
const openedAt = ref<number>(Date.now())
// Computed
const hasReleases = computed(() => releaseStore.releases.length > 0)
@@ -273,11 +290,34 @@ const moreMenuItem = computed(() =>
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
key: 'feedback',
type: 'item',
icon: 'icon-[lucide--clipboard-pen]',
label: t('helpCenter.feedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'icon-[lucide--message-circle-question]',
label: t('helpCenter.help'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
},
{
key: 'docs',
type: 'item',
icon: 'pi pi-book',
icon: 'icon-[lucide--book-open]',
label: t('helpCenter.docs'),
showExternalIcon: true,
action: () => {
trackResourceClick('docs', true)
const path = isCloud ? '/get_started/cloud' : '/'
@@ -290,6 +330,7 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
icon: 'pi pi-discord',
label: 'Discord',
showExternalIcon: true,
action: () => {
trackResourceClick('discord', true)
openExternalLink(staticUrls.discord)
@@ -299,24 +340,14 @@ const menuItems = computed<MenuItem[]>(() => {
{
key: 'github',
type: 'item',
icon: 'pi pi-github',
icon: 'icon-[lucide--github]',
label: t('helpCenter.github'),
showExternalIcon: true,
action: () => {
trackResourceClick('github', true)
openExternalLink(staticUrls.github)
emit('close')
}
},
{
key: 'help',
type: 'item',
icon: 'pi pi-question-circle',
label: t('helpCenter.helpFeedback'),
action: () => {
trackResourceClick('help_feedback', false)
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
}
]
@@ -438,32 +469,22 @@ const formatReleaseDate = (dateString?: string): string => {
const diffTime = Math.abs(now.getTime() - date.getTime())
const timeUnits = [
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
{ unit: TIME_UNITS.DAY, suffix: 'd' },
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
]
for (const { unit, suffix } of timeUnits) {
for (const { unit, key } of timeUnits) {
const value = Math.floor(diffTime / unit)
if (value > 0) {
return `${value}${suffix} ago`
return t(`g.relativeTime.${key}`, { count: value })
}
}
return 'now'
}
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
// Hide update buttons in cloud distribution
if (isCloud) return false
return (
releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0]
)
return t('g.relativeTime.now')
}
// Event Handlers
@@ -533,14 +554,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
)
emit('close')
}
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
@@ -557,38 +570,37 @@ onBeforeUnmount(() => {
<style scoped>
.help-center-menu {
width: 380px;
width: 256px;
max-height: 500px;
overflow-y: auto;
background: var(--p-content-background);
border-radius: 12px;
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
border: 1px solid var(--p-content-border-color);
backdrop-filter: blur(8px);
background: var(--interface-menu-surface);
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
border: 1px solid var(--interface-menu-stroke);
padding: 12px 8px;
position: relative;
}
.help-menu-section {
padding: 0.5rem 0;
border-bottom: 1px solid var(--p-content-border-color);
}
.help-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
height: 32px;
min-height: 24px;
padding: 8px;
gap: 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.9rem;
color: inherit;
color: var(--text-primary);
text-align: left;
}
.help-menu-item:hover {
background-color: #007aff26;
background-color: var(--interface-menu-component-surface-hovered);
}
.help-menu-item:focus,
@@ -599,16 +611,16 @@ onBeforeUnmount(() => {
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.help-menu-icon {
margin-right: 0.75rem;
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
height: 16px;
font-size: 16px;
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
@@ -616,7 +628,9 @@ onBeforeUnmount(() => {
}
.help-menu-icon svg {
color: var(--p-text-muted-color);
width: 16px;
height: 16px;
color: var(--text-primary);
}
.menu-red-dot {
@@ -639,16 +653,14 @@ onBeforeUnmount(() => {
justify-content: space-between;
}
.whats-new-section {
padding: 0.5rem 0;
}
.section-description {
font-size: 0.8rem;
font-weight: 600;
color: var(--p-text-muted-color);
margin: 0 0 0.5rem;
padding: 0 1rem;
color: var(--text-secondary);
font-family: var(--font-inter);
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: normal;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -661,7 +673,7 @@ onBeforeUnmount(() => {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 4px;
min-width: 0;
}
@@ -669,12 +681,22 @@ onBeforeUnmount(() => {
font-size: 0.9rem;
line-height: 1.2;
font-weight: 500;
color: var(--text-primary);
}
.release-date {
height: 16px;
font-size: 0.75rem;
color: var(--p-text-muted-color);
color: var(--text-secondary);
font-family: var(--font-inter);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.release-date .hover-state {
@@ -691,35 +713,31 @@ onBeforeUnmount(() => {
display: inline;
}
.update-button {
margin-left: 0.5rem;
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
flex-shrink: 0;
}
/* Submenu Styles */
.more-submenu {
width: 210px;
padding: 0.5rem 0;
background: var(--p-content-background);
border-radius: 12px;
border: 1px solid var(--p-content-border-color);
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
padding: 12px 8px;
background: var(--interface-menu-surface);
border-radius: 8px;
border: 1px solid var(--interface-menu-stroke);
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
overflow: hidden;
transition: opacity 0.15s ease-out;
}
.submenu-item {
padding: 0.75rem 1rem;
color: inherit;
padding: 8px;
height: 32px;
min-height: 24px;
border-radius: 4px;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: inherit;
line-height: inherit;
}
.submenu-item:hover {
background-color: #007aff26;
background-color: var(--interface-menu-component-surface-hovered);
}
.submenu-item:focus,
@@ -730,8 +748,8 @@ onBeforeUnmount(() => {
.submenu-divider {
height: 1px;
background: #3e3e3e;
margin: 0.5rem 0;
background: var(--interface-menu-stroke);
margin: 4px 0;
}
/* Scrollbar Styling */
@@ -744,12 +762,12 @@ onBeforeUnmount(() => {
}
.help-center-menu::-webkit-scrollbar-thumb {
background: var(--p-content-border-color);
background: var(--interface-menu-stroke);
border-radius: 3px;
}
.help-center-menu::-webkit-scrollbar-thumb:hover {
background: var(--p-text-muted-color);
background: var(--text-secondary);
}
/* Reduced Motion */

View File

@@ -199,6 +199,15 @@
"copy": "Copy",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"relativeTime": {
"now": "now",
"yearsAgo": "{count}y ago",
"monthsAgo": "{count}mo ago",
"weeksAgo": "{count}w ago",
"daysAgo": "{count}d ago",
"hoursAgo": "{count}h ago",
"minutesAgo": "{count}min ago"
},
"jobIdCopied": "Job ID copied to clipboard",
"failedToCopyJobId": "Failed to copy job ID",
"imageUrl": "Image URL",
@@ -746,9 +755,10 @@
}
},
"helpCenter": {
"feedback": "Give Feedback",
"docs": "Docs",
"github": "Github",
"helpFeedback": "Help & Feedback",
"help": "Help & Support",
"managerExtension": "Manager Extension",
"more": "More...",
"whatsNew": "What's New?",
@@ -762,10 +772,11 @@
"reinstall": "Re-Install"
},
"releaseToast": {
"newVersionAvailable": "New Version Available!",
"whatsNew": "What's New?",
"newVersionAvailable": "New update is out!",
"whatsNew": "See what's new",
"skip": "Skip",
"update": "Update"
"update": "Update",
"description": "Check out the latest improvements and features in this update."
},
"menu": {
"hideMenu": "Hide Menu",
@@ -1927,6 +1938,7 @@
},
"whatsNewPopup": {
"learnMore": "Learn more",
"later": "Later",
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
@@ -2271,6 +2283,12 @@
"inputs": "INPUTS",
"inputsNone": "NO INPUTS",
"inputsNoneTooltip": "Node has no inputs",
"nodeState": "Node state"
"properties": "Properties",
"nodeState": "Node state",
"settings": "Settings"
},
"help": {
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}

View File

@@ -0,0 +1,177 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
// Mock release data with realistic CMS content
const mockReleases: ReleaseNote[] = [
{
id: 1,
project: 'comfyui',
version: '1.2.3',
attention: 'medium',
published_at: '2024-01-15T10:00:00Z',
content: `# ComfyUI 1.2.3 Release
**What's new**
New features and improvements for better workflow management.
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
},
{
id: 2,
project: 'comfyui',
version: '1.2.4',
attention: 'high',
published_at: '2024-02-01T14:30:00Z',
content: `# ComfyUI 1.2.4 Major Release
**What's new**
Revolutionary updates that change how you create with ComfyUI.
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
- **Custom Node Store**: Browse and install community nodes directly from the interface
- **Performance Boost**: 40% faster generation times for SDXL models
- **Dark Mode**: Beautiful new dark interface theme`
},
{
id: 3,
project: 'comfyui',
version: '1.3.0',
attention: 'high',
published_at: '2024-03-10T09:15:00Z',
content: `# ComfyUI 1.3.0 - The Biggest Update Yet
**What's new**
Introducing powerful new features that unlock creative possibilities.
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
- **Workflow Templates**: Start from professionally designed templates
- **Advanced Queuing**: Batch process multiple generations with queue management
- **Mobile Preview**: Preview your workflows on mobile devices
- **API Improvements**: Enhanced REST API with better documentation
- **Community Hub**: Share workflows and discover creations from other users`
}
]
interface StoryArgs {
releaseData: ReleaseNote
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Updates/ReleaseNotificationToast',
component: ReleaseNotificationToast,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
releaseData: {
control: 'object',
description: 'Release data with version and markdown content'
}
},
decorators: [
(_, context) => {
// Set up the store with mock data for this story
const releaseStore = useReleaseStore()
// Patch store state directly for Storybook
releaseStore.$patch({
releases: [context.args.releaseData]
})
// Override shouldShowToast getter for Storybook
Object.defineProperty(releaseStore, 'shouldShowToast', {
get: () => true,
configurable: true
})
// Override recentRelease getter for Storybook
Object.defineProperty(releaseStore, 'recentRelease', {
get: () => context.args.releaseData,
configurable: true
})
// Mock the store methods to prevent errors
releaseStore.handleSkipRelease = async () => {
// Mock implementation for Storybook
}
releaseStore.handleShowChangelog = async () => {
// Mock implementation for Storybook
}
return {
template: `
<div class="min-h-screen flex items-center justify-center bg-base-background p-8">
<story />
</div>
`
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
releaseData: mockReleases[0]
}
}
export const MajorRelease: Story = {
args: {
releaseData: mockReleases[1]
}
}
export const ExtensiveFeatures: Story = {
args: {
releaseData: mockReleases[2]
}
}
export const LongContent: Story = {
args: {
releaseData: {
id: 4,
project: 'comfyui',
version: '1.4.0',
attention: 'high',
published_at: '2024-04-05T11:00:00Z',
content: `# ComfyUI 1.4.0 - Comprehensive Update
**What's new**
This is a comprehensive update with many new features and improvements. This release includes extensive changes across the entire platform.
- **Revolutionary Workflow Engine**: Complete rewrite of the workflow processing engine with 300% performance improvements
- **Advanced Model Management**: Sophisticated model organization with tagging, favorites, and automatic duplicate detection
- **Real-time Collaboration Suite**: Complete collaboration platform with user management, permissions, and shared workspaces
- **Professional Animation Tools**: Timeline-based animation system with keyframes and interpolation
- **Cloud Integration**: Seamless cloud storage integration with automatic backup and sync
- **Advanced Debugging Tools**: Comprehensive debugging suite with step-through execution and variable inspection`
}
}
}
export const EmptyContent: Story = {
args: {
releaseData: {
id: 5,
project: 'comfyui',
version: '1.0.0',
attention: 'low',
published_at: '2024-01-01T00:00:00Z',
content: ''
}
}
}

View File

@@ -0,0 +1,278 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReleaseNote } from '../common/releaseService'
import ReleaseNotificationToast from './ReleaseNotificationToast.vue'
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'releaseToast.newVersionAvailable': 'New update is out!',
'releaseToast.whatsNew': "See what's new",
'releaseToast.skip': 'Skip',
'releaseToast.update': 'Update',
'releaseToast.description':
'Check out the latest improvements and features in this update.'
}
return translations[key] || key
})
}))
}))
vi.mock('@/utils/formatUtil', () => ({
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
}))
// Mock release store
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowToast: false,
handleSkipRelease: vi.fn(),
handleShowChangelog: vi.fn(),
releases: [],
fetchReleases: vi.fn()
}
vi.mock('../common/releaseStore', () => ({
useReleaseStore: vi.fn(() => mockReleaseStore)
}))
describe('ReleaseNotificationToast', () => {
let wrapper: VueWrapper<InstanceType<typeof ReleaseNotificationToast>>
const mountComponent = (props = {}) => {
return mount(ReleaseNotificationToast, {
global: {
mocks: {
$t: (key: string) => {
const translations: Record<string, string> = {
'releaseToast.newVersionAvailable': 'New update is out!',
'releaseToast.whatsNew': "See what's new",
'releaseToast.skip': 'Skip',
'releaseToast.update': 'Update',
'releaseToast.description':
'Check out the latest improvements and features in this update.'
}
return translations[key] || key
}
},
stubs: {
// Stub Lucide icons
'i-lucide-rocket': true,
'i-lucide-external-link': true
}
},
props
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowToast = true // Force show for testing
})
it('renders correctly when shouldShow is true', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
})
it('displays rocket icon', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('displays release version', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.text()).toContain('1.2.3')
})
it('calls handleSkipRelease when skip button is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const buttons = wrapper.findAll('button')
const skipButton = buttons.find(
(btn) =>
btn.text().includes('Skip') || btn.element.innerHTML.includes('skip')
)
expect(skipButton).toBeDefined()
await skipButton!.trigger('click')
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalledWith('1.2.3')
})
it('opens update URL when update button is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
// Mock window.open
const mockWindowOpen = vi.fn()
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true
})
wrapper = mountComponent()
// Call the handler directly instead of triggering DOM event
await wrapper.vm.handleUpdate()
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/installation/update_comfyui',
'_blank'
)
})
it('calls handleShowChangelog when learn more link is clicked', async () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Call the handler directly instead of triggering DOM event
await wrapper.vm.handleLearnMore()
expect(mockReleaseStore.handleShowChangelog).toHaveBeenCalledWith('1.2.3')
})
it('generates correct changelog URL', () => {
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const learnMoreLink = wrapper.find('a[target="_blank"]')
expect(learnMoreLink.exists()).toBe(true)
expect(learnMoreLink.attributes('href')).toContain(
'docs.comfy.org/changelog'
)
})
it('removes title from markdown content for toast display', async () => {
const mockMarkdownRendererModule = (await vi.importMock(
'@/utils/markdownRendererUtil'
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
const mockMarkdownRenderer = vi.mocked(
mockMarkdownRendererModule.renderMarkdownToHtml
)
mockMarkdownRenderer.mockReturnValue('<div>Content without title</div>')
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release Title\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
// Should call markdown renderer with title removed
expect(mockMarkdownRenderer).toHaveBeenCalledWith('\n\nSome content')
})
it('fetches releases on mount when not already loaded', async () => {
mockReleaseStore.releases = [] // Empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('handles missing release content gracefully', () => {
mockReleaseStore.shouldShowToast = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: ''
} as ReleaseNote
wrapper = mountComponent()
// Should render fallback content
const descriptionElement = wrapper.find('.pl-14')
expect(descriptionElement.exists()).toBe(true)
expect(descriptionElement.text()).toContain('Check out the latest')
})
it('auto-hides after timeout', async () => {
vi.useFakeTimers()
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Initially visible
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
// Fast-forward time to trigger auto-hide
vi.advanceTimersByTime(8000)
await wrapper.vm.$nextTick()
// Component should call dismissToast internally which hides it
// We can't test DOM visibility change because the component uses local state
// But we can verify the timer was set and would have triggered
expect(vi.getTimerCount()).toBe(0) // Timer should be cleared after auto-hide
vi.useRealTimers()
})
it('clears auto-hide timer when manually dismissed', async () => {
vi.useFakeTimers()
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Start the timer
vi.advanceTimersByTime(1000)
// Manually dismiss by calling handler directly
await wrapper.vm.handleSkip()
// Timer should be cleared
expect(vi.getTimerCount()).toBe(0)
// Verify the store method was called (manual dismissal)
expect(mockReleaseStore.handleSkipRelease).toHaveBeenCalled()
vi.useRealTimers()
})
})

View File

@@ -1,43 +1,63 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<div class="release-notification-toast">
<!-- Header section with icon and text -->
<div class="toast-header">
<div class="toast-icon">
<i class="pi pi-download" />
</div>
<div class="toast-text">
<div class="toast-title">
{{ $t('releaseToast.newVersionAvailable') }}
<div
class="w-96 max-h-96 bg-base-background border border-border-default rounded-lg shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)] flex flex-col"
>
<!-- Main content -->
<div class="p-4 flex flex-col gap-4 flex-1 min-h-0">
<!-- Header section with icon and text -->
<div class="flex items-center gap-4">
<div
class="p-3 bg-primary-background-hover rounded-lg flex items-center justify-center shrink-0"
>
<i class="icon-[lucide--rocket] w-4 h-4 text-white" />
</div>
<div class="toast-version-badge">
{{ latestRelease?.version }}
<div class="flex flex-col gap-1">
<div
class="text-sm font-normal text-base-foreground leading-[1.429]"
>
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div
class="text-sm font-normal text-muted-foreground leading-[1.21]"
>
{{ latestRelease?.version }}
</div>
</div>
</div>
<!-- Description section -->
<div
class="pl-14 text-sm font-normal text-muted-foreground leading-[1.21] overflow-y-auto flex-1 min-h-0"
v-html="formattedContent"
></div>
</div>
<!-- Actions section -->
<div class="toast-actions-section">
<div class="actions-row">
<div class="left-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
@click="handleLearnMore"
>
{{ $t('releaseToast.whatsNew') }}
</a>
</div>
<div class="right-actions">
<button class="skip-button" @click="handleSkip">
{{ $t('releaseToast.skip') }}
</button>
<button class="cta-button" @click="handleUpdate">
{{ $t('releaseToast.update') }}
</button>
</div>
<!-- Footer section -->
<div class="flex justify-between items-center px-4 pb-4">
<a
class="flex items-center gap-2 text-sm font-normal py-1 text-muted-foreground hover:text-base-foreground"
:href="changelogUrl"
target="_blank"
rel="noopener noreferrer"
@click="handleLearnMore"
>
<i class="icon-[lucide--external-link] w-4 h-4"></i>
{{ $t('releaseToast.whatsNew') }}
</a>
<div class="flex items-center gap-4">
<button
class="h-6 px-0 bg-transparent border-none text-sm font-normal text-muted-foreground hover:text-base-foreground cursor-pointer"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</button>
<button
class="h-10 px-4 bg-secondary-background hover:bg-secondary-background-hover rounded-lg border-none text-sm font-normal text-base-foreground cursor-pointer"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
@@ -45,24 +65,28 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useExternalLink } from '@/composables/useExternalLink'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const { t } = useI18n()
// Local state for dismissed status
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
const latestRelease = computed<ReleaseNote | null>(() => {
return releaseStore.recentRelease
})
// Show toast when new version available and not dismissed
const shouldShow = computed(
@@ -79,6 +103,38 @@ const changelogUrl = computed(() => {
return changelogBaseUrl
})
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
try {
const markdown = latestRelease.value.content
// Remove the h1 title line and images for toast mode
const contentWithoutTitle = markdown.replace(/^# .+$/m, '')
const contentWithoutImages = contentWithoutTitle.replace(
/!\[.*?\]\(.*?\)/g,
''
)
// Check if there's meaningful content left after cleanup
const trimmedContent = contentWithoutImages.trim()
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
return DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
return renderMarkdownToHtml(contentWithoutImages)
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks - sanitize the HTML we create
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
return fallbackContent.trim()
? DOMPurify.sanitize(fallbackContent)
: DOMPurify.sanitize(`<p>${t('releaseToast.description')}</p>`)
}
})
// Auto-hide timer
let hideTimer: ReturnType<typeof setTimeout> | null = null
@@ -124,8 +180,6 @@ const handleUpdate = () => {
dismissToast()
}
// Learn more handled by anchor href
// Start auto-hide when toast becomes visible
watch(shouldShow, (isVisible) => {
if (isVisible) {
@@ -142,6 +196,13 @@ onMounted(async () => {
await releaseStore.fetchReleases()
}
})
// Expose methods for testing
defineExpose({
handleSkip,
handleLearnMore,
handleUpdate
})
</script>
<style scoped>
@@ -154,10 +215,7 @@ onMounted(async () => {
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left {
left: 1rem;
}
.release-toast-popup.sidebar-left,
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
@@ -165,139 +223,4 @@ onMounted(async () => {
.release-toast-popup.sidebar-right {
right: 1rem;
}
/* Main toast container */
.release-notification-toast {
width: 448px;
padding: 16px 16px 8px;
background: #353535;
box-shadow: 0 4px 4px rgb(0 0 0 / 0.25);
border-radius: 12px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Header section */
.toast-header {
display: flex;
gap: 16px;
align-items: flex-start;
}
/* Icon container */
.toast-icon {
width: 42px;
height: 42px;
padding: 10px;
background: rgb(0 122 255 / 0.2);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.toast-icon i {
color: #007aff;
font-size: 16px;
}
/* Text content */
.toast-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.toast-title {
color: white;
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
}
.toast-version-badge {
color: #a0a1a2;
font-size: 12px;
font-weight: 500;
line-height: 15.6px;
}
/* Actions section */
.toast-actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-row {
padding-left: 58px; /* Align with text content */
padding-right: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
align-items: center;
}
/* Learn more link - simple text link */
.learn-more-link {
color: #60a5fa;
font-size: 12px;
font-weight: 500;
line-height: 15.6px;
text-decoration: none;
}
.learn-more-link:hover {
text-decoration: underline;
}
.right-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Button styles */
.skip-button {
padding: 8px 16px;
background: #353535;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: #aeaeb2;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.skip-button:hover {
background: #404040;
}
.cta-button {
padding: 8px 16px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
border: none;
color: black;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,211 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
import WhatsNewPopup from './WhatsNewPopup.vue'
// Mock release data with realistic CMS content
const mockReleases: ReleaseNote[] = [
{
id: 1,
project: 'comfyui',
version: '1.2.3',
attention: 'medium',
published_at: '2024-01-15T10:00:00Z',
content: `# ComfyUI 1.2.3 Release
**What's new**
New features and improvements for better workflow management.
- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
- **Bug Fixes**: Resolved memory leak issues in the backend processing`
},
{
id: 2,
project: 'comfyui',
version: '1.2.4',
attention: 'high',
published_at: '2024-02-01T14:30:00Z',
content: `![Featured Image](https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop&fm=jpg)
# ComfyUI 1.2.4 Major Release
**What's new**
Revolutionary updates that change how you create with ComfyUI.
- **Real-time Collaboration**: Share and edit workflows with your team in real-time
- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
- **Custom Node Store**: Browse and install community nodes directly from the interface
- **Performance Boost**: 40% faster generation times for SDXL models
- **Dark Mode**: Beautiful new dark interface theme`
},
{
id: 3,
project: 'comfyui',
version: '1.3.0',
attention: 'high',
published_at: '2024-03-10T09:15:00Z',
content: `![Release Image](https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=400&h=200&fit=crop&fm=jpg)
# ComfyUI 1.3.0 - The Biggest Update Yet
**What's new**
Introducing powerful new features that unlock creative possibilities.
- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
- **Workflow Templates**: Start from professionally designed templates
- **Advanced Queuing**: Batch process multiple generations with queue management
- **Mobile Preview**: Preview your workflows on mobile devices
- **API Improvements**: Enhanced REST API with better documentation
- **Community Hub**: Share workflows and discover creations from other users`
}
]
interface StoryArgs {
releaseData: ReleaseNote
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Updates/WhatsNewPopup',
component: WhatsNewPopup,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
releaseData: {
control: 'object',
description: 'Release data with version and markdown content'
}
},
decorators: [
(_story, context) => {
// Set up the store with mock data for this story
const releaseStore = useReleaseStore()
// Override store data with story args
releaseStore.releases = [context.args.releaseData]
// Force the computed properties to return the values we want
Object.defineProperty(releaseStore, 'recentRelease', {
value: context.args.releaseData,
writable: true
})
Object.defineProperty(releaseStore, 'shouldShowPopup', {
value: true,
writable: true
})
// Mock the store methods to prevent errors
releaseStore.handleWhatsNewSeen = async () => {
// Mock implementation for Storybook
}
return {
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-900 p-8">
<story />
</div>
`
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
releaseData: mockReleases[0]
}
}
export const WithImage: Story = {
args: {
releaseData: mockReleases[1]
}
}
export const MajorRelease: Story = {
args: {
releaseData: mockReleases[2]
}
}
export const LongContent: Story = {
args: {
releaseData: {
id: 4,
project: 'comfyui',
version: '2.0.0',
attention: 'high',
published_at: '2024-04-20T16:00:00Z',
content: `![Major Update](https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=400&h=200&fit=crop)
# ComfyUI 2.0.0 - Complete Rewrite
**What's new**
The most significant update in ComfyUI history with complete platform rewrite.
## Core Engine Improvements
- **Next-Generation Workflow Engine**: Completely rewritten from the ground up with 500% performance improvements for complex workflows
- **Advanced Memory Management**: Intelligent memory allocation reducing VRAM usage by up to 60% while maintaining quality
- **Multi-Threading Support**: Full multi-core CPU utilization for preprocessing and post-processing tasks
- **GPU Optimization**: Advanced GPU scheduling with automatic optimization for different hardware configurations
## New User Interface
- **Modern Design Language**: Beautiful new interface with improved accessibility and mobile responsiveness
- **Customizable Workspace**: Fully customizable layout with dockable panels and saved workspace configurations
- **Advanced Node Browser**: Intelligent node search with AI-powered suggestions and visual node previews
- **Real-time Preview**: Live preview of changes as you build your workflow without needing to execute
## Professional Features
- **Version Control Integration**: Native Git integration for workflow version control and collaboration
- **Enterprise Security**: Advanced security features including end-to-end encryption and audit logging
- **Scalable Architecture**: Designed to handle enterprise-scale deployments with thousands of concurrent users
- **Plugin Ecosystem**: Robust plugin system with hot-loading and automatic dependency management`
}
}
}
export const MinimalContent: Story = {
args: {
releaseData: {
id: 5,
project: 'comfyui',
version: '1.0.1',
attention: 'low',
published_at: '2024-01-05T12:00:00Z',
content: `# ComfyUI 1.0.1
**What's new**
Quick patch release.
- **Bug Fix**: Fixed critical save issue`
}
}
}
export const EmptyContent: Story = {
args: {
releaseData: {
id: 6,
project: 'comfyui',
version: '1.0.0',
attention: 'low',
published_at: '2024-01-01T00:00:00Z',
content: ''
}
}
}

View File

@@ -0,0 +1,206 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReleaseNote } from '../common/releaseService'
import WhatsNewPopup from './WhatsNewPopup.vue'
// Mock dependencies
const mockTranslations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.later': 'Later',
'whatsNewPopup.learnMore': 'Learn More',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) => {
return params
? `${mockTranslations[key] || key}:${JSON.stringify(params)}`
: mockTranslations[key] || key
},
d: (date: Date) => date.toLocaleDateString()
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key: string) => {
return mockTranslations[key] || key
})
}))
}))
vi.mock('@/utils/formatUtil', () => ({
formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content: string) => `<div>${content}</div>`)
}))
// Mock release store
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
vi.mock('../common/releaseStore', () => ({
useReleaseStore: vi.fn(() => mockReleaseStore)
}))
describe('WhatsNewPopup', () => {
let wrapper: VueWrapper
const mountComponent = (props = {}) => {
return mount(WhatsNewPopup, {
global: {
plugins: [PrimeVue],
components: { Button },
mocks: {
$t: (key: string) => {
return mockTranslations[key] || key
}
},
stubs: {
// Stub Lucide icons
'i-lucide-x': true,
'i-lucide-external-link': true
}
},
props
})
}
beforeEach(() => {
vi.clearAllMocks()
// Reset store state
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
mockReleaseStore.handleWhatsNewSeen = vi.fn()
mockReleaseStore.fetchReleases = vi.fn()
})
it('renders correctly when shouldShow is true', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release\n\nSome content'
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('does not render when shouldShow is false', () => {
mockReleaseStore.shouldShowPopup = false
wrapper = mountComponent()
expect(wrapper.find('.whats-new-popup').exists()).toBe(false)
})
it('calls handleWhatsNewSeen when close button is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const closeButton = wrapper.findComponent(Button)
await closeButton.trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.2.3')
})
it('generates correct changelog URL', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toContain(
'docs.comfy.org/changelog'
)
})
it('handles missing release content gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: ''
} as ReleaseNote
wrapper = mountComponent()
// Should render fallback content
const contentElement = wrapper.find('.content-text')
expect(contentElement.exists()).toBe(true)
})
it('emits whats-new-dismissed event when popup is closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Test Release'
} as ReleaseNote
wrapper = mountComponent()
// Call the close method directly instead of triggering DOM event
await (wrapper.vm as any).closePopup()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('fetches releases on mount when not already loaded', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.releases = [] // Empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('does not fetch releases when already loaded', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.releases = [{ version: '1.0.0' } as ReleaseNote] // Non-empty releases array
wrapper = mountComponent()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
it('processes markdown content correctly', async () => {
const mockMarkdownRendererModule = (await vi.importMock(
'@/utils/markdownRendererUtil'
)) as { renderMarkdownToHtml: ReturnType<typeof vi.fn> }
const mockMarkdownRenderer = vi.mocked(
mockMarkdownRendererModule.renderMarkdownToHtml
)
mockMarkdownRenderer.mockReturnValue('<h1>Processed Content</h1>')
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
version: '1.2.3',
content: '# Original Title\n\nContent'
} as ReleaseNote
wrapper = mountComponent()
// Should call markdown renderer with original content (no modification)
expect(mockMarkdownRenderer).toHaveBeenCalledWith(
'# Original Title\n\nContent'
)
})
})

View File

@@ -1,62 +1,50 @@
<template>
<div v-if="shouldShow" class="whats-new-popup-container">
<!-- Arrow pointing to help center -->
<div class="help-center-arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="19"
viewBox="0 0 16 19"
fill="none"
>
<!-- Arrow fill -->
<path
d="M15.25 1.27246L15.25 17.7275L0.999023 9.5L15.25 1.27246Z"
fill="#353535"
/>
<!-- Top and bottom outlines only -->
<path
d="M15.25 1.27246L0.999023 9.5"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
<path
d="M0.999023 9.5L15.25 17.7275"
stroke="#4e4e4e"
stroke-width="1"
fill="none"
/>
</svg>
</div>
<div v-if="shouldShow" class="whats-new-popup-container left-4">
<div class="whats-new-popup" @click.stop>
<!-- Close Button -->
<button
class="close-button"
<Button
class="close-button absolute top-2 right-2 z-10 w-8 h-8 p-2 rounded-lg opacity-50"
:aria-label="$t('g.close')"
icon="icon-[lucide--x]"
size="small"
severity="secondary"
text
@click="closePopup"
/>
<!-- Modal Body -->
<div class="modal-body flex flex-col gap-4 px-0 pt-0 pb-2 flex-1">
<!-- Release Content -->
<div
class="content-text max-h-96 overflow-y-auto"
v-html="formattedContent"
></div>
</div>
<!-- Modal Footer -->
<div
class="modal-footer flex justify-between items-center gap-4 px-4 pb-4"
>
<div class="close-icon"></div>
</button>
<!-- Release Content -->
<div class="popup-content">
<div class="content-text" v-html="formattedContent"></div>
<!-- Actions Section -->
<div class="popup-actions">
<a
class="learn-more-link"
:href="changelogUrl"
target="_blank"
rel="noopener,noreferrer"
<a
class="learn-more-link flex items-center gap-2 text-sm font-normal py-1"
:href="changelogUrl"
target="_blank"
rel="noopener noreferrer"
@click="closePopup"
>
<i class="icon-[lucide--external-link]"></i>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<div class="footer-actions flex items-center gap-4">
<Button
class="h-8"
size="small"
severity="secondary"
text
@click="closePopup"
>
{{ $t('whatsNewPopup.learnMore') }}
</a>
<!-- TODO: CTA button -->
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
{{ $t('whatsNewPopup.later') }}
</Button>
</div>
</div>
</div>
@@ -64,6 +52,8 @@
</template>
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -74,9 +64,9 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { t } = useI18n()
const { buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const { t } = useI18n()
// Define emits
const emit = defineEmits<{
@@ -87,9 +77,9 @@ const emit = defineEmits<{
const isDismissed = ref(false)
// Get latest release from store
const latestRelease = computed<ReleaseNote | null>(
() => releaseStore.recentRelease
)
const latestRelease = computed<ReleaseNote | null>(() => {
return releaseStore.recentRelease
})
// Show popup when on latest version and not dismissed
const shouldShow = computed(
@@ -108,15 +98,39 @@ const changelogUrl = computed(() => {
const formattedContent = computed(() => {
if (!latestRelease.value?.content) {
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
try {
return renderMarkdownToHtml(latestRelease.value.content)
const markdown = latestRelease.value.content
// Check if content is meaningful (not just whitespace)
const trimmedContent = markdown.trim()
if (!trimmedContent || trimmedContent.replace(/\s+/g, '') === '') {
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
// Extract image and remaining content separately
const imageMatch = markdown.match(/!\[.*?\]\(.*?\)/)
const image = imageMatch ? imageMatch[0] : ''
// Remove image from content but keep original title
const contentWithoutImage = markdown.replace(/!\[.*?\]\(.*?\)/, '').trim()
// Reorder: image first, then original content
const reorderedContent = [image, contentWithoutImage]
.filter(Boolean)
.join('\n\n')
// renderMarkdownToHtml already sanitizes with DOMPurify, so this is safe
return renderMarkdownToHtml(reorderedContent)
} catch (error) {
console.error('Error parsing markdown:', error)
// Fallback to plain text with line breaks
return latestRelease.value.content.replace(/\n/g, '<br>')
// Fallback to plain text with line breaks - sanitize the HTML we create
const fallbackContent = latestRelease.value.content.replace(/\n/g, '<br>')
return fallbackContent.trim()
? DOMPurify.sanitize(fallbackContent)
: DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
}
})
@@ -145,10 +159,11 @@ onMounted(async () => {
}
})
// Expose methods for parent component
// Expose methods for parent component and tests
defineExpose({
show,
hide
hide,
closePopup
})
</script>
@@ -163,165 +178,80 @@ defineExpose({
pointer-events: auto;
}
/* Arrow pointing to help center */
.help-center-arrow {
position: absolute;
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-width) / 2
); /* Position to center of help center icon (2 icons below + half icon height for center) */
transform: none;
z-index: 999;
pointer-events: none;
}
/* Position arrow based on sidebar location */
.whats-new-popup-container.sidebar-left .help-center-arrow {
left: -14px; /* Overlap with popup outline */
}
.whats-new-popup-container.sidebar-left.small-sidebar .help-center-arrow {
left: -14px; /* Overlap with popup outline */
bottom: calc(
var(--sidebar-width) * 2 + var(--sidebar-icon-size) / 2 -
var(--whats-new-popup-bottom)
); /* Position to center of help center icon (2 icons below + half icon height for center - what's new popup bottom position ) */
}
/* Sidebar positioning classes applied by parent */
.whats-new-popup-container.sidebar-left {
left: 1rem;
}
.whats-new-popup-container.sidebar-left.small-sidebar {
left: 1rem;
}
.whats-new-popup-container.sidebar-right {
right: 1rem;
}
.whats-new-popup {
background: #353535;
border-radius: 12px;
background: var(--interface-menu-surface);
border-radius: 8px;
max-width: 400px;
width: 400px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
box-shadow: 0 8px 32px rgb(0 0 0 / 0.3);
border: 1px solid var(--interface-menu-stroke);
box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.2);
position: relative;
}
/* Content Section */
.popup-content {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
padding: 32px 32px 24px;
border-radius: 12px;
}
/* Close button */
.close-button {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
padding: 6px;
background: #7c7c7c;
border-radius: 16px;
border: none;
cursor: pointer;
/* Modal Body */
.modal-body {
display: flex;
justify-content: center;
align-items: center;
transform: translate(30%, -30%);
transition:
background-color 0.2s ease,
transform 0.1s ease;
z-index: 1;
flex-direction: column;
gap: 1rem;
padding: 0;
flex: 1;
}
.close-button:hover {
background: #8e8e8e;
}
.close-button:active {
background: #6a6a6a;
transform: translate(30%, -30%) scale(0.95);
}
.close-icon {
width: 16px;
height: 16px;
position: relative;
opacity: 0.9;
transition: opacity 0.2s ease;
}
.close-button:hover .close-icon {
opacity: 1;
}
.close-icon::before,
.close-icon::after {
content: '';
position: absolute;
width: 12px;
height: 2px;
background: white;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
transition: background-color 0.2s ease;
}
.close-icon::after {
transform: translate(-50%, -50%) rotate(-45deg);
.modal-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-text {
color: white;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
padding: 0 1rem;
}
/* Style the markdown content */
/* Title */
.content-text :deep(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.content-text :deep(h1) {
font-size: 16px;
font-weight: 700;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
margin-top: 1rem;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Version subtitle - targets the first p tag after h1 */
.content-text :deep(h1 + p) {
color: #c0c0c0;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
opacity: 0.8;
/* What's new title - targets h2 or strong text after h1 */
.content-text :deep(h2),
.content-text :deep(h1 + p strong) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.429;
}
/* Regular paragraphs - short description */
.content-text :deep(p) {
margin-bottom: 16px;
color: #e0e0e0;
color: var(--text-secondary);
font-family: Inter, sans-serif;
margin: 1rem 0;
}
/* List */
.content-text :deep(ul),
.content-text :deep(ol) {
margin-bottom: 16px;
margin-bottom: 0;
padding-left: 0;
list-style: none;
}
@@ -336,110 +266,168 @@ defineExpose({
margin-bottom: 0;
}
/* List items */
.content-text :deep(li) {
margin-bottom: 8px;
margin-bottom: 6px;
position: relative;
padding-left: 20px;
padding-left: 18px;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
}
.content-text :deep(li:last-child) {
margin-bottom: 0;
}
/* Custom bullet points */
.content-text :deep(li::before) {
content: '';
position: absolute;
left: 0;
top: 10px;
display: flex;
width: 8px;
height: 8px;
justify-content: center;
align-items: center;
aspect-ratio: 1/1;
border-radius: 100px;
background: #60a5fa;
left: 4px;
top: 7px;
width: 6px;
height: 6px;
border: 2px solid var(--text-secondary);
border-radius: 50%;
background: transparent;
}
/* List item strong text */
.content-text :deep(li strong) {
color: #fff;
color: var(--text-secondary);
font-family: Inter, sans-serif;
font-size: 14px;
display: block;
margin-bottom: 4px;
font-weight: 400;
line-height: 1.2102;
margin-right: 4px;
}
.content-text :deep(li p) {
font-size: 12px;
margin-bottom: 0;
line-height: 2;
margin: 2px 0 0;
display: inline;
}
/* Code styling */
.content-text :deep(code) {
background-color: #2a2a2a;
border: 1px solid #4a4a4a;
background-color: var(--input-surface);
border: 1px solid var(--interface-menu-stroke);
border-radius: 4px;
padding: 2px 6px;
color: #f8f8f2;
color: var(--text-primary);
white-space: nowrap;
}
/* Remove top margin for first media element */
.content-text :deep(img:first-child),
.content-text :deep(video:first-child),
.content-text :deep(iframe:first-child) {
margin-top: -32px; /* Align with the top edge of the popup content */
margin-bottom: 24px;
}
/* Media elements */
.content-text :deep(img),
.content-text :deep(video),
.content-text :deep(iframe) {
width: calc(100% + 64px);
height: auto;
margin: 24px -32px;
.content-text :deep(img) {
width: 100%;
height: 200px;
margin: 0 0 16px;
object-fit: cover;
display: block;
border-radius: 8px;
}
/* Actions Section */
.popup-actions {
.content-text :deep(img:first-child) {
margin: -1rem -1rem 16px;
width: calc(100% + 2rem);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
/* Add border to content when image is present */
.content-text:has(img:first-child) {
border-left: 1px solid var(--interface-menu-stroke);
border-right: 1px solid var(--interface-menu-stroke);
border-top: 1px solid var(--interface-menu-stroke);
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
margin: -1px;
margin-bottom: 0;
}
.content-text :deep(img + h1) {
margin-top: 0;
}
/* Secondary headings */
.content-text :deep(h3) {
color: var(--text-primary);
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 600;
margin: 16px 0 8px;
line-height: 1.4;
}
/* Modal Footer */
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
gap: 16px;
padding: 16px;
border-top: none;
}
.footer-actions {
display: flex;
align-items: center;
gap: 16px;
}
.learn-more-link {
color: #60a5fa;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
line-height: 18.2px;
font-weight: 400;
line-height: 1.2102;
text-decoration: none;
padding: 4px 0;
}
.learn-more-link:hover {
text-decoration: underline;
color: var(--text-primary);
}
.cta-button {
height: 40px;
padding: 0 20px;
background: white;
border-radius: 6px;
outline: 1px solid #4e4e4e;
outline-offset: -1px;
.learn-more-link i {
width: 16px;
height: 16px;
}
.action-secondary {
height: 32px;
padding: 4px 0;
background: transparent;
border: none;
color: #121212;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
border-radius: 4px;
}
.action-secondary:hover {
color: var(--text-primary);
}
.action-primary {
height: 40px;
padding: 8px 16px;
background: var(--interface-menu-component-surface-hovered);
border-radius: 8px;
border: none;
color: var(--text-primary);
font-size: 14px;
font-weight: 400;
line-height: 1.2102;
cursor: pointer;
}
.cta-button:hover {
background: #f0f0f0;
.action-primary:hover {
background: var(--button-hover-surface);
}
</style>

View File

@@ -1,440 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import type { components } from '@/types/comfyRegistryTypes'
type ReleaseNote = components['schemas']['ReleaseNote']
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key) => key)
})),
createI18n: vi.fn(() => ({
global: {
locale: { value: 'en' }
}
}))
}))
vi.mock('@/utils/markdownRendererUtil', () => ({
renderMarkdownToHtml: vi.fn((content) => `<p>${content}</p>`)
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: vi.fn()
}))
describe('WhatsNewPopup', () => {
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
const createWrapper = (props = {}) => {
return mount(WhatsNewPopup, {
props,
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
return translations[key] || key
})
}
}
})
}
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock store
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
// Mock release store
const { useReleaseStore } = await import(
'@/platform/updates/common/releaseStore'
)
vi.mocked(useReleaseStore).mockReturnValue(mockReleaseStore as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('visibility', () => {
it('should not show when shouldShowPopup is false', () => {
mockReleaseStore.shouldShowPopup = false
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
it('should show when shouldShowPopup is true and not dismissed', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('should hide when dismissed locally', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Click close button
await wrapper.find('.close-button').trigger('click')
// Should be hidden
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('content rendering', () => {
it('should render release content using renderMarkdownToHtml', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '# Release Notes\n\nNew features',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Check that the content is rendered (renderMarkdownToHtml is mocked to return processed content)
expect(wrapper.find('.content-text').exists()).toBe(true)
const contentHtml = wrapper.find('.content-text').html()
expect(contentHtml).toContain('<p># Release Notes')
})
it('should handle missing release content', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.content-text').html()).toContain(
'whatsNewPopup.noReleaseNotes'
)
})
it('should handle markdown parsing errors gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content with\nnewlines',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Should show content even without markdown processing
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('changelog URL generation', () => {
it('should generate English changelog URL with version anchor', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0-beta.1',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
// formatVersionAnchor replaces dots with dashes: 1.24.0-beta.1 -> v1-24-0-beta-1
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0-beta-1'
)
})
it('should generate Chinese changelog URL when locale is zh', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper({
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available',
'whatsNewPopup.learnMore': 'Learn More'
}
return translations[key] || key
})
},
provide: {
// Mock vue-i18n locale as Chinese
locale: { value: 'zh' }
}
}
})
// Since the locale mocking doesn't work well in tests, just check the English URL for now
// In a real component test with proper i18n setup, this would show the Chinese URL
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0'
)
})
it('should generate base changelog URL when no version available', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog'
)
})
})
describe('popup dismissal', () => {
it('should call handleWhatsNewSeen and emit event when closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click close button
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
expect(wrapper.emitted('whats-new-dismissed')).toHaveLength(1)
})
it('should close when learn more link is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click learn more link
await wrapper.find('.learn-more-link').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('should handle cases where no release is available during close', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = null
const wrapper = createWrapper()
// Try to close
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).not.toHaveBeenCalled()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
})
describe('exposed methods', () => {
it('should expose show and hide methods', () => {
const wrapper = createWrapper()
expect(wrapper.vm.show).toBeDefined()
expect(wrapper.vm.hide).toBeDefined()
expect(typeof wrapper.vm.show).toBe('function')
expect(typeof wrapper.vm.hide).toBe('function')
})
it('should show popup when show method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
// Show it
wrapper.vm.show()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
})
it('should hide popup when hide method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('initialization', () => {
it('should fetch releases on mount if not already loaded', async () => {
mockReleaseStore.releases = []
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('should not fetch releases if already loaded', async () => {
mockReleaseStore.releases = [
{
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium' as const,
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
]
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
})
describe('accessibility', () => {
it('should have proper aria-label for close button', () => {
const mockT = vi.fn((key) => (key === 'g.close' ? 'Close' : key))
vi.doMock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: mockT
}))
}))
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.close-button').attributes('aria-label')).toBe(
'Close'
)
})
it('should have proper link attributes for external changelog', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('target')).toBe('_blank')
expect(learnMoreLink.attributes('rel')).toBe('noopener,noreferrer')
})
})
})