diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5ac0da3cf..422ab434c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -302,6 +302,14 @@ runtime, and `frontend-modern/src/components/shared/searchTipsPopoverModel.ts` owns trigger variant, label/id defaults, hover policy, and trigger/popover class selection. Future search-tips work should extend those owners instead of pushing listener lifecycle or trigger policy back into the shared shell. +The shared what's-new modal now follows that same owner split. +`frontend-modern/src/components/shared/WhatsNewModal.tsx` stays the render +shell, `frontend-modern/src/components/shared/useWhatsNewModalState.ts` owns +local-storage dismissal, session dismissal, and close behavior, and +`frontend-modern/src/components/shared/whatsNewModalModel.ts` owns the feature +card catalog, telemetry copy, labels, and canonical docs/privacy links. Future +what's-new work should extend those owners instead of pushing dismissal state, +product copy, or external links back into the shared shell. The shared tooltip now follows that same owner split. `frontend-modern/src/components/shared/Tooltip.tsx` stays the render shell and singleton API boundary, `frontend-modern/src/components/shared/useTooltipState.ts` diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index d3829b5ce..a183f6284 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -25,6 +25,8 @@ import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?ra import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw'; import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw'; import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw'; +import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw'; +import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw'; import searchFieldSource from '@/components/shared/SearchField.tsx?raw'; import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw'; import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; @@ -54,6 +56,7 @@ import infrastructureDetailsDrawerStateSource from '@/components/shared/useInfra import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw'; import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw'; import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw'; +import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw'; import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw'; import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; @@ -513,6 +516,31 @@ describe('shared primitive guardrails', () => { expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover'); }); + it('keeps whats new modal on shell, runtime, and model owners', () => { + expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalSource).not.toContain('createSignal'); + expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalSource).not.toContain('Documentation'); + expect(whatsNewModalSource).not.toContain( + 'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md', + ); + + expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState'); + expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalStateSource).toContain('handleClose'); + + expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); + expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'"); + }); + it('keeps tooltip on shell, runtime, and model owners', () => { expect(tooltipSource).toContain('useTooltipState'); expect(tooltipSource).toContain('createTooltipSystemState'); diff --git a/frontend-modern/src/components/shared/WhatsNewModal.tsx b/frontend-modern/src/components/shared/WhatsNewModal.tsx index 7b80703cb..4a7ac2058 100644 --- a/frontend-modern/src/components/shared/WhatsNewModal.tsx +++ b/frontend-modern/src/components/shared/WhatsNewModal.tsx @@ -1,5 +1,4 @@ -import { createSignal } from 'solid-js'; -import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; +import { For } from 'solid-js'; import { Dialog } from '@/components/shared/Dialog'; import ServerIcon from 'lucide-solid/icons/server'; import BoxesIcon from 'lucide-solid/icons/boxes'; @@ -8,31 +7,46 @@ import ShieldCheckIcon from 'lucide-solid/icons/shield-check'; import ChartBarIcon from 'lucide-solid/icons/chart-bar'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import XIcon from 'lucide-solid/icons/x'; +import { + WHATS_NEW_CLOSE_LABEL, + WHATS_NEW_DOCS_LABEL, + WHATS_NEW_DOCS_URL, + WHATS_NEW_DO_NOT_SHOW_LABEL, + WHATS_NEW_FEATURE_CARDS, + WHATS_NEW_PRIMARY_ACTION_LABEL, + WHATS_NEW_PRIVACY_URL, + WHATS_NEW_RECOVERY_LINK_LABEL, + WHATS_NEW_SUBTITLE, + WHATS_NEW_TELEMETRY_COPY, + WHATS_NEW_TELEMETRY_ENV_VAR, + WHATS_NEW_TELEMETRY_PRIVACY_LABEL, + WHATS_NEW_TELEMETRY_SETTINGS_PATH, + WHATS_NEW_TELEMETRY_TITLE, + WHATS_NEW_TITLE, + type WhatsNewFeatureCard, +} from './whatsNewModalModel'; +import { useWhatsNewModalState } from './useWhatsNewModalState'; -const DOCS_URL = 'https://github.com/rcourtman/Pulse/blob/main/docs/README.md'; +function WhatsNewFeatureIcon(props: { card: WhatsNewFeatureCard }) { + switch (props.card.icon) { + case 'infrastructure': + return ; + case 'workloads': + return ; + case 'storage': + return ; + case 'recovery': + return ; + } +} export function WhatsNewModal() { - const [hasSeen, setHasSeen] = createLocalStorageBooleanSignal( - STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN, - false, - ); - const [dontShowAgain, setDontShowAgain] = createSignal(true); - const [dismissedForSession, setDismissedForSession] = createSignal(false); - - const isOpen = () => !hasSeen() && !dismissedForSession(); - - const handleClose = () => { - if (dontShowAgain()) { - setHasSeen(true); - return; - } - setDismissedForSession(true); - }; + const state = useWhatsNewModalState(); return ( @@ -40,16 +54,14 @@ export function WhatsNewModal() {

- Welcome to the New Navigation! + {WHATS_NEW_TITLE}

-

- Everything is now organized by what you want to do, not where the data comes from. -

+

{WHATS_NEW_SUBTITLE}

diff --git a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx index 27fb13c1d..aa035bd7f 100644 --- a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { WhatsNewModal } from '@/components/shared/WhatsNewModal'; +import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw'; +import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw'; +import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw'; import { STORAGE_KEYS } from '@/utils/localStorage'; describe('WhatsNewModal', () => { @@ -12,6 +15,30 @@ describe('WhatsNewModal', () => { cleanup(); }); + it('keeps whats new modal on shell, runtime, and model owners', () => { + expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalSource).not.toContain('createSignal'); + expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalSource).not.toContain('Infrastructure'); + expect(whatsNewModalSource).not.toContain('Documentation'); + expect(whatsNewModalSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md'); + + expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState'); + expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalStateSource).toContain('handleClose'); + + expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); + expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'"); + }); + it('renders when the navigation modal has not been seen yet', async () => { render(() => ); diff --git a/frontend-modern/src/components/shared/useWhatsNewModalState.ts b/frontend-modern/src/components/shared/useWhatsNewModalState.ts new file mode 100644 index 000000000..59ffab50f --- /dev/null +++ b/frontend-modern/src/components/shared/useWhatsNewModalState.ts @@ -0,0 +1,29 @@ +import { createSignal } from 'solid-js'; +import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; + +export function useWhatsNewModalState() { + const [hasSeen, setHasSeen] = createLocalStorageBooleanSignal( + STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN, + false, + ); + const [dontShowAgain, setDontShowAgain] = createSignal(true); + const [dismissedForSession, setDismissedForSession] = createSignal(false); + + const isOpen = () => !hasSeen() && !dismissedForSession(); + + const handleClose = () => { + if (dontShowAgain()) { + setHasSeen(true); + return; + } + + setDismissedForSession(true); + }; + + return { + dontShowAgain, + handleClose, + isOpen, + setDontShowAgain, + }; +} diff --git a/frontend-modern/src/components/shared/whatsNewModalModel.ts b/frontend-modern/src/components/shared/whatsNewModalModel.ts new file mode 100644 index 000000000..e3fe3272b --- /dev/null +++ b/frontend-modern/src/components/shared/whatsNewModalModel.ts @@ -0,0 +1,55 @@ +export interface WhatsNewFeatureCard { + accent: string; + description: string; + icon: 'infrastructure' | 'workloads' | 'storage' | 'recovery'; + title: string; +} + +export const WHATS_NEW_DOCS_URL = 'https://github.com/rcourtman/Pulse/blob/main/docs/README.md'; +export const WHATS_NEW_PRIVACY_URL = + 'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md'; + +export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [ + { + accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900', + description: 'Proxmox nodes, agents, and container runtimes live together in one unified view.', + icon: 'infrastructure', + title: 'Infrastructure', + }, + { + accent: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900', + description: 'All VMs, containers, and Kubernetes workloads now share a single list.', + icon: 'workloads', + title: 'Workloads', + }, + { + accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900', + description: 'Storage is now a top-level destination across all systems.', + icon: 'storage', + title: 'Storage', + }, + { + accent: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900', + description: + 'Recovery events (backups, snapshots, and replication) are now first-class pages.', + icon: 'recovery', + title: 'Recovery', + }, +]; + +export const WHATS_NEW_TITLE = 'Welcome to the New Navigation!'; +export const WHATS_NEW_SUBTITLE = + 'Everything is now organized by what you want to do, not where the data comes from.'; +export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous telemetry'; +export const WHATS_NEW_TELEMETRY_COPY = [ + 'Pulse now sends a lightweight anonymous ping once a day — just a random install ID, version, platform, resource counts, and feature flags. No hostnames, credentials, IP addresses, or personal information is ever sent.', + 'This helps the developer understand how Pulse is used and prioritise what to build next.', +]; +export const WHATS_NEW_TELEMETRY_SETTINGS_PATH = 'Settings → System → General'; +export const WHATS_NEW_TELEMETRY_ENV_VAR = 'PULSE_TELEMETRY=false'; +export const WHATS_NEW_TELEMETRY_PRIVACY_LABEL = 'Full details'; +export const WHATS_NEW_PRIMARY_ACTION_LABEL = "Let's go"; +export const WHATS_NEW_CLOSE_LABEL = 'Close'; +export const WHATS_NEW_DOCS_LABEL = 'Documentation'; +export const WHATS_NEW_RECOVERY_LINK_LABEL = 'Recovery events'; +export const WHATS_NEW_DO_NOT_SHOW_LABEL = "Don't show again"; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 09bd1464a..8372b22f0 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -30,6 +30,8 @@ import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?ra import infrastructureSelectorSource from '@/components/shared/InfrastructureSelector.tsx?raw'; import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw'; import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw'; +import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw'; +import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw'; import searchFieldSource from '@/components/shared/SearchField.tsx?raw'; import searchFieldModelSource from '@/components/shared/searchFieldModel.ts?raw'; import searchInputSource from '@/components/shared/SearchInput.tsx?raw'; @@ -53,6 +55,7 @@ import historyChartStateSource from '@/components/shared/useHistoryChartState.ts import mobileNavBarStateSource from '@/components/shared/useMobileNavBarState.ts?raw'; import infrastructureSelectorStateSource from '@/components/shared/useInfrastructureSelectorState.ts?raw'; import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw'; +import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.ts?raw'; import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw'; import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw'; import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw'; @@ -2706,6 +2709,24 @@ describe('frontend resource type boundaries', () => { expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass'); expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerVariant'); expect(searchTipsPopoverModelSource).toContain('shouldSearchTipsPopoverOpenOnHover'); + expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalSource).not.toContain('createSignal'); + expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalSource).not.toContain('Documentation'); + expect(whatsNewModalSource).not.toContain( + 'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md', + ); + expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); + expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); + expect(whatsNewModalStateSource).toContain('handleClose'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); + expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); expect(tooltipSource).toContain('useTooltipState'); expect(tooltipSource).toContain('createTooltipSystemState'); expect(tooltipSource).not.toContain('createSignal');