mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +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()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Verify "What's New?" section shows the release
|
// 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()
|
await expect(whatsNewSection).toBeVisible()
|
||||||
|
|
||||||
// Should show the release version
|
// Should show the release version
|
||||||
@@ -79,7 +79,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await expect(helpMenu).toBeVisible()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Verify "What's New?" section shows no releases
|
// 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()
|
await expect(whatsNewSection).toBeVisible()
|
||||||
|
|
||||||
// Should show "No recent releases" message
|
// Should show "No recent releases" message
|
||||||
@@ -125,7 +125,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await expect(helpMenu).toBeVisible()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Should show no releases due to error
|
// 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(
|
await expect(
|
||||||
whatsNewSection.locator('text=No recent releases')
|
whatsNewSection.locator('text=No recent releases')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
@@ -175,7 +175,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await expect(helpMenu).toBeVisible()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Verify "What's New?" section is hidden
|
// 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()
|
await expect(whatsNewSection).not.toBeVisible()
|
||||||
|
|
||||||
// Should not show any popups or toasts
|
// Should not show any popups or toasts
|
||||||
@@ -260,7 +260,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await expect(helpMenu).toBeVisible()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Verify "What's New?" section is visible
|
// 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()
|
await expect(whatsNewSection).toBeVisible()
|
||||||
|
|
||||||
// Should show the release
|
// Should show the release
|
||||||
@@ -308,7 +308,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await helpCenterButton.click()
|
await helpCenterButton.click()
|
||||||
|
|
||||||
// Verify "What's New?" section is visible
|
// 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()
|
await expect(whatsNewSection).toBeVisible()
|
||||||
|
|
||||||
// Close help center
|
// Close help center
|
||||||
@@ -359,7 +359,7 @@ test.describe('Release Notifications', () => {
|
|||||||
await expect(helpMenu).toBeVisible()
|
await expect(helpMenu).toBeVisible()
|
||||||
|
|
||||||
// Section should be hidden regardless of empty releases
|
// 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()
|
await expect(whatsNewSection).not.toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -89,6 +89,8 @@
|
|||||||
--color-danger-100: #c02323;
|
--color-danger-100: #c02323;
|
||||||
--color-danger-200: #d62952;
|
--color-danger-200: #d62952;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
--color-coral-red-600: #973a40;
|
--color-coral-red-600: #973a40;
|
||||||
--color-coral-red-500: #c53f49;
|
--color-coral-red-500: #c53f49;
|
||||||
--color-coral-red-400: #dd424e;
|
--color-coral-red-400: #dd424e;
|
||||||
@@ -183,9 +185,13 @@
|
|||||||
--interface-menu-component-surface-hovered: var(--color-smoke-200);
|
--interface-menu-component-surface-hovered: var(--color-smoke-200);
|
||||||
--interface-menu-component-surface-selected: var(--color-smoke-400);
|
--interface-menu-component-surface-selected: var(--color-smoke-400);
|
||||||
--interface-menu-keybind-surface-default: var(--color-smoke-500);
|
--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-panel-surface: var(--color-white);
|
||||||
--interface-stroke: var(--color-smoke-300);
|
--interface-stroke: var(--color-smoke-300);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
--nav-background: var(--color-white);
|
--nav-background: var(--color-white);
|
||||||
|
|
||||||
--node-border: var(--color-smoke-300);
|
--node-border: var(--color-smoke-300);
|
||||||
@@ -301,6 +307,8 @@
|
|||||||
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
|
||||||
--interface-menu-component-surface-selected: var(--color-charcoal-300);
|
--interface-menu-component-surface-selected: var(--color-charcoal-300);
|
||||||
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
|
--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-panel-surface: var(--color-charcoal-800);
|
||||||
--interface-stroke: var(--color-charcoal-400);
|
--interface-stroke: var(--color-charcoal-400);
|
||||||
|
|
||||||
@@ -416,6 +424,8 @@
|
|||||||
--color-interface-menu-keybind-surface-default: var(
|
--color-interface-menu-keybind-surface-default: var(
|
||||||
--interface-menu-keybind-surface-default
|
--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-surface: var(--interface-panel-surface);
|
||||||
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
|
||||||
--color-interface-panel-selected-surface: var(
|
--color-interface-panel-selected-surface: var(
|
||||||
|
|||||||
@@ -1,38 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="help-center-menu"
|
class="help-center-menu flex flex-col items-start gap-1"
|
||||||
role="menu"
|
role="menu"
|
||||||
:aria-label="$t('helpCenter.helpFeedback')"
|
:aria-label="$t('help.helpCenterMenu')"
|
||||||
>
|
>
|
||||||
<!-- Main Menu Items -->
|
<!-- Main Menu Items -->
|
||||||
<nav class="help-menu-section" role="menubar">
|
<div class="w-full">
|
||||||
<button
|
<nav class="flex w-full flex-col gap-2" role="menubar">
|
||||||
v-for="menuItem in menuItems"
|
<button
|
||||||
v-show="menuItem.visible !== false"
|
v-for="menuItem in menuItems"
|
||||||
:key="menuItem.key"
|
v-show="menuItem.visible !== false"
|
||||||
type="button"
|
:key="menuItem.key"
|
||||||
class="help-menu-item"
|
type="button"
|
||||||
:class="{ 'more-item': menuItem.key === 'more' }"
|
class="help-menu-item"
|
||||||
role="menuitem"
|
:class="{ 'more-item': menuItem.key === 'more' }"
|
||||||
@click="menuItem.action"
|
role="menuitem"
|
||||||
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
@click="menuItem.action"
|
||||||
@mouseleave="onMenuItemLeave(menuItem.key)"
|
@mouseenter="onMenuItemHover(menuItem.key, $event)"
|
||||||
>
|
@mouseleave="onMenuItemLeave(menuItem.key)"
|
||||||
<div class="help-menu-icon-container">
|
>
|
||||||
<div class="help-menu-icon">
|
<div class="help-menu-icon-container">
|
||||||
<component
|
<div class="help-menu-icon">
|
||||||
:is="menuItem.icon"
|
<component
|
||||||
v-if="typeof menuItem.icon === 'object'"
|
:is="menuItem.icon"
|
||||||
:size="16"
|
v-if="typeof menuItem.icon === 'object'"
|
||||||
/>
|
:size="16"
|
||||||
<i v-else :class="menuItem.icon" />
|
/>
|
||||||
|
<i v-else :class="menuItem.icon" />
|
||||||
|
</div>
|
||||||
|
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
|
<span class="menu-label">{{ menuItem.label }}</span>
|
||||||
</div>
|
<i
|
||||||
<span class="menu-label">{{ menuItem.label }}</span>
|
v-if="menuItem.showExternalIcon"
|
||||||
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
|
class="icon-[lucide--external-link] text-primary w-4 h-4 ml-auto"
|
||||||
</button>
|
/>
|
||||||
</nav>
|
<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 -->
|
<!-- More Submenu -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -68,26 +82,34 @@
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- What's New Section -->
|
<!-- What's New Section -->
|
||||||
<section v-if="showVersionUpdates" class="whats-new-section">
|
<section
|
||||||
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
|
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 -->
|
<!-- Release Items -->
|
||||||
<div
|
<div
|
||||||
v-if="hasReleases"
|
v-if="hasReleases"
|
||||||
role="group"
|
role="group"
|
||||||
:aria-label="$t('helpCenter.recentReleases')"
|
:aria-label="$t('help.recentReleases')"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
v-for="release in releaseStore.recentReleases"
|
v-for="release in releaseStore.recentReleases"
|
||||||
:key="release.id || release.version"
|
: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"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="onReleaseClick(release)"
|
@click="onReleaseClick(release)"
|
||||||
@keydown.enter="onReleaseClick(release)"
|
@keydown.enter="onReleaseClick(release)"
|
||||||
@keydown.space.prevent="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">
|
<div class="release-content">
|
||||||
<span class="release-title">
|
<span class="release-title">
|
||||||
{{
|
{{
|
||||||
@@ -106,13 +128,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
v-if="shouldShowUpdateButton(release)"
|
|
||||||
:label="$t('helpCenter.updateAvailable')"
|
|
||||||
size="small"
|
|
||||||
class="update-button"
|
|
||||||
@click.stop="onUpdate(release)"
|
|
||||||
/>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,7 +152,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import type { CSSProperties, Component } from 'vue'
|
import type { CSSProperties, Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -166,6 +180,7 @@ interface MenuItem {
|
|||||||
type?: 'item' | 'divider'
|
type?: 'item' | 'divider'
|
||||||
items?: MenuItem[]
|
items?: MenuItem[]
|
||||||
showRedDot?: boolean
|
showRedDot?: boolean
|
||||||
|
showExternalIcon?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
@@ -192,6 +207,9 @@ const commandStore = useCommandStore()
|
|||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const telemetry = useTelemetry()
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
|
// Track when help center was opened
|
||||||
|
const openedAt = ref(Date.now())
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
@@ -202,7 +220,6 @@ const isSubmenuVisible = ref(false)
|
|||||||
const submenuRef = ref<HTMLElement | null>(null)
|
const submenuRef = ref<HTMLElement | null>(null)
|
||||||
const submenuStyle = ref<CSSProperties>({})
|
const submenuStyle = ref<CSSProperties>({})
|
||||||
let hoverTimeout: number | null = null
|
let hoverTimeout: number | null = null
|
||||||
const openedAt = ref<number>(Date.now())
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||||
@@ -273,11 +290,34 @@ const moreMenuItem = computed(() =>
|
|||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
const items: 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',
|
key: 'docs',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: 'pi pi-book',
|
icon: 'icon-[lucide--book-open]',
|
||||||
label: t('helpCenter.docs'),
|
label: t('helpCenter.docs'),
|
||||||
|
showExternalIcon: true,
|
||||||
action: () => {
|
action: () => {
|
||||||
trackResourceClick('docs', true)
|
trackResourceClick('docs', true)
|
||||||
const path = isCloud ? '/get_started/cloud' : '/'
|
const path = isCloud ? '/get_started/cloud' : '/'
|
||||||
@@ -290,6 +330,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
icon: 'pi pi-discord',
|
icon: 'pi pi-discord',
|
||||||
label: 'Discord',
|
label: 'Discord',
|
||||||
|
showExternalIcon: true,
|
||||||
action: () => {
|
action: () => {
|
||||||
trackResourceClick('discord', true)
|
trackResourceClick('discord', true)
|
||||||
openExternalLink(staticUrls.discord)
|
openExternalLink(staticUrls.discord)
|
||||||
@@ -299,24 +340,14 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
{
|
{
|
||||||
key: 'github',
|
key: 'github',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
icon: 'pi pi-github',
|
icon: 'icon-[lucide--github]',
|
||||||
label: t('helpCenter.github'),
|
label: t('helpCenter.github'),
|
||||||
|
showExternalIcon: true,
|
||||||
action: () => {
|
action: () => {
|
||||||
trackResourceClick('github', true)
|
trackResourceClick('github', true)
|
||||||
openExternalLink(staticUrls.github)
|
openExternalLink(staticUrls.github)
|
||||||
emit('close')
|
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 diffTime = Math.abs(now.getTime() - date.getTime())
|
||||||
|
|
||||||
const timeUnits = [
|
const timeUnits = [
|
||||||
{ unit: TIME_UNITS.YEAR, suffix: 'y' },
|
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
|
||||||
{ unit: TIME_UNITS.MONTH, suffix: 'mo' },
|
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
|
||||||
{ unit: TIME_UNITS.WEEK, suffix: 'w' },
|
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
|
||||||
{ unit: TIME_UNITS.DAY, suffix: 'd' },
|
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
|
||||||
{ unit: TIME_UNITS.HOUR, suffix: 'h' },
|
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
|
||||||
{ unit: TIME_UNITS.MINUTE, suffix: 'min' }
|
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const { unit, suffix } of timeUnits) {
|
for (const { unit, key } of timeUnits) {
|
||||||
const value = Math.floor(diffTime / unit)
|
const value = Math.floor(diffTime / unit)
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
return `${value}${suffix} ago`
|
return t(`g.relativeTime.${key}`, { count: value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'now'
|
return t('g.relativeTime.now')
|
||||||
}
|
|
||||||
|
|
||||||
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
|
|
||||||
// Hide update buttons in cloud distribution
|
|
||||||
if (isCloud) return false
|
|
||||||
|
|
||||||
return (
|
|
||||||
releaseStore.shouldShowUpdateButton &&
|
|
||||||
release === releaseStore.recentReleases[0]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event Handlers
|
// Event Handlers
|
||||||
@@ -533,14 +554,6 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onUpdate = (_: ReleaseNote): void => {
|
|
||||||
trackResourceClick('docs', true)
|
|
||||||
openExternalLink(
|
|
||||||
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
|
|
||||||
)
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||||
@@ -557,38 +570,37 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.help-center-menu {
|
.help-center-menu {
|
||||||
width: 380px;
|
width: 256px;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--p-content-background);
|
background: var(--interface-menu-surface);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
|
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
|
||||||
border: 1px solid var(--p-content-border-color);
|
border: 1px solid var(--interface-menu-stroke);
|
||||||
backdrop-filter: blur(8px);
|
padding: 12px 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-menu-section {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
border-bottom: 1px solid var(--p-content-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-menu-item {
|
.help-menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem 1rem;
|
height: 32px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: inherit;
|
color: var(--text-primary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-menu-item:hover {
|
.help-menu-item:hover {
|
||||||
background-color: #007aff26;
|
background-color: var(--interface-menu-component-surface-hovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-menu-item:focus,
|
.help-menu-item:focus,
|
||||||
@@ -599,16 +611,16 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.help-menu-icon-container {
|
.help-menu-icon-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: 0.75rem;
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-menu-icon {
|
.help-menu-icon {
|
||||||
margin-right: 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--p-text-muted-color);
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -616,7 +628,9 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-menu-icon svg {
|
.help-menu-icon svg {
|
||||||
color: var(--p-text-muted-color);
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-red-dot {
|
.menu-red-dot {
|
||||||
@@ -639,16 +653,14 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.whats-new-section {
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-description {
|
.section-description {
|
||||||
font-size: 0.8rem;
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-family: var(--font-inter);
|
||||||
color: var(--p-text-muted-color);
|
font-size: 12px;
|
||||||
margin: 0 0 0.5rem;
|
font-style: normal;
|
||||||
padding: 0 1rem;
|
font-weight: 700;
|
||||||
|
line-height: normal;
|
||||||
|
margin: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -661,7 +673,7 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,12 +681,22 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-date {
|
.release-date {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
font-size: 0.75rem;
|
color: var(--text-secondary);
|
||||||
color: var(--p-text-muted-color);
|
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 {
|
.release-date .hover-state {
|
||||||
@@ -691,35 +713,31 @@ onBeforeUnmount(() => {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-button {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submenu Styles */
|
/* Submenu Styles */
|
||||||
.more-submenu {
|
.more-submenu {
|
||||||
width: 210px;
|
width: 210px;
|
||||||
padding: 0.5rem 0;
|
padding: 12px 8px;
|
||||||
background: var(--p-content-background);
|
background: var(--interface-menu-surface);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--p-content-border-color);
|
border: 1px solid var(--interface-menu-stroke);
|
||||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.15);
|
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 0.1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: opacity 0.15s ease-out;
|
transition: opacity 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-item {
|
.submenu-item {
|
||||||
padding: 0.75rem 1rem;
|
padding: 8px;
|
||||||
color: inherit;
|
height: 32px;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-item:hover {
|
.submenu-item:hover {
|
||||||
background-color: #007aff26;
|
background-color: var(--interface-menu-component-surface-hovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-item:focus,
|
.submenu-item:focus,
|
||||||
@@ -730,8 +748,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.submenu-divider {
|
.submenu-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #3e3e3e;
|
background: var(--interface-menu-stroke);
|
||||||
margin: 0.5rem 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar Styling */
|
/* Scrollbar Styling */
|
||||||
@@ -744,12 +762,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-center-menu::-webkit-scrollbar-thumb {
|
.help-center-menu::-webkit-scrollbar-thumb {
|
||||||
background: var(--p-content-border-color);
|
background: var(--interface-menu-stroke);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-center-menu::-webkit-scrollbar-thumb:hover {
|
.help-center-menu::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--p-text-muted-color);
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced Motion */
|
/* Reduced Motion */
|
||||||
|
|||||||
@@ -199,6 +199,15 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copyJobId": "Copy Job ID",
|
"copyJobId": "Copy Job ID",
|
||||||
"copied": "Copied",
|
"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",
|
"jobIdCopied": "Job ID copied to clipboard",
|
||||||
"failedToCopyJobId": "Failed to copy job ID",
|
"failedToCopyJobId": "Failed to copy job ID",
|
||||||
"imageUrl": "Image URL",
|
"imageUrl": "Image URL",
|
||||||
@@ -746,9 +755,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"helpCenter": {
|
"helpCenter": {
|
||||||
|
"feedback": "Give Feedback",
|
||||||
"docs": "Docs",
|
"docs": "Docs",
|
||||||
"github": "Github",
|
"github": "Github",
|
||||||
"helpFeedback": "Help & Feedback",
|
"help": "Help & Support",
|
||||||
"managerExtension": "Manager Extension",
|
"managerExtension": "Manager Extension",
|
||||||
"more": "More...",
|
"more": "More...",
|
||||||
"whatsNew": "What's New?",
|
"whatsNew": "What's New?",
|
||||||
@@ -762,10 +772,11 @@
|
|||||||
"reinstall": "Re-Install"
|
"reinstall": "Re-Install"
|
||||||
},
|
},
|
||||||
"releaseToast": {
|
"releaseToast": {
|
||||||
"newVersionAvailable": "New Version Available!",
|
"newVersionAvailable": "New update is out!",
|
||||||
"whatsNew": "What's New?",
|
"whatsNew": "See what's new",
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
"update": "Update"
|
"update": "Update",
|
||||||
|
"description": "Check out the latest improvements and features in this update."
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"hideMenu": "Hide Menu",
|
"hideMenu": "Hide Menu",
|
||||||
@@ -1927,6 +1938,7 @@
|
|||||||
},
|
},
|
||||||
"whatsNewPopup": {
|
"whatsNewPopup": {
|
||||||
"learnMore": "Learn more",
|
"learnMore": "Learn more",
|
||||||
|
"later": "Later",
|
||||||
"noReleaseNotes": "No release notes available."
|
"noReleaseNotes": "No release notes available."
|
||||||
},
|
},
|
||||||
"breadcrumbsMenu": {
|
"breadcrumbsMenu": {
|
||||||
@@ -2271,6 +2283,12 @@
|
|||||||
"inputs": "INPUTS",
|
"inputs": "INPUTS",
|
||||||
"inputsNone": "NO INPUTS",
|
"inputsNone": "NO INPUTS",
|
||||||
"inputsNoneTooltip": "Node has 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>
|
<template>
|
||||||
<div v-if="shouldShow" class="release-toast-popup">
|
<div v-if="shouldShow" class="release-toast-popup">
|
||||||
<div class="release-notification-toast">
|
<div
|
||||||
<!-- Header section with icon and text -->
|
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"
|
||||||
<div class="toast-header">
|
>
|
||||||
<div class="toast-icon">
|
<!-- Main content -->
|
||||||
<i class="pi pi-download" />
|
<div class="p-4 flex flex-col gap-4 flex-1 min-h-0">
|
||||||
</div>
|
<!-- Header section with icon and text -->
|
||||||
<div class="toast-text">
|
<div class="flex items-center gap-4">
|
||||||
<div class="toast-title">
|
<div
|
||||||
{{ $t('releaseToast.newVersionAvailable') }}
|
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>
|
||||||
<div class="toast-version-badge">
|
<div class="flex flex-col gap-1">
|
||||||
{{ latestRelease?.version }}
|
<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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions section -->
|
<!-- Footer section -->
|
||||||
<div class="toast-actions-section">
|
<div class="flex justify-between items-center px-4 pb-4">
|
||||||
<div class="actions-row">
|
<a
|
||||||
<div class="left-actions">
|
class="flex items-center gap-2 text-sm font-normal py-1 text-muted-foreground hover:text-base-foreground"
|
||||||
<a
|
:href="changelogUrl"
|
||||||
class="learn-more-link"
|
target="_blank"
|
||||||
:href="changelogUrl"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
@click="handleLearnMore"
|
||||||
rel="noopener,noreferrer"
|
>
|
||||||
@click="handleLearnMore"
|
<i class="icon-[lucide--external-link] w-4 h-4"></i>
|
||||||
>
|
{{ $t('releaseToast.whatsNew') }}
|
||||||
{{ $t('releaseToast.whatsNew') }}
|
</a>
|
||||||
</a>
|
<div class="flex items-center gap-4">
|
||||||
</div>
|
<button
|
||||||
<div class="right-actions">
|
class="h-6 px-0 bg-transparent border-none text-sm font-normal text-muted-foreground hover:text-base-foreground cursor-pointer"
|
||||||
<button class="skip-button" @click="handleSkip">
|
@click="handleSkip"
|
||||||
{{ $t('releaseToast.skip') }}
|
>
|
||||||
</button>
|
{{ $t('releaseToast.skip') }}
|
||||||
<button class="cta-button" @click="handleUpdate">
|
</button>
|
||||||
{{ $t('releaseToast.update') }}
|
<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"
|
||||||
</div>
|
@click="handleUpdate"
|
||||||
|
>
|
||||||
|
{{ $t('releaseToast.update') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,24 +65,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { default as DOMPurify } from 'dompurify'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useExternalLink } from '@/composables/useExternalLink'
|
import { useExternalLink } from '@/composables/useExternalLink'
|
||||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||||
|
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||||
|
|
||||||
import type { ReleaseNote } from '../common/releaseService'
|
import type { ReleaseNote } from '../common/releaseService'
|
||||||
import { useReleaseStore } from '../common/releaseStore'
|
import { useReleaseStore } from '../common/releaseStore'
|
||||||
|
|
||||||
const { buildDocsUrl } = useExternalLink()
|
const { buildDocsUrl } = useExternalLink()
|
||||||
const releaseStore = useReleaseStore()
|
const releaseStore = useReleaseStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Local state for dismissed status
|
// Local state for dismissed status
|
||||||
const isDismissed = ref(false)
|
const isDismissed = ref(false)
|
||||||
|
|
||||||
// Get latest release from store
|
// Get latest release from store
|
||||||
const latestRelease = computed<ReleaseNote | null>(
|
const latestRelease = computed<ReleaseNote | null>(() => {
|
||||||
() => releaseStore.recentRelease
|
return releaseStore.recentRelease
|
||||||
)
|
})
|
||||||
|
|
||||||
// Show toast when new version available and not dismissed
|
// Show toast when new version available and not dismissed
|
||||||
const shouldShow = computed(
|
const shouldShow = computed(
|
||||||
@@ -79,6 +103,38 @@ const changelogUrl = computed(() => {
|
|||||||
return changelogBaseUrl
|
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
|
// Auto-hide timer
|
||||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -124,8 +180,6 @@ const handleUpdate = () => {
|
|||||||
dismissToast()
|
dismissToast()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Learn more handled by anchor href
|
|
||||||
|
|
||||||
// Start auto-hide when toast becomes visible
|
// Start auto-hide when toast becomes visible
|
||||||
watch(shouldShow, (isVisible) => {
|
watch(shouldShow, (isVisible) => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
@@ -142,6 +196,13 @@ onMounted(async () => {
|
|||||||
await releaseStore.fetchReleases()
|
await releaseStore.fetchReleases()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Expose methods for testing
|
||||||
|
defineExpose({
|
||||||
|
handleSkip,
|
||||||
|
handleLearnMore,
|
||||||
|
handleUpdate
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -154,10 +215,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar positioning classes applied by parent - matching help center */
|
/* Sidebar positioning classes applied by parent - matching help center */
|
||||||
.release-toast-popup.sidebar-left {
|
.release-toast-popup.sidebar-left,
|
||||||
left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.release-toast-popup.sidebar-left.small-sidebar {
|
.release-toast-popup.sidebar-left.small-sidebar {
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
}
|
}
|
||||||
@@ -165,139 +223,4 @@ onMounted(async () => {
|
|||||||
.release-toast-popup.sidebar-right {
|
.release-toast-popup.sidebar-right {
|
||||||
right: 1rem;
|
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>
|
</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>
|
<template>
|
||||||
<div v-if="shouldShow" class="whats-new-popup-container">
|
<div v-if="shouldShow" class="whats-new-popup-container left-4">
|
||||||
<!-- 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 class="whats-new-popup" @click.stop>
|
<div class="whats-new-popup" @click.stop>
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button
|
<Button
|
||||||
class="close-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')"
|
:aria-label="$t('g.close')"
|
||||||
|
icon="icon-[lucide--x]"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
@click="closePopup"
|
@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>
|
<a
|
||||||
</button>
|
class="learn-more-link flex items-center gap-2 text-sm font-normal py-1"
|
||||||
|
:href="changelogUrl"
|
||||||
<!-- Release Content -->
|
target="_blank"
|
||||||
<div class="popup-content">
|
rel="noopener noreferrer"
|
||||||
<div class="content-text" v-html="formattedContent"></div>
|
@click="closePopup"
|
||||||
|
>
|
||||||
<!-- Actions Section -->
|
<i class="icon-[lucide--external-link]"></i>
|
||||||
<div class="popup-actions">
|
{{ $t('whatsNewPopup.learnMore') }}
|
||||||
<a
|
</a>
|
||||||
class="learn-more-link"
|
<div class="footer-actions flex items-center gap-4">
|
||||||
:href="changelogUrl"
|
<Button
|
||||||
target="_blank"
|
class="h-8"
|
||||||
rel="noopener,noreferrer"
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
@click="closePopup"
|
@click="closePopup"
|
||||||
>
|
>
|
||||||
{{ $t('whatsNewPopup.learnMore') }}
|
{{ $t('whatsNewPopup.later') }}
|
||||||
</a>
|
</Button>
|
||||||
<!-- TODO: CTA button -->
|
|
||||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +52,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { default as DOMPurify } from 'dompurify'
|
||||||
|
import Button from 'primevue/button'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -74,9 +64,9 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
|||||||
import type { ReleaseNote } from '../common/releaseService'
|
import type { ReleaseNote } from '../common/releaseService'
|
||||||
import { useReleaseStore } from '../common/releaseStore'
|
import { useReleaseStore } from '../common/releaseStore'
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { buildDocsUrl } = useExternalLink()
|
const { buildDocsUrl } = useExternalLink()
|
||||||
const releaseStore = useReleaseStore()
|
const releaseStore = useReleaseStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Define emits
|
// Define emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -87,9 +77,9 @@ const emit = defineEmits<{
|
|||||||
const isDismissed = ref(false)
|
const isDismissed = ref(false)
|
||||||
|
|
||||||
// Get latest release from store
|
// Get latest release from store
|
||||||
const latestRelease = computed<ReleaseNote | null>(
|
const latestRelease = computed<ReleaseNote | null>(() => {
|
||||||
() => releaseStore.recentRelease
|
return releaseStore.recentRelease
|
||||||
)
|
})
|
||||||
|
|
||||||
// Show popup when on latest version and not dismissed
|
// Show popup when on latest version and not dismissed
|
||||||
const shouldShow = computed(
|
const shouldShow = computed(
|
||||||
@@ -108,15 +98,39 @@ const changelogUrl = computed(() => {
|
|||||||
|
|
||||||
const formattedContent = computed(() => {
|
const formattedContent = computed(() => {
|
||||||
if (!latestRelease.value?.content) {
|
if (!latestRelease.value?.content) {
|
||||||
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
|
return DOMPurify.sanitize(`<p>${t('whatsNewPopup.noReleaseNotes')}</p>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Error parsing markdown:', error)
|
console.error('Error parsing markdown:', error)
|
||||||
// Fallback to plain text with line breaks
|
// Fallback to plain text with line breaks - sanitize the HTML we create
|
||||||
return latestRelease.value.content.replace(/\n/g, '<br>')
|
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({
|
defineExpose({
|
||||||
show,
|
show,
|
||||||
hide
|
hide,
|
||||||
|
closePopup
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -163,165 +178,80 @@ defineExpose({
|
|||||||
pointer-events: auto;
|
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 {
|
.whats-new-popup {
|
||||||
background: #353535;
|
background: var(--interface-menu-surface);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
outline: 1px solid #4e4e4e;
|
border: 1px solid var(--interface-menu-stroke);
|
||||||
outline-offset: -1px;
|
box-shadow: 1px 1px 8px 0 rgb(0 0 0 / 0.2);
|
||||||
box-shadow: 0 8px 32px rgb(0 0 0 / 0.3);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
/* Content Section */
|
|
||||||
.popup-content {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 32px 32px 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Close button */
|
/* Modal Body */
|
||||||
.close-button {
|
.modal-body {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 6px;
|
|
||||||
background: #7c7c7c;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 1rem;
|
||||||
transform: translate(30%, -30%);
|
padding: 0;
|
||||||
transition:
|
flex: 1;
|
||||||
background-color 0.2s ease,
|
|
||||||
transform 0.1s ease;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
.modal-header {
|
||||||
background: #8e8e8e;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-text {
|
.content-text {
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style the markdown content */
|
/* Style the markdown content */
|
||||||
/* Title */
|
/* Title */
|
||||||
.content-text :deep(*) {
|
.content-text :deep(*) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-text :deep(h1) {
|
.content-text :deep(h1) {
|
||||||
font-size: 16px;
|
color: var(--text-secondary);
|
||||||
font-weight: 700;
|
font-family: Inter, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 1rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Version subtitle - targets the first p tag after h1 */
|
/* What's new title - targets h2 or strong text after h1 */
|
||||||
.content-text :deep(h1 + p) {
|
.content-text :deep(h2),
|
||||||
color: #c0c0c0;
|
.content-text :deep(h1 + p strong) {
|
||||||
font-size: 16px;
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-family: Inter, sans-serif;
|
||||||
margin-bottom: 16px;
|
font-size: 14px;
|
||||||
opacity: 0.8;
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
line-height: 1.429;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Regular paragraphs - short description */
|
/* Regular paragraphs - short description */
|
||||||
.content-text :deep(p) {
|
.content-text :deep(p) {
|
||||||
margin-bottom: 16px;
|
color: var(--text-secondary);
|
||||||
color: #e0e0e0;
|
font-family: Inter, sans-serif;
|
||||||
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List */
|
/* List */
|
||||||
.content-text :deep(ul),
|
.content-text :deep(ul),
|
||||||
.content-text :deep(ol) {
|
.content-text :deep(ol) {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
@@ -336,110 +266,168 @@ defineExpose({
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List items */
|
|
||||||
.content-text :deep(li) {
|
.content-text :deep(li) {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
position: relative;
|
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) {
|
.content-text :deep(li:last-child) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom bullet points */
|
|
||||||
.content-text :deep(li::before) {
|
.content-text :deep(li::before) {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 4px;
|
||||||
top: 10px;
|
top: 7px;
|
||||||
display: flex;
|
width: 6px;
|
||||||
width: 8px;
|
height: 6px;
|
||||||
height: 8px;
|
border: 2px solid var(--text-secondary);
|
||||||
justify-content: center;
|
border-radius: 50%;
|
||||||
align-items: center;
|
background: transparent;
|
||||||
aspect-ratio: 1/1;
|
|
||||||
border-radius: 100px;
|
|
||||||
background: #60a5fa;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List item strong text */
|
|
||||||
.content-text :deep(li strong) {
|
.content-text :deep(li strong) {
|
||||||
color: #fff;
|
color: var(--text-secondary);
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
display: block;
|
font-weight: 400;
|
||||||
margin-bottom: 4px;
|
line-height: 1.2102;
|
||||||
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-text :deep(li p) {
|
.content-text :deep(li p) {
|
||||||
font-size: 12px;
|
margin: 2px 0 0;
|
||||||
margin-bottom: 0;
|
display: inline;
|
||||||
line-height: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code styling */
|
/* Code styling */
|
||||||
.content-text :deep(code) {
|
.content-text :deep(code) {
|
||||||
background-color: #2a2a2a;
|
background-color: var(--input-surface);
|
||||||
border: 1px solid #4a4a4a;
|
border: 1px solid var(--interface-menu-stroke);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
color: #f8f8f2;
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove top margin for first media element */
|
.content-text :deep(img) {
|
||||||
.content-text :deep(img:first-child),
|
width: 100%;
|
||||||
.content-text :deep(video:first-child),
|
height: 200px;
|
||||||
.content-text :deep(iframe:first-child) {
|
margin: 0 0 16px;
|
||||||
margin-top: -32px; /* Align with the top edge of the popup content */
|
object-fit: cover;
|
||||||
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;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions Section */
|
.content-text :deep(img:first-child) {
|
||||||
.popup-actions {
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.learn-more-link {
|
.learn-more-link {
|
||||||
color: #60a5fa;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
line-height: 18.2px;
|
line-height: 1.2102;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.learn-more-link:hover {
|
.learn-more-link:hover {
|
||||||
text-decoration: underline;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-button {
|
.learn-more-link i {
|
||||||
height: 40px;
|
width: 16px;
|
||||||
padding: 0 20px;
|
height: 16px;
|
||||||
background: white;
|
}
|
||||||
border-radius: 6px;
|
|
||||||
outline: 1px solid #4e4e4e;
|
.action-secondary {
|
||||||
outline-offset: -1px;
|
height: 32px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #121212;
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-button:hover {
|
.action-primary:hover {
|
||||||
background: #f0f0f0;
|
background: var(--button-hover-surface);
|
||||||
}
|
}
|
||||||
</style>
|
</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