mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
278
src/platform/updates/components/ReleaseNotificationToast.test.ts
Normal file
278
src/platform/updates/components/ReleaseNotificationToast.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
211
src/platform/updates/components/WhatsNewPopup.stories.ts
Normal file
211
src/platform/updates/components/WhatsNewPopup.stories.ts
Normal 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: `
|
||||
|
||||
# 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/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: `
|
||||
|
||||
# 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: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
206
src/platform/updates/components/WhatsNewPopup.test.ts
Normal file
206
src/platform/updates/components/WhatsNewPopup.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user