Split shared whats new modal owners

This commit is contained in:
rcourtman 2026-03-23 08:34:23 +00:00
parent 326b02842e
commit 9420018e99
7 changed files with 233 additions and 86 deletions

View file

@ -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`

View file

@ -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');

View file

@ -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 <ServerIcon class="h-4 w-4" />;
case 'workloads':
return <BoxesIcon class="h-4 w-4" />;
case 'storage':
return <HardDriveIcon class="h-4 w-4" />;
case 'recovery':
return <ShieldCheckIcon class="h-4 w-4" />;
}
}
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 (
<Dialog
isOpen={isOpen()}
onClose={handleClose}
isOpen={state.isOpen()}
onClose={state.handleClose}
panelClass="max-w-2xl"
ariaLabelledBy="whats-new-title"
>
@ -40,16 +54,14 @@ export function WhatsNewModal() {
<div class="flex-shrink-0 flex items-start justify-between border-b border-border px-6 py-4">
<div>
<h2 id="whats-new-title" class="text-xl sm:text-2xl font-semibold text-base-content">
Welcome to the New Navigation!
{WHATS_NEW_TITLE}
</h2>
<p class="mt-1 text-sm text-muted">
Everything is now organized by what you want to do, not where the data comes from.
</p>
<p class="mt-1 text-sm text-muted">{WHATS_NEW_SUBTITLE}</p>
</div>
<button
onClick={handleClose}
onClick={state.handleClose}
class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-surface-hover hover:text-muted"
aria-label="Close"
aria-label={WHATS_NEW_CLOSE_LABEL}
type="button"
>
<XIcon class="h-5 w-5" />
@ -58,72 +70,39 @@ export function WhatsNewModal() {
<div class="flex-1 overflow-y-auto space-y-4 sm:space-y-6 px-4 sm:px-6 py-4 sm:py-5">
<div class="grid gap-3 sm:gap-4 sm:grid-cols-2">
<div class="rounded-md border border-blue-200 bg-blue-50 p-3 sm:p-4 dark:border-blue-800 dark:bg-blue-900">
<div class="flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100">
<ServerIcon class="h-4 w-4" />
Infrastructure
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-blue-900 dark:text-blue-100">
Proxmox nodes, agents, and container runtimes live together in one unified view.
</p>
</div>
<div class="rounded-md border border-purple-200 bg-purple-50 p-3 sm:p-4 dark:border-purple-800 dark:bg-purple-900">
<div class="flex items-center gap-2 text-sm font-semibold text-purple-900 dark:text-purple-100">
<BoxesIcon class="h-4 w-4" />
Workloads
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-purple-900 dark:text-purple-100">
All VMs, containers, and Kubernetes workloads now share a single list.
</p>
</div>
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 sm:p-4 dark:border-emerald-800 dark:bg-emerald-900">
<div class="flex items-center gap-2 text-sm font-semibold text-emerald-900 dark:text-emerald-100">
<HardDriveIcon class="h-4 w-4" />
Storage
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-emerald-900 dark:text-emerald-100">
Storage is now a top-level destination across all systems.
</p>
</div>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 sm:p-4 dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-center gap-2 text-sm font-semibold text-amber-900 dark:text-amber-100">
<ShieldCheckIcon class="h-4 w-4" />
Recovery
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-amber-900 dark:text-amber-100">
Recovery events (backups, snapshots, and replication) are now first-class pages.
</p>
</div>
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card) => (
<div class={`rounded-md border p-3 sm:p-4 ${card.accent}`}>
<div class="flex items-center gap-2 text-sm font-semibold text-inherit">
<WhatsNewFeatureIcon card={card} />
{card.title}
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-inherit">{card.description}</p>
</div>
)}
</For>
</div>
<div class="rounded-md border border-sky-200 bg-sky-50 p-3 sm:p-4 dark:border-sky-800 dark:bg-sky-900/40">
<div class="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<ChartBarIcon class="h-4 w-4 flex-shrink-0" />
Anonymous telemetry
{WHATS_NEW_TELEMETRY_TITLE}
</div>
<p class="mt-1.5 text-xs text-sky-900 dark:text-sky-200">{WHATS_NEW_TELEMETRY_COPY[0]}</p>
<p class="mt-1.5 text-xs text-sky-900 dark:text-sky-200">
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.
</p>
<p class="mt-1.5 text-xs text-sky-900 dark:text-sky-200">
This helps the developer understand how Pulse is used and prioritise what to build
next. You can disable it any time in{' '}
<span class="font-medium">Settings &rarr; System &rarr; General</span> or by setting{' '}
{WHATS_NEW_TELEMETRY_COPY[1]} You can disable it any time in{' '}
<span class="font-medium">{WHATS_NEW_TELEMETRY_SETTINGS_PATH}</span> or by setting{' '}
<code class="rounded bg-sky-100 px-1 py-0.5 text-[10px] font-mono dark:bg-sky-800">
PULSE_TELEMETRY=false
{WHATS_NEW_TELEMETRY_ENV_VAR}
</code>
.{' '}
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md"
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-sky-700 dark:hover:text-sky-100"
>
Full details
{WHATS_NEW_TELEMETRY_PRIVACY_LABEL}
</a>
</p>
</div>
@ -132,38 +111,38 @@ export function WhatsNewModal() {
<label class="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={dontShowAgain()}
onChange={(event) => setDontShowAgain(event.currentTarget.checked)}
checked={state.dontShowAgain()}
onChange={(event) => state.setDontShowAgain(event.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
/>
Don&#39;t show again
{WHATS_NEW_DO_NOT_SHOW_LABEL}
</label>
<a
href={DOCS_URL}
href={WHATS_NEW_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Documentation
{WHATS_NEW_DOCS_LABEL}
<ExternalLinkIcon class="h-4 w-4" />
</a>
<a
href="/recovery?view=events&mode=remote"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Recovery events
{WHATS_NEW_RECOVERY_LINK_LABEL}
</a>
</div>
</div>
<div class="flex-shrink-0 flex items-center justify-end border-t border-border bg-surface-hover px-4 sm:px-6 py-3 sm:py-4">
<button
onClick={handleClose}
onClick={state.handleClose}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
type="button"
>
Let&#39;s go
{WHATS_NEW_PRIMARY_ACTION_LABEL}
</button>
</div>
</div>

View file

@ -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(() => <WhatsNewModal />);

View file

@ -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,
};
}

View file

@ -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";

View file

@ -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');